first commit

This commit is contained in:
2026-10-03 13:40:26 +02:00
parent 732a5b5a16
commit 566f415fa7
555 changed files with 62704 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs';
import { CommentFactory } from '@task-stream/api/tracker/domain';
import { AddCommentCommand } from './add-comment.command';
import { CommandResult, ack } from './command-result';
import { WriteAccess } from './write-access';
@CommandHandler(AddCommentCommand)
export class AddCommentCommandHandler
implements ICommandHandler<AddCommentCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly commentFactory: CommentFactory,
private readonly eventPublisher: EventPublisher,
) {}
async execute(command: AddCommentCommand): Promise<CommandResult> {
const { issue } = await this.access.issueForContributor(
command.issueId,
command.actorId,
);
const comment = this.commentFactory.create({
issueId: issue.id,
projectId: issue.projectId,
authorId: command.actorId,
body: command.body,
});
this.eventPublisher.mergeObjectContext(comment);
await comment.commitAndWait();
return ack(comment);
}
}

View File

@@ -0,0 +1,12 @@
import { Command } from '@nestjs/cqrs';
import { CommandResult } from './command-result';
export class AddCommentCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
public readonly body: string,
) {
super();
}
}

View File

@@ -0,0 +1,22 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { AddProjectMemberCommand } from './add-project-member.command';
import { CommandResult, ack } from './command-result';
import { WriteAccess } from './write-access';
@CommandHandler(AddProjectMemberCommand)
export class AddProjectMemberCommandHandler
implements ICommandHandler<AddProjectMemberCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: AddProjectMemberCommand): Promise<CommandResult> {
const project = await this.access.project(command.projectId);
await this.access.assertKnownActiveUser(
command.userId,
command.accessToken,
);
project.addMember(command.actorId, command.userId, command.role);
await project.commitAndWait();
return ack(project);
}
}

View File

@@ -0,0 +1,16 @@
import { Command } from '@nestjs/cqrs';
import { ProjectRole } from '@task-stream/api/tracker/domain';
import { CommandResult } from './command-result';
export class AddProjectMemberCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly projectId: string,
public readonly userId: string,
public readonly role: ProjectRole,
/** Caller's Pireon token, to look up users unknown to task-stream. */
public readonly accessToken?: string,
) {
super();
}
}

View File

@@ -0,0 +1,21 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { ArchiveReleaseCommand } from './archive-release.command';
import { CommandResult, ack } from './command-result';
import { WriteAccess } from './write-access';
@CommandHandler(ArchiveReleaseCommand)
export class ArchiveReleaseCommandHandler
implements ICommandHandler<ArchiveReleaseCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: ArchiveReleaseCommand): Promise<CommandResult> {
const release = await this.access.release(command.releaseId);
(await this.access.project(release.projectId)).assertCanManage(
command.actorId,
);
release.archive(command.actorId);
await release.commitAndWait();
return ack(release);
}
}

View File

@@ -0,0 +1,11 @@
import { Command } from '@nestjs/cqrs';
import { CommandResult } from './command-result';
export class ArchiveReleaseCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly releaseId: string,
) {
super();
}
}

View File

@@ -0,0 +1,23 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { CommandResult, ack } from './command-result';
import { AssignIssueCommand } from './assign-issue.command';
import { WriteAccess } from './write-access';
@CommandHandler(AssignIssueCommand)
export class AssignIssueCommandHandler
implements ICommandHandler<AssignIssueCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: AssignIssueCommand): Promise<CommandResult> {
const { issue, project } = await this.access.issueForContributor(
command.issueId,
command.actorId,
);
if (command.assigneeId)
this.access.assertAssignable(project, command.assigneeId);
issue.assign(command.actorId, command.assigneeId);
await issue.commitAndWait();
return ack(issue);
}
}

View File

@@ -0,0 +1,13 @@
import { Command } from '@nestjs/cqrs';
import { CommandResult } from './command-result';
export class AssignIssueCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
/** null to unassign. */
public readonly assigneeId: string | null,
) {
super();
}
}

View File

@@ -0,0 +1,21 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { CommandResult, ack } from './command-result';
import { ChangeIssueStatusCommand } from './change-issue-status.command';
import { WriteAccess } from './write-access';
@CommandHandler(ChangeIssueStatusCommand)
export class ChangeIssueStatusCommandHandler
implements ICommandHandler<ChangeIssueStatusCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: ChangeIssueStatusCommand): Promise<CommandResult> {
const { issue } = await this.access.issueForContributor(
command.issueId,
command.actorId,
);
issue.changeStatus(command.actorId, command.status, command.reason);
await issue.commitAndWait();
return ack(issue);
}
}

View File

@@ -0,0 +1,15 @@
import { Command } from '@nestjs/cqrs';
import { IssueStatus } from '@task-stream/api/tracker/domain';
import { CommandResult } from './command-result';
export class ChangeIssueStatusCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
public readonly status: IssueStatus,
/** Required for QA_FAILED and CANCELLED. */
public readonly reason?: string | null,
) {
super();
}
}

View File

@@ -0,0 +1,20 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { ChangeProjectMemberRoleCommand } from './change-project-member-role.command';
import { CommandResult, ack } from './command-result';
import { WriteAccess } from './write-access';
@CommandHandler(ChangeProjectMemberRoleCommand)
export class ChangeProjectMemberRoleCommandHandler
implements ICommandHandler<ChangeProjectMemberRoleCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(
command: ChangeProjectMemberRoleCommand,
): Promise<CommandResult> {
const project = await this.access.project(command.projectId);
project.changeMemberRole(command.actorId, command.userId, command.role);
await project.commitAndWait();
return ack(project);
}
}

View File

@@ -0,0 +1,14 @@
import { Command } from '@nestjs/cqrs';
import { ProjectRole } from '@task-stream/api/tracker/domain';
import { CommandResult } from './command-result';
export class ChangeProjectMemberRoleCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly projectId: string,
public readonly userId: string,
public readonly role: ProjectRole,
) {
super();
}
}

View File

@@ -0,0 +1,41 @@
import { CreateProjectCommandHandler } from './create-project.command-handler';
import { UpdateProjectCommandHandler } from './update-project.command-handler';
import { AddProjectMemberCommandHandler } from './add-project-member.command-handler';
import { ChangeProjectMemberRoleCommandHandler } from './change-project-member-role.command-handler';
import { RemoveProjectMemberCommandHandler } from './remove-project-member.command-handler';
import { CreateIssueCommandHandler } from './create-issue.command-handler';
import { UpdateIssueCommandHandler } from './update-issue.command-handler';
import { ChangeIssueStatusCommandHandler } from './change-issue-status.command-handler';
import { AssignIssueCommandHandler } from './assign-issue.command-handler';
import { LinkIssuesCommandHandler } from './link-issues.command-handler';
import { UnlinkIssuesCommandHandler } from './unlink-issues.command-handler';
import { SetIssueReleaseCommandHandler } from './set-issue-release.command-handler';
import { AddCommentCommandHandler } from './add-comment.command-handler';
import { EditCommentCommandHandler } from './edit-comment.command-handler';
import { DeleteCommentCommandHandler } from './delete-comment.command-handler';
import { CreateReleaseCommandHandler } from './create-release.command-handler';
import { UpdateReleaseCommandHandler } from './update-release.command-handler';
import { PublishReleaseCommandHandler } from './publish-release.command-handler';
import { ArchiveReleaseCommandHandler } from './archive-release.command-handler';
export const COMMAND_HANDLERS = [
CreateProjectCommandHandler,
UpdateProjectCommandHandler,
AddProjectMemberCommandHandler,
ChangeProjectMemberRoleCommandHandler,
RemoveProjectMemberCommandHandler,
CreateIssueCommandHandler,
UpdateIssueCommandHandler,
ChangeIssueStatusCommandHandler,
AssignIssueCommandHandler,
LinkIssuesCommandHandler,
UnlinkIssuesCommandHandler,
SetIssueReleaseCommandHandler,
AddCommentCommandHandler,
EditCommentCommandHandler,
DeleteCommentCommandHandler,
CreateReleaseCommandHandler,
UpdateReleaseCommandHandler,
PublishReleaseCommandHandler,
ArchiveReleaseCommandHandler,
];

View File

@@ -0,0 +1,21 @@
import { VersionedAggregateRoot } from '@task-stream/api/shared/domain';
/**
* Commands only acknowledge: the read model is updated asynchronously.
* Clients can wait for a subscription event with `version >= result.version`.
*/
export interface CommandResult {
id: string;
version: number;
key?: string;
}
export function ack(
aggregate: VersionedAggregateRoot & { key?: string },
): CommandResult {
return {
id: aggregate.id,
key: aggregate.key,
version: aggregate.version.value,
};
}

View File

@@ -0,0 +1,287 @@
import { CommandBus, CqrsModule, EventBus } from '@nestjs/cqrs';
import { Test } from '@nestjs/testing';
import {
AggregateRehydrator,
EventStore,
} from '@task-stream/api/shared/application';
import {
BusinessRuleError,
ConcurrencyError,
ForbiddenError,
NotFoundError,
} from '@task-stream/api/shared/domain';
import {
Comment,
IssueStatus,
Project,
IssueType,
ProjectRole,
TRACKER_FACTORIES,
} from '@task-stream/api/tracker/domain';
import {
FakeUserDirectory,
InMemoryEventStore,
InMemoryEventStorePublisher,
InMemoryIssueNumbers,
InMemoryProjectKeys,
InMemoryReleaseNames,
InMemoryReleaseIssues,
} from '../../testing/in-memory';
import { ReleaseIssuesRepository } from '../ports/release-issues.repository';
import { IssueNumbersRepository } from '../ports/issue-numbers.repository';
import { ProjectKeysRepository } from '../ports/project-keys.repository';
import { ReleaseNamesRepository } from '../ports/release-names.repository';
import { UserDirectory } from '../ports/user-directory';
import { AddCommentCommand } from './add-comment.command';
import { AddProjectMemberCommand } from './add-project-member.command';
import { AssignIssueCommand } from './assign-issue.command';
import { ChangeIssueStatusCommand } from './change-issue-status.command';
import { COMMAND_HANDLERS } from './command-handlers';
import { CommandResult } from './command-result';
import { CreateIssueCommand } from './create-issue.command';
import { CreateProjectCommand } from './create-project.command';
import { CreateReleaseCommand } from './create-release.command';
import { DeleteCommentCommand } from './delete-comment.command';
import { PublishReleaseCommand } from './publish-release.command';
import { SetIssueReleaseCommand } from './set-issue-release.command';
import { WriteAccess } from './write-access';
async function setup() {
const store = new InMemoryEventStore();
const users = new FakeUserDirectory()
.add('owner')
.add('dev')
.add('qa')
.add('pm')
.add('gone', 'Gone', false);
const moduleRef = await Test.createTestingModule({
imports: [CqrsModule.forRoot()],
providers: [
...COMMAND_HANDLERS,
...TRACKER_FACTORIES,
WriteAccess,
AggregateRehydrator,
{ provide: EventStore, useValue: store },
{ provide: UserDirectory, useValue: users },
{ provide: ProjectKeysRepository, useClass: InMemoryProjectKeys },
{ provide: IssueNumbersRepository, useClass: InMemoryIssueNumbers },
{ provide: ReleaseNamesRepository, useClass: InMemoryReleaseNames },
{
provide: ReleaseIssuesRepository,
useValue: new InMemoryReleaseIssues(store),
},
],
}).compile();
await moduleRef.init();
InMemoryEventStorePublisher.install(moduleRef.get(EventBus), store);
const commandBus = moduleRef.get(CommandBus);
const run = (command: object): Promise<CommandResult> =>
commandBus.execute(command as never);
return { store, run, rehydrator: moduleRef.get(AggregateRehydrator) };
}
async function projectWithTeam(run: (c: object) => Promise<CommandResult>) {
const { id } = await run(
new CreateProjectCommand('owner', 'ts', 'Task Stream'),
);
await run(
new AddProjectMemberCommand('owner', id, 'dev', ProjectRole.MEMBER),
);
await run(new AddProjectMemberCommand('owner', id, 'qa', ProjectRole.MEMBER));
await run(new AddProjectMemberCommand('owner', id, 'pm', ProjectRole.VIEWER));
return id;
}
describe('tracker commands (event sourced)', () => {
it('appends events to the store and returns the new version', async () => {
const { store, run } = await setup();
const created = await run(
new CreateProjectCommand('owner', 'ts', 'Task Stream'),
);
const added = await run(
new AddProjectMemberCommand(
'owner',
created.id,
'dev',
ProjectRole.MEMBER,
),
);
expect(created).toMatchObject({ key: 'TS', version: 1 });
expect(added.version).toBe(2);
expect(store.types()).toEqual([
'ProjectCreatedEvent',
'ProjectMemberAddedEvent',
]);
});
it('rejects duplicated project keys', async () => {
const { run } = await setup();
await run(new CreateProjectCommand('owner', 'TS', 'One'));
await expect(
run(new CreateProjectCommand('dev', 'ts', 'Two')),
).rejects.toThrow(BusinessRuleError);
});
it('only adds known, active Pireon users', async () => {
const { run } = await setup();
const projectId = await projectWithTeam(run);
await expect(
run(
new AddProjectMemberCommand(
'owner',
projectId,
'ghost',
ProjectRole.MEMBER,
),
),
).rejects.toThrow(NotFoundError);
await expect(
run(
new AddProjectMemberCommand(
'owner',
projectId,
'gone',
ProjectRole.MEMBER,
),
),
).rejects.toThrow(/deactivated/);
});
it('numbers tickets per project and assigns members only', async () => {
const { run } = await setup();
const projectId = await projectWithTeam(run);
const first = await run(
new CreateIssueCommand(
'qa',
projectId,
IssueType.BUG,
'Crash on login',
{},
'dev',
),
);
const second = await run(
new CreateIssueCommand(
'dev',
projectId,
IssueType.FEATURE,
'Dark mode',
{},
),
);
expect([first.key, second.key]).toEqual(['TS-1', 'TS-2']);
await expect(
run(new AssignIssueCommand('dev', second.id, 'stranger')),
).rejects.toThrow(BusinessRuleError);
});
it('does not let viewers modify tickets', async () => {
const { run } = await setup();
const projectId = await projectWithTeam(run);
await expect(
run(new CreateIssueCommand('pm', projectId, IssueType.TASK, 'Nope', {})),
).rejects.toThrow(ForbiddenError);
});
it('rebuilds the issue from its events on every command (QA round trip)', async () => {
const { run, store } = await setup();
const projectId = await projectWithTeam(run);
const { id } = await run(
new CreateIssueCommand('qa', projectId, IssueType.BUG, 'Crash', {}),
);
for (const status of [
IssueStatus.TODO,
IssueStatus.IN_PROGRESS,
IssueStatus.READY_FOR_QA,
IssueStatus.IN_QA,
]) {
await run(new ChangeIssueStatusCommand('dev', id, status));
}
const failed = await run(
new ChangeIssueStatusCommand(
'qa',
id,
IssueStatus.QA_FAILED,
'Still crashes on iOS',
),
);
expect(failed.version).toBe(6);
expect(store.events.at(-1)?.data).toMatchObject({
from: IssueStatus.IN_QA,
to: IssueStatus.QA_FAILED,
reason: 'Still crashes on iOS',
aggregateVersion: 6,
});
});
it('controls which tickets ship in a release', async () => {
const { run } = await setup();
const projectId = await projectWithTeam(run);
const release = await run(
new CreateReleaseCommand('owner', projectId, 'v1.0.0'),
);
const issue = await run(
new CreateIssueCommand(
'dev',
projectId,
IssueType.FEATURE,
'Export CSV',
{},
null,
release.id,
),
);
await expect(
run(new PublishReleaseCommand('owner', release.id)),
).rejects.toThrow(/unfinished/);
await expect(
run(new PublishReleaseCommand('dev', release.id, true)),
).rejects.toThrow(ForbiddenError);
await run(new SetIssueReleaseCommand('dev', issue.id, null));
await run(new PublishReleaseCommand('owner', release.id));
await expect(
run(new SetIssueReleaseCommand('dev', issue.id, release.id)),
).rejects.toThrow(/locked/);
});
it('lets admins moderate comments', async () => {
const { run, store } = await setup();
const projectId = await projectWithTeam(run);
const issue = await run(
new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Spike', {}),
);
const comment = await run(
new AddCommentCommand('dev', issue.id, 'Decision: use Redis streams'),
);
await expect(
run(new DeleteCommentCommand('qa', comment.id)),
).rejects.toThrow(ForbiddenError);
await run(new DeleteCommentCommand('owner', comment.id));
const [rebuilt] = await store.all(Comment, 'Comment');
expect(rebuilt.deleted).toBe(true);
});
it('detects concurrent modifications through stream positions', async () => {
const { run, rehydrator } = await setup();
const projectId = await projectWithTeam(run);
// Two requests load the same version of the project at the same time...
const first = await rehydrator.rehydrate(projectId, Project);
const second = await rehydrator.rehydrate(projectId, Project);
first.updateDetails('owner', { name: 'Renamed by first' });
second.updateDetails('owner', { name: 'Renamed by second' });
// ...the first one wins, the second is stale.
await first.commitAndWait();
await expect(second.commitAndWait()).rejects.toThrow(ConcurrencyError);
expect(first.version.value).toBe(5);
});
});

View File

@@ -0,0 +1,43 @@
import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs';
import { IssueFactory } from '@task-stream/api/tracker/domain';
import { IssueNumbersRepository } from '../ports/issue-numbers.repository';
import { CommandResult, ack } from './command-result';
import { CreateIssueCommand } from './create-issue.command';
import { WriteAccess } from './write-access';
@CommandHandler(CreateIssueCommand)
export class CreateIssueCommandHandler
implements ICommandHandler<CreateIssueCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly issueFactory: IssueFactory,
private readonly issueNumbers: IssueNumbersRepository,
private readonly eventPublisher: EventPublisher,
) {}
async execute(command: CreateIssueCommand): Promise<CommandResult> {
const project = await this.access.project(command.projectId);
project.assertCanContribute(command.actorId);
if (command.assigneeId)
this.access.assertAssignable(project, command.assigneeId);
if (command.releaseId)
await this.access.openReleaseOf(project, command.releaseId);
const issue = this.issueFactory.create({
...command.details,
projectId: project.id,
projectKey: project.key,
number: await this.issueNumbers.next(project.id),
type: command.type,
title: command.title,
reporterId: command.actorId,
assigneeId: command.assigneeId,
});
if (command.releaseId) issue.setRelease(command.actorId, command.releaseId);
this.eventPublisher.mergeObjectContext(issue);
await issue.commitAndWait();
return ack(issue);
}
}

View File

@@ -0,0 +1,17 @@
import { Command } from '@nestjs/cqrs';
import { IssueDetails, IssueType } from '@task-stream/api/tracker/domain';
import { CommandResult } from './command-result';
export class CreateIssueCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly projectId: string,
public readonly type: IssueType,
public readonly title: string,
public readonly details: Omit<IssueDetails, 'title' | 'type'>,
public readonly assigneeId?: string | null,
public readonly releaseId?: string | null,
) {
super();
}
}

View File

@@ -0,0 +1,30 @@
import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs';
import { ProjectFactory } from '@task-stream/api/tracker/domain';
import { ProjectKeysRepository } from '../ports/project-keys.repository';
import { CommandResult, ack } from './command-result';
import { CreateProjectCommand } from './create-project.command';
@CommandHandler(CreateProjectCommand)
export class CreateProjectCommandHandler
implements ICommandHandler<CreateProjectCommand>
{
constructor(
private readonly projectFactory: ProjectFactory,
private readonly projectKeys: ProjectKeysRepository,
private readonly eventPublisher: EventPublisher,
) {}
async execute(command: CreateProjectCommand): Promise<CommandResult> {
const project = this.projectFactory.create({
key: command.key,
name: command.name,
description: command.description,
ownerId: command.actorId,
});
await this.projectKeys.reserve(project.key, project.id);
this.eventPublisher.mergeObjectContext(project);
await project.commitAndWait();
return ack(project);
}
}

View File

@@ -0,0 +1,13 @@
import { Command } from '@nestjs/cqrs';
import { CommandResult } from './command-result';
export class CreateProjectCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly key: string,
public readonly name: string,
public readonly description?: string | null,
) {
super();
}
}

View File

@@ -0,0 +1,34 @@
import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs';
import { ReleaseFactory } from '@task-stream/api/tracker/domain';
import { ReleaseNamesRepository } from '../ports/release-names.repository';
import { CommandResult, ack } from './command-result';
import { CreateReleaseCommand } from './create-release.command';
import { WriteAccess } from './write-access';
@CommandHandler(CreateReleaseCommand)
export class CreateReleaseCommandHandler
implements ICommandHandler<CreateReleaseCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly releaseFactory: ReleaseFactory,
private readonly releaseNames: ReleaseNamesRepository,
private readonly eventPublisher: EventPublisher,
) {}
async execute(command: CreateReleaseCommand): Promise<CommandResult> {
const project = await this.access.project(command.projectId);
project.assertCanManage(command.actorId);
const release = this.releaseFactory.create(command.actorId, {
projectId: project.id,
name: command.name,
description: command.description,
targetDate: command.targetDate,
});
await this.releaseNames.reserve(project.id, release.name, release.id);
this.eventPublisher.mergeObjectContext(release);
await release.commitAndWait();
return ack(release);
}
}

View File

@@ -0,0 +1,14 @@
import { Command } from '@nestjs/cqrs';
import { CommandResult } from './command-result';
export class CreateReleaseCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly projectId: string,
public readonly name: string,
public readonly description?: string | null,
public readonly targetDate?: string | null,
) {
super();
}
}

View File

@@ -0,0 +1,24 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { ProjectRole } from '@task-stream/api/tracker/domain';
import { CommandResult, ack } from './command-result';
import { DeleteCommentCommand } from './delete-comment.command';
import { WriteAccess } from './write-access';
@CommandHandler(DeleteCommentCommand)
export class DeleteCommentCommandHandler
implements ICommandHandler<DeleteCommentCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: DeleteCommentCommand): Promise<CommandResult> {
const comment = await this.access.comment(command.commentId);
const project = await this.access.project(comment.projectId);
const role = project.roleOf(command.actorId);
comment.delete(
command.actorId,
role === ProjectRole.OWNER || role === ProjectRole.ADMIN,
);
await comment.commitAndWait();
return ack(comment);
}
}

View File

@@ -0,0 +1,11 @@
import { Command } from '@nestjs/cqrs';
import { CommandResult } from './command-result';
export class DeleteCommentCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly commentId: string,
) {
super();
}
}

View File

@@ -0,0 +1,18 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { CommandResult, ack } from './command-result';
import { EditCommentCommand } from './edit-comment.command';
import { WriteAccess } from './write-access';
@CommandHandler(EditCommentCommand)
export class EditCommentCommandHandler
implements ICommandHandler<EditCommentCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: EditCommentCommand): Promise<CommandResult> {
const comment = await this.access.comment(command.commentId);
comment.edit(command.actorId, command.body);
await comment.commitAndWait();
return ack(comment);
}
}

View File

@@ -0,0 +1,12 @@
import { Command } from '@nestjs/cqrs';
import { CommandResult } from './command-result';
export class EditCommentCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly commentId: string,
public readonly body: string,
) {
super();
}
}

View File

@@ -0,0 +1,20 @@
export * from './command-result';
export * from './add-comment.command';
export * from './add-project-member.command';
export * from './archive-release.command';
export * from './assign-issue.command';
export * from './change-issue-status.command';
export * from './change-project-member-role.command';
export * from './create-issue.command';
export * from './create-project.command';
export * from './create-release.command';
export * from './delete-comment.command';
export * from './edit-comment.command';
export * from './link-issues.command';
export * from './publish-release.command';
export * from './remove-project-member.command';
export * from './set-issue-release.command';
export * from './unlink-issues.command';
export * from './update-issue.command';
export * from './update-project.command';
export * from './update-release.command';

View File

@@ -0,0 +1,30 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { CommandResult, ack } from './command-result';
import { LinkIssuesCommand } from './link-issues.command';
import { WriteAccess } from './write-access';
@CommandHandler(LinkIssuesCommand)
export class LinkIssuesCommandHandler
implements ICommandHandler<LinkIssuesCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: LinkIssuesCommand): Promise<CommandResult> {
const { issue } = await this.access.issueForContributor(
command.issueId,
command.actorId,
);
// Links may cross projects (FE ↔ BE ↔ QA), but the actor must see the target.
const target = await this.access.issue(command.targetIssueId);
(await this.access.project(target.projectId)).assertCanView(
command.actorId,
);
issue.linkTo(command.actorId, command.type, {
id: target.id,
key: target.key,
});
await issue.commitAndWait();
return ack(issue);
}
}

View File

@@ -0,0 +1,14 @@
import { Command } from '@nestjs/cqrs';
import { IssueLinkType } from '@task-stream/api/tracker/domain';
import { CommandResult } from './command-result';
export class LinkIssuesCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
public readonly type: IssueLinkType,
public readonly targetIssueId: string,
) {
super();
}
}

View File

@@ -0,0 +1,38 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { IssueWorkflow } from '@task-stream/api/tracker/domain';
import { ReleaseIssuesRepository } from '../ports/release-issues.repository';
import { CommandResult, ack } from './command-result';
import { PublishReleaseCommand } from './publish-release.command';
import { WriteAccess } from './write-access';
@CommandHandler(PublishReleaseCommand)
export class PublishReleaseCommandHandler
implements ICommandHandler<PublishReleaseCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly releaseIssues: ReleaseIssuesRepository,
) {}
async execute(command: PublishReleaseCommand): Promise<CommandResult> {
const release = await this.access.release(command.releaseId);
(await this.access.project(release.projectId)).assertCanManage(
command.actorId,
);
// Rebuilt from the event store (source of truth), not from the read model.
const candidates = await this.releaseIssues.findIssueIds(release.id);
const issues = await Promise.all(
candidates.map((id) => this.access.issue(id)),
);
const unfinished = issues.filter(
(issue) =>
issue.releaseId === release.id &&
!IssueWorkflow.isFinished(issue.status),
).length;
release.ship(command.actorId, unfinished, command.force);
await release.commitAndWait();
return ack(release);
}
}

View File

@@ -0,0 +1,13 @@
import { Command } from '@nestjs/cqrs';
import { CommandResult } from './command-result';
export class PublishReleaseCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly releaseId: string,
/** Ship even with unfinished tickets. */
public readonly force = false,
) {
super();
}
}

View File

@@ -0,0 +1,18 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { CommandResult, ack } from './command-result';
import { RemoveProjectMemberCommand } from './remove-project-member.command';
import { WriteAccess } from './write-access';
@CommandHandler(RemoveProjectMemberCommand)
export class RemoveProjectMemberCommandHandler
implements ICommandHandler<RemoveProjectMemberCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: RemoveProjectMemberCommand): Promise<CommandResult> {
const project = await this.access.project(command.projectId);
project.removeMember(command.actorId, command.userId);
await project.commitAndWait();
return ack(project);
}
}

View File

@@ -0,0 +1,12 @@
import { Command } from '@nestjs/cqrs';
import { CommandResult } from './command-result';
export class RemoveProjectMemberCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly projectId: string,
public readonly userId: string,
) {
super();
}
}

View File

@@ -0,0 +1,26 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { CommandResult, ack } from './command-result';
import { SetIssueReleaseCommand } from './set-issue-release.command';
import { WriteAccess } from './write-access';
@CommandHandler(SetIssueReleaseCommand)
export class SetIssueReleaseCommandHandler
implements ICommandHandler<SetIssueReleaseCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: SetIssueReleaseCommand): Promise<CommandResult> {
const { issue, project } = await this.access.issueForContributor(
command.issueId,
command.actorId,
);
// Neither the old nor the new release may be already shipped.
if (issue.releaseId)
await this.access.openReleaseOf(project, issue.releaseId);
if (command.releaseId)
await this.access.openReleaseOf(project, command.releaseId);
issue.setRelease(command.actorId, command.releaseId);
await issue.commitAndWait();
return ack(issue);
}
}

View File

@@ -0,0 +1,13 @@
import { Command } from '@nestjs/cqrs';
import { CommandResult } from './command-result';
export class SetIssueReleaseCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
/** null takes the ticket out of its release. */
public readonly releaseId: string | null,
) {
super();
}
}

View File

@@ -0,0 +1,21 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { CommandResult, ack } from './command-result';
import { UnlinkIssuesCommand } from './unlink-issues.command';
import { WriteAccess } from './write-access';
@CommandHandler(UnlinkIssuesCommand)
export class UnlinkIssuesCommandHandler
implements ICommandHandler<UnlinkIssuesCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: UnlinkIssuesCommand): Promise<CommandResult> {
const { issue } = await this.access.issueForContributor(
command.issueId,
command.actorId,
);
issue.unlink(command.actorId, command.type, command.targetIssueId);
await issue.commitAndWait();
return ack(issue);
}
}

View File

@@ -0,0 +1,14 @@
import { Command } from '@nestjs/cqrs';
import { IssueLinkType } from '@task-stream/api/tracker/domain';
import { CommandResult } from './command-result';
export class UnlinkIssuesCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
public readonly type: IssueLinkType,
public readonly targetIssueId: string,
) {
super();
}
}

View File

@@ -0,0 +1,21 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { CommandResult, ack } from './command-result';
import { UpdateIssueCommand } from './update-issue.command';
import { WriteAccess } from './write-access';
@CommandHandler(UpdateIssueCommand)
export class UpdateIssueCommandHandler
implements ICommandHandler<UpdateIssueCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: UpdateIssueCommand): Promise<CommandResult> {
const { issue } = await this.access.issueForContributor(
command.issueId,
command.actorId,
);
issue.updateDetails(command.actorId, command.changes);
await issue.commitAndWait();
return ack(issue);
}
}

View File

@@ -0,0 +1,13 @@
import { Command } from '@nestjs/cqrs';
import { IssueDetails } from '@task-stream/api/tracker/domain';
import { CommandResult } from './command-result';
export class UpdateIssueCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
public readonly changes: IssueDetails,
) {
super();
}
}

View File

@@ -0,0 +1,18 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { CommandResult, ack } from './command-result';
import { UpdateProjectCommand } from './update-project.command';
import { WriteAccess } from './write-access';
@CommandHandler(UpdateProjectCommand)
export class UpdateProjectCommandHandler
implements ICommandHandler<UpdateProjectCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: UpdateProjectCommand): Promise<CommandResult> {
const project = await this.access.project(command.projectId);
project.updateDetails(command.actorId, command.changes);
await project.commitAndWait();
return ack(project);
}
}

View File

@@ -0,0 +1,12 @@
import { Command } from '@nestjs/cqrs';
import { CommandResult } from './command-result';
export class UpdateProjectCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly projectId: string,
public readonly changes: { name?: string; description?: string | null },
) {
super();
}
}

View File

@@ -0,0 +1,37 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { ReleaseNamesRepository } from '../ports/release-names.repository';
import { CommandResult, ack } from './command-result';
import { UpdateReleaseCommand } from './update-release.command';
import { WriteAccess } from './write-access';
@CommandHandler(UpdateReleaseCommand)
export class UpdateReleaseCommandHandler
implements ICommandHandler<UpdateReleaseCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly releaseNames: ReleaseNamesRepository,
) {}
async execute(command: UpdateReleaseCommand): Promise<CommandResult> {
const release = await this.access.release(command.releaseId);
(await this.access.project(release.projectId)).assertCanManage(
command.actorId,
);
const previousName = release.name;
release.update(command.actorId, command.changes);
if (release.name !== previousName) {
await this.releaseNames.reserve(
release.projectId,
release.name,
release.id,
);
}
await release.commitAndWait();
if (release.name !== previousName) {
await this.releaseNames.free(release.projectId, previousName);
}
return ack(release);
}
}

View File

@@ -0,0 +1,16 @@
import { Command } from '@nestjs/cqrs';
import { CommandResult } from './command-result';
export class UpdateReleaseCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly releaseId: string,
public readonly changes: {
name?: string;
description?: string | null;
targetDate?: string | null;
},
) {
super();
}
}

View File

@@ -0,0 +1,90 @@
import { Injectable, Type } from '@nestjs/common';
import { AggregateRehydrator } from '@task-stream/api/shared/application';
import {
BusinessRuleError,
NotFoundError,
VersionedAggregateRoot,
} from '@task-stream/api/shared/domain';
import {
Comment,
Issue,
Project,
Release,
} from '@task-stream/api/tracker/domain';
import { UserDirectory } from '../ports/user-directory';
/** Loads aggregates from the event store, failing with domain errors. */
@Injectable()
export class WriteAccess {
constructor(
private readonly rehydrator: AggregateRehydrator,
private readonly users: UserDirectory,
) {}
project(id: string): Promise<Project> {
return this.load(id, Project);
}
issue(id: string): Promise<Issue> {
return this.load(id, Issue);
}
release(id: string): Promise<Release> {
return this.load(id, Release);
}
comment(id: string): Promise<Comment> {
return this.load(id, Comment);
}
/** Issue + its project, checking the actor can modify tickets there. */
async issueForContributor(
issueId: string,
actorId: string,
): Promise<{ issue: Issue; project: Project }> {
const issue = await this.issue(issueId);
const project = await this.project(issue.projectId);
project.assertCanContribute(actorId);
return { issue, project };
}
/** Release of the same project that still accepts tickets. */
async openReleaseOf(project: Project, releaseId: string): Promise<Release> {
const release = await this.release(releaseId);
if (release.projectId !== project.id) {
throw new BusinessRuleError(
`Release ${release.name} belongs to another project`,
);
}
release.assertAcceptsIssues();
return release;
}
async assertKnownActiveUser(
userId: string,
accessToken?: string,
): Promise<void> {
const user = await this.users.findById(userId, accessToken);
if (!user) throw new NotFoundError('User', userId);
if (!user.isActive)
throw new BusinessRuleError(`User ${user.name} is deactivated`);
}
assertAssignable(project: Project, userId: string): void {
if (!project.isMember(userId)) {
throw new BusinessRuleError(
`User ${userId} must be a member of ${project.key} to be assigned`,
);
}
}
private async load<T extends VersionedAggregateRoot & { createdAt?: string }>(
id: string,
AggregateCls: Type<T>,
): Promise<T> {
const aggregate = await this.rehydrator.rehydrate(id, AggregateCls);
// The stream exists but belongs to another kind of aggregate.
if (!aggregate.createdAt) throw new NotFoundError(AggregateCls.name, id);
return aggregate;
}
}