Files
task-stream/libs/api/tracker/domain/src/lib/issue/issue.ts
Josep Piguillem 0606f53026 feat: an ordered backlog
A Backlog tab lists the tickets still to start in the order the team will
take them; drag them, or use the arrows, to reorder. Moving a ticket only
rewrites that ticket's rank, read from the event store, and board columns
follow the same order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:01:44 +02:00

565 lines
18 KiB
TypeScript

import {
BusinessRuleError,
ForbiddenError,
VersionedAggregateRoot,
assertThat,
} from '@task-stream/api/shared/domain';
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 { IssueRankedEvent } from '../events/issue-ranked.event';
import { IssueReleaseChangedEvent } from '../events/issue-release-changed.event';
import { IssueStatusChangedEvent } from '../events/issue-status-changed.event';
import { IssueUnlinkedEvent } from '../events/issue-unlinked.event';
import { IssueUpdatedEvent } from '../events/issue-updated.event';
import { IssueUnwatchedEvent } from '../events/issue-unwatched.event';
import { IssueWatchedEvent } from '../events/issue-watched.event';
import { ProjectWorkflow } from '../project/project-settings';
import {
IssueAttachment,
MAX_ATTACHMENTS_PER_ISSUE,
normalizeAttachment,
} from './issue-attachment';
import {
ChecklistItem,
MAX_CHECKLIST_ITEMS,
normalizeChecklistText,
} from './issue-checklist';
import { ParentCandidate, assertCanNest } from './issue-hierarchy';
import { implicitRank, rankBetween } from './issue-rank';
import { IssueLinkType, IssuePriority } from './issue.enums';
export interface IssueLink {
type: IssueLinkType;
targetIssueId: string;
targetKey: string;
}
/** Fields editable after creation. */
export interface IssueDetails {
title?: string;
description?: string | null;
/** Id of one of the project's ticket types. */
type?: string;
/** Id of one of the project's areas. */
area?: string | null;
priority?: IssuePriority;
labels?: string[];
figmaUrl?: string | null;
storyPoints?: number | null;
dueDate?: string | null;
}
/** Data of a ticket (what IssueCreatedEvent carries). */
export interface IssueProps {
id: string;
projectId: string;
number: number;
key: string;
type: string;
area: string | null;
title: string;
description: string | null;
/** Id of a status of the project's workflow. */
status: string;
priority: IssuePriority;
reporterId: string;
assigneeId: string | null;
releaseId: string | null;
labels: string[];
figmaUrl: string | null;
storyPoints: number | null;
dueDate: string | null;
links: IssueLink[];
createdAt: string;
updatedAt: string;
}
/**
* A ticket: bug, feature, UI change, task or QA. Event-sourced: create it
* with IssueFactory, load it with the AggregateRehydrator.
*/
export class Issue extends VersionedAggregateRoot implements IssueProps {
public projectId!: string;
public number!: number;
public key!: string;
public type!: string;
public area: string | null = null;
public title!: string;
public description: string | null = null;
public status!: string;
public priority: IssuePriority = IssuePriority.MEDIUM;
public reporterId!: string;
public assigneeId: string | null = null;
public releaseId: string | null = null;
public labels: string[] = [];
public figmaUrl: string | null = null;
public storyPoints: number | null = null;
public dueDate: string | null = null;
public links: IssueLink[] = [];
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[] = [];
/** Place in the backlog once someone moved it (see `backlogRank`). */
public rank: string | null = null;
public createdAt!: string;
public updatedAt!: string;
public deleted = false;
constructor(id: string) {
super();
this.id = id;
}
/** `workflow` (the project's) validates a new type or area. */
updateDetails(
actorId: string,
changes: IssueDetails,
workflow: ProjectWorkflow = ProjectWorkflow.default(),
): void {
const next = normalizeIssueDetails(changes);
const patch: IssueDetails = {};
for (const field of Object.keys(next) as (keyof IssueDetails)[]) {
const value = next[field];
if (value === undefined) continue;
if (JSON.stringify(value) === JSON.stringify(this[field])) continue;
(patch as Record<string, unknown>)[field] = value;
}
if (patch.title !== undefined)
assertThat(patch.title.length > 0, 'Issue title is required');
if (patch.type !== undefined) workflow.assertActiveType(patch.type);
if (patch.area !== undefined) workflow.assertActiveArea(patch.area);
if (Object.keys(patch).length === 0) return;
this.apply(new IssueUpdatedEvent(actorId, this.id, this.projectId, patch));
}
/** Moves the ticket along its project's workflow. */
changeStatus(
actorId: string,
to: string,
reason?: string | null,
workflow: ProjectWorkflow = ProjectWorkflow.default(),
): void {
const from = this.status;
if (from === to) return;
if (!workflow.canTransition(from, to)) {
throw new BusinessRuleError(
`Cannot move ${this.key} from ${workflow.statusName(from)} to ${workflow.statusName(to)}. ` +
`Allowed: ${
workflow
.allowedFrom(from)
.map((s) => workflow.statusName(s))
.join(', ') || 'none'
}`,
);
}
const trimmedReason = reason?.trim() || null;
if (workflow.requiresReason(to) && !trimmedReason) {
throw new BusinessRuleError(
`A reason is required to move ${this.key} to ${workflow.statusName(to)}`,
);
}
this.apply(
new IssueStatusChangedEvent(
actorId,
this.id,
this.projectId,
from,
to,
trimmedReason,
{
fromName: workflow.statusName(from),
toName: workflow.statusName(to),
fromCategory: workflow.categoryOf(from),
toCategory: workflow.categoryOf(to),
},
),
);
}
/** @param assigneeId null to unassign */
assign(actorId: string, assigneeId: string | null): void {
if (this.assigneeId === assigneeId) return;
this.apply(
new IssueAssignedEvent(
actorId,
this.id,
this.projectId,
assigneeId,
this.assigneeId,
),
);
}
linkTo(
actorId: string,
type: IssueLinkType,
target: { id: string; key: string },
): void {
if (target.id === this.id)
throw new BusinessRuleError('An issue cannot be linked to itself');
if (
this.links.some((l) => l.type === type && l.targetIssueId === target.id)
) {
throw new BusinessRuleError(`${this.key} already ${type} ${target.key}`);
}
this.apply(
new IssueLinkedEvent(actorId, this.id, this.projectId, {
type,
targetIssueId: target.id,
targetKey: target.key,
}),
);
}
unlink(actorId: string, type: IssueLinkType, targetIssueId: string): void {
const link = this.links.find(
(l) => l.type === type && l.targetIssueId === targetIssueId,
);
if (!link) throw new BusinessRuleError('Link not found');
this.apply(
new IssueUnlinkedEvent(actorId, this.id, this.projectId, { ...link }),
);
}
/** Moves the ticket in or out of a release (null removes it). */
setRelease(actorId: string, releaseId: string | null): void {
if (this.releaseId === releaseId) return;
this.apply(
new IssueReleaseChangedEvent(
actorId,
this.id,
this.projectId,
releaseId,
this.releaseId,
),
);
}
/** Records a file stored for this ticket (or one of its comments). */
attach(actorId: string, attachment: IssueAttachment): void {
const next = normalizeAttachment(attachment);
if (this.attachments.some((a) => a.id === next.id)) return;
if (this.attachments.length >= MAX_ATTACHMENTS_PER_ISSUE) {
throw new BusinessRuleError(
`${this.key} already has ${MAX_ATTACHMENTS_PER_ISSUE} attachments; remove one first`,
);
}
this.apply(
new IssueAttachmentAddedEvent(actorId, this.id, this.projectId, next),
);
}
/**
* Removes a file. Whoever uploaded it may, and so may whoever manages the
* project (`canManage`).
*/
detach(actorId: string, attachmentId: string, canManage: boolean): void {
const attachment = this.attachments.find((a) => a.id === attachmentId);
if (!attachment) throw new BusinessRuleError('Attachment not found');
if (attachment.uploadedBy !== actorId && !canManage) {
throw new ForbiddenError(
'Only whoever uploaded a file, or a project admin, can remove it',
);
}
this.apply(
new IssueAttachmentRemovedEvent(
actorId,
this.id,
this.projectId,
attachment.id,
attachment.fileName,
),
);
}
/** `userId` starts following the ticket. Idempotent. */
watch(actorId: string, userId: string): void {
if (this.watchers.includes(userId)) return;
this.apply(new IssueWatchedEvent(actorId, this.id, this.projectId, userId));
}
/** `userId` stops following the ticket. Idempotent. */
unwatch(actorId: string, userId: string): void {
if (!this.watchers.includes(userId)) return;
this.apply(
new IssueUnwatchedEvent(actorId, this.id, this.projectId, userId),
);
}
/**
* 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,
),
);
}
/** Where it sits in the backlog: where it was put, or its creation order. */
get backlogRank(): string {
return this.rank ?? implicitRank(this.number);
}
/** Puts the ticket between two others of the backlog (null: an end). */
moveBetween(actorId: string, above: Issue | null, below: Issue | null): void {
for (const neighbour of [above, below]) {
if (neighbour?.id === this.id)
throw new BusinessRuleError(`${this.key} cannot move next to itself`);
if (neighbour && neighbour.projectId !== this.projectId)
throw new BusinessRuleError(
`${neighbour.key} belongs to another project`,
);
}
const rank = rankBetween(
above?.backlogRank ?? null,
below?.backlogRank ?? null,
);
this.apply(new IssueRankedEvent(actorId, this.id, this.projectId, rank));
}
/** 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;
this.apply(
new IssueDeletedEvent(actorId, this.id, this.projectId, this.key),
);
}
/** Drops every link to `targetIssueId` (that ticket was deleted). */
unlinkAllTo(actorId: string, targetIssueId: string): void {
for (const link of this.links.filter(
(l) => l.targetIssueId === targetIssueId,
)) {
this.apply(
new IssueUnlinkedEvent(actorId, this.id, this.projectId, { ...link }),
);
}
}
/* ---- State transitions (also replayed by loadFromHistory) ---- */
onIssueCreatedEvent({ issue }: IssueCreatedEvent): void {
Object.assign(this, {
...issue,
labels: [...issue.labels],
links: issue.links.map((l) => ({ ...l })),
});
}
onIssueUpdatedEvent(event: IssueUpdatedEvent): void {
Object.assign(this, event.changes);
this.updatedAt = event.occurredAt;
}
onIssueStatusChangedEvent(event: IssueStatusChangedEvent): void {
this.status = event.to;
this.updatedAt = event.occurredAt;
}
onIssueAssignedEvent(event: IssueAssignedEvent): void {
this.assigneeId = event.assigneeId;
this.updatedAt = event.occurredAt;
}
onIssueLinkedEvent(event: IssueLinkedEvent): void {
this.links = [...this.links, { ...event.link }];
this.updatedAt = event.occurredAt;
}
onIssueUnlinkedEvent(event: IssueUnlinkedEvent): void {
this.links = this.links.filter(
(l) =>
!(
l.type === event.link.type &&
l.targetIssueId === event.link.targetIssueId
),
);
this.updatedAt = event.occurredAt;
}
onIssueAttachmentAddedEvent(event: IssueAttachmentAddedEvent): void {
this.attachments = [...this.attachments, { ...event.attachment }];
this.updatedAt = event.occurredAt;
}
onIssueAttachmentRemovedEvent(event: IssueAttachmentRemovedEvent): void {
this.attachments = this.attachments.filter(
(a) => a.id !== event.attachmentId,
);
this.updatedAt = event.occurredAt;
}
onIssueWatchedEvent(event: IssueWatchedEvent): void {
this.watchers = [...this.watchers, event.userId];
}
onIssueUnwatchedEvent(event: IssueUnwatchedEvent): void {
this.watchers = this.watchers.filter((id) => id !== event.userId);
}
onIssueParentChangedEvent(event: IssueParentChangedEvent): void {
this.parentId = event.parentId;
this.updatedAt = event.occurredAt;
}
onIssueRankedEvent(event: IssueRankedEvent): void {
// Reordering is not a change to the ticket: `updatedAt` stays.
this.rank = event.rank;
}
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;
}
onIssueReleaseChangedEvent(event: IssueReleaseChangedEvent): void {
this.releaseId = event.releaseId;
this.updatedAt = event.occurredAt;
}
}
/** Trims, deduplicates and validates editable issue fields. */
export function normalizeIssueDetails(details: IssueDetails): IssueDetails {
const out: IssueDetails = { ...details };
if (details.title !== undefined) out.title = details.title.trim();
if (details.description !== undefined)
out.description = details.description?.trim() || null;
if (details.labels !== undefined) {
out.labels = [
...new Set(
details.labels.map((l) => l.trim().toLowerCase()).filter(Boolean),
),
];
}
if (details.figmaUrl !== undefined && details.figmaUrl !== null) {
const url = details.figmaUrl.trim();
assertThat(
/^https:\/\/([\w-]+\.)?figma\.com\//.test(url),
'figmaUrl must be a figma.com link',
);
out.figmaUrl = url;
}
if (details.storyPoints !== undefined && details.storyPoints !== null) {
assertThat(
Number.isInteger(details.storyPoints) &&
details.storyPoints >= 0 &&
details.storyPoints <= 100,
'storyPoints must be an integer between 0 and 100',
);
}
if (details.dueDate !== undefined && details.dueDate !== null) {
assertThat(
!Number.isNaN(Date.parse(details.dueDate)),
'dueDate must be an ISO date',
);
}
return out;
}