import { BusinessRuleError, VersionedAggregateRoot, assertThat, } from '@task-stream/api/shared/domain'; import { ReleaseArchivedEvent } from '../events/release-archived.event'; import { ReleaseCreatedEvent } from '../events/release-created.event'; import { ReleaseDeletedEvent } from '../events/release-deleted.event'; import { ReleaseNotesWrittenEvent } from '../events/release-notes-written.event'; import { ReleaseShippedEvent } from '../events/release-shipped.event'; import { ReleaseUpdatedEvent } from '../events/release-updated.event'; import { ReleaseStatus } from './release-status'; /** Data of a release (what ReleaseCreatedEvent carries). */ export interface ReleaseProps { id: string; projectId: string; name: string; description: string | null; targetDate: string | null; status: ReleaseStatus; releasedAt: string | null; createdAt: string; } export const MAX_RELEASE_NOTES = 20_000; /** A version of the product: controls which tickets ship together. */ export class Release extends VersionedAggregateRoot implements ReleaseProps { public projectId!: string; public name!: string; public description: string | null = null; public targetDate: string | null = null; public status: ReleaseStatus = ReleaseStatus.PLANNED; public releasedAt: string | null = null; /** What shipped, told for people (Markdown). */ public notes: string | null = null; public createdAt!: string; public deleted = false; constructor(id: string) { super(); this.id = id; } /** Only planned releases can receive or lose tickets. */ assertAcceptsIssues(): void { if (this.status !== ReleaseStatus.PLANNED) { throw new BusinessRuleError( `Release ${this.name} is ${this.status} and is locked`, ); } } update( actorId: string, changes: { name?: string; description?: string | null; targetDate?: string | null; }, ): void { this.assertAcceptsIssues(); const patch: { name?: string; description?: string | null; targetDate?: string | null; } = {}; if (changes.name !== undefined) { const name = changes.name.trim(); assertThat(name.length > 0, 'Release name is required'); if (name !== this.name) patch.name = name; } if (changes.description !== undefined) { const description = changes.description?.trim() || null; if (description !== this.description) patch.description = description; } if ( changes.targetDate !== undefined && changes.targetDate !== this.targetDate ) { if (changes.targetDate) { assertThat( !Number.isNaN(Date.parse(changes.targetDate)), 'targetDate must be an ISO date', ); } patch.targetDate = changes.targetDate; } if (Object.keys(patch).length === 0) return; this.apply( new ReleaseUpdatedEvent(actorId, this.id, this.projectId, patch), ); } /** * Ships the release. Unfinished tickets block it unless forced, so nothing * goes out unnoticed. (Not called `publish`: that name belongs to AggregateRoot.) */ ship(actorId: string, unfinishedIssues: number, force = false): void { this.assertAcceptsIssues(); if (unfinishedIssues > 0 && !force) { throw new BusinessRuleError( `Release ${this.name} still has ${unfinishedIssues} unfinished ticket(s); move them out or publish with force`, ); } this.apply( new ReleaseShippedEvent( actorId, this.id, this.projectId, unfinishedIssues, ), ); } /** * Writes the notes. Unlike the rest of the release they can be written * after it ships: that is usually when they are needed. Not once archived. */ writeNotes(actorId: string, notes: string | null): void { if (this.status === ReleaseStatus.ARCHIVED) throw new BusinessRuleError(`Release ${this.name} is archived`); const next = notes?.trim() || null; assertThat( (next?.length ?? 0) <= MAX_RELEASE_NOTES, `Release notes are at most ${MAX_RELEASE_NOTES} characters`, ); if (next === this.notes) return; this.apply( new ReleaseNotesWrittenEvent(actorId, this.id, this.projectId, next), ); } archive(actorId: string): void { if (this.status === ReleaseStatus.ARCHIVED) return; this.apply(new ReleaseArchivedEvent(actorId, this.id, this.projectId)); } /** Deletes the release (its project was deleted). Idempotent. */ delete(actorId: string): void { if (this.deleted) return; this.apply(new ReleaseDeletedEvent(actorId, this.id, this.projectId)); } /* ---- State transitions (also replayed by loadFromHistory) ---- */ onReleaseCreatedEvent({ release }: ReleaseCreatedEvent): void { Object.assign(this, release); } onReleaseUpdatedEvent(event: ReleaseUpdatedEvent): void { Object.assign(this, event.changes); } onReleaseShippedEvent(event: ReleaseShippedEvent): void { this.status = ReleaseStatus.RELEASED; this.releasedAt = event.occurredAt; } onReleaseNotesWrittenEvent(event: ReleaseNotesWrittenEvent): void { this.notes = event.notes; } onReleaseArchivedEvent(): void { this.status = ReleaseStatus.ARCHIVED; } onReleaseDeletedEvent(): void { this.deleted = true; } }