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>
565 lines
18 KiB
TypeScript
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;
|
|
}
|