import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs'; import { Issue, Project, Sprint, SprintFactory, SprintStatus, StatusCategory, } from '@task-stream/api/tracker/domain'; import { FindIssuesRepository } from '../ports/find-issues.repository'; import { FindSprintsRepository } from '../ports/find-sprints.repository'; import { CommandResult, ack } from './command-result'; import { CarryOverIssueCommand, CloseSprintCommand, CreateSprintCommand, DeleteSprintCommand, SetIssueSprintCommand, StartSprintCommand, UpdateSprintCommand, } from './sprint.commands'; import { WriteAccess } from './write-access'; /* Sprints are planned and run by whoever manages the project; any member puts tickets in them. */ @CommandHandler(CreateSprintCommand) export class CreateSprintCommandHandler implements ICommandHandler { constructor( private readonly access: WriteAccess, private readonly sprintFactory: SprintFactory, private readonly eventPublisher: EventPublisher, ) {} async execute(command: CreateSprintCommand): Promise { const project = await this.access.project(command.projectId); project.assertCanManage(command.actorId); const sprint = this.sprintFactory.create(command.actorId, { projectId: project.id, name: command.name, goal: command.goal, startDate: command.startDate, endDate: command.endDate, }); this.eventPublisher.mergeObjectContext(sprint); await sprint.commitAndWait(); return ack(sprint); } } /** The sprint, checking the actor manages its project. */ async function managedSprint( access: WriteAccess, sprintId: string, actorId: string, ): Promise { const sprint = await access.sprint(sprintId); (await access.project(sprint.projectId)).assertCanManage(actorId); return sprint; } @CommandHandler(UpdateSprintCommand) export class UpdateSprintCommandHandler implements ICommandHandler { constructor(private readonly access: WriteAccess) {} async execute(command: UpdateSprintCommand): Promise { const sprint = await managedSprint( this.access, command.sprintId, command.actorId, ); sprint.update(command.actorId, command.changes); await sprint.commitAndWait(); return ack(sprint); } } @CommandHandler(StartSprintCommand) export class StartSprintCommandHandler implements ICommandHandler { constructor( private readonly access: WriteAccess, private readonly findSprints: FindSprintsRepository, ) {} async execute(command: StartSprintCommand): Promise { const sprint = await managedSprint( this.access, command.sprintId, command.actorId, ); const active = await this.findSprints.findActive(sprint.projectId); sprint.start( command.actorId, active && active.id !== sprint.id ? active : null, ); await sprint.commitAndWait(); return ack(sprint); } } @CommandHandler(CloseSprintCommand) export class CloseSprintCommandHandler implements ICommandHandler { constructor( private readonly access: WriteAccess, private readonly findIssues: FindIssuesRepository, ) {} async execute(command: CloseSprintCommand): Promise { const sprint = await managedSprint( this.access, command.sprintId, command.actorId, ); const target = command.carryOverTo ? await this.access.sprint(command.carryOverTo) : null; const progress = await this.findIssues.sprintProgress(sprint.id); sprint.close(command.actorId, target, progress.total - progress.finished); await sprint.commitAndWait(); return ack(sprint); } } @CommandHandler(DeleteSprintCommand) export class DeleteSprintCommandHandler implements ICommandHandler { constructor(private readonly access: WriteAccess) {} async execute(command: DeleteSprintCommand): Promise { if (command.cascade) { // Saga: idempotent, the sprint may be gone already. const sprint = await this.access.live(command.sprintId, Sprint); if (!sprint) return; sprint.delete(command.actorId, true); await sprint.commitAndWait(); return; } const sprint = await managedSprint( this.access, command.sprintId, command.actorId, ); sprint.delete(command.actorId); await sprint.commitAndWait(); return ack(sprint); } } @CommandHandler(SetIssueSprintCommand) export class SetIssueSprintCommandHandler implements ICommandHandler { constructor(private readonly access: WriteAccess) {} async execute(command: SetIssueSprintCommand): Promise { const { issue, project } = await this.access.issueForContributor( command.issueId, command.actorId, ); // A closed sprint keeps its tickets: neither side may be closed. if (issue.sprintId) await this.access.openSprintOf(project, issue.sprintId); if (command.sprintId) await this.access.openSprintOf(project, command.sprintId); issue.setSprint(command.actorId, command.sprintId); await issue.commitAndWait(); return ack(issue); } } const FINISHED = [StatusCategory.DONE, StatusCategory.CANCELLED]; @CommandHandler(CarryOverIssueCommand) export class CarryOverIssueCommandHandler implements ICommandHandler { constructor(private readonly access: WriteAccess) {} async execute(command: CarryOverIssueCommand): Promise { const issue = await this.access.live(command.issueId, Issue); // Gone, or it left that sprint meanwhile: nothing to do. if (!issue || issue.sprintId !== command.fromSprintId) return; const project = await this.access.live(issue.projectId, Project); if (!project) return; if ( command.onlyUnfinished && FINISHED.includes(project.workflow.categoryOf(issue.status)) ) return; if (command.toSprintId) { const target = await this.access.live(command.toSprintId, Sprint); // The target closed or vanished meanwhile: back to the backlog instead. const open = target && !target.deleted && target.status !== SprintStatus.CLOSED; issue.setSprint(command.actorId, open ? command.toSprintId : null); } else { issue.setSprint(command.actorId, null); } await issue.commitAndWait(); } } export const SPRINT_COMMAND_HANDLERS = [ CreateSprintCommandHandler, UpdateSprintCommandHandler, StartSprintCommandHandler, CloseSprintCommandHandler, DeleteSprintCommandHandler, SetIssueSprintCommandHandler, // Saga only. CarryOverIssueCommandHandler, ];