import { BusinessRuleError, VersionedAggregateRoot, assertThat, } from '@task-stream/api/shared/domain'; import { IssueAssignedEvent } from '../events/issue-assigned.event'; import { IssueCreatedEvent } from '../events/issue-created.event'; import { IssueLinkedEvent } from '../events/issue-linked.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 { IssueArea, IssueLinkType, IssuePriority, IssueStatus, IssueType, } from './issue.enums'; import { IssueWorkflow } from './issue-workflow'; export interface IssueLink { type: IssueLinkType; targetIssueId: string; targetKey: string; } /** Fields editable after creation. */ export interface IssueDetails { title?: string; description?: string | null; type?: IssueType; area?: IssueArea | 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: IssueType; area: IssueArea | null; title: string; description: string | null; status: IssueStatus; 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!: IssueType; public area: IssueArea | null = null; public title!: string; public description: string | null = null; public status: IssueStatus = IssueStatus.BACKLOG; 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 createdAt!: string; public updatedAt!: string; constructor(id: string) { super(); this.id = id; } updateDetails(actorId: string, changes: IssueDetails): 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 (Object.keys(patch).length === 0) return; this.apply(new IssueUpdatedEvent(actorId, this.id, this.projectId, patch)); } changeStatus(actorId: string, to: IssueStatus, reason?: string | null): void { const from = this.status; if (from === to) return; if (!IssueWorkflow.canTransition(from, to)) { throw new BusinessRuleError( `Cannot move ${this.key} from ${from} to ${to}. Allowed: ${IssueWorkflow.allowedFrom(from).join(', ')}`, ); } const trimmedReason = reason?.trim() || null; if (IssueWorkflow.requiresReason(to) && !trimmedReason) { throw new BusinessRuleError( `A reason is required to move ${this.key} to ${to}`, ); } this.apply( new IssueStatusChangedEvent( actorId, this.id, this.projectId, from, to, trimmedReason, ), ); } /** @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, ), ); } /* ---- 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; } 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; }