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

@@ -7,9 +7,13 @@ import {
import { IssueAssignedEvent } from '../events/issue-assigned.event';
import { IssueAttachmentAddedEvent } from '../events/issue-attachment-added.event';
import { IssueAttachmentRemovedEvent } from '../events/issue-attachment-removed.event';
import { IssueChecklistItemAddedEvent } from '../events/issue-checklist-item-added.event';
import { IssueChecklistItemRemovedEvent } from '../events/issue-checklist-item-removed.event';
import { IssueChecklistItemUpdatedEvent } from '../events/issue-checklist-item-updated.event';
import { IssueCreatedEvent } from '../events/issue-created.event';
import { IssueDeletedEvent } from '../events/issue-deleted.event';
import { IssueLinkedEvent } from '../events/issue-linked.event';
import { IssueParentChangedEvent } from '../events/issue-parent-changed.event';
import { IssueReleaseChangedEvent } from '../events/issue-release-changed.event';
import { IssueStatusChangedEvent } from '../events/issue-status-changed.event';
import { IssueUnlinkedEvent } from '../events/issue-unlinked.event';
@@ -22,6 +26,12 @@ import {
MAX_ATTACHMENTS_PER_ISSUE,
normalizeAttachment,
} from './issue-attachment';
import {
ChecklistItem,
MAX_CHECKLIST_ITEMS,
normalizeChecklistText,
} from './issue-checklist';
import { ParentCandidate, assertCanNest } from './issue-hierarchy';
import { IssueLinkType, IssuePriority } from './issue.enums';
export interface IssueLink {
@@ -95,6 +105,9 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
public attachments: IssueAttachment[] = [];
/** People who asked to be told about this ticket. */
public watchers: string[] = [];
/** The epic or ticket this one breaks down. */
public parentId: string | null = null;
public checklist: ChecklistItem[] = [];
public createdAt!: string;
public updatedAt!: string;
public deleted = false;
@@ -280,6 +293,91 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
);
}
/**
* Places the ticket under `parent` (null takes it out). `childHeight` is
* how many levels of tickets already hang from this one.
*/
setParent(
actorId: string,
parent: ParentCandidate | null,
childHeight = 0,
): void {
if ((parent?.id ?? null) === this.parentId) return;
if (parent) assertCanNest(this, parent, childHeight);
this.apply(
new IssueParentChangedEvent(
actorId,
this.id,
this.projectId,
parent?.id ?? null,
parent?.key ?? null,
this.parentId,
),
);
}
/** Adds a step to the checklist; `id` is generated by the caller. */
addChecklistItem(actorId: string, id: string, text: string): void {
if (this.checklist.some((i) => i.id === id)) return;
if (this.checklist.length >= MAX_CHECKLIST_ITEMS) {
throw new BusinessRuleError(
`${this.key} already has ${MAX_CHECKLIST_ITEMS} checklist items`,
);
}
this.apply(
new IssueChecklistItemAddedEvent(actorId, this.id, this.projectId, {
id,
text: normalizeChecklistText(text),
done: false,
}),
);
}
/** Rewords, ticks or unticks a step. Nothing changes, nothing happens. */
updateChecklistItem(
actorId: string,
itemId: string,
changes: { text?: string; done?: boolean },
): void {
const item = this.checklistItem(itemId);
const patch: { text?: string; done?: boolean } = {};
if (changes.text !== undefined) {
const text = normalizeChecklistText(changes.text);
if (text !== item.text) patch.text = text;
}
if (changes.done !== undefined && changes.done !== item.done)
patch.done = changes.done;
if (Object.keys(patch).length === 0) return;
this.apply(
new IssueChecklistItemUpdatedEvent(
actorId,
this.id,
this.projectId,
itemId,
patch,
),
);
}
removeChecklistItem(actorId: string, itemId: string): void {
const item = this.checklistItem(itemId);
this.apply(
new IssueChecklistItemRemovedEvent(
actorId,
this.id,
this.projectId,
item.id,
item.text,
),
);
}
private checklistItem(itemId: string): ChecklistItem {
const item = this.checklist.find((i) => i.id === itemId);
if (!item) throw new BusinessRuleError('Checklist item not found');
return item;
}
/** Deletes the ticket (its project was deleted). Idempotent. */
delete(actorId: string): void {
if (this.deleted) return;
@@ -360,6 +458,32 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
this.watchers = this.watchers.filter((id) => id !== event.userId);
}
onIssueParentChangedEvent(event: IssueParentChangedEvent): void {
this.parentId = event.parentId;
this.updatedAt = event.occurredAt;
}
onIssueChecklistItemAddedEvent(event: IssueChecklistItemAddedEvent): void {
this.checklist = [...this.checklist, { ...event.item }];
this.updatedAt = event.occurredAt;
}
onIssueChecklistItemUpdatedEvent(
event: IssueChecklistItemUpdatedEvent,
): void {
this.checklist = this.checklist.map((i) =>
i.id === event.itemId ? { ...i, ...event.changes } : i,
);
this.updatedAt = event.occurredAt;
}
onIssueChecklistItemRemovedEvent(
event: IssueChecklistItemRemovedEvent,
): void {
this.checklist = this.checklist.filter((i) => i.id !== event.itemId);
this.updatedAt = event.occurredAt;
}
onIssueDeletedEvent(): void {
this.deleted = true;
}