feat: epics, subtasks and checklists

Tickets can hang from another one of the same project, three levels deep
at most (epic → ticket → subtask). The ticket page lists its subtasks with
their progress, creates new ones and moves the ticket under an epic; a new
Épicas tab shows every epic and how far it has got. Each ticket also gets a
checklist for the small steps that don't deserve a ticket.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 13:52:43 +02:00
parent 75323115fb
commit d4da33d4e8
84 changed files with 4719 additions and 79 deletions

View File

@@ -0,0 +1,72 @@
import { randomUUID } from 'node:crypto';
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import {
AddChecklistItemCommand,
RemoveChecklistItemCommand,
UpdateChecklistItemCommand,
} from './change-checklist.command';
import { CommandResult, ack } from './command-result';
import { WriteAccess } from './write-access';
type ChecklistCommand =
| AddChecklistItemCommand
| UpdateChecklistItemCommand
| RemoveChecklistItemCommand;
/** Every change to a ticket's checklist: whoever can edit the ticket may. */
@CommandHandler(AddChecklistItemCommand)
export class AddChecklistItemCommandHandler
implements ICommandHandler<AddChecklistItemCommand>
{
constructor(private readonly access: WriteAccess) {}
execute(command: AddChecklistItemCommand): Promise<CommandResult> {
return change(this.access, command, (issue) =>
issue.addChecklistItem(command.actorId, randomUUID(), command.text),
);
}
}
@CommandHandler(UpdateChecklistItemCommand)
export class UpdateChecklistItemCommandHandler
implements ICommandHandler<UpdateChecklistItemCommand>
{
constructor(private readonly access: WriteAccess) {}
execute(command: UpdateChecklistItemCommand): Promise<CommandResult> {
return change(this.access, command, (issue) =>
issue.updateChecklistItem(
command.actorId,
command.itemId,
command.changes,
),
);
}
}
@CommandHandler(RemoveChecklistItemCommand)
export class RemoveChecklistItemCommandHandler
implements ICommandHandler<RemoveChecklistItemCommand>
{
constructor(private readonly access: WriteAccess) {}
execute(command: RemoveChecklistItemCommand): Promise<CommandResult> {
return change(this.access, command, (issue) =>
issue.removeChecklistItem(command.actorId, command.itemId),
);
}
}
async function change(
access: WriteAccess,
command: ChecklistCommand,
apply: (issue: Awaited<ReturnType<WriteAccess['issue']>>) => void,
): Promise<CommandResult> {
const { issue } = await access.issueForContributor(
command.issueId,
command.actorId,
);
apply(issue);
await issue.commitAndWait();
return ack(issue);
}

View File

@@ -0,0 +1,34 @@
import { Command } from '@nestjs/cqrs';
import { CommandResult } from './command-result';
export class AddChecklistItemCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
public readonly text: string,
) {
super();
}
}
/** Rewords, ticks or unticks a checklist step. */
export class UpdateChecklistItemCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
public readonly itemId: string,
public readonly changes: { text?: string; done?: boolean },
) {
super();
}
}
export class RemoveChecklistItemCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
public readonly itemId: string,
) {
super();
}
}

View File

@@ -28,6 +28,13 @@ import { UnassignIssueCommandHandler } from './unassign-issue.command-handler';
import { AttachFileCommandHandler } from './attach-file.command-handler';
import { DetachFileCommandHandler } from './detach-file.command-handler';
import { WatchIssueCommandHandler } from './watch-issue.command-handler';
import { SetIssueParentCommandHandler } from './set-issue-parent.command-handler';
import { DetachFromParentCommandHandler } from './detach-from-parent.command-handler';
import {
AddChecklistItemCommandHandler,
RemoveChecklistItemCommandHandler,
UpdateChecklistItemCommandHandler,
} from './change-checklist.command-handler';
export const COMMAND_HANDLERS = [
CreateProjectCommandHandler,
@@ -61,4 +68,10 @@ export const COMMAND_HANDLERS = [
AttachFileCommandHandler,
DetachFileCommandHandler,
WatchIssueCommandHandler,
SetIssueParentCommandHandler,
AddChecklistItemCommandHandler,
UpdateChecklistItemCommandHandler,
RemoveChecklistItemCommandHandler,
// Saga only: the parent was deleted.
DetachFromParentCommandHandler,
];

View File

@@ -28,7 +28,15 @@ import {
InMemoryProjectKeys,
InMemoryReleaseNames,
InMemoryReleaseIssues,
InMemoryTrackerStreams,
} from '../../testing/in-memory';
import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
import { IssueTree } from './issue-tree';
import { SetIssueParentCommand } from './set-issue-parent.command';
import {
AddChecklistItemCommand,
UpdateChecklistItemCommand,
} from './change-checklist.command';
import { ReleaseIssuesRepository } from '../ports/release-issues.repository';
import { IssueNumbersRepository } from '../ports/issue-numbers.repository';
import { ProjectKeysRepository } from '../ports/project-keys.repository';
@@ -67,6 +75,7 @@ async function setup() {
...COMMAND_HANDLERS,
...TRACKER_FACTORIES,
WriteAccess,
IssueTree,
AggregateRehydrator,
{ provide: EventStore, useValue: store },
{ provide: AttachmentStorage, useValue: files },
@@ -78,6 +87,10 @@ async function setup() {
provide: ReleaseIssuesRepository,
useValue: new InMemoryReleaseIssues(store),
},
{
provide: TrackerStreamsRepository,
useValue: new InMemoryTrackerStreams(store),
},
],
}).compile();
await moduleRef.init();
@@ -281,6 +294,84 @@ describe('tracker commands (event sourced)', () => {
).rejects.toThrow(ForbiddenError);
});
describe('epics, subtasks and checklists', () => {
const create = (
run: (c: object) => Promise<CommandResult>,
projectId: string,
title: string,
parentId?: string,
) =>
run(
new CreateIssueCommand(
'dev',
projectId,
IssueType.TASK,
title,
{},
null,
null,
parentId,
),
);
it('nests tickets three levels deep at most, without loops', async () => {
const { run, rehydrator } = await setup();
const projectId = await projectWithTeam(run);
const epic = await create(run, projectId, 'Checkout');
const story = await create(run, projectId, 'Pay by card', epic.id);
const subtask = await create(run, projectId, 'Stripe form', story.id);
const loose = await create(run, projectId, 'Loose ticket');
expect((await rehydrator.rehydrate(subtask.id, Issue)).parentId).toBe(
story.id,
);
// A fourth level, and a loop.
await expect(
create(run, projectId, 'Too deep', subtask.id),
).rejects.toThrow(/at most 2 levels/);
await expect(
run(new SetIssueParentCommand('dev', epic.id, subtask.id)),
).rejects.toThrow(BusinessRuleError);
// A ticket with subtasks cannot go under one that has a parent.
await run(new SetIssueParentCommand('dev', loose.id, epic.id));
await expect(
run(new SetIssueParentCommand('dev', story.id, loose.id)),
).rejects.toThrow(/at most 2 levels/);
// ...but it can move to another epic, and leave.
await run(new SetIssueParentCommand('dev', loose.id, null));
await run(new SetIssueParentCommand('dev', story.id, loose.id));
expect((await rehydrator.rehydrate(story.id, Issue)).parentId).toBe(
loose.id,
);
await expect(
run(new SetIssueParentCommand('pm', story.id, null)),
).rejects.toThrow(ForbiddenError);
});
it('keeps a checklist on the ticket', async () => {
const { run, rehydrator } = await setup();
const projectId = await projectWithTeam(run);
const issue = await create(run, projectId, 'Release 1.0');
await run(new AddChecklistItemCommand('dev', issue.id, 'Changelog'));
await run(new AddChecklistItemCommand('qa', issue.id, 'Smoke test'));
const [first] = (await rehydrator.rehydrate(issue.id, Issue)).checklist;
await run(
new UpdateChecklistItemCommand('qa', issue.id, first.id, {
done: true,
}),
);
const { checklist } = await rehydrator.rehydrate(issue.id, Issue);
expect(checklist.map((i) => [i.text, i.done])).toEqual([
['Changelog', true],
['Smoke test', false],
]);
await expect(
run(new AddChecklistItemCommand('pm', issue.id, 'Nope')),
).rejects.toThrow(ForbiddenError);
});
});
it('does not let viewers modify tickets', async () => {
const { run } = await setup();
const projectId = await projectWithTeam(run);

View File

@@ -1,8 +1,9 @@
import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs';
import { IssueFactory } from '@task-stream/api/tracker/domain';
import { IssueFactory, assertCanNest } 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 { IssueTree } from './issue-tree';
import { WriteAccess } from './write-access';
@CommandHandler(CreateIssueCommand)
@@ -14,6 +15,7 @@ export class CreateIssueCommandHandler
private readonly issueFactory: IssueFactory,
private readonly issueNumbers: IssueNumbersRepository,
private readonly eventPublisher: EventPublisher,
private readonly tree: IssueTree,
) {}
async execute(command: CreateIssueCommand): Promise<CommandResult> {
@@ -27,6 +29,15 @@ export class CreateIssueCommandHandler
this.access.assertAssignable(project, command.assigneeId);
if (command.releaseId)
await this.access.openReleaseOf(project, command.releaseId);
const parent = command.parentId
? await this.tree.candidate(command.parentId)
: null;
if (parent)
assertCanNest(
{ id: '', key: 'The new ticket', projectId: project.id },
parent,
0,
);
const issue = this.issueFactory.create(
{
@@ -42,6 +53,7 @@ export class CreateIssueCommandHandler
workflow,
);
if (command.releaseId) issue.setRelease(command.actorId, command.releaseId);
if (parent) issue.setParent(command.actorId, parent);
this.eventPublisher.mergeObjectContext(issue);
await issue.commitAndWait();

View File

@@ -12,6 +12,8 @@ export class CreateIssueCommand extends Command<CommandResult> {
public readonly details: Omit<IssueDetails, 'title' | 'type'>,
public readonly assigneeId?: string | null,
public readonly releaseId?: string | null,
/** Epic or ticket the new one breaks down. */
public readonly parentId?: string | null,
) {
super();
}

View File

@@ -0,0 +1,20 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { Issue, Project } from '@task-stream/api/tracker/domain';
import { DetachFromParentCommand } from './detach-from-parent.command';
import { WriteAccess } from './write-access';
@CommandHandler(DetachFromParentCommand)
export class DetachFromParentCommandHandler
implements ICommandHandler<DetachFromParentCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: DetachFromParentCommand): Promise<void> {
const issue = await this.access.live(command.issueId, Issue);
// Gone (or its whole project is going), or it moved elsewhere already.
if (!issue || issue.parentId !== command.parentId) return;
if (!(await this.access.live(issue.projectId, Project))) return;
issue.setParent(command.actorId, null);
await issue.commitAndWait();
}
}

View File

@@ -0,0 +1,12 @@
import { Command } from '@nestjs/cqrs';
/** Saga only: the parent of a ticket was deleted, so it goes back to the top. */
export class DetachFromParentCommand extends Command<void> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
public readonly parentId: string,
) {
super();
}
}

View File

@@ -29,3 +29,6 @@ export * from './unassign-issue.command';
export * from './attach-file.command';
export * from './detach-file.command';
export * from './watch-issue.command';
export * from './set-issue-parent.command';
export * from './detach-from-parent.command';
export * from './change-checklist.command';

View File

@@ -0,0 +1,60 @@
import { Injectable } from '@nestjs/common';
import {
Issue,
MAX_HIERARCHY_DEPTH,
ParentCandidate,
} from '@task-stream/api/tracker/domain';
import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
import { WriteAccess } from './write-access';
/**
* Walks the epic → ticket → subtask tree on the write side (the event store),
* so nesting rules never depend on a read model that may lag behind.
*/
@Injectable()
export class IssueTree {
constructor(
private readonly access: WriteAccess,
private readonly streams: TrackerStreamsRepository,
) {}
/** `parentId` as a would-be parent: with its ancestors, closest first. */
async candidate(parentId: string): Promise<ParentCandidate> {
const parent = await this.access.issue(parentId);
const ancestorIds: string[] = [];
let next = parent.parentId;
// One more than allowed is enough to tell a tree is too deep (or loops).
while (next && ancestorIds.length <= MAX_HIERARCHY_DEPTH) {
ancestorIds.push(next);
next = (await this.access.live(next, Issue))?.parentId ?? null;
}
return {
id: parent.id,
key: parent.key,
projectId: parent.projectId,
ancestorIds,
};
}
/** Tickets hanging from `issueId` right now. */
async childrenOf(issueId: string): Promise<Issue[]> {
const everUnder = await this.streams.issueIdsEverUnder(issueId);
const issues = await Promise.all(
everUnder.map((id) => this.access.live(id, Issue)),
);
return issues.filter(
(issue): issue is Issue => !!issue && issue.parentId === issueId,
);
}
/** Levels of tickets below `issueId` (0: none). */
async heightOf(issueId: string, depth = 0): Promise<number> {
if (depth > MAX_HIERARCHY_DEPTH) return 0;
const children = await this.childrenOf(issueId);
if (children.length === 0) return 0;
const heights = await Promise.all(
children.map((child) => this.heightOf(child.id, depth + 1)),
);
return 1 + Math.max(...heights);
}
}

View File

@@ -0,0 +1,33 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { CommandResult, ack } from './command-result';
import { IssueTree } from './issue-tree';
import { SetIssueParentCommand } from './set-issue-parent.command';
import { WriteAccess } from './write-access';
@CommandHandler(SetIssueParentCommand)
export class SetIssueParentCommandHandler
implements ICommandHandler<SetIssueParentCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly tree: IssueTree,
) {}
async execute(command: SetIssueParentCommand): Promise<CommandResult> {
const { issue } = await this.access.issueForContributor(
command.issueId,
command.actorId,
);
if (command.parentId === null) {
issue.setParent(command.actorId, null);
} else if (command.parentId !== issue.parentId) {
const [parent, height] = await Promise.all([
this.tree.candidate(command.parentId),
this.tree.heightOf(issue.id),
]);
issue.setParent(command.actorId, parent, height);
}
await issue.commitAndWait();
return ack(issue);
}
}

View File

@@ -0,0 +1,13 @@
import { Command } from '@nestjs/cqrs';
import { CommandResult } from './command-result';
/** Places a ticket under an epic or another ticket (`parentId` null: out). */
export class SetIssueParentCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
public readonly parentId: string | null,
) {
super();
}
}

View File

@@ -5,6 +5,10 @@ import {
IssueAssignedEvent,
IssueAttachmentAddedEvent,
IssueAttachmentRemovedEvent,
IssueChecklistItemAddedEvent,
IssueChecklistItemRemovedEvent,
IssueChecklistItemUpdatedEvent,
IssueParentChangedEvent,
IssueCreatedEvent,
IssueLinkedEvent,
IssueLinkType,
@@ -142,6 +146,23 @@ export function describeEvent(
if (event instanceof IssueAttachmentRemovedEvent)
return `${actor} quitó el adjunto ${event.fileName} de ${issueKey}`;
if (event instanceof IssueParentChangedEvent) {
return event.parentKey
? `${actor} colocó ${issueKey} bajo ${event.parentKey}`
: `${actor} sacó ${issueKey} de su ticket padre`;
}
if (event instanceof IssueChecklistItemAddedEvent)
return `${actor} añadió «${event.item.text}» a la lista de ${issueKey}`;
if (event instanceof IssueChecklistItemUpdatedEvent) {
if (event.changes.done === true)
return `${actor} completó un paso de la lista de ${issueKey}`;
if (event.changes.done === false)
return `${actor} reabrió un paso de la lista de ${issueKey}`;
return `${actor} cambió un paso de la lista de ${issueKey}`;
}
if (event instanceof IssueChecklistItemRemovedEvent)
return `${actor} quitó «${event.text}» de la lista de ${issueKey}`;
if (event instanceof CommentAddedEvent)
return `${actor} comentó en ${issueKey}`;
if (event instanceof CommentEditedEvent)

View File

@@ -7,6 +7,10 @@ import {
IssueAssignedEvent,
IssueAttachmentAddedEvent,
IssueAttachmentRemovedEvent,
IssueChecklistItemAddedEvent,
IssueChecklistItemRemovedEvent,
IssueChecklistItemUpdatedEvent,
IssueParentChangedEvent,
IssueCreatedEvent,
IssueLinkedEvent,
IssueReleaseChangedEvent,
@@ -55,6 +59,10 @@ import { versionOf } from './read-model-notifier';
IssueReleaseChangedEvent,
IssueAttachmentAddedEvent,
IssueAttachmentRemovedEvent,
IssueParentChangedEvent,
IssueChecklistItemAddedEvent,
IssueChecklistItemUpdatedEvent,
IssueChecklistItemRemovedEvent,
CommentAddedEvent,
CommentEditedEvent,
CommentDeletedEvent,

View File

@@ -28,6 +28,8 @@ import { IssueAttachmentAddedEventHandler } from './issue-attachment-added.event
import { IssueAttachmentRemovedEventHandler } from './issue-attachment-removed.event-handler';
import { AttachmentCleanupEventHandler } from './attachment-cleanup.event-handler';
import { IssueWatchersEventHandler } from './issue-watchers.event-handler';
import { IssueParentChangedEventHandler } from './issue-parent-changed.event-handler';
import { IssueChecklistEventHandler } from './issue-checklist.event-handler';
/** Read-model projections (MongoDB) + realtime, fed by the EventsBridge. */
export const EVENT_HANDLERS = [
@@ -60,6 +62,8 @@ export const EVENT_HANDLERS = [
IssueAttachmentRemovedEventHandler,
AttachmentCleanupEventHandler,
IssueWatchersEventHandler,
IssueParentChangedEventHandler,
IssueChecklistEventHandler,
];
export { ReadModelNotifier };

View File

@@ -0,0 +1,48 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import {
IssueChecklistItemAddedEvent,
IssueChecklistItemRemovedEvent,
IssueChecklistItemUpdatedEvent,
} from '@task-stream/api/tracker/domain';
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
type ChecklistEvent =
| IssueChecklistItemAddedEvent
| IssueChecklistItemUpdatedEvent
| IssueChecklistItemRemovedEvent;
@EventsHandler(
IssueChecklistItemAddedEvent,
IssueChecklistItemUpdatedEvent,
IssueChecklistItemRemovedEvent,
)
export class IssueChecklistEventHandler
implements IEventHandler<ChecklistEvent>
{
constructor(
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: ChecklistEvent) {
const repository = this.upsertMaterializedIssueRepository;
await repository.upsert({
id: event.aggregateId,
updatedAt: event.occurredAt,
version: versionOf(event),
});
if (event instanceof IssueChecklistItemAddedEvent) {
await repository.addChecklistItem(event.aggregateId, event.item);
} else if (event instanceof IssueChecklistItemUpdatedEvent) {
await repository.updateChecklistItem(
event.aggregateId,
event.itemId,
event.changes,
);
} else {
await repository.removeChecklistItem(event.aggregateId, event.itemId);
}
await this.notifier.issue(event.aggregateId, event);
}
}

View File

@@ -48,6 +48,7 @@ export class IssueCreatedEventHandler
reporter: users.of(issue.reporterId),
assignee: users.ofNullable(issue.assigneeId),
release: release ? { id: release.id, name: release.name } : null,
parent: null,
labels: issue.labels,
figmaUrl: issue.figmaUrl,
storyPoints: issue.storyPoints,

View File

@@ -0,0 +1,39 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { IssueParentChangedEvent } 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(IssueParentChangedEvent)
export class IssueParentChangedEventHandler
implements IEventHandler<IssueParentChangedEvent>
{
constructor(
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
private readonly findIssues: FindIssuesRepository,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: IssueParentChangedEvent) {
const parent = event.parentId
? await this.findIssues.findById(event.parentId)
: null;
await this.upsertMaterializedIssueRepository.upsert({
id: event.aggregateId,
parent: event.parentId
? {
id: event.parentId,
key: parent?.key ?? event.parentKey ?? '',
title: parent?.title ?? '',
}
: null,
updatedAt: event.occurredAt,
version: versionOf(event),
});
await this.notifier.issue(event.aggregateId, event);
// Both parents list their subtasks: they changed too.
for (const id of [event.parentId, event.previousParentId]) {
if (id) await this.notifier.issue(id, event);
}
}
}

View File

@@ -19,6 +19,12 @@ export class IssueUpdatedEventHandler
updatedAt: event.occurredAt,
version: versionOf(event),
});
// Its subtasks show its title.
if (event.changes.title !== undefined)
await this.upsertMaterializedIssueRepository.renameParent(
event.aggregateId,
event.changes.title,
);
await this.notifier.issue(event.aggregateId, event);
}
}

View File

@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import {
ActivityReadModel,
EpicReadModel,
IssueDetails,
IssueLinkType,
IssueReadModel,
@@ -13,6 +14,12 @@ import {
} from './commands/attach-file.command';
import { DetachFileCommand } from './commands/detach-file.command';
import { WatchIssueCommand } from './commands/watch-issue.command';
import { SetIssueParentCommand } from './commands/set-issue-parent.command';
import {
AddChecklistItemCommand,
RemoveChecklistItemCommand,
UpdateChecklistItemCommand,
} from './commands/change-checklist.command';
import { ChangeIssueStatusCommand } from './commands/change-issue-status.command';
import { CommandResult } from './commands/command-result';
import { CreateIssueCommand } from './commands/create-issue.command';
@@ -29,6 +36,7 @@ import {
import { GetIssueQuery } from './queries/get-issue.query';
import { GetIssuesByIdsQuery } from './queries/get-issues-by-ids.query';
import { SearchIssuesQuery } from './queries/search-issues.query';
import { GetEpicsQuery } from './queries/get-epics.query';
import {
MyIssuesFilter,
SearchMyIssuesQuery,
@@ -41,6 +49,8 @@ export interface CreateIssueInput extends Omit<IssueDetails, 'title' | 'type'> {
title: string;
assigneeId?: string | null;
releaseId?: string | null;
/** Epic or ticket the new one breaks down. */
parentId?: string | null;
}
export interface IssueLinkInput {
@@ -57,7 +67,15 @@ export class IssuesService {
) {}
create(actorId: string, input: CreateIssueInput): Promise<CommandResult> {
const { projectId, type, title, assigneeId, releaseId, ...details } = input;
const {
projectId,
type,
title,
assigneeId,
releaseId,
parentId,
...details
} = input;
return this.commandBus.execute(
new CreateIssueCommand(
actorId,
@@ -67,6 +85,7 @@ export class IssuesService {
details,
assigneeId,
releaseId,
parentId,
),
);
}
@@ -188,6 +207,52 @@ export class IssuesService {
return this.queryBus.execute(new GetIssuesByIdsQuery(viewerId, ids));
}
/** Places the ticket under `parentId` (null: back to the top). */
setParent(
actorId: string,
issueId: string,
parentId: string | null,
): Promise<CommandResult> {
return this.commandBus.execute(
new SetIssueParentCommand(actorId, issueId, parentId),
);
}
addChecklistItem(
actorId: string,
issueId: string,
text: string,
): Promise<CommandResult> {
return this.commandBus.execute(
new AddChecklistItemCommand(actorId, issueId, text),
);
}
updateChecklistItem(
actorId: string,
issueId: string,
itemId: string,
changes: { text?: string; done?: boolean },
): Promise<CommandResult> {
return this.commandBus.execute(
new UpdateChecklistItemCommand(actorId, issueId, itemId, changes),
);
}
removeChecklistItem(
actorId: string,
issueId: string,
itemId: string,
): Promise<CommandResult> {
return this.commandBus.execute(
new RemoveChecklistItemCommand(actorId, issueId, itemId),
);
}
epics(viewerId: string | null, projectId: string): Promise<EpicReadModel[]> {
return this.queryBus.execute(new GetEpicsQuery(viewerId, projectId));
}
search(
viewerId: string | null,
filter: IssueFilter,

View File

@@ -1,4 +1,5 @@
import {
EpicReadModel,
IssuePriority,
IssueReadModel,
ProjectStatsReadModel,
@@ -18,6 +19,8 @@ export interface IssueFilter {
unassigned?: boolean;
releaseId?: string;
labels?: string[];
/** Only the tickets hanging from this one. */
parentId?: string;
search?: string;
limit: number;
offset: number;
@@ -51,6 +54,8 @@ export abstract class FindIssuesRepository {
abstract findOpenAssignedTo(userId: string): Promise<IssueReadModel[]>;
/** Every ticket of a project (statistics). */
abstract findByProject(projectId: string): Promise<IssueReadModel[]>;
/** Top-level tickets with others hanging from them, by key. */
abstract epics(projectId: string): Promise<EpicReadModel[]>;
abstract releaseProgress(
releaseId: string,
): Promise<ReleaseProgressReadModel>;

View File

@@ -10,4 +10,6 @@ export abstract class TrackerStreamsRepository {
abstract commentIdsOfIssue(issueId: string): Promise<string[]>;
/** Tickets that were ever linked to `issueId` (they may have unlinked since). */
abstract issueIdsLinkingTo(issueId: string): Promise<string[]>;
/** Tickets that were ever placed under `issueId` (they may have left since). */
abstract issueIdsEverUnder(issueId: string): Promise<string[]>;
}

View File

@@ -1,5 +1,6 @@
import {
AttachmentReadModel,
ChecklistItemReadModel,
IssueLinkReadModel,
IssueReadModel,
StatusCategory,
@@ -13,7 +14,7 @@ export abstract class UpsertMaterializedIssueRepository {
Partial<
Omit<
IssueReadModel,
'links' | 'commentCount' | 'attachments' | 'watchers'
'links' | 'commentCount' | 'attachments' | 'watchers' | 'checklist'
>
>,
): Promise<void>;
@@ -35,6 +36,19 @@ export abstract class UpsertMaterializedIssueRepository {
/** Adds a follower (idempotent). */
abstract addWatcher(issueId: string, user: UserRefReadModel): Promise<void>;
abstract removeWatcher(issueId: string, userId: string): Promise<void>;
/** Adds a checklist step (idempotent). */
abstract addChecklistItem(
issueId: string,
item: ChecklistItemReadModel,
): Promise<void>;
abstract updateChecklistItem(
issueId: string,
itemId: string,
changes: Partial<Pick<ChecklistItemReadModel, 'text' | 'done'>>,
): Promise<void>;
abstract removeChecklistItem(issueId: string, itemId: string): Promise<void>;
/** Keeps the denormalised parent title of a ticket's children in sync. */
abstract renameParent(parentId: string, title: string): Promise<void>;
abstract setCommentCount(
issueId: string,
commentCount: number,

View File

@@ -0,0 +1,20 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { EpicReadModel } from '@task-stream/api/tracker/domain';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { GetEpicsQuery } from './get-epics.query';
import { ReadAccess } from './read-access';
@QueryHandler(GetEpicsQuery)
export class GetEpicsQueryHandler
implements IQueryHandler<GetEpicsQuery, EpicReadModel[]>
{
constructor(
private readonly findIssues: FindIssuesRepository,
private readonly access: ReadAccess,
) {}
async execute(query: GetEpicsQuery): Promise<EpicReadModel[]> {
await this.access.assertCanView(query.projectId, query.viewerId);
return this.findIssues.epics(query.projectId);
}
}

View File

@@ -0,0 +1,13 @@
import { Query } from '@nestjs/cqrs';
import { EpicReadModel } from '@task-stream/api/tracker/domain';
/** A project's epics: top-level tickets that group others, with progress. */
export class GetEpicsQuery extends Query<EpicReadModel[]> {
constructor(
/** Null: anonymous (public projects only). */
public readonly viewerId: string | null,
public readonly projectId: string,
) {
super();
}
}

View File

@@ -17,3 +17,4 @@ export * from './search-issues.query';
export * from './search-my-issues.query';
export * from './get-public-projects.query';
export * from './get-attachment.query';
export * from './get-epics.query';

View File

@@ -16,6 +16,7 @@ import { GetReleaseProgressQueryHandler } from './get-release-progress.query-han
import { GetReleasesQueryHandler } from './get-releases.query-handler';
import { GetUpcomingReleasesQueryHandler } from './get-upcoming-releases.query-handler';
import { SearchIssuesQueryHandler } from './search-issues.query-handler';
import { GetEpicsQueryHandler } from './get-epics.query-handler';
import { SearchMyIssuesQueryHandler } from './search-my-issues.query-handler';
export const QUERY_HANDLERS = [
@@ -36,6 +37,7 @@ export const QUERY_HANDLERS = [
GetReleasesQueryHandler,
GetUpcomingReleasesQueryHandler,
SearchIssuesQueryHandler,
GetEpicsQueryHandler,
SearchMyIssuesQueryHandler,
GetAttachmentQueryHandler,
];

View File

@@ -7,6 +7,7 @@ import {
import { from, mergeMap, Observable } from 'rxjs';
import { DeleteIssueCommand } from '../commands/delete-issue.command';
import { DeleteReleaseCommand } from '../commands/delete-release.command';
import { DetachFromParentCommand } from '../commands/detach-from-parent.command';
import { DropLinksToIssueCommand } from '../commands/drop-links-to-issue.command';
import { PurgeCommentCommand } from '../commands/purge-comment.command';
import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
@@ -17,7 +18,8 @@ import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
*
* ProjectDeleted → DeleteIssue (each ticket) + DeleteRelease (each release)
* IssueDeleted → PurgeComment (each comment) + DropLinksToIssue (tickets
* of other projects that pointed to it)
* of other projects that pointed to it) + DetachFromParent
* (tickets that hung from it go back to the top)
*
* Who to delete comes from the event store, not the read model. The changes
* are attributed to whoever deleted the project.
@@ -48,7 +50,7 @@ export class ProjectDeletionSaga {
ofType(IssueDeletedEvent),
mergeMap((event) =>
from(this.dependentsOf(event.aggregateId)).pipe(
mergeMap(({ commentIds, linkingIds }) => [
mergeMap(({ commentIds, linkingIds, childIds }) => [
...commentIds.map(
(id) => new PurgeCommentCommand(event.actorId, id),
),
@@ -60,6 +62,14 @@ export class ProjectDeletionSaga {
event.aggregateId,
),
),
...childIds.map(
(id) =>
new DetachFromParentCommand(
event.actorId,
id,
event.aggregateId,
),
),
]),
),
),
@@ -74,13 +84,15 @@ export class ProjectDeletionSaga {
}
private async dependentsOf(issueId: string) {
const [commentIds, linkingIds] = await Promise.all([
const [commentIds, linkingIds, childIds] = await Promise.all([
this.streams.commentIdsOfIssue(issueId),
this.streams.issueIdsLinkingTo(issueId),
this.streams.issueIdsEverUnder(issueId),
]);
return {
commentIds,
linkingIds: linkingIds.filter((id) => id !== issueId),
childIds,
};
}
}

View File

@@ -39,12 +39,14 @@ import { ChangeIssueStatusCommand } from '../commands/change-issue-status.comman
import { COMMAND_HANDLERS } from '../commands/command-handlers';
import { CommandResult } from '../commands/command-result';
import { CreateIssueCommand } from '../commands/create-issue.command';
import { DeleteIssueCommand } from '../commands/delete-issue.command';
import { CreateProjectCommand } from '../commands/create-project.command';
import { CreateReleaseCommand } from '../commands/create-release.command';
import { DeleteProjectCommand } from '../commands/delete-project.command';
import { LinkIssuesCommand } from '../commands/link-issues.command';
import { AssignIssueCommand } from '../commands/assign-issue.command';
import { WriteAccess } from '../commands/write-access';
import { IssueTree } from '../commands/issue-tree';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { IssueNumbersRepository } from '../ports/issue-numbers.repository';
import { ProjectKeysRepository } from '../ports/project-keys.repository';
@@ -72,6 +74,7 @@ async function setup() {
ReleaseArchivedSaga,
UserDeactivatedSaga,
WriteAccess,
IssueTree,
AggregateRehydrator,
{ provide: EventStore, useValue: store },
{ provide: AttachmentStorage, useValue: files },
@@ -178,6 +181,31 @@ describe('sagas', () => {
).resolves.toMatchObject({ key: 'WEB' });
});
it('a deleted ticket lets the tickets under it go back to the top', async () => {
const { run, drain, rehydrator } = await setup();
const web = (await run(new CreateProjectCommand('owner', 'WEB', 'Web'))).id;
const epic = (await run(ticket(web, 'Epic'))).id;
const child = (
await run(
new CreateIssueCommand(
'owner',
web,
'TASK',
'child',
{},
null,
null,
epic,
),
)
).id;
await run(new DeleteIssueCommand('owner', epic));
await drain();
expect((await rehydrator.rehydrate(child, Issue)).parentId).toBeNull();
});
it('only the owner deletes a project', async () => {
const { run } = await setup();
const web = (await run(new CreateProjectCommand('owner', 'WEB', 'Web'))).id;

View File

@@ -4,6 +4,7 @@ import { TRACKER_FACTORIES } from '@task-stream/api/tracker/domain';
import { ChangesService } from './changes.service';
import { COMMAND_HANDLERS } from './commands/command-handlers';
import { WriteAccess } from './commands/write-access';
import { IssueTree } from './commands/issue-tree';
import { CommentsService } from './comments.service';
import {
EVENT_HANDLERS,
@@ -59,6 +60,7 @@ const SERVICES = [
...SERVICES,
...TRACKER_FACTORIES,
WriteAccess,
IssueTree,
ReadAccess,
ReadModelNotifier,
...COMMAND_HANDLERS,

View File

@@ -256,6 +256,13 @@ export class InMemoryTrackerStreams extends TrackerStreamsRepository {
(d) => (d['link'] as { targetIssueId: string }).targetIssueId === issueId,
);
}
async issueIdsEverUnder(issueId: string) {
return this.streams(
'IssueParentChangedEvent',
(d) => d['parentId'] === issueId,
);
}
}
/** Attachment storage double: files in a map, keyed like on disk. */