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)[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; }