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

@@ -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,