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,9 @@
export * from './lib/ports';
export * from './lib/commands';
export * from './lib/queries';
export * from './lib/projects.service';
export * from './lib/issues.service';
export * from './lib/comments.service';
export * from './lib/releases.service';
export * from './lib/changes.service';
export * from './lib/tracker-application.module';

View File

@@ -0,0 +1,77 @@
import { Injectable } from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs';
import { NotFoundError } from '@task-stream/api/shared/domain';
import { ActivityReadModel } from '@task-stream/api/tracker/domain';
import {
ChangeFeed,
CommentChange,
IssueChange,
ProjectChange,
ReleaseChange,
} from './ports/change-feed';
import { CanViewProjectQuery } from './queries/can-view-project.query';
import { GetIssueQuery } from './queries/get-issue.query';
/** Realtime subscriptions, authorised at subscribe time. */
@Injectable()
export class ChangesService {
constructor(
private readonly queryBus: QueryBus,
private readonly changeFeed: ChangeFeed,
) {}
async watchIssues(
viewerId: string,
projectId: string,
): Promise<AsyncIterableIterator<IssueChange>> {
await this.assertCanView(viewerId, projectId);
return this.changeFeed.issues(projectId);
}
async watchComments(
viewerId: string,
issueId: string,
): Promise<AsyncIterableIterator<CommentChange>> {
await this.queryBus.execute(new GetIssueQuery(viewerId, issueId)); // access check
return this.changeFeed.comments(issueId);
}
async watchActivity(
viewerId: string,
projectId: string,
): Promise<AsyncIterableIterator<ActivityReadModel>> {
await this.assertCanView(viewerId, projectId);
return this.changeFeed.activity(projectId);
}
async watchReleases(
viewerId: string,
projectId: string,
): Promise<AsyncIterableIterator<ReleaseChange>> {
await this.assertCanView(viewerId, projectId);
return this.changeFeed.releases(projectId);
}
async watchProject(
viewerId: string,
projectId: string,
): Promise<AsyncIterableIterator<ProjectChange>> {
await this.assertCanView(viewerId, projectId);
return this.changeFeed.project(projectId);
}
/** Projects the viewer is added to or removed from. */
watchMyProjects(viewerId: string): AsyncIterableIterator<ProjectChange> {
return this.changeFeed.userProjects(viewerId);
}
private async assertCanView(
viewerId: string,
projectId: string,
): Promise<void> {
const allowed = await this.queryBus.execute(
new CanViewProjectQuery(viewerId, projectId),
);
if (!allowed) throw new NotFoundError('Project', projectId);
}
}

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;
}
}

View File

@@ -0,0 +1,50 @@
import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { CommentReadModel } from '@task-stream/api/tracker/domain';
import { AddCommentCommand } from './commands/add-comment.command';
import { CommandResult } from './commands/command-result';
import { DeleteCommentCommand } from './commands/delete-comment.command';
import { EditCommentCommand } from './commands/edit-comment.command';
import { Page } from './ports/find-issues.repository';
import { GetCommentsQuery } from './queries/get-comments.query';
@Injectable()
export class CommentsService {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
add(actorId: string, issueId: string, body: string): Promise<CommandResult> {
return this.commandBus.execute(
new AddCommentCommand(actorId, issueId, body),
);
}
edit(
actorId: string,
commentId: string,
body: string,
): Promise<CommandResult> {
return this.commandBus.execute(
new EditCommentCommand(actorId, commentId, body),
);
}
delete(actorId: string, commentId: string): Promise<CommandResult> {
return this.commandBus.execute(
new DeleteCommentCommand(actorId, commentId),
);
}
list(
viewerId: string,
issueId: string,
limit?: number,
offset?: number,
): Promise<Page<CommentReadModel>> {
return this.queryBus.execute(
new GetCommentsQuery(viewerId, issueId, limit, offset),
);
}
}

View File

@@ -0,0 +1,155 @@
import {
CommentAddedEvent,
CommentDeletedEvent,
CommentEditedEvent,
IssueAssignedEvent,
IssueCreatedEvent,
IssueLinkedEvent,
IssueLinkType,
IssueReleaseChangedEvent,
IssueStatus,
IssueStatusChangedEvent,
IssueType,
IssueUnlinkedEvent,
IssueUpdatedEvent,
ProjectCreatedEvent,
ProjectMemberAddedEvent,
ProjectMemberRemovedEvent,
ProjectMemberRoleChangedEvent,
ProjectRole,
ProjectUpdatedEvent,
ReleaseArchivedEvent,
ReleaseCreatedEvent,
ReleaseShippedEvent,
ReleaseUpdatedEvent,
TrackerEvent,
} from '@task-stream/api/tracker/domain';
export interface SummaryContext {
actor: string;
user: (id: string | null) => string;
issueKey: string;
projectKey: string;
releaseName: string;
}
/* The activity feed is read by people: Spanish, with the same labels as the web app. */
const STATUS: Record<IssueStatus, string> = {
[IssueStatus.BACKLOG]: 'Backlog',
[IssueStatus.TODO]: 'Por hacer',
[IssueStatus.IN_PROGRESS]: 'En curso',
[IssueStatus.IN_REVIEW]: 'En revisión',
[IssueStatus.READY_FOR_QA]: 'Listo para QA',
[IssueStatus.IN_QA]: 'En QA',
[IssueStatus.QA_FAILED]: 'QA fallido',
[IssueStatus.DONE]: 'Hecho',
[IssueStatus.CANCELLED]: 'Cancelado',
};
const TYPE: Record<IssueType, string> = {
[IssueType.BUG]: 'bug',
[IssueType.FEATURE]: 'feature',
[IssueType.UI_CHANGE]: 'cambio de UI',
[IssueType.TASK]: 'tarea',
[IssueType.QA]: 'QA',
};
const LINK: Record<IssueLinkType, string> = {
[IssueLinkType.BLOCKS]: 'bloquea a',
[IssueLinkType.DEPENDS_ON]: 'depende de',
[IssueLinkType.TESTS]: 'prueba',
[IssueLinkType.DUPLICATES]: 'duplica a',
[IssueLinkType.RELATES_TO]: 'está relacionado con',
};
const ROLE: Record<ProjectRole, string> = {
[ProjectRole.OWNER]: 'propietario',
[ProjectRole.ADMIN]: 'admin',
[ProjectRole.MEMBER]: 'miembro',
[ProjectRole.VIEWER]: 'lector',
};
const FIELD: Record<string, string> = {
title: 'el título',
description: 'la descripción',
type: 'el tipo',
area: 'el área',
priority: 'la prioridad',
labels: 'las etiquetas',
figmaUrl: 'el enlace de Figma',
storyPoints: 'los puntos',
dueDate: 'la fecha límite',
};
const list = (items: string[]) =>
items.length <= 1
? items.join('')
: `${items.slice(0, -1).join(', ')} y ${items[items.length - 1]}`;
/** One human-readable line per event, for the project activity feed. */
export function describeEvent(
event: TrackerEvent,
ctx: SummaryContext,
): string {
const { actor, issueKey } = ctx;
if (event instanceof ProjectCreatedEvent)
return `${actor} creó el proyecto ${event.project.key}`;
if (event instanceof ProjectUpdatedEvent)
return `${actor} actualizó el proyecto ${ctx.projectKey}`;
if (event instanceof ProjectMemberAddedEvent) {
return `${actor} añadió a ${ctx.user(event.userId)} como ${ROLE[event.role]}`;
}
if (event instanceof ProjectMemberRemovedEvent) {
return event.userId === event.actorId
? `${actor} salió del proyecto`
: `${actor} quitó a ${ctx.user(event.userId)} del proyecto`;
}
if (event instanceof ProjectMemberRoleChangedEvent) {
return `${actor} cambió el rol de ${ctx.user(event.userId)} a ${ROLE[event.role]}`;
}
if (event instanceof IssueCreatedEvent) {
return `${actor} creó ${event.issue.key} (${TYPE[event.issue.type]}): ${event.issue.title}`;
}
if (event instanceof IssueUpdatedEvent) {
return `${actor} cambió ${list(Object.keys(event.changes).map((f) => FIELD[f] ?? f))} de ${issueKey}`;
}
if (event instanceof IssueStatusChangedEvent) {
return `${actor} movió ${issueKey} de ${STATUS[event.from]} a ${STATUS[event.to]}${event.reason ? `: ${event.reason}` : ''}`;
}
if (event instanceof IssueAssignedEvent) {
return event.assigneeId
? `${actor} asignó ${issueKey} a ${ctx.user(event.assigneeId)}`
: `${actor} dejó ${issueKey} sin asignar`;
}
if (event instanceof IssueLinkedEvent) {
return `${actor} indicó que ${issueKey} ${LINK[event.link.type]} ${event.link.targetKey}`;
}
if (event instanceof IssueUnlinkedEvent) {
return `${actor} quitó la relación entre ${issueKey} y ${event.link.targetKey}`;
}
if (event instanceof IssueReleaseChangedEvent) {
return event.releaseId
? `${actor} metió ${issueKey} en la release ${ctx.releaseName}`
: `${actor} sacó ${issueKey} de su release`;
}
if (event instanceof CommentAddedEvent)
return `${actor} comentó en ${issueKey}`;
if (event instanceof CommentEditedEvent)
return `${actor} editó un comentario en ${issueKey}`;
if (event instanceof CommentDeletedEvent)
return `${actor} borró un comentario en ${issueKey}`;
if (event instanceof ReleaseCreatedEvent)
return `${actor} creó la release ${event.release.name}`;
if (event instanceof ReleaseUpdatedEvent)
return `${actor} actualizó la release ${ctx.releaseName}`;
if (event instanceof ReleaseShippedEvent)
return `${actor} publicó la release ${ctx.releaseName} 🚀`;
if (event instanceof ReleaseArchivedEvent)
return `${actor} archivó la release ${ctx.releaseName}`;
return `${actor}: ${event.constructor.name}`;
}

View File

@@ -0,0 +1,123 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import {
ActivityReadModel,
CommentAddedEvent,
CommentDeletedEvent,
CommentEditedEvent,
IssueAssignedEvent,
IssueCreatedEvent,
IssueLinkedEvent,
IssueReleaseChangedEvent,
IssueStatusChangedEvent,
IssueUnlinkedEvent,
IssueUpdatedEvent,
ProjectCreatedEvent,
ProjectMemberAddedEvent,
ProjectMemberRemovedEvent,
ProjectMemberRoleChangedEvent,
ProjectUpdatedEvent,
ReleaseArchivedEvent,
ReleaseCreatedEvent,
ReleaseShippedEvent,
ReleaseUpdatedEvent,
TrackerEvent,
} from '@task-stream/api/tracker/domain';
import { ChangeFeed } from '../ports/change-feed';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { FindReleasesRepository } from '../ports/find-releases.repository';
import { UpsertMaterializedActivityRepository } from '../ports/upsert-materialized-activity.repository';
import { UserDirectory } from '../ports/user-directory';
import { describeEvent } from './activity-summary';
import { versionOf } from './read-model-notifier';
/** Project activity feed / ticket history: one entry per domain event. */
@EventsHandler(
ProjectCreatedEvent,
ProjectUpdatedEvent,
ProjectMemberAddedEvent,
ProjectMemberRemovedEvent,
ProjectMemberRoleChangedEvent,
IssueCreatedEvent,
IssueUpdatedEvent,
IssueStatusChangedEvent,
IssueAssignedEvent,
IssueLinkedEvent,
IssueUnlinkedEvent,
IssueReleaseChangedEvent,
CommentAddedEvent,
CommentEditedEvent,
CommentDeletedEvent,
ReleaseCreatedEvent,
ReleaseUpdatedEvent,
ReleaseShippedEvent,
ReleaseArchivedEvent,
)
export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
constructor(
private readonly upsertMaterializedActivityRepository: UpsertMaterializedActivityRepository,
private readonly findProjects: FindProjectsRepository,
private readonly findIssues: FindIssuesRepository,
private readonly findReleases: FindReleasesRepository,
private readonly users: UserDirectory,
private readonly changeFeed: ChangeFeed,
) {}
async handle(event: TrackerEvent) {
const issueId = this.issueIdOf(event);
const issueKey =
event instanceof IssueCreatedEvent
? event.issue.key
: issueId
? ((await this.findIssues.findById(issueId))?.key ?? null)
: null;
const releaseId = this.releaseIdOf(event);
const release = releaseId
? await this.findReleases.findById(releaseId)
: null;
const project = await this.findProjects.findById(event.projectId);
const mentioned = [
event.actorId,
(event as { userId?: string }).userId,
(event as { assigneeId?: string }).assigneeId,
];
const users = await this.users.refs(mentioned);
const activity: ActivityReadModel = {
id: `${event.aggregateId}:${versionOf(event)}`,
type: event.constructor.name,
projectId: event.projectId,
issueId,
issueKey,
actor: users.of(event.actorId),
summary: describeEvent(event, {
actor: users.of(event.actorId).name,
user: (id) => users.ofNullable(id)?.name ?? '?',
issueKey: issueKey ?? '',
projectKey: project?.key ?? '',
releaseName: release?.name ?? '',
}),
occurredAt: event.occurredAt,
};
if (await this.upsertMaterializedActivityRepository.insert(activity)) {
await this.changeFeed.publishActivity(activity);
}
}
private issueIdOf(event: TrackerEvent): string | null {
if (event.constructor.name.startsWith('Issue')) return event.aggregateId;
if (event instanceof CommentAddedEvent) return event.comment.issueId;
if (
event instanceof CommentEditedEvent ||
event instanceof CommentDeletedEvent
)
return event.issueId;
return null;
}
private releaseIdOf(event: TrackerEvent): string | null {
if (event.constructor.name.startsWith('Release')) return event.aggregateId;
if (event instanceof IssueReleaseChangedEvent) return event.releaseId;
return null;
}
}

View File

@@ -0,0 +1,42 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { CommentAddedEvent } from '@task-stream/api/tracker/domain';
import { FindCommentsRepository } from '../ports/find-comments.repository';
import { UpsertMaterializedCommentRepository } from '../ports/upsert-materialized-comment.repository';
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
import { UserDirectory } from '../ports/user-directory';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@EventsHandler(CommentAddedEvent)
export class CommentAddedEventHandler
implements IEventHandler<CommentAddedEvent>
{
constructor(
private readonly upsertMaterializedCommentRepository: UpsertMaterializedCommentRepository,
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
private readonly findComments: FindCommentsRepository,
private readonly users: UserDirectory,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: CommentAddedEvent) {
const { comment } = event;
const users = await this.users.refs([comment.authorId]);
await this.upsertMaterializedCommentRepository.upsert({
id: comment.id,
issueId: comment.issueId,
projectId: comment.projectId,
author: users.of(comment.authorId),
body: comment.body,
createdAt: comment.createdAt,
editedAt: null,
version: versionOf(event),
});
// Recount instead of incrementing, so redelivery stays idempotent.
await this.upsertMaterializedIssueRepository.setCommentCount(
comment.issueId,
await this.findComments.countByIssue(comment.issueId),
);
await this.notifier.comment(comment.id, comment.issueId, event);
await this.notifier.issue(comment.issueId, event);
}
}

View File

@@ -0,0 +1,28 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { CommentDeletedEvent } from '@task-stream/api/tracker/domain';
import { FindCommentsRepository } from '../ports/find-comments.repository';
import { UpsertMaterializedCommentRepository } from '../ports/upsert-materialized-comment.repository';
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
import { ReadModelNotifier } from './read-model-notifier';
@EventsHandler(CommentDeletedEvent)
export class CommentDeletedEventHandler
implements IEventHandler<CommentDeletedEvent>
{
constructor(
private readonly upsertMaterializedCommentRepository: UpsertMaterializedCommentRepository,
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
private readonly findComments: FindCommentsRepository,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: CommentDeletedEvent) {
await this.upsertMaterializedCommentRepository.remove(event.aggregateId);
await this.upsertMaterializedIssueRepository.setCommentCount(
event.issueId,
await this.findComments.countByIssue(event.issueId),
);
await this.notifier.comment(event.aggregateId, event.issueId, event);
await this.notifier.issue(event.issueId, event);
}
}

View File

@@ -0,0 +1,24 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { CommentEditedEvent } from '@task-stream/api/tracker/domain';
import { UpsertMaterializedCommentRepository } from '../ports/upsert-materialized-comment.repository';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@EventsHandler(CommentEditedEvent)
export class CommentEditedEventHandler
implements IEventHandler<CommentEditedEvent>
{
constructor(
private readonly upsertMaterializedCommentRepository: UpsertMaterializedCommentRepository,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: CommentEditedEvent) {
await this.upsertMaterializedCommentRepository.upsert({
id: event.aggregateId,
body: event.body,
editedAt: event.occurredAt,
version: versionOf(event),
});
await this.notifier.comment(event.aggregateId, event.issueId, event);
}
}

View File

@@ -0,0 +1,47 @@
import { ActivityEventHandler } from './activity.event-handler';
import { CommentAddedEventHandler } from './comment-added.event-handler';
import { CommentDeletedEventHandler } from './comment-deleted.event-handler';
import { CommentEditedEventHandler } from './comment-edited.event-handler';
import { IssueAssignedEventHandler } from './issue-assigned.event-handler';
import { IssueCreatedEventHandler } from './issue-created.event-handler';
import { IssueLinkedEventHandler } from './issue-linked.event-handler';
import { IssueReleaseChangedEventHandler } from './issue-release-changed.event-handler';
import { IssueStatusChangedEventHandler } from './issue-status-changed.event-handler';
import { IssueUnlinkedEventHandler } from './issue-unlinked.event-handler';
import { IssueUpdatedEventHandler } from './issue-updated.event-handler';
import { ProjectCreatedEventHandler } from './project-created.event-handler';
import { ProjectMemberAddedEventHandler } from './project-member-added.event-handler';
import { ProjectMemberRemovedEventHandler } from './project-member-removed.event-handler';
import { ProjectMemberRoleChangedEventHandler } from './project-member-role-changed.event-handler';
import { ProjectUpdatedEventHandler } from './project-updated.event-handler';
import { ReleaseArchivedEventHandler } from './release-archived.event-handler';
import { ReleaseCreatedEventHandler } from './release-created.event-handler';
import { ReleaseShippedEventHandler } from './release-shipped.event-handler';
import { ReleaseUpdatedEventHandler } from './release-updated.event-handler';
import { ReadModelNotifier } from './read-model-notifier';
/** Read-model projections (MongoDB) + realtime, fed by the EventsBridge. */
export const EVENT_HANDLERS = [
ActivityEventHandler,
CommentAddedEventHandler,
CommentDeletedEventHandler,
CommentEditedEventHandler,
IssueAssignedEventHandler,
IssueCreatedEventHandler,
IssueLinkedEventHandler,
IssueReleaseChangedEventHandler,
IssueStatusChangedEventHandler,
IssueUnlinkedEventHandler,
IssueUpdatedEventHandler,
ProjectCreatedEventHandler,
ProjectMemberAddedEventHandler,
ProjectMemberRemovedEventHandler,
ProjectMemberRoleChangedEventHandler,
ProjectUpdatedEventHandler,
ReleaseArchivedEventHandler,
ReleaseCreatedEventHandler,
ReleaseShippedEventHandler,
ReleaseUpdatedEventHandler,
];
export { ReadModelNotifier };

View File

@@ -0,0 +1,27 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { IssueAssignedEvent } from '@task-stream/api/tracker/domain';
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
import { UserDirectory } from '../ports/user-directory';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@EventsHandler(IssueAssignedEvent)
export class IssueAssignedEventHandler
implements IEventHandler<IssueAssignedEvent>
{
constructor(
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
private readonly users: UserDirectory,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: IssueAssignedEvent) {
const users = await this.users.refs([event.assigneeId]);
await this.upsertMaterializedIssueRepository.upsert({
id: event.aggregateId,
assignee: users.ofNullable(event.assigneeId),
updatedAt: event.occurredAt,
version: versionOf(event),
});
await this.notifier.issue(event.aggregateId, event);
}
}

View File

@@ -0,0 +1,50 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { IssueCreatedEvent } from '@task-stream/api/tracker/domain';
import { FindReleasesRepository } from '../ports/find-releases.repository';
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
import { UserDirectory } from '../ports/user-directory';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@EventsHandler(IssueCreatedEvent)
export class IssueCreatedEventHandler
implements IEventHandler<IssueCreatedEvent>
{
constructor(
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
private readonly findReleases: FindReleasesRepository,
private readonly users: UserDirectory,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: IssueCreatedEvent) {
const { issue } = event;
const users = await this.users.refs([issue.reporterId, issue.assigneeId]);
const release = issue.releaseId
? await this.findReleases.findById(issue.releaseId)
: null;
await this.upsertMaterializedIssueRepository.upsert({
id: issue.id,
projectId: issue.projectId,
projectKey: issue.key.slice(0, issue.key.lastIndexOf('-')),
number: issue.number,
key: issue.key,
type: issue.type,
area: issue.area,
title: issue.title,
description: issue.description,
status: issue.status,
priority: issue.priority,
reporter: users.of(issue.reporterId),
assignee: users.ofNullable(issue.assigneeId),
release: release ? { id: release.id, name: release.name } : null,
labels: issue.labels,
figmaUrl: issue.figmaUrl,
storyPoints: issue.storyPoints,
dueDate: issue.dueDate,
createdAt: issue.createdAt,
updatedAt: issue.updatedAt,
version: versionOf(event),
});
await this.notifier.issue(issue.id, event);
}
}

View File

@@ -0,0 +1,42 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { IssueLinkedEvent } from '@task-stream/api/tracker/domain';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@EventsHandler(IssueLinkedEvent)
export class IssueLinkedEventHandler
implements IEventHandler<IssueLinkedEvent>
{
constructor(
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
private readonly findIssues: FindIssuesRepository,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: IssueLinkedEvent) {
const source = await this.findIssues.findById(event.aggregateId);
if (!source) return;
const { type, targetIssueId, targetKey } = event.link;
await this.upsertMaterializedIssueRepository.upsert({
id: event.aggregateId,
updatedAt: event.occurredAt,
version: versionOf(event),
});
// Both sides show the relation: TS-1 DEPENDS_ON BE-3 / BE-3 is depended on by TS-1.
await this.upsertMaterializedIssueRepository.addLink(event.aggregateId, {
type,
direction: 'OUTWARD',
issueId: targetIssueId,
issueKey: targetKey,
});
await this.upsertMaterializedIssueRepository.addLink(targetIssueId, {
type,
direction: 'INWARD',
issueId: source.id,
issueKey: source.key,
});
await this.notifier.issue(event.aggregateId, event);
await this.notifier.issue(targetIssueId, event);
}
}

View File

@@ -0,0 +1,31 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { IssueReleaseChangedEvent } from '@task-stream/api/tracker/domain';
import { FindReleasesRepository } from '../ports/find-releases.repository';
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@EventsHandler(IssueReleaseChangedEvent)
export class IssueReleaseChangedEventHandler
implements IEventHandler<IssueReleaseChangedEvent>
{
constructor(
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
private readonly findReleases: FindReleasesRepository,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: IssueReleaseChangedEvent) {
const release = event.releaseId
? await this.findReleases.findById(event.releaseId)
: null;
await this.upsertMaterializedIssueRepository.upsert({
id: event.aggregateId,
release: event.releaseId
? { id: event.releaseId, name: release?.name ?? '' }
: null,
updatedAt: event.occurredAt,
version: versionOf(event),
});
await this.notifier.issue(event.aggregateId, event);
}
}

View File

@@ -0,0 +1,24 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { IssueStatusChangedEvent } from '@task-stream/api/tracker/domain';
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@EventsHandler(IssueStatusChangedEvent)
export class IssueStatusChangedEventHandler
implements IEventHandler<IssueStatusChangedEvent>
{
constructor(
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: IssueStatusChangedEvent) {
await this.upsertMaterializedIssueRepository.upsert({
id: event.aggregateId,
status: event.to,
updatedAt: event.occurredAt,
version: versionOf(event),
});
await this.notifier.issue(event.aggregateId, event);
}
}

View File

@@ -0,0 +1,35 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { IssueUnlinkedEvent } from '@task-stream/api/tracker/domain';
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@EventsHandler(IssueUnlinkedEvent)
export class IssueUnlinkedEventHandler
implements IEventHandler<IssueUnlinkedEvent>
{
constructor(
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: IssueUnlinkedEvent) {
const { type, targetIssueId } = event.link;
await this.upsertMaterializedIssueRepository.upsert({
id: event.aggregateId,
updatedAt: event.occurredAt,
version: versionOf(event),
});
await this.upsertMaterializedIssueRepository.removeLink(event.aggregateId, {
type,
direction: 'OUTWARD',
issueId: targetIssueId,
});
await this.upsertMaterializedIssueRepository.removeLink(targetIssueId, {
type,
direction: 'INWARD',
issueId: event.aggregateId,
});
await this.notifier.issue(event.aggregateId, event);
await this.notifier.issue(targetIssueId, event);
}
}

View File

@@ -0,0 +1,24 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { IssueUpdatedEvent } from '@task-stream/api/tracker/domain';
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@EventsHandler(IssueUpdatedEvent)
export class IssueUpdatedEventHandler
implements IEventHandler<IssueUpdatedEvent>
{
constructor(
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: IssueUpdatedEvent) {
await this.upsertMaterializedIssueRepository.upsert({
id: event.aggregateId,
...event.changes,
updatedAt: event.occurredAt,
version: versionOf(event),
});
await this.notifier.issue(event.aggregateId, event);
}
}

View File

@@ -0,0 +1,36 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { ProjectCreatedEvent } from '@task-stream/api/tracker/domain';
import { UpsertMaterializedProjectRepository } from '../ports/upsert-materialized-project.repository';
import { UserDirectory } from '../ports/user-directory';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@EventsHandler(ProjectCreatedEvent)
export class ProjectCreatedEventHandler
implements IEventHandler<ProjectCreatedEvent>
{
constructor(
private readonly upsertMaterializedProjectRepository: UpsertMaterializedProjectRepository,
private readonly users: UserDirectory,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: ProjectCreatedEvent) {
const { project } = event;
const users = await this.users.refs(project.members.map((m) => m.userId));
await this.upsertMaterializedProjectRepository.upsert({
id: project.id,
key: project.key,
name: project.name,
description: project.description,
owner: users.of(project.ownerId),
members: project.members.map((m) => ({
user: users.of(m.userId),
role: m.role,
})),
createdAt: project.createdAt,
updatedAt: project.updatedAt,
version: versionOf(event),
});
await this.notifier.project(event, [project.ownerId]);
}
}

View File

@@ -0,0 +1,32 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { ProjectMemberAddedEvent } from '@task-stream/api/tracker/domain';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { UpsertMaterializedProjectRepository } from '../ports/upsert-materialized-project.repository';
import { UserDirectory } from '../ports/user-directory';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@EventsHandler(ProjectMemberAddedEvent)
export class ProjectMemberAddedEventHandler
implements IEventHandler<ProjectMemberAddedEvent>
{
constructor(
private readonly upsertMaterializedProjectRepository: UpsertMaterializedProjectRepository,
private readonly findProjects: FindProjectsRepository,
private readonly users: UserDirectory,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: ProjectMemberAddedEvent) {
const project = await this.findProjects.findById(event.projectId);
if (!project) return;
const users = await this.users.refs([event.userId]);
const others = project.members.filter((m) => m.user.id !== event.userId);
await this.upsertMaterializedProjectRepository.upsert({
id: event.projectId,
members: [...others, { user: users.of(event.userId), role: event.role }],
updatedAt: event.occurredAt,
version: versionOf(event),
});
await this.notifier.project(event, [event.userId]);
}
}

View File

@@ -0,0 +1,28 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { ProjectMemberRemovedEvent } from '@task-stream/api/tracker/domain';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { UpsertMaterializedProjectRepository } from '../ports/upsert-materialized-project.repository';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@EventsHandler(ProjectMemberRemovedEvent)
export class ProjectMemberRemovedEventHandler
implements IEventHandler<ProjectMemberRemovedEvent>
{
constructor(
private readonly upsertMaterializedProjectRepository: UpsertMaterializedProjectRepository,
private readonly findProjects: FindProjectsRepository,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: ProjectMemberRemovedEvent) {
const project = await this.findProjects.findById(event.projectId);
if (!project) return;
await this.upsertMaterializedProjectRepository.upsert({
id: event.projectId,
members: project.members.filter((m) => m.user.id !== event.userId),
updatedAt: event.occurredAt,
version: versionOf(event),
});
await this.notifier.project(event, [event.userId]);
}
}

View File

@@ -0,0 +1,30 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { ProjectMemberRoleChangedEvent } from '@task-stream/api/tracker/domain';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { UpsertMaterializedProjectRepository } from '../ports/upsert-materialized-project.repository';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@EventsHandler(ProjectMemberRoleChangedEvent)
export class ProjectMemberRoleChangedEventHandler
implements IEventHandler<ProjectMemberRoleChangedEvent>
{
constructor(
private readonly upsertMaterializedProjectRepository: UpsertMaterializedProjectRepository,
private readonly findProjects: FindProjectsRepository,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: ProjectMemberRoleChangedEvent) {
const project = await this.findProjects.findById(event.projectId);
if (!project) return;
await this.upsertMaterializedProjectRepository.upsert({
id: event.projectId,
members: project.members.map((m) =>
m.user.id === event.userId ? { ...m, role: event.role } : m,
),
updatedAt: event.occurredAt,
version: versionOf(event),
});
await this.notifier.project(event);
}
}

View File

@@ -0,0 +1,24 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { ProjectUpdatedEvent } from '@task-stream/api/tracker/domain';
import { UpsertMaterializedProjectRepository } from '../ports/upsert-materialized-project.repository';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@EventsHandler(ProjectUpdatedEvent)
export class ProjectUpdatedEventHandler
implements IEventHandler<ProjectUpdatedEvent>
{
constructor(
private readonly upsertMaterializedProjectRepository: UpsertMaterializedProjectRepository,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: ProjectUpdatedEvent) {
await this.upsertMaterializedProjectRepository.upsert({
id: event.projectId,
...event.changes,
updatedAt: event.occurredAt,
version: versionOf(event),
});
await this.notifier.project(event);
}
}

View File

@@ -0,0 +1,73 @@
import { Injectable } from '@nestjs/common';
import { TrackerEvent } from '@task-stream/api/tracker/domain';
import { ChangeFeed } from '../ports/change-feed';
import { FindCommentsRepository } from '../ports/find-comments.repository';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { FindReleasesRepository } from '../ports/find-releases.repository';
/** Position of the event in its stream (stamped by the event store). */
export function versionOf(event: TrackerEvent): number {
return event.aggregateVersion ?? 0;
}
/**
* Pushes the fresh read model to realtime subscribers, after a projection
* updated it.
*/
@Injectable()
export class ReadModelNotifier {
constructor(
private readonly changeFeed: ChangeFeed,
private readonly findProjects: FindProjectsRepository,
private readonly findIssues: FindIssuesRepository,
private readonly findComments: FindCommentsRepository,
private readonly findReleases: FindReleasesRepository,
) {}
async project(
event: TrackerEvent,
notifyUserIds: string[] = [],
): Promise<void> {
const project = await this.findProjects.findById(event.projectId);
if (!project) return;
await this.changeFeed.publishProject(
{ ...this.meta(event), project },
notifyUserIds,
);
}
async issue(issueId: string, event: TrackerEvent): Promise<void> {
const issue = await this.findIssues.findById(issueId);
if (!issue) return;
await this.changeFeed.publishIssue({ ...this.meta(event), issue });
}
async comment(
commentId: string,
issueId: string,
event: TrackerEvent,
): Promise<void> {
const comment = await this.findComments.findById(commentId);
await this.changeFeed.publishComment({
...this.meta(event),
issueId,
commentId,
comment,
});
}
async release(releaseId: string, event: TrackerEvent): Promise<void> {
const release = await this.findReleases.findById(releaseId);
if (!release) return;
await this.changeFeed.publishRelease({ ...this.meta(event), release });
}
private meta(event: TrackerEvent) {
return {
action: event.constructor.name,
version: versionOf(event),
actorId: event.actorId,
};
}
}

View File

@@ -0,0 +1,26 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import {
ReleaseArchivedEvent,
ReleaseStatus,
} from '@task-stream/api/tracker/domain';
import { UpsertMaterializedReleaseRepository } from '../ports/upsert-materialized-release.repository';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@EventsHandler(ReleaseArchivedEvent)
export class ReleaseArchivedEventHandler
implements IEventHandler<ReleaseArchivedEvent>
{
constructor(
private readonly upsertMaterializedReleaseRepository: UpsertMaterializedReleaseRepository,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: ReleaseArchivedEvent) {
await this.upsertMaterializedReleaseRepository.upsert({
id: event.aggregateId,
status: ReleaseStatus.ARCHIVED,
version: versionOf(event),
});
await this.notifier.release(event.aggregateId, event);
}
}

View File

@@ -0,0 +1,23 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { ReleaseCreatedEvent } from '@task-stream/api/tracker/domain';
import { UpsertMaterializedReleaseRepository } from '../ports/upsert-materialized-release.repository';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@EventsHandler(ReleaseCreatedEvent)
export class ReleaseCreatedEventHandler
implements IEventHandler<ReleaseCreatedEvent>
{
constructor(
private readonly upsertMaterializedReleaseRepository: UpsertMaterializedReleaseRepository,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: ReleaseCreatedEvent) {
const { release } = event;
await this.upsertMaterializedReleaseRepository.upsert({
...release,
version: versionOf(event),
});
await this.notifier.release(release.id, event);
}
}

View File

@@ -0,0 +1,27 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import {
ReleaseShippedEvent,
ReleaseStatus,
} from '@task-stream/api/tracker/domain';
import { UpsertMaterializedReleaseRepository } from '../ports/upsert-materialized-release.repository';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@EventsHandler(ReleaseShippedEvent)
export class ReleaseShippedEventHandler
implements IEventHandler<ReleaseShippedEvent>
{
constructor(
private readonly upsertMaterializedReleaseRepository: UpsertMaterializedReleaseRepository,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: ReleaseShippedEvent) {
await this.upsertMaterializedReleaseRepository.upsert({
id: event.aggregateId,
status: ReleaseStatus.RELEASED,
releasedAt: event.occurredAt,
version: versionOf(event),
});
await this.notifier.release(event.aggregateId, event);
}
}

View File

@@ -0,0 +1,31 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { ReleaseUpdatedEvent } from '@task-stream/api/tracker/domain';
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
import { UpsertMaterializedReleaseRepository } from '../ports/upsert-materialized-release.repository';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@EventsHandler(ReleaseUpdatedEvent)
export class ReleaseUpdatedEventHandler
implements IEventHandler<ReleaseUpdatedEvent>
{
constructor(
private readonly upsertMaterializedReleaseRepository: UpsertMaterializedReleaseRepository,
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: ReleaseUpdatedEvent) {
await this.upsertMaterializedReleaseRepository.upsert({
id: event.aggregateId,
...event.changes,
version: versionOf(event),
});
if (event.changes.name) {
await this.upsertMaterializedIssueRepository.renameRelease(
event.aggregateId,
event.changes.name,
);
}
await this.notifier.release(event.aggregateId, event);
}
}

View File

@@ -0,0 +1,157 @@
import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import {
ActivityReadModel,
IssueDetails,
IssueLinkType,
IssueReadModel,
IssueStatus,
IssueType,
} from '@task-stream/api/tracker/domain';
import { AssignIssueCommand } from './commands/assign-issue.command';
import { ChangeIssueStatusCommand } from './commands/change-issue-status.command';
import { CommandResult } from './commands/command-result';
import { CreateIssueCommand } from './commands/create-issue.command';
import { LinkIssuesCommand } from './commands/link-issues.command';
import { SetIssueReleaseCommand } from './commands/set-issue-release.command';
import { UnlinkIssuesCommand } from './commands/unlink-issues.command';
import { UpdateIssueCommand } from './commands/update-issue.command';
import { IssueFilter, Page } from './ports/find-issues.repository';
import { GetActivityQuery } from './queries/get-activity.query';
import { GetIssueQuery } from './queries/get-issue.query';
import { GetIssuesByIdsQuery } from './queries/get-issues-by-ids.query';
import { SearchIssuesQuery } from './queries/search-issues.query';
export interface CreateIssueInput extends Omit<IssueDetails, 'title' | 'type'> {
projectId: string;
type: IssueType;
title: string;
assigneeId?: string | null;
releaseId?: string | null;
}
export interface IssueLinkInput {
issueId: string;
type: IssueLinkType;
targetIssueId: string;
}
@Injectable()
export class IssuesService {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
create(actorId: string, input: CreateIssueInput): Promise<CommandResult> {
const { projectId, type, title, assigneeId, releaseId, ...details } = input;
return this.commandBus.execute(
new CreateIssueCommand(
actorId,
projectId,
type,
title,
details,
assigneeId,
releaseId,
),
);
}
update(
actorId: string,
issueId: string,
changes: IssueDetails,
): Promise<CommandResult> {
return this.commandBus.execute(
new UpdateIssueCommand(actorId, issueId, changes),
);
}
changeStatus(
actorId: string,
issueId: string,
status: IssueStatus,
reason?: string | null,
): Promise<CommandResult> {
return this.commandBus.execute(
new ChangeIssueStatusCommand(actorId, issueId, status, reason),
);
}
/** @param assigneeId null to unassign */
assign(
actorId: string,
issueId: string,
assigneeId: string | null,
): Promise<CommandResult> {
return this.commandBus.execute(
new AssignIssueCommand(actorId, issueId, assigneeId),
);
}
link(actorId: string, input: IssueLinkInput): Promise<CommandResult> {
return this.commandBus.execute(
new LinkIssuesCommand(
actorId,
input.issueId,
input.type,
input.targetIssueId,
),
);
}
unlink(actorId: string, input: IssueLinkInput): Promise<CommandResult> {
return this.commandBus.execute(
new UnlinkIssuesCommand(
actorId,
input.issueId,
input.type,
input.targetIssueId,
),
);
}
/** @param releaseId null to take the ticket out of its release */
setRelease(
actorId: string,
issueId: string,
releaseId: string | null,
): Promise<CommandResult> {
return this.commandBus.execute(
new SetIssueReleaseCommand(actorId, issueId, releaseId),
);
}
findOne(viewerId: string, idOrKey: string): Promise<IssueReadModel> {
return this.queryBus.execute(new GetIssueQuery(viewerId, idOrKey));
}
findMany(viewerId: string, ids: string[]): Promise<IssueReadModel[]> {
return this.queryBus.execute(new GetIssuesByIdsQuery(viewerId, ids));
}
search(viewerId: string, filter: IssueFilter): Promise<Page<IssueReadModel>> {
return this.queryBus.execute(new SearchIssuesQuery(viewerId, filter));
}
activity(
viewerId: string,
params: {
projectId: string;
issueId?: string;
limit?: number;
before?: string;
},
): Promise<ActivityReadModel[]> {
return this.queryBus.execute(
new GetActivityQuery(
viewerId,
params.projectId,
params.issueId,
params.limit,
params.before,
),
);
}
}

View File

@@ -0,0 +1,59 @@
import {
ActivityReadModel,
CommentReadModel,
IssueReadModel,
ProjectReadModel,
ReleaseReadModel,
} from '@task-stream/api/tracker/domain';
/** Every realtime message says which event caused it and the resulting version. */
export interface Change {
/** Event class name, e.g. `IssueStatusChangedEvent`. */
action: string;
version: number;
actorId: string;
}
export interface IssueChange extends Change {
issue: IssueReadModel;
}
export interface CommentChange extends Change {
issueId: string;
commentId: string;
/** Null when the comment was deleted. */
comment: CommentReadModel | null;
}
export interface ReleaseChange extends Change {
release: ReleaseReadModel;
}
export interface ProjectChange extends Change {
project: ProjectReadModel;
}
/**
* Live stream of read-model changes: event handlers publish once the read
* model is up to date, GraphQL subscriptions watch.
*/
export abstract class ChangeFeed {
abstract publishIssue(change: IssueChange): Promise<void>;
abstract publishComment(change: CommentChange): Promise<void>;
abstract publishActivity(activity: ActivityReadModel): Promise<void>;
abstract publishRelease(change: ReleaseChange): Promise<void>;
/** @param notifyUserIds users whose project list changed (joined / left). */
abstract publishProject(
change: ProjectChange,
notifyUserIds?: string[],
): Promise<void>;
abstract issues(projectId: string): AsyncIterableIterator<IssueChange>;
abstract comments(issueId: string): AsyncIterableIterator<CommentChange>;
abstract activity(
projectId: string,
): AsyncIterableIterator<ActivityReadModel>;
abstract releases(projectId: string): AsyncIterableIterator<ReleaseChange>;
abstract project(projectId: string): AsyncIterableIterator<ProjectChange>;
abstract userProjects(userId: string): AsyncIterableIterator<ProjectChange>;
}

View File

@@ -0,0 +1,11 @@
import { ActivityReadModel } from '@task-stream/api/tracker/domain';
export abstract class FindActivityRepository {
abstract find(params: {
projectId: string;
issueId?: string;
limit: number;
/** ISO date cursor: only activity strictly older than this. */
before?: string;
}): Promise<ActivityReadModel[]>;
}

View File

@@ -0,0 +1,12 @@
import { CommentReadModel } from '@task-stream/api/tracker/domain';
import { Page } from './find-issues.repository';
export abstract class FindCommentsRepository {
abstract findById(id: string): Promise<CommentReadModel | null>;
abstract findByIssue(
issueId: string,
limit: number,
offset: number,
): Promise<Page<CommentReadModel>>;
abstract countByIssue(issueId: string): Promise<number>;
}

View File

@@ -0,0 +1,40 @@
import {
IssueArea,
IssuePriority,
IssueReadModel,
IssueStatus,
IssueType,
ProjectStatsReadModel,
ReleaseProgressReadModel,
} from '@task-stream/api/tracker/domain';
export interface IssueFilter {
projectId: string;
statuses?: IssueStatus[];
types?: IssueType[];
areas?: IssueArea[];
priorities?: IssuePriority[];
assigneeId?: string;
unassigned?: boolean;
releaseId?: string;
labels?: string[];
search?: string;
limit: number;
offset: number;
}
export interface Page<T> {
items: T[];
total: number;
}
export abstract class FindIssuesRepository {
abstract findById(id: string): Promise<IssueReadModel | null>;
abstract findByKey(key: string): Promise<IssueReadModel | null>;
abstract findByIds(ids: string[]): Promise<IssueReadModel[]>;
abstract search(filter: IssueFilter): Promise<Page<IssueReadModel>>;
abstract stats(projectId: string): Promise<ProjectStatsReadModel>;
abstract releaseProgress(
releaseId: string,
): Promise<ReleaseProgressReadModel>;
}

View File

@@ -0,0 +1,11 @@
import { ProjectReadModel, ProjectRole } from '@task-stream/api/tracker/domain';
export abstract class FindProjectsRepository {
abstract findById(id: string): Promise<ProjectReadModel | null>;
abstract findByKey(key: string): Promise<ProjectReadModel | null>;
abstract findByMember(userId: string): Promise<ProjectReadModel[]>;
abstract findRole(
projectId: string,
userId: string,
): Promise<ProjectRole | null>;
}

View File

@@ -0,0 +1,6 @@
import { ReleaseReadModel } from '@task-stream/api/tracker/domain';
export abstract class FindReleasesRepository {
abstract findById(id: string): Promise<ReleaseReadModel | null>;
abstract findByProject(projectId: string): Promise<ReleaseReadModel[]>;
}

View File

@@ -0,0 +1,16 @@
export * from './project-keys.repository';
export * from './issue-numbers.repository';
export * from './release-names.repository';
export * from './find-projects.repository';
export * from './find-issues.repository';
export * from './find-comments.repository';
export * from './find-releases.repository';
export * from './find-activity.repository';
export * from './upsert-materialized-project.repository';
export * from './upsert-materialized-issue.repository';
export * from './upsert-materialized-comment.repository';
export * from './upsert-materialized-release.repository';
export * from './upsert-materialized-activity.repository';
export * from './change-feed';
export * from './user-directory';
export * from './release-issues.repository';

View File

@@ -0,0 +1,5 @@
/** Per-project ticket counter (TS-1, TS-2...). */
export abstract class IssueNumbersRepository {
/** Atomically reserves the next number of the project. */
abstract next(projectId: string): Promise<number>;
}

View File

@@ -0,0 +1,5 @@
/** Write-side uniqueness of project keys ("TS", "SHOP"...). */
export abstract class ProjectKeysRepository {
/** Throws BusinessRuleError if the key is already taken. */
abstract reserve(key: string, projectId: string): Promise<void>;
}

View File

@@ -0,0 +1,8 @@
/**
* Write-side lookup over the event store: tickets that were ever put in a
* release. Rules about a release (e.g. "no unfinished tickets") must not
* depend on the eventually-consistent read model.
*/
export abstract class ReleaseIssuesRepository {
abstract findIssueIds(releaseId: string): Promise<string[]>;
}

View File

@@ -0,0 +1,10 @@
/** Write-side uniqueness of release names inside a project. */
export abstract class ReleaseNamesRepository {
/** Throws BusinessRuleError if the name is already used in the project. */
abstract reserve(
projectId: string,
name: string,
releaseId: string,
): Promise<void>;
abstract free(projectId: string, name: string): Promise<void>;
}

View File

@@ -0,0 +1,6 @@
import { ActivityReadModel } from '@task-stream/api/tracker/domain';
export abstract class UpsertMaterializedActivityRepository {
/** Inserts the entry unless it already exists. Returns true when inserted. */
abstract insert(activity: ActivityReadModel): Promise<boolean>;
}

View File

@@ -0,0 +1,10 @@
import { CommentReadModel } from '@task-stream/api/tracker/domain';
export abstract class UpsertMaterializedCommentRepository {
/** Applied only if `version` is newer than the stored one (idempotent). */
abstract upsert(
comment: Pick<CommentReadModel, 'id' | 'version'> &
Partial<CommentReadModel>,
): Promise<void>;
abstract remove(id: string): Promise<void>;
}

View File

@@ -0,0 +1,24 @@
import {
IssueLinkReadModel,
IssueReadModel,
} from '@task-stream/api/tracker/domain';
export abstract class UpsertMaterializedIssueRepository {
/** Applied only if `version` is newer than the stored one (idempotent). */
abstract upsert(
issue: Pick<IssueReadModel, 'id' | 'version'> &
Partial<Omit<IssueReadModel, 'links' | 'commentCount'>>,
): Promise<void>;
/** Adds a relation to an issue (idempotent). */
abstract addLink(issueId: string, link: IssueLinkReadModel): Promise<void>;
abstract removeLink(
issueId: string,
link: Omit<IssueLinkReadModel, 'issueKey'>,
): Promise<void>;
abstract setCommentCount(
issueId: string,
commentCount: number,
): Promise<void>;
/** Keeps the denormalised release name of every issue in sync. */
abstract renameRelease(releaseId: string, name: string): Promise<void>;
}

View File

@@ -0,0 +1,9 @@
import { ProjectReadModel } from '@task-stream/api/tracker/domain';
export abstract class UpsertMaterializedProjectRepository {
/** Applied only if `version` is newer than the stored one (idempotent). */
abstract upsert(
project: Pick<ProjectReadModel, 'id' | 'version'> &
Partial<ProjectReadModel>,
): Promise<void>;
}

View File

@@ -0,0 +1,9 @@
import { ReleaseReadModel } from '@task-stream/api/tracker/domain';
export abstract class UpsertMaterializedReleaseRepository {
/** Applied only if `version` is newer than the stored one (idempotent). */
abstract upsert(
release: Pick<ReleaseReadModel, 'id' | 'version'> &
Partial<ReleaseReadModel>,
): Promise<void>;
}

View File

@@ -0,0 +1,28 @@
import { UserRefReadModel } from '@task-stream/api/tracker/domain';
export interface DirectoryUser {
id: string;
name: string;
isActive: boolean;
}
/** Outbound port to the identity context. */
export abstract class UserDirectory {
/**
* @param accessToken caller's token, lets the identity server be asked for
* users that never used task-stream yet.
*/
abstract findById(
userId: string,
accessToken?: string,
): Promise<DirectoryUser | null>;
/** Resolves user ids to {id, name} for read models, in one lookup. */
abstract refs(userIds: (string | null | undefined)[]): Promise<UserRefs>;
}
export interface UserRefs {
/** Unknown users keep their id as name. */
of(userId: string): UserRefReadModel;
ofNullable(userId: string | null | undefined): UserRefReadModel | null;
}

View File

@@ -0,0 +1,118 @@
import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import {
ProjectReadModel,
ProjectRole,
ProjectStatsReadModel,
ReleaseReadModel,
} from '@task-stream/api/tracker/domain';
import { AddProjectMemberCommand } from './commands/add-project-member.command';
import { ChangeProjectMemberRoleCommand } from './commands/change-project-member-role.command';
import { CommandResult } from './commands/command-result';
import { CreateProjectCommand } from './commands/create-project.command';
import { RemoveProjectMemberCommand } from './commands/remove-project-member.command';
import { UpdateProjectCommand } from './commands/update-project.command';
import { IssueFilter } from './ports/find-issues.repository';
import { BoardColumn, GetBoardQuery } from './queries/get-board.query';
import { GetMyProjectsQuery } from './queries/get-my-projects.query';
import { GetProjectStatsQuery } from './queries/get-project-stats.query';
import { GetProjectQuery } from './queries/get-project.query';
import { GetReleasesQuery } from './queries/get-releases.query';
@Injectable()
export class ProjectsService {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
create(
actorId: string,
input: { key: string; name: string; description?: string | null },
): Promise<CommandResult> {
return this.commandBus.execute(
new CreateProjectCommand(
actorId,
input.key,
input.name,
input.description,
),
);
}
update(
actorId: string,
projectId: string,
changes: { name?: string; description?: string | null },
): Promise<CommandResult> {
return this.commandBus.execute(
new UpdateProjectCommand(actorId, projectId, changes),
);
}
addMember(
actorId: string,
input: { projectId: string; userId: string; role: ProjectRole },
accessToken?: string,
): Promise<CommandResult> {
return this.commandBus.execute(
new AddProjectMemberCommand(
actorId,
input.projectId,
input.userId,
input.role,
accessToken,
),
);
}
changeMemberRole(
actorId: string,
input: { projectId: string; userId: string; role: ProjectRole },
): Promise<CommandResult> {
return this.commandBus.execute(
new ChangeProjectMemberRoleCommand(
actorId,
input.projectId,
input.userId,
input.role,
),
);
}
removeMember(
actorId: string,
projectId: string,
userId: string,
): Promise<CommandResult> {
return this.commandBus.execute(
new RemoveProjectMemberCommand(actorId, projectId, userId),
);
}
findMine(viewerId: string): Promise<ProjectReadModel[]> {
return this.queryBus.execute(new GetMyProjectsQuery(viewerId));
}
findOne(viewerId: string, idOrKey: string): Promise<ProjectReadModel> {
return this.queryBus.execute(new GetProjectQuery(viewerId, idOrKey));
}
stats(viewerId: string, projectId: string): Promise<ProjectStatsReadModel> {
return this.queryBus.execute(new GetProjectStatsQuery(viewerId, projectId));
}
board(
viewerId: string,
filter: Omit<IssueFilter, 'statuses' | 'limit' | 'offset'>,
perColumn?: number,
): Promise<BoardColumn[]> {
return this.queryBus.execute(
new GetBoardQuery(viewerId, filter, perColumn),
);
}
releases(viewerId: string, projectId: string): Promise<ReleaseReadModel[]> {
return this.queryBus.execute(new GetReleasesQuery(viewerId, projectId));
}
}

View File

@@ -0,0 +1,14 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { ReadAccess } from './read-access';
import { CanViewProjectQuery } from './can-view-project.query';
@QueryHandler(CanViewProjectQuery)
export class CanViewProjectQueryHandler
implements IQueryHandler<CanViewProjectQuery, boolean>
{
constructor(private readonly access: ReadAccess) {}
async execute(query: CanViewProjectQuery): Promise<boolean> {
return this.access.canView(query.projectId, query.viewerId);
}
}

View File

@@ -0,0 +1,10 @@
import { Query } from '@nestjs/cqrs';
export class CanViewProjectQuery extends Query<boolean> {
constructor(
public readonly viewerId: string,
public readonly projectId: string,
) {
super();
}
}

View File

@@ -0,0 +1,25 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { ActivityReadModel } from '@task-stream/api/tracker/domain';
import { FindActivityRepository } from '../ports/find-activity.repository';
import { ReadAccess } from './read-access';
import { GetActivityQuery } from './get-activity.query';
@QueryHandler(GetActivityQuery)
export class GetActivityQueryHandler
implements IQueryHandler<GetActivityQuery, ActivityReadModel[]>
{
constructor(
private readonly findActivity: FindActivityRepository,
private readonly access: ReadAccess,
) {}
async execute(query: GetActivityQuery): Promise<ActivityReadModel[]> {
await this.access.assertCanView(query.projectId, query.viewerId);
return this.findActivity.find({
projectId: query.projectId,
issueId: query.issueId,
limit: Math.min(query.limit, 200),
before: query.before,
});
}
}

View File

@@ -0,0 +1,15 @@
import { Query } from '@nestjs/cqrs';
import { ActivityReadModel } from '@task-stream/api/tracker/domain';
export class GetActivityQuery extends Query<ActivityReadModel[]> {
constructor(
public readonly viewerId: string,
public readonly projectId: string,
public readonly issueId?: string,
public readonly limit = 50,
/** ISO date cursor: only activity strictly older than this. */
public readonly before?: string,
) {
super();
}
}

View File

@@ -0,0 +1,41 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { IssueStatus } from '@task-stream/api/tracker/domain';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { BoardColumn, GetBoardQuery } from './get-board.query';
import { ReadAccess } from './read-access';
const BOARD_COLUMNS: IssueStatus[] = [
IssueStatus.BACKLOG,
IssueStatus.TODO,
IssueStatus.IN_PROGRESS,
IssueStatus.IN_REVIEW,
IssueStatus.READY_FOR_QA,
IssueStatus.IN_QA,
IssueStatus.QA_FAILED,
IssueStatus.DONE,
];
@QueryHandler(GetBoardQuery)
export class GetBoardQueryHandler
implements IQueryHandler<GetBoardQuery, BoardColumn[]>
{
constructor(
private readonly findIssues: FindIssuesRepository,
private readonly access: ReadAccess,
) {}
async execute(query: GetBoardQuery): Promise<BoardColumn[]> {
await this.access.assertCanView(query.filter.projectId, query.viewerId);
return Promise.all(
BOARD_COLUMNS.map(async (status) => {
const page = await this.findIssues.search({
...query.filter,
statuses: [status],
limit: Math.min(query.perColumn, 200),
offset: 0,
});
return { status, issues: page.items, total: page.total };
}),
);
}
}

View File

@@ -0,0 +1,20 @@
import { Query } from '@nestjs/cqrs';
import { IssueReadModel, IssueStatus } from '@task-stream/api/tracker/domain';
import { IssueFilter } from '../ports/find-issues.repository';
export interface BoardColumn {
status: IssueStatus;
issues: IssueReadModel[];
total: number;
}
/** Kanban board: one column per status. */
export class GetBoardQuery extends Query<BoardColumn[]> {
constructor(
public readonly viewerId: string,
public readonly filter: Omit<IssueFilter, 'statuses' | 'limit' | 'offset'>,
public readonly perColumn = 50,
) {
super();
}
}

View File

@@ -0,0 +1,31 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { CommentReadModel } from '@task-stream/api/tracker/domain';
import { FindCommentsRepository } from '../ports/find-comments.repository';
import { FindIssuesRepository, Page } from '../ports/find-issues.repository';
import { ReadAccess } from './read-access';
import { GetCommentsQuery } from './get-comments.query';
@QueryHandler(GetCommentsQuery)
export class GetCommentsQueryHandler
implements IQueryHandler<GetCommentsQuery, Page<CommentReadModel>>
{
constructor(
private readonly findIssues: FindIssuesRepository,
private readonly findComments: FindCommentsRepository,
private readonly access: ReadAccess,
) {}
async execute(query: GetCommentsQuery): Promise<Page<CommentReadModel>> {
const issue = this.access.assertFound(
await this.findIssues.findById(query.issueId),
'Issue',
query.issueId,
);
await this.access.assertCanView(issue.projectId, query.viewerId);
return this.findComments.findByIssue(
issue.id,
Math.min(query.limit, 200),
query.offset,
);
}
}

View File

@@ -0,0 +1,14 @@
import { Query } from '@nestjs/cqrs';
import { CommentReadModel } from '@task-stream/api/tracker/domain';
import { Page } from '../ports/find-issues.repository';
export class GetCommentsQuery extends Query<Page<CommentReadModel>> {
constructor(
public readonly viewerId: string,
public readonly issueId: string,
public readonly limit = 50,
public readonly offset = 0,
) {
super();
}
}

View File

@@ -0,0 +1,24 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { IssueReadModel } from '@task-stream/api/tracker/domain';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { ReadAccess, UUID } from './read-access';
import { GetIssueQuery } from './get-issue.query';
@QueryHandler(GetIssueQuery)
export class GetIssueQueryHandler
implements IQueryHandler<GetIssueQuery, IssueReadModel>
{
constructor(
private readonly findIssues: FindIssuesRepository,
private readonly access: ReadAccess,
) {}
async execute(query: GetIssueQuery): Promise<IssueReadModel> {
const issue = UUID.test(query.idOrKey)
? await this.findIssues.findById(query.idOrKey)
: await this.findIssues.findByKey(query.idOrKey.toUpperCase());
const found = this.access.assertFound(issue, 'Issue', query.idOrKey);
await this.access.assertCanView(found.projectId, query.viewerId);
return found;
}
}

View File

@@ -0,0 +1,12 @@
import { Query } from '@nestjs/cqrs';
import { IssueReadModel } from '@task-stream/api/tracker/domain';
export class GetIssueQuery extends Query<IssueReadModel> {
constructor(
public readonly viewerId: string,
/** Issue id or key ("TS-42"). */
public readonly idOrKey: string,
) {
super();
}
}

View File

@@ -0,0 +1,23 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { IssueReadModel } from '@task-stream/api/tracker/domain';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { GetIssuesByIdsQuery } from './get-issues-by-ids.query';
@QueryHandler(GetIssuesByIdsQuery)
export class GetIssuesByIdsQueryHandler
implements IQueryHandler<GetIssuesByIdsQuery, IssueReadModel[]>
{
constructor(
private readonly findIssues: FindIssuesRepository,
private readonly findProjects: FindProjectsRepository,
) {}
async execute(query: GetIssuesByIdsQuery): Promise<IssueReadModel[]> {
const issues = await this.findIssues.findByIds([...new Set(query.ids)]);
const visible = new Set(
(await this.findProjects.findByMember(query.viewerId)).map((p) => p.id),
);
return issues.filter((issue) => visible.has(issue.projectId));
}
}

View File

@@ -0,0 +1,11 @@
import { Query } from '@nestjs/cqrs';
import { IssueReadModel } from '@task-stream/api/tracker/domain';
export class GetIssuesByIdsQuery extends Query<IssueReadModel[]> {
constructor(
public readonly viewerId: string,
public readonly ids: string[],
) {
super();
}
}

Some files were not shown because too many files have changed in this diff Show More