Files
task-stream/libs/api/tracker/application/src/lib/commands/sprint.command-handlers.ts
Josep Piguillem 50d3ffd1dd feat: sprints with burndown and carry-over
Plan sprints with dates and a goal, run one at a time per project, and
put tickets in them from the ticket page. The Sprints tab follows the one
under way with its progress and a burndown in tickets or points against
the ideal line. Closing a sprint sends what was left to the next one or
back to the backlog; the board can be filtered by sprint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:59:01 +02:00

220 lines
6.6 KiB
TypeScript

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<CreateSprintCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly sprintFactory: SprintFactory,
private readonly eventPublisher: EventPublisher,
) {}
async execute(command: CreateSprintCommand): Promise<CommandResult> {
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<Sprint> {
const sprint = await access.sprint(sprintId);
(await access.project(sprint.projectId)).assertCanManage(actorId);
return sprint;
}
@CommandHandler(UpdateSprintCommand)
export class UpdateSprintCommandHandler
implements ICommandHandler<UpdateSprintCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: UpdateSprintCommand): Promise<CommandResult> {
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<StartSprintCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly findSprints: FindSprintsRepository,
) {}
async execute(command: StartSprintCommand): Promise<CommandResult> {
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<CloseSprintCommand>
{
constructor(
private readonly access: WriteAccess,
private readonly findIssues: FindIssuesRepository,
) {}
async execute(command: CloseSprintCommand): Promise<CommandResult> {
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<DeleteSprintCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: DeleteSprintCommand): Promise<CommandResult | void> {
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<SetIssueSprintCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: SetIssueSprintCommand): Promise<CommandResult> {
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<CarryOverIssueCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: CarryOverIssueCommand): Promise<void> {
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,
];