import { BusinessRuleError, VersionedAggregateRoot, assertThat, } from '@task-stream/api/shared/domain'; import { SprintClosedEvent } from '../events/sprint-closed.event'; import { SprintCreatedEvent } from '../events/sprint-created.event'; import { SprintDeletedEvent } from '../events/sprint-deleted.event'; import { SprintStartedEvent } from '../events/sprint-started.event'; import { SprintUpdatedEvent } from '../events/sprint-updated.event'; import { SprintStatus } from './sprint-status'; /** Data of a sprint (what SprintCreatedEvent carries). */ export interface SprintProps { id: string; projectId: string; name: string; goal: string | null; /** First and last day, `YYYY-MM-DD`. */ startDate: string; endDate: string; status: SprintStatus; startedAt: string | null; closedAt: string | null; createdAt: string; } export type SprintDetails = Partial< Pick >; const DATE = /^\d{4}-\d{2}-\d{2}$/; /** Long enough for any real iteration, short enough to catch a typo. */ export const MAX_SPRINT_DAYS = 90; /** Checks the name and the dates of a sprint; returns them tidied. */ export function normalizeSprintDetails( details: Required> & Pick, ): Required> { const name = details.name.trim(); assertThat( name.length > 0 && name.length <= 80, 'A sprint needs a name (up to 80 characters)', ); for (const date of [details.startDate, details.endDate]) assertThat( DATE.test(date) && !Number.isNaN(Date.parse(date)), 'Sprint dates must be YYYY-MM-DD', ); const days = (Date.parse(details.endDate) - Date.parse(details.startDate)) / 86_400_000; assertThat(days >= 0, 'A sprint cannot end before it starts'); assertThat( days < MAX_SPRINT_DAYS, `A sprint lasts at most ${MAX_SPRINT_DAYS} days`, ); return { name, goal: details.goal?.trim() || null, startDate: details.startDate, endDate: details.endDate, }; } /** A time-boxed iteration: the tickets the team commits to for a few weeks. */ export class Sprint extends VersionedAggregateRoot implements SprintProps { public projectId!: string; public name!: string; public goal: string | null = null; public startDate!: string; public endDate!: string; public status: SprintStatus = SprintStatus.PLANNED; public startedAt: string | null = null; public closedAt: string | null = null; public createdAt!: string; public deleted = false; constructor(id: string) { super(); this.id = id; } /** Closed sprints keep what they had: their record is fixed. */ assertAcceptsIssues(): void { if (this.status === SprintStatus.CLOSED) throw new BusinessRuleError(`Sprint ${this.name} is closed`); } update(actorId: string, changes: SprintDetails): void { this.assertAcceptsIssues(); const next = normalizeSprintDetails({ name: changes.name ?? this.name, goal: changes.goal !== undefined ? changes.goal : this.goal, startDate: changes.startDate ?? this.startDate, endDate: changes.endDate ?? this.endDate, }); if ( this.status === SprintStatus.ACTIVE && next.startDate !== this.startDate ) throw new BusinessRuleError('A sprint under way keeps its start date'); const patch: SprintDetails = {}; for (const field of ['name', 'goal', 'startDate', 'endDate'] as const) if (next[field] !== this[field]) (patch as Record)[field] = next[field]; if (Object.keys(patch).length === 0) return; this.apply(new SprintUpdatedEvent(actorId, this.id, this.projectId, patch)); } /** `otherActive`: the sprint already under way in the project, if any. */ start(actorId: string, otherActive: { name: string } | null): void { if (this.status !== SprintStatus.PLANNED) throw new BusinessRuleError(`Sprint ${this.name} is ${this.status}`); if (otherActive) throw new BusinessRuleError( `Sprint ${otherActive.name} is under way; close it first`, ); this.apply(new SprintStartedEvent(actorId, this.id, this.projectId)); } /** * Ends the sprint. Its unfinished tickets go to `carryOverTo` (another * open sprint of the project) or, with null, back to the backlog. */ close( actorId: string, carryOverTo: Sprint | null, unfinishedIssues: number, ): void { if (this.status !== SprintStatus.ACTIVE) throw new BusinessRuleError( `Only a sprint under way can be closed; ${this.name} is ${this.status}`, ); if (carryOverTo) { if ( carryOverTo.id === this.id || carryOverTo.projectId !== this.projectId ) throw new BusinessRuleError( 'Unfinished tickets go to another sprint of the same project', ); carryOverTo.assertAcceptsIssues(); } this.apply( new SprintClosedEvent( actorId, this.id, this.projectId, carryOverTo?.id ?? null, unfinishedIssues, ), ); } /** * Removes a sprint that never started (its tickets go back to the * backlog), or any sprint when its project is deleted (`cascade`). */ delete(actorId: string, cascade = false): void { if (this.deleted) return; if (!cascade && this.status !== SprintStatus.PLANNED) throw new BusinessRuleError( `Sprint ${this.name} is ${this.status}; only planned sprints can be deleted`, ); this.apply(new SprintDeletedEvent(actorId, this.id, this.projectId)); } /* ---- State transitions (also replayed by loadFromHistory) ---- */ onSprintCreatedEvent({ sprint }: SprintCreatedEvent): void { Object.assign(this, sprint); } onSprintUpdatedEvent(event: SprintUpdatedEvent): void { Object.assign(this, event.changes); } onSprintStartedEvent(event: SprintStartedEvent): void { this.status = SprintStatus.ACTIVE; this.startedAt = event.occurredAt; } onSprintClosedEvent(event: SprintClosedEvent): void { this.status = SprintStatus.CLOSED; this.closedAt = event.occurredAt; } onSprintDeletedEvent(): void { this.deleted = true; } }