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>
This commit is contained in:
2026-10-08 14:59:01 +02:00
parent d2e31789a7
commit 50d3ffd1dd
104 changed files with 7209 additions and 33 deletions

View File

@@ -4,10 +4,13 @@ import {
ReleaseReadModel,
ReleaseStatus,
StatusCategory as C,
SprintReadModel,
SprintStatus,
} from '@task-stream/api/tracker/domain';
import {
computeProjectAnalytics,
computeReleaseBurnup,
computeSprintBurndown,
weekOf,
} from './analytics';
@@ -182,3 +185,62 @@ describe('release burnup', () => {
expect(at(burnup.done, '2026-10-14')).toBe(2);
});
});
describe('sprint burndown', () => {
const sprint = {
id: 's1',
projectId: 'p',
startDate: '2026-10-12',
endDate: '2026-10-16',
status: SprintStatus.ACTIVE,
closedAt: null,
} as SprintReadModel;
const into = (issueId: string, at: string): IssueTimelineEntry => ({
id: `${issueId}:${++seq}`,
issueId,
projectId: 'p',
at,
kind: 'sprint',
sprintId: 's1',
});
// X (3 points) and Y (5) committed on Monday; Z (2) added on Tuesday.
// X done on Tuesday, Y cancelled on Wednesday. Today is Wednesday noon.
const issues = [
{ ...issue('x', '2026-10-01T09:00:00Z', 'DONE', C.DONE), storyPoints: 3 },
{
...issue('y', '2026-10-01T09:00:00Z', 'CANCELLED', C.CANCELLED),
storyPoints: 5,
},
{ ...issue('z', '2026-10-01T09:00:00Z', 'TODO', C.TODO), storyPoints: 2 },
] as IssueReadModel[];
const timeline = [
step('x', '2026-10-01T09:00:00Z', 'TODO', C.TODO),
step('y', '2026-10-01T09:00:00Z', 'TODO', C.TODO),
step('z', '2026-10-01T09:00:00Z', 'TODO', C.TODO),
into('x', '2026-10-12T08:00:00Z'),
into('y', '2026-10-12T08:00:00Z'),
into('z', '2026-10-13T10:00:00Z'),
step('x', '2026-10-13T16:00:00Z', 'DONE', C.DONE),
step('y', '2026-10-14T09:00:00Z', 'CANCELLED', C.CANCELLED),
];
it('counts open tickets and points per day, and leaves the future blank', () => {
const burndown = computeSprintBurndown({
sprint,
issues,
timeline,
now: new Date('2026-10-14T12:00:00Z'),
});
expect(burndown.dates).toEqual([
'2026-10-12',
'2026-10-13',
'2026-10-14',
'2026-10-15',
'2026-10-16',
]);
expect(burndown.scope).toEqual([2, 3, 2, null, null]);
expect(burndown.remaining).toEqual([2, 2, 1, null, null]);
expect(burndown.remainingPoints).toEqual([8, 7, 2, null, null]);
expect(burndown.ideal).toEqual([2, 1.5, 1, 0.5, 0]);
});
});

View File

@@ -5,6 +5,8 @@ import {
ProjectAnalyticsReadModel,
ReleaseBurnupReadModel,
ReleaseReadModel,
SprintBurndownReadModel,
SprintReadModel,
StatusCategory,
UserRefReadModel,
} from '@task-stream/api/tracker/domain';
@@ -313,3 +315,85 @@ export function computeReleaseBurnup(
done,
};
}
export interface SprintBurndownInput {
sprint: SprintReadModel;
issues: IssueReadModel[];
timeline: IssueTimelineEntry[];
now: Date;
}
/**
* Per day of the sprint (start to end date, UTC): tickets in it, how many
* are still open and their points, and the ideal line. Points are today's
* estimates (their history is not kept). Days after today, or after the
* sprint closed, are null.
*/
export function computeSprintBurndown(
input: SprintBurndownInput,
): SprintBurndownReadModel {
const { sprint } = input;
const steps = stepsByIssue(input.issues, input.timeline);
const points = new Map(input.issues.map((i) => [i.id, i.storyPoints ?? 0]));
const membership = new Map<
string,
{ at: number; sprintId: string | null }[]
>();
for (const entry of input.timeline) {
if (entry.kind !== 'sprint') continue;
const list = membership.get(entry.issueId) ?? [];
list.push({ at: Date.parse(entry.at), sprintId: entry.sprintId ?? null });
membership.set(entry.issueId, list);
}
for (const list of membership.values()) list.sort((a, b) => a.at - b.at);
const start = Date.parse(`${sprint.startDate}T00:00:00Z`);
const end = Date.parse(`${sprint.endDate}T00:00:00Z`);
const last = Math.min(
sprint.closedAt ? Date.parse(sprint.closedAt) : input.now.getTime(),
end + DAY - 1,
);
const dayEnds = days(start, end + DAY - 1);
const scope: (number | null)[] = [];
const remaining: (number | null)[] = [];
const remainingPoints: (number | null)[] = [];
for (const time of dayEnds) {
// The day under way counts up to now; the ones to come are unknown.
const at = Math.min(time, last);
if (time - DAY >= last) {
scope.push(null);
remaining.push(null);
remainingPoints.push(null);
continue;
}
let inScope = 0;
let open = 0;
let openPoints = 0;
for (const [issueId, list] of membership) {
const current = list.filter((m) => m.at <= at).at(-1);
if (current?.sprintId !== sprint.id) continue;
const step = stepAt(steps.get(issueId) ?? [], at);
if (!step || step.category === StatusCategory.CANCELLED) continue;
inScope++;
if (step.category !== StatusCategory.DONE) {
open++;
openPoints += points.get(issueId) ?? 0;
}
}
scope.push(inScope);
remaining.push(open);
remainingPoints.push(openPoints);
}
const first = remaining[0] ?? 0;
const span = Math.max(dayEnds.length - 1, 1);
return {
sprintId: sprint.id,
dates: dayEnds.map(dayOf),
scope,
remaining,
remainingPoints,
ideal: dayEnds.map(
(_, i) => Math.round((first * (span - i) * 100) / span) / 100,
),
};
}

View File

@@ -8,6 +8,7 @@ import {
IssueChange,
ProjectChange,
ReleaseChange,
SprintChange,
} from './ports/change-feed';
import { CanViewProjectQuery } from './queries/can-view-project.query';
import { GetIssueQuery } from './queries/get-issue.query';
@@ -52,6 +53,14 @@ export class ChangesService {
return this.changeFeed.releases(projectId);
}
async watchSprints(
viewerId: string,
projectId: string,
): Promise<AsyncIterableIterator<SprintChange>> {
await this.assertCanView(viewerId, projectId);
return this.changeFeed.sprints(projectId);
}
async watchProject(
viewerId: string,
projectId: string,

View File

@@ -30,6 +30,7 @@ import { DetachFileCommandHandler } from './detach-file.command-handler';
import { WatchIssueCommandHandler } from './watch-issue.command-handler';
import { SetIssueParentCommandHandler } from './set-issue-parent.command-handler';
import { RankIssueCommandHandler } from './rank-issue.command-handler';
import { SPRINT_COMMAND_HANDLERS } from './sprint.command-handlers';
import { DetachFromParentCommandHandler } from './detach-from-parent.command-handler';
import {
AddChecklistItemCommandHandler,
@@ -71,6 +72,7 @@ export const COMMAND_HANDLERS = [
WatchIssueCommandHandler,
SetIssueParentCommandHandler,
RankIssueCommandHandler,
...SPRINT_COMMAND_HANDLERS,
AddChecklistItemCommandHandler,
UpdateChecklistItemCommandHandler,
RemoveChecklistItemCommandHandler,

View File

@@ -31,6 +31,13 @@ import {
InMemoryTrackerStreams,
} from '../../testing/in-memory';
import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
import { FindSprintsRepository } from '../ports/find-sprints.repository';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import {
CreateSprintCommand,
SetIssueSprintCommand,
StartSprintCommand,
} from './sprint.commands';
import { IssueTree } from './issue-tree';
import { SetIssueParentCommand } from './set-issue-parent.command';
import { RankIssueCommand } from './rank-issue.command';
@@ -63,6 +70,10 @@ import { WriteAccess } from './write-access';
async function setup() {
const store = new InMemoryEventStore();
const sprints = {
active: null as { id: string; name: string } | null,
findActive: async () => sprints.active,
};
const files = new InMemoryAttachmentStorage();
const users = new FakeUserDirectory()
.add('owner')
@@ -92,6 +103,20 @@ async function setup() {
provide: TrackerStreamsRepository,
useValue: new InMemoryTrackerStreams(store),
},
// Read-model lookups: the sprint under way is set by each test.
{ provide: FindSprintsRepository, useValue: sprints },
{
provide: FindIssuesRepository,
useValue: {
sprintProgress: async () => ({
total: 0,
finished: 0,
percent: 0,
storyPoints: 0,
finishedStoryPoints: 0,
}),
},
},
],
}).compile();
await moduleRef.init();
@@ -103,6 +128,7 @@ async function setup() {
return {
store,
files,
sprints,
run,
rehydrator: moduleRef.get(AggregateRehydrator),
};
@@ -405,6 +431,51 @@ describe('tracker commands (event sourced)', () => {
);
});
it('plans and starts sprints, one under way at a time, and fills them', async () => {
const { run, rehydrator, sprints } = await setup();
const projectId = await projectWithTeam(run);
const plan = (name: string) =>
run(
new CreateSprintCommand(
'owner',
projectId,
name,
'2026-10-12',
'2026-10-23',
),
);
const first = await plan('Sprint 1');
const second = await plan('Sprint 2');
await expect(
run(
new CreateSprintCommand(
'dev',
projectId,
'X',
'2026-10-12',
'2026-10-23',
),
),
).rejects.toThrow(ForbiddenError);
await run(new StartSprintCommand('owner', first.id));
sprints.active = { id: first.id, name: 'Sprint 1' };
await expect(
run(new StartSprintCommand('owner', second.id)),
).rejects.toThrow(/Sprint 1 is under way/);
const issue = await run(
new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Login', {}),
);
await run(new SetIssueSprintCommand('dev', issue.id, first.id));
expect((await rehydrator.rehydrate(issue.id, Issue)).sprintId).toBe(
first.id,
);
await expect(
run(new SetIssueSprintCommand('pm', issue.id, null)),
).rejects.toThrow(ForbiddenError);
});
it('does not let viewers modify tickets', async () => {
const { run } = await setup();
const projectId = await projectWithTeam(run);

View File

@@ -33,3 +33,4 @@ export * from './set-issue-parent.command';
export * from './detach-from-parent.command';
export * from './change-checklist.command';
export * from './rank-issue.command';
export * from './sprint.commands';

View File

@@ -0,0 +1,219 @@
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,
];

View File

@@ -0,0 +1,85 @@
import { Command } from '@nestjs/cqrs';
import { SprintDetails } from '@task-stream/api/tracker/domain';
import { CommandResult } from './command-result';
export class CreateSprintCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly projectId: string,
public readonly name: string,
public readonly startDate: string,
public readonly endDate: string,
public readonly goal: string | null = null,
) {
super();
}
}
export class UpdateSprintCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly sprintId: string,
public readonly changes: SprintDetails,
) {
super();
}
}
export class StartSprintCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly sprintId: string,
) {
super();
}
}
/** Ends a sprint; unfinished tickets go to `carryOverTo` (null: backlog). */
export class CloseSprintCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly sprintId: string,
public readonly carryOverTo: string | null,
) {
super();
}
}
/** A planned sprint (people), or any sprint of a deleted project (`cascade`, saga). */
export class DeleteSprintCommand extends Command<CommandResult | void> {
constructor(
public readonly actorId: string,
public readonly sprintId: string,
public readonly cascade = false,
) {
super();
}
}
/** Puts a ticket in a sprint (null: back to the backlog). */
export class SetIssueSprintCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
public readonly sprintId: string | null,
) {
super();
}
}
/**
* Saga only: a sprint closed (or was deleted), so a ticket of it moves to
* `toSprintId` (null: backlog). With `onlyUnfinished`, finished ones stay as
* the record of what the sprint achieved.
*/
export class CarryOverIssueCommand extends Command<void> {
constructor(
public readonly actorId: string,
public readonly issueId: string,
public readonly fromSprintId: string,
public readonly toSprintId: string | null,
public readonly onlyUnfinished: boolean,
) {
super();
}
}

View File

@@ -10,6 +10,7 @@ import {
Issue,
Project,
Release,
Sprint,
} from '@task-stream/api/tracker/domain';
import { UserDirectory } from '../ports/user-directory';
@@ -38,6 +39,22 @@ export class WriteAccess {
return this.load(id, Release);
}
sprint(id: string): Promise<Sprint> {
return this.load(id, Sprint);
}
/** Sprint of the same project that still takes tickets (not closed). */
async openSprintOf(project: Project, sprintId: string): Promise<Sprint> {
const sprint = await this.sprint(sprintId);
if (sprint.projectId !== project.id) {
throw new BusinessRuleError(
`Sprint ${sprint.name} belongs to another project`,
);
}
sprint.assertAcceptsIssues();
return sprint;
}
comment(id: string): Promise<Comment> {
return this.load(id, Comment);
}

View File

@@ -28,6 +28,12 @@ import {
ReleaseCreatedEvent,
ReleaseShippedEvent,
ReleaseUpdatedEvent,
IssueSprintChangedEvent,
SprintClosedEvent,
SprintCreatedEvent,
SprintDeletedEvent,
SprintStartedEvent,
SprintUpdatedEvent,
TrackerEvent,
} from '@task-stream/api/tracker/domain';
@@ -37,6 +43,8 @@ export interface SummaryContext {
issueKey: string;
projectKey: string;
releaseName: string;
/** '' when unknown (e.g. the sprint was deleted). */
sprintName: string;
/** The project's workflow: names of statuses and types. */
workflow: ProjectWorkflow;
}
@@ -146,6 +154,12 @@ export function describeEvent(
if (event instanceof IssueAttachmentRemovedEvent)
return `${actor} quitó el adjunto ${event.fileName} de ${issueKey}`;
if (event instanceof IssueSprintChangedEvent) {
const sprint = ctx.sprintName ? `el sprint ${ctx.sprintName}` : 'un sprint';
return event.sprintId
? `${actor} metió ${issueKey} en ${sprint}`
: `${actor} sacó ${issueKey} de ${sprint}`;
}
if (event instanceof IssueParentChangedEvent) {
return event.parentKey
? `${actor} colocó ${issueKey} bajo ${event.parentKey}`
@@ -178,5 +192,20 @@ export function describeEvent(
return `${actor} publicó la release ${ctx.releaseName} 🚀`;
if (event instanceof ReleaseArchivedEvent)
return `${actor} archivó la release ${ctx.releaseName}`;
const sprint = ctx.sprintName || 'un sprint';
if (event instanceof SprintCreatedEvent)
return `${actor} planificó el sprint ${event.sprint.name}`;
if (event instanceof SprintUpdatedEvent)
return `${actor} actualizó el sprint ${sprint}`;
if (event instanceof SprintStartedEvent)
return `${actor} empezó el sprint ${sprint} 🏁`;
if (event instanceof SprintClosedEvent) {
const left = event.unfinishedIssues
? `; ${event.unfinishedIssues} ticket(s) sin terminar pasan ${event.carryOverTo ? 'al siguiente sprint' : 'al backlog'}`
: '';
return `${actor} cerró el sprint ${sprint}${left}`;
}
if (event instanceof SprintDeletedEvent)
return `${actor} borró el sprint ${sprint}`;
return `${actor}: ${event.constructor.name}`;
}

View File

@@ -23,6 +23,12 @@ import {
ProjectMemberRoleChangedEvent,
ProjectUpdatedEvent,
ReleaseArchivedEvent,
IssueSprintChangedEvent,
SprintClosedEvent,
SprintCreatedEvent,
SprintDeletedEvent,
SprintStartedEvent,
SprintUpdatedEvent,
ReleaseCreatedEvent,
ReleaseShippedEvent,
ReleaseUpdatedEvent,
@@ -37,6 +43,7 @@ import { ChangeFeed } from '../ports/change-feed';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { FindReleasesRepository } from '../ports/find-releases.repository';
import { FindSprintsRepository } from '../ports/find-sprints.repository';
import { UpsertMaterializedActivityRepository } from '../ports/upsert-materialized-activity.repository';
import { UserDirectory } from '../ports/user-directory';
import { describeEvent } from './activity-summary';
@@ -70,6 +77,12 @@ import { versionOf } from './read-model-notifier';
ReleaseUpdatedEvent,
ReleaseShippedEvent,
ReleaseArchivedEvent,
IssueSprintChangedEvent,
SprintCreatedEvent,
SprintUpdatedEvent,
SprintStartedEvent,
SprintClosedEvent,
SprintDeletedEvent,
)
export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
constructor(
@@ -77,6 +90,7 @@ export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
private readonly findProjects: FindProjectsRepository,
private readonly findIssues: FindIssuesRepository,
private readonly findReleases: FindReleasesRepository,
private readonly findSprints: FindSprintsRepository,
private readonly users: UserDirectory,
private readonly changeFeed: ChangeFeed,
) {}
@@ -93,6 +107,7 @@ export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
const release = releaseId
? await this.findReleases.findById(releaseId)
: null;
const sprint = await this.sprintOf(event);
const project = await this.findProjects.findById(event.projectId);
// The project was deleted (cascade in progress): no feed to write to.
if (!project) return;
@@ -118,6 +133,7 @@ export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
issueKey: issueKey ?? '',
projectKey: project?.key ?? '',
releaseName: release?.name ?? '',
sprintName: sprint,
workflow: new ProjectWorkflow(
project?.settings ?? DEFAULT_PROJECT_SETTINGS,
),
@@ -129,6 +145,17 @@ export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
}
}
/** Name of the sprint the event is about ('' when it is gone). */
private async sprintOf(event: TrackerEvent): Promise<string> {
if (event instanceof SprintCreatedEvent) return event.sprint.name;
const id = event.constructor.name.startsWith('Sprint')
? event.aggregateId
: event instanceof IssueSprintChangedEvent
? (event.sprintId ?? event.previousSprintId)
: null;
return id ? ((await this.findSprints.findById(id))?.name ?? '') : '';
}
private issueIdOf(event: TrackerEvent): string | null {
if (event.constructor.name.startsWith('Issue')) return event.aggregateId;
if (event instanceof CommentAddedEvent) return event.comment.issueId;

View File

@@ -31,6 +31,8 @@ import { IssueWatchersEventHandler } from './issue-watchers.event-handler';
import { IssueParentChangedEventHandler } from './issue-parent-changed.event-handler';
import { IssueChecklistEventHandler } from './issue-checklist.event-handler';
import { IssueRankedEventHandler } from './issue-ranked.event-handler';
import { SprintEventHandler } from './sprint.event-handler';
import { IssueSprintChangedEventHandler } from './issue-sprint-changed.event-handler';
/** Read-model projections (MongoDB) + realtime, fed by the EventsBridge. */
export const EVENT_HANDLERS = [
@@ -66,6 +68,8 @@ export const EVENT_HANDLERS = [
IssueParentChangedEventHandler,
IssueChecklistEventHandler,
IssueRankedEventHandler,
SprintEventHandler,
IssueSprintChangedEventHandler,
];
export { ReadModelNotifier };

View File

@@ -50,6 +50,7 @@ export class IssueCreatedEventHandler
assignee: users.ofNullable(issue.assigneeId),
release: release ? { id: release.id, name: release.name } : null,
parent: null,
sprint: null,
rank: implicitRank(issue.number),
labels: issue.labels,
figmaUrl: issue.figmaUrl,

View File

@@ -0,0 +1,31 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { IssueSprintChangedEvent } from '@task-stream/api/tracker/domain';
import { FindSprintsRepository } from '../ports/find-sprints.repository';
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@EventsHandler(IssueSprintChangedEvent)
export class IssueSprintChangedEventHandler
implements IEventHandler<IssueSprintChangedEvent>
{
constructor(
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
private readonly findSprints: FindSprintsRepository,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: IssueSprintChangedEvent) {
const sprint = event.sprintId
? await this.findSprints.findById(event.sprintId)
: null;
await this.upsertMaterializedIssueRepository.upsert({
id: event.aggregateId,
sprint: event.sprintId
? { id: event.sprintId, name: sprint?.name ?? '' }
: null,
updatedAt: event.occurredAt,
version: versionOf(event),
});
await this.notifier.issue(event.aggregateId, event);
}
}

View File

@@ -3,6 +3,7 @@ import {
DEFAULT_PROJECT_SETTINGS,
IssueCreatedEvent,
IssueReleaseChangedEvent,
IssueSprintChangedEvent,
IssueStatusChangedEvent,
ProjectWorkflow,
TrackerEvent,
@@ -14,12 +15,13 @@ import { versionOf } from './read-model-notifier';
/**
* The life of every ticket, step by step: where it went (status + category)
* and which release it joined. Statistics are computed from it.
* and which release and sprint it joined. Statistics are computed from it.
*/
@EventsHandler(
IssueCreatedEvent,
IssueStatusChangedEvent,
IssueReleaseChangedEvent,
IssueSprintChangedEvent,
)
export class IssueTimelineEventHandler implements IEventHandler<TrackerEvent> {
constructor(
@@ -66,6 +68,14 @@ export class IssueTimelineEventHandler implements IEventHandler<TrackerEvent> {
kind: 'release',
releaseId: event.releaseId,
});
return;
}
if (event instanceof IssueSprintChangedEvent) {
await this.timeline.record({
...base,
kind: 'sprint',
sprintId: event.sprintId,
});
}
}

View File

@@ -5,6 +5,7 @@ import { FindCommentsRepository } from '../ports/find-comments.repository';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { FindReleasesRepository } from '../ports/find-releases.repository';
import { FindSprintsRepository } from '../ports/find-sprints.repository';
/** Position of the event in its stream (stamped by the event store). */
export function versionOf(event: TrackerEvent): number {
@@ -23,6 +24,7 @@ export class ReadModelNotifier {
private readonly findIssues: FindIssuesRepository,
private readonly findComments: FindCommentsRepository,
private readonly findReleases: FindReleasesRepository,
private readonly findSprints: FindSprintsRepository,
) {}
async project(
@@ -63,6 +65,16 @@ export class ReadModelNotifier {
await this.changeFeed.publishRelease({ ...this.meta(event), release });
}
async sprint(sprintId: string, event: TrackerEvent): Promise<void> {
const sprint = await this.findSprints.findById(sprintId);
await this.changeFeed.publishSprint({
...this.meta(event),
sprintId,
projectId: event.projectId,
sprint,
});
}
private meta(event: TrackerEvent) {
return {
action: event.constructor.name,

View File

@@ -0,0 +1,65 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import {
SprintClosedEvent,
SprintCreatedEvent,
SprintDeletedEvent,
SprintStartedEvent,
SprintStatus,
SprintUpdatedEvent,
} from '@task-stream/api/tracker/domain';
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
import { UpsertMaterializedSprintRepository } from '../ports/upsert-materialized-sprint.repository';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
type SprintEvent =
| SprintCreatedEvent
| SprintUpdatedEvent
| SprintStartedEvent
| SprintClosedEvent
| SprintDeletedEvent;
/** The sprints view (`sprints_view`), one document per sprint. */
@EventsHandler(
SprintCreatedEvent,
SprintUpdatedEvent,
SprintStartedEvent,
SprintClosedEvent,
SprintDeletedEvent,
)
export class SprintEventHandler implements IEventHandler<SprintEvent> {
constructor(
private readonly sprints: UpsertMaterializedSprintRepository,
private readonly issues: UpsertMaterializedIssueRepository,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: SprintEvent) {
const version = versionOf(event);
const id = event.aggregateId;
if (event instanceof SprintCreatedEvent) {
await this.sprints.upsert({ ...event.sprint, version });
} else if (event instanceof SprintUpdatedEvent) {
await this.sprints.upsert({ id, ...event.changes, version });
// Tickets show its name.
if (event.changes.name)
await this.issues.renameSprint(id, event.changes.name);
} else if (event instanceof SprintStartedEvent) {
await this.sprints.upsert({
id,
status: SprintStatus.ACTIVE,
startedAt: event.occurredAt,
version,
});
} else if (event instanceof SprintClosedEvent) {
await this.sprints.upsert({
id,
status: SprintStatus.CLOSED,
closedAt: event.occurredAt,
version,
});
} else {
await this.sprints.remove(id);
}
await this.notifier.sprint(id, event);
}
}

View File

@@ -5,6 +5,7 @@ import {
IssueReadModel,
ProjectReadModel,
ReleaseReadModel,
SprintReadModel,
} from '@task-stream/api/tracker/domain';
/** Every realtime message says which event caused it and the resulting version. */
@@ -30,6 +31,13 @@ export interface ReleaseChange extends Change {
release: ReleaseReadModel;
}
export interface SprintChange extends Change {
/** Null when the sprint was deleted. */
sprint: SprintReadModel | null;
sprintId: string;
projectId: string;
}
export interface ProjectChange extends Change {
project: ProjectReadModel;
}
@@ -43,6 +51,7 @@ export abstract class ChangeFeed {
abstract publishComment(change: CommentChange): Promise<void>;
abstract publishActivity(activity: ActivityReadModel): Promise<void>;
abstract publishRelease(change: ReleaseChange): Promise<void>;
abstract publishSprint(change: SprintChange): Promise<void>;
/** @param notifyUserIds users whose project list changed (joined / left). */
abstract publishProject(
change: ProjectChange,
@@ -59,6 +68,7 @@ export abstract class ChangeFeed {
projectId: string,
): AsyncIterableIterator<ActivityReadModel>;
abstract releases(projectId: string): AsyncIterableIterator<ReleaseChange>;
abstract sprints(projectId: string): AsyncIterableIterator<SprintChange>;
abstract project(projectId: string): AsyncIterableIterator<ProjectChange>;
abstract userProjects(userId: string): AsyncIterableIterator<ProjectChange>;
abstract notifications(

View File

@@ -4,6 +4,7 @@ import {
IssueReadModel,
ProjectStatsReadModel,
ReleaseProgressReadModel,
SprintProgressReadModel,
StatusCategory,
} from '@task-stream/api/tracker/domain';
@@ -26,6 +27,9 @@ export interface IssueFilter {
labels?: string[];
/** Only the tickets hanging from this one. */
parentId?: string;
sprintId?: string;
/** Only tickets in no sprint (wins over `sprintId`). */
noSprint?: boolean;
/** RECENT (default): last updated first. RANK: backlog order. */
sort?: IssueSort;
search?: string;
@@ -63,6 +67,7 @@ export abstract class FindIssuesRepository {
abstract findByProject(projectId: string): Promise<IssueReadModel[]>;
/** Top-level tickets with others hanging from them, by key. */
abstract epics(projectId: string): Promise<EpicReadModel[]>;
abstract sprintProgress(sprintId: string): Promise<SprintProgressReadModel>;
abstract releaseProgress(
releaseId: string,
): Promise<ReleaseProgressReadModel>;

View File

@@ -0,0 +1,9 @@
import { SprintReadModel } from '@task-stream/api/tracker/domain';
export abstract class FindSprintsRepository {
abstract findById(id: string): Promise<SprintReadModel | null>;
/** Active first, then planned by start date, then closed (newest first). */
abstract findByProject(projectId: string): Promise<SprintReadModel[]>;
/** The sprint under way in the project, if any. */
abstract findActive(projectId: string): Promise<SprintReadModel | null>;
}

View File

@@ -21,3 +21,5 @@ export * from './attachment-storage';
export * from './notifications.repository';
export * from './notification-mailer';
export * from './saved-filters.repository';
export * from './find-sprints.repository';
export * from './upsert-materialized-sprint.repository';

View File

@@ -7,6 +7,9 @@
export abstract class TrackerStreamsRepository {
abstract issueIdsOfProject(projectId: string): Promise<string[]>;
abstract releaseIdsOfProject(projectId: string): Promise<string[]>;
abstract sprintIdsOfProject(projectId: string): Promise<string[]>;
/** Tickets that were ever put in `sprintId` (they may have left since). */
abstract issueIdsEverInSprint(sprintId: string): Promise<string[]>;
abstract commentIdsOfIssue(issueId: string): Promise<string[]>;
/** Tickets that were ever linked to `issueId` (they may have unlinked since). */
abstract issueIdsLinkingTo(issueId: string): Promise<string[]>;

View File

@@ -60,6 +60,8 @@ export abstract class UpsertMaterializedIssueRepository {
): Promise<void>;
/** The ticket was deleted: its view, its timeline and the links shown on other tickets go. */
abstract remove(issueId: string): Promise<void>;
/** Keeps the denormalised sprint name of every issue in sync. */
abstract renameSprint(sprintId: string, name: string): Promise<void>;
/** Keeps the denormalised release name of every issue in sync. */
abstract renameRelease(releaseId: string, name: string): Promise<void>;
}

View File

@@ -0,0 +1,9 @@
import { SprintReadModel } from '@task-stream/api/tracker/domain';
export abstract class UpsertMaterializedSprintRepository {
/** Applied only if `version` is newer than the stored one (idempotent). */
abstract upsert(
sprint: Pick<SprintReadModel, 'id' | 'version'> & Partial<SprintReadModel>,
): Promise<void>;
abstract remove(sprintId: string): Promise<void>;
}

View File

@@ -18,3 +18,4 @@ export * from './search-my-issues.query';
export * from './get-public-projects.query';
export * from './get-attachment.query';
export * from './get-epics.query';
export * from './sprint.queries';

View File

@@ -17,6 +17,7 @@ import { GetReleasesQueryHandler } from './get-releases.query-handler';
import { GetUpcomingReleasesQueryHandler } from './get-upcoming-releases.query-handler';
import { SearchIssuesQueryHandler } from './search-issues.query-handler';
import { GetEpicsQueryHandler } from './get-epics.query-handler';
import { SPRINT_QUERY_HANDLERS } from './sprint.queries';
import { SearchMyIssuesQueryHandler } from './search-my-issues.query-handler';
export const QUERY_HANDLERS = [
@@ -38,6 +39,7 @@ export const QUERY_HANDLERS = [
GetUpcomingReleasesQueryHandler,
SearchIssuesQueryHandler,
GetEpicsQueryHandler,
...SPRINT_QUERY_HANDLERS,
SearchMyIssuesQueryHandler,
GetAttachmentQueryHandler,
];

View File

@@ -0,0 +1,159 @@
import { IQueryHandler, Query, QueryHandler } from '@nestjs/cqrs';
import {
SprintBurndownReadModel,
SprintProgressReadModel,
SprintReadModel,
} from '@task-stream/api/tracker/domain';
import { computeSprintBurndown } from '../analytics/analytics';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindSprintsRepository } from '../ports/find-sprints.repository';
import { FindTimelineRepository } from '../ports/find-timeline.repository';
import { ReadAccess } from './read-access';
export class GetSprintsQuery extends Query<SprintReadModel[]> {
constructor(
/** Null: anonymous (public projects only). */
public readonly viewerId: string | null,
public readonly projectId: string,
) {
super();
}
}
export class GetSprintQuery extends Query<SprintReadModel> {
constructor(
public readonly viewerId: string | null,
public readonly sprintId: string,
) {
super();
}
}
export class GetSprintProgressQuery extends Query<SprintProgressReadModel> {
constructor(
public readonly viewerId: string | null,
public readonly sprintId: string,
) {
super();
}
}
export class GetSprintBurndownQuery extends Query<SprintBurndownReadModel> {
constructor(
public readonly viewerId: string | null,
public readonly sprintId: string,
) {
super();
}
}
/** The sprint, if the viewer may see its project (same error otherwise). */
async function visibleSprint(
findSprints: FindSprintsRepository,
access: ReadAccess,
viewerId: string | null,
sprintId: string,
): Promise<SprintReadModel> {
const sprint = access.assertFound(
await findSprints.findById(sprintId),
'Sprint',
sprintId,
);
await access.assertCanView(sprint.projectId, viewerId, {
entity: 'Sprint',
ref: sprintId,
});
return sprint;
}
@QueryHandler(GetSprintsQuery)
export class GetSprintsQueryHandler
implements IQueryHandler<GetSprintsQuery, SprintReadModel[]>
{
constructor(
private readonly findSprints: FindSprintsRepository,
private readonly access: ReadAccess,
) {}
async execute(query: GetSprintsQuery): Promise<SprintReadModel[]> {
await this.access.assertCanView(query.projectId, query.viewerId);
return this.findSprints.findByProject(query.projectId);
}
}
@QueryHandler(GetSprintQuery)
export class GetSprintQueryHandler
implements IQueryHandler<GetSprintQuery, SprintReadModel>
{
constructor(
private readonly findSprints: FindSprintsRepository,
private readonly access: ReadAccess,
) {}
execute(query: GetSprintQuery): Promise<SprintReadModel> {
return visibleSprint(
this.findSprints,
this.access,
query.viewerId,
query.sprintId,
);
}
}
@QueryHandler(GetSprintProgressQuery)
export class GetSprintProgressQueryHandler
implements IQueryHandler<GetSprintProgressQuery, SprintProgressReadModel>
{
constructor(
private readonly findSprints: FindSprintsRepository,
private readonly findIssues: FindIssuesRepository,
private readonly access: ReadAccess,
) {}
async execute(
query: GetSprintProgressQuery,
): Promise<SprintProgressReadModel> {
const sprint = await visibleSprint(
this.findSprints,
this.access,
query.viewerId,
query.sprintId,
);
return this.findIssues.sprintProgress(sprint.id);
}
}
@QueryHandler(GetSprintBurndownQuery)
export class GetSprintBurndownQueryHandler
implements IQueryHandler<GetSprintBurndownQuery, SprintBurndownReadModel>
{
constructor(
private readonly findSprints: FindSprintsRepository,
private readonly findIssues: FindIssuesRepository,
private readonly findTimeline: FindTimelineRepository,
private readonly access: ReadAccess,
) {}
async execute(
query: GetSprintBurndownQuery,
): Promise<SprintBurndownReadModel> {
const sprint = await visibleSprint(
this.findSprints,
this.access,
query.viewerId,
query.sprintId,
);
const [issues, timeline] = await Promise.all([
this.findIssues.findByProject(sprint.projectId),
this.findTimeline.findByProject(sprint.projectId),
]);
return computeSprintBurndown({ sprint, issues, timeline, now: new Date() });
}
}
export const SPRINT_QUERY_HANDLERS = [
GetSprintsQueryHandler,
GetSprintQueryHandler,
GetSprintProgressQueryHandler,
GetSprintBurndownQueryHandler,
];

View File

@@ -9,6 +9,7 @@ import { DeleteIssueCommand } from '../commands/delete-issue.command';
import { DeleteReleaseCommand } from '../commands/delete-release.command';
import { DetachFromParentCommand } from '../commands/detach-from-parent.command';
import { DropLinksToIssueCommand } from '../commands/drop-links-to-issue.command';
import { DeleteSprintCommand } from '../commands/sprint.commands';
import { PurgeCommentCommand } from '../commands/purge-comment.command';
import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
@@ -17,6 +18,7 @@ import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
* with its own "deleted" event):
*
* ProjectDeleted → DeleteIssue (each ticket) + DeleteRelease (each release)
* + DeleteSprint (each sprint, as a cascade)
* IssueDeleted → PurgeComment (each comment) + DropLinksToIssue (tickets
* of other projects that pointed to it) + DetachFromParent
* (tickets that hung from it go back to the top)
@@ -34,11 +36,14 @@ export class ProjectDeletionSaga {
ofType(ProjectDeletedEvent),
mergeMap((event) =>
from(this.contentsOf(event.projectId)).pipe(
mergeMap(({ issueIds, releaseIds }) => [
mergeMap(({ issueIds, releaseIds, sprintIds }) => [
...issueIds.map((id) => new DeleteIssueCommand(event.actorId, id)),
...releaseIds.map(
(id) => new DeleteReleaseCommand(event.actorId, id),
),
...sprintIds.map(
(id) => new DeleteSprintCommand(event.actorId, id, true),
),
]),
),
),
@@ -76,11 +81,12 @@ export class ProjectDeletionSaga {
);
private async contentsOf(projectId: string) {
const [issueIds, releaseIds] = await Promise.all([
const [issueIds, releaseIds, sprintIds] = await Promise.all([
this.streams.issueIdsOfProject(projectId),
this.streams.releaseIdsOfProject(projectId),
this.streams.sprintIdsOfProject(projectId),
]);
return { issueIds, releaseIds };
return { issueIds, releaseIds, sprintIds };
}
private async dependentsOf(issueId: string) {

View File

@@ -47,6 +47,15 @@ import { LinkIssuesCommand } from '../commands/link-issues.command';
import { AssignIssueCommand } from '../commands/assign-issue.command';
import { WriteAccess } from '../commands/write-access';
import { IssueTree } from '../commands/issue-tree';
import {
CloseSprintCommand,
CreateSprintCommand,
DeleteSprintCommand,
SetIssueSprintCommand,
StartSprintCommand,
} from '../commands/sprint.commands';
import { FindSprintsRepository } from '../ports/find-sprints.repository';
import { SprintCarryOverSaga } from './sprint-carry-over.saga';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { IssueNumbersRepository } from '../ports/issue-numbers.repository';
import { ProjectKeysRepository } from '../ports/project-keys.repository';
@@ -73,6 +82,7 @@ async function setup() {
ProjectDeletionSaga,
ReleaseArchivedSaga,
UserDeactivatedSaga,
SprintCarryOverSaga,
WriteAccess,
IssueTree,
AggregateRehydrator,
@@ -90,11 +100,22 @@ async function setup() {
provide: TrackerStreamsRepository,
useValue: new InMemoryTrackerStreams(store),
},
{
provide: FindSprintsRepository,
useValue: { findActive: async () => null },
},
{
provide: FindIssuesRepository,
useValue: {
findOpenAssignedTo: async (userId: string) =>
(openAssigned.get(userId) ?? []).map((id) => ({ id })),
sprintProgress: async () => ({
total: 2,
finished: 1,
percent: 50,
storyPoints: 0,
finishedStoryPoints: 0,
}),
},
},
],
@@ -109,6 +130,7 @@ async function setup() {
...Object.values(moduleRef.get(ProjectDeletionSaga)),
...Object.values(moduleRef.get(ReleaseArchivedSaga)),
...Object.values(moduleRef.get(UserDeactivatedSaga)),
...Object.values(moduleRef.get(SprintCarryOverSaga)),
].filter((value): value is ISaga => typeof value === 'function');
/** What the events bridge does: every stored event through every saga, until quiet. */
@@ -206,6 +228,41 @@ describe('sagas', () => {
expect((await rehydrator.rehydrate(child, Issue)).parentId).toBeNull();
});
it('closing a sprint carries its unfinished tickets over; deleting one empties it', async () => {
const { run, drain, rehydrator } = await setup();
const web = (await run(new CreateProjectCommand('owner', 'WEB', 'Web'))).id;
const sprint = async (name: string) =>
(
await run(
new CreateSprintCommand(
'owner',
web,
name,
'2026-10-12',
'2026-10-23',
),
)
).id;
const [one, two] = [await sprint('One'), await sprint('Two')];
const open = (await run(ticket(web, 'open'))).id;
const done = (await run(ticket(web, 'done'))).id;
for (const id of [open, done])
await run(new SetIssueSprintCommand('owner', id, one));
for (const status of ['TODO', 'IN_PROGRESS', 'IN_REVIEW', 'DONE'])
await run(new ChangeIssueStatusCommand('owner', done, status));
await run(new StartSprintCommand('owner', one));
await run(new CloseSprintCommand('owner', one, two));
await drain();
expect((await rehydrator.rehydrate(open, Issue)).sprintId).toBe(two);
expect((await rehydrator.rehydrate(done, Issue)).sprintId).toBe(one);
await run(new DeleteSprintCommand('owner', two));
await drain();
expect((await rehydrator.rehydrate(open, Issue)).sprintId).toBeNull();
});
it('only the owner deletes a project', async () => {
const { run } = await setup();
const web = (await run(new CreateProjectCommand('owner', 'WEB', 'Web'))).id;

View File

@@ -2,6 +2,7 @@ import { ProjectDeletionSaga } from './project-deletion.saga';
import { ReleaseArchivedSaga } from './release-archived.saga';
import { UserDeactivatedSaga } from './user-deactivated.saga';
import { CommentersFollowSaga } from './commenters-follow.saga';
import { SprintCarryOverSaga } from './sprint-carry-over.saga';
/** Reactions to events that change other aggregates (run by the events bridge). */
export const SAGAS = [
@@ -9,4 +10,5 @@ export const SAGAS = [
ReleaseArchivedSaga,
UserDeactivatedSaga,
CommentersFollowSaga,
SprintCarryOverSaga,
];

View File

@@ -0,0 +1,46 @@
import { Injectable } from '@nestjs/common';
import { ICommand, IEvent, ofType, Saga } from '@nestjs/cqrs';
import {
SprintClosedEvent,
SprintDeletedEvent,
} from '@task-stream/api/tracker/domain';
import { from, mergeMap, Observable } from 'rxjs';
import { CarryOverIssueCommand } from '../commands/sprint.commands';
import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
/**
* When a sprint ends, what was left moves on:
*
* SprintClosed → CarryOverIssue for its unfinished tickets, to the sprint
* chosen when closing it (or back to the backlog)
* SprintDeleted → CarryOverIssue for all its tickets, back to the backlog
*
* Who was in the sprint comes from the event store. The moves are
* attributed to whoever closed or deleted the sprint.
*/
@Injectable()
export class SprintCarryOverSaga {
constructor(private readonly streams: TrackerStreamsRepository) {}
@Saga()
sprintEnded = (events$: Observable<IEvent>): Observable<ICommand> =>
events$.pipe(
ofType(SprintClosedEvent, SprintDeletedEvent),
mergeMap((event) =>
from(this.streams.issueIdsEverInSprint(event.aggregateId)).pipe(
mergeMap((issueIds) =>
issueIds.map(
(issueId) =>
new CarryOverIssueCommand(
event.actorId,
issueId,
event.aggregateId,
event instanceof SprintClosedEvent ? event.carryOverTo : null,
event instanceof SprintClosedEvent,
),
),
),
),
),
);
}

View File

@@ -0,0 +1,122 @@
import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import {
SprintBurndownReadModel,
SprintDetails,
SprintProgressReadModel,
SprintReadModel,
} from '@task-stream/api/tracker/domain';
import { CommandResult } from './commands/command-result';
import {
CloseSprintCommand,
CreateSprintCommand,
DeleteSprintCommand,
SetIssueSprintCommand,
StartSprintCommand,
UpdateSprintCommand,
} from './commands/sprint.commands';
import {
GetSprintBurndownQuery,
GetSprintProgressQuery,
GetSprintQuery,
GetSprintsQuery,
} from './queries/sprint.queries';
export interface CreateSprintInput {
projectId: string;
name: string;
goal?: string | null;
startDate: string;
endDate: string;
}
/** Sprints: plan them, run them, close them (and what was left moves on). */
@Injectable()
export class SprintsService {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
list(viewerId: string | null, projectId: string): Promise<SprintReadModel[]> {
return this.queryBus.execute(new GetSprintsQuery(viewerId, projectId));
}
findOne(viewerId: string | null, sprintId: string): Promise<SprintReadModel> {
return this.queryBus.execute(new GetSprintQuery(viewerId, sprintId));
}
progress(
viewerId: string | null,
sprintId: string,
): Promise<SprintProgressReadModel> {
return this.queryBus.execute(
new GetSprintProgressQuery(viewerId, sprintId),
);
}
burndown(
viewerId: string | null,
sprintId: string,
): Promise<SprintBurndownReadModel> {
return this.queryBus.execute(
new GetSprintBurndownQuery(viewerId, sprintId),
);
}
create(actorId: string, input: CreateSprintInput): Promise<CommandResult> {
return this.commandBus.execute(
new CreateSprintCommand(
actorId,
input.projectId,
input.name,
input.startDate,
input.endDate,
input.goal ?? null,
),
);
}
update(
actorId: string,
sprintId: string,
changes: SprintDetails,
): Promise<CommandResult> {
return this.commandBus.execute(
new UpdateSprintCommand(actorId, sprintId, changes),
);
}
start(actorId: string, sprintId: string): Promise<CommandResult> {
return this.commandBus.execute(new StartSprintCommand(actorId, sprintId));
}
/** Unfinished tickets go to `carryOverTo` (null: the backlog). */
close(
actorId: string,
sprintId: string,
carryOverTo: string | null,
): Promise<CommandResult> {
return this.commandBus.execute(
new CloseSprintCommand(actorId, sprintId, carryOverTo),
);
}
/** Planned sprints only; its tickets go back to the backlog. */
delete(actorId: string, sprintId: string): Promise<CommandResult> {
return this.commandBus.execute(
new DeleteSprintCommand(actorId, sprintId),
) as Promise<CommandResult>;
}
/** Puts a ticket in a sprint (null: back to the backlog). */
setIssueSprint(
actorId: string,
issueId: string,
sprintId: string | null,
): Promise<CommandResult> {
return this.commandBus.execute(
new SetIssueSprintCommand(actorId, issueId, sprintId),
);
}
}

View File

@@ -31,6 +31,7 @@ import {
import { SAVED_FILTERS } from './saved-filters/saved-filters';
import { SavedFiltersService } from './saved-filters.service';
import { SprintsService } from './sprints.service';
/** Notifications: derived from events, kept and dismissed per person. */
const NOTIFICATIONS = [
@@ -51,6 +52,7 @@ const SERVICES = [
WorkflowsService,
NotificationsService,
SavedFiltersService,
SprintsService,
];
/**