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:
@@ -31,3 +31,9 @@ export * from './issue-checklist-item-added.event';
|
||||
export * from './issue-checklist-item-updated.event';
|
||||
export * from './issue-checklist-item-removed.event';
|
||||
export * from './issue-ranked.event';
|
||||
export * from './sprint-created.event';
|
||||
export * from './sprint-updated.event';
|
||||
export * from './sprint-started.event';
|
||||
export * from './sprint-closed.event';
|
||||
export * from './sprint-deleted.event';
|
||||
export * from './issue-sprint-changed.event';
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||
import { TrackerEvent } from './tracker.event';
|
||||
|
||||
/** A ticket was moved in or out of a sprint. */
|
||||
@AutowiredEvent
|
||||
export class IssueSprintChangedEvent extends TrackerEvent {
|
||||
constructor(
|
||||
actorId: string,
|
||||
issueId: string,
|
||||
projectId: string,
|
||||
readonly sprintId: string | null,
|
||||
readonly previousSprintId: string | null,
|
||||
) {
|
||||
super(actorId, issueId, projectId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||
import { TrackerEvent } from './tracker.event';
|
||||
|
||||
/**
|
||||
* A sprint ended. Its unfinished tickets move to `carryOverTo` (null: back
|
||||
* to the backlog); the SprintCarryOverSaga moves them.
|
||||
*/
|
||||
@AutowiredEvent
|
||||
export class SprintClosedEvent extends TrackerEvent {
|
||||
constructor(
|
||||
actorId: string,
|
||||
sprintId: string,
|
||||
projectId: string,
|
||||
readonly carryOverTo: string | null,
|
||||
readonly unfinishedIssues: number,
|
||||
) {
|
||||
super(actorId, sprintId, projectId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||
import type { SprintProps } from '../sprint/sprint';
|
||||
import { TrackerEvent } from './tracker.event';
|
||||
|
||||
/** A sprint was planned. */
|
||||
@AutowiredEvent
|
||||
export class SprintCreatedEvent extends TrackerEvent {
|
||||
constructor(
|
||||
actorId: string,
|
||||
readonly sprint: SprintProps,
|
||||
) {
|
||||
super(actorId, sprint.id, sprint.projectId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||
import { TrackerEvent } from './tracker.event';
|
||||
|
||||
/** A sprint was deleted: its tickets go back to the backlog. */
|
||||
@AutowiredEvent
|
||||
export class SprintDeletedEvent extends TrackerEvent {
|
||||
constructor(actorId: string, sprintId: string, projectId: string) {
|
||||
super(actorId, sprintId, projectId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||
import { TrackerEvent } from './tracker.event';
|
||||
|
||||
/** A sprint got under way. */
|
||||
@AutowiredEvent
|
||||
export class SprintStartedEvent extends TrackerEvent {
|
||||
constructor(actorId: string, sprintId: string, projectId: string) {
|
||||
super(actorId, sprintId, projectId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||
import type { SprintDetails } from '../sprint/sprint';
|
||||
import { TrackerEvent } from './tracker.event';
|
||||
|
||||
/** A sprint was renamed, re-dated or got another goal (only what changed). */
|
||||
@AutowiredEvent
|
||||
export class SprintUpdatedEvent extends TrackerEvent {
|
||||
constructor(
|
||||
actorId: string,
|
||||
sprintId: string,
|
||||
projectId: string,
|
||||
readonly changes: SprintDetails,
|
||||
) {
|
||||
super(actorId, sprintId, projectId);
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,15 @@ import { CommentFactory } from './comment.factory';
|
||||
import { IssueFactory } from './issue.factory';
|
||||
import { ProjectFactory } from './project.factory';
|
||||
import { ReleaseFactory } from './release.factory';
|
||||
import { SprintFactory } from './sprint.factory';
|
||||
|
||||
export * from './issue.factory';
|
||||
export { CommentFactory, ProjectFactory, ReleaseFactory };
|
||||
export { CommentFactory, ProjectFactory, ReleaseFactory, SprintFactory };
|
||||
|
||||
export const TRACKER_FACTORIES = [
|
||||
ProjectFactory,
|
||||
IssueFactory,
|
||||
CommentFactory,
|
||||
ReleaseFactory,
|
||||
SprintFactory,
|
||||
];
|
||||
|
||||
39
libs/api/tracker/domain/src/lib/factories/sprint.factory.ts
Normal file
39
libs/api/tracker/domain/src/lib/factories/sprint.factory.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { newId } from '@task-stream/api/shared/domain';
|
||||
import { SprintCreatedEvent } from '../events/sprint-created.event';
|
||||
import { Sprint, SprintProps, normalizeSprintDetails } from '../sprint/sprint';
|
||||
import { SprintStatus } from '../sprint/sprint-status';
|
||||
|
||||
@Injectable()
|
||||
export class SprintFactory {
|
||||
create(
|
||||
actorId: string,
|
||||
props: {
|
||||
projectId: string;
|
||||
name: string;
|
||||
goal?: string | null;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
},
|
||||
): Sprint {
|
||||
const details = normalizeSprintDetails({
|
||||
name: props.name,
|
||||
goal: props.goal ?? null,
|
||||
startDate: props.startDate,
|
||||
endDate: props.endDate,
|
||||
});
|
||||
const data: SprintProps = {
|
||||
id: newId(),
|
||||
projectId: props.projectId,
|
||||
...details,
|
||||
status: SprintStatus.PLANNED,
|
||||
startedAt: null,
|
||||
closedAt: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
const sprint = new Sprint(data.id);
|
||||
Object.assign(sprint, { ...data });
|
||||
sprint.apply(new SprintCreatedEvent(actorId, data), { skipHandler: true });
|
||||
return sprint;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { IssueLinkedEvent } from '../events/issue-linked.event';
|
||||
import { IssueParentChangedEvent } from '../events/issue-parent-changed.event';
|
||||
import { IssueRankedEvent } from '../events/issue-ranked.event';
|
||||
import { IssueReleaseChangedEvent } from '../events/issue-release-changed.event';
|
||||
import { IssueSprintChangedEvent } from '../events/issue-sprint-changed.event';
|
||||
import { IssueStatusChangedEvent } from '../events/issue-status-changed.event';
|
||||
import { IssueUnlinkedEvent } from '../events/issue-unlinked.event';
|
||||
import { IssueUpdatedEvent } from '../events/issue-updated.event';
|
||||
@@ -112,6 +113,7 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
public checklist: ChecklistItem[] = [];
|
||||
/** Place in the backlog once someone moved it (see `backlogRank`). */
|
||||
public rank: string | null = null;
|
||||
public sprintId: string | null = null;
|
||||
public createdAt!: string;
|
||||
public updatedAt!: string;
|
||||
public deleted = false;
|
||||
@@ -246,6 +248,20 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
);
|
||||
}
|
||||
|
||||
/** Moves the ticket in or out of a sprint (null: back to the backlog). */
|
||||
setSprint(actorId: string, sprintId: string | null): void {
|
||||
if (this.sprintId === sprintId) return;
|
||||
this.apply(
|
||||
new IssueSprintChangedEvent(
|
||||
actorId,
|
||||
this.id,
|
||||
this.projectId,
|
||||
sprintId,
|
||||
this.sprintId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Records a file stored for this ticket (or one of its comments). */
|
||||
attach(actorId: string, attachment: IssueAttachment): void {
|
||||
const next = normalizeAttachment(attachment);
|
||||
@@ -519,6 +535,11 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
this.deleted = true;
|
||||
}
|
||||
|
||||
onIssueSprintChangedEvent(event: IssueSprintChangedEvent): void {
|
||||
this.sprintId = event.sprintId;
|
||||
this.updatedAt = event.occurredAt;
|
||||
}
|
||||
|
||||
onIssueReleaseChangedEvent(event: IssueReleaseChangedEvent): void {
|
||||
this.releaseId = event.releaseId;
|
||||
this.updatedAt = event.occurredAt;
|
||||
|
||||
@@ -12,12 +12,13 @@ export class IssueTimelineEntry {
|
||||
issueId!: string;
|
||||
projectId!: string;
|
||||
at!: string;
|
||||
kind!: 'status' | 'release';
|
||||
kind!: 'status' | 'release' | 'sprint';
|
||||
status?: string;
|
||||
category?: StatusCategory;
|
||||
/** Assignee when the status changed (throughput per person). */
|
||||
assigneeId?: string | null;
|
||||
releaseId?: string | null;
|
||||
sprintId?: string | null;
|
||||
}
|
||||
|
||||
export class DurationStats {
|
||||
|
||||
@@ -8,3 +8,4 @@ export * from './project-stats.read-model';
|
||||
export * from './analytics.read-model';
|
||||
export * from './notification.read-model';
|
||||
export * from './saved-filter.read-model';
|
||||
export * from './sprint.read-model';
|
||||
|
||||
@@ -70,6 +70,7 @@ export class IssueReadModel {
|
||||
reporter!: UserRefReadModel;
|
||||
assignee!: UserRefReadModel | null;
|
||||
release!: { id: string; name: string } | null;
|
||||
sprint!: { id: string; name: string } | null;
|
||||
labels!: string[];
|
||||
figmaUrl!: string | null;
|
||||
storyPoints!: number | null;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { SprintStatus } from '../sprint/sprint-status';
|
||||
|
||||
export class SprintReadModel {
|
||||
id!: string;
|
||||
projectId!: string;
|
||||
name!: string;
|
||||
goal!: string | null;
|
||||
/** `YYYY-MM-DD`, both included. */
|
||||
startDate!: string;
|
||||
endDate!: string;
|
||||
status!: SprintStatus;
|
||||
startedAt!: string | null;
|
||||
closedAt!: string | null;
|
||||
createdAt!: string;
|
||||
version!: number;
|
||||
}
|
||||
|
||||
/** How far a sprint's tickets have got. */
|
||||
export class SprintProgressReadModel {
|
||||
total!: number;
|
||||
/** Done or cancelled. */
|
||||
finished!: number;
|
||||
percent!: number;
|
||||
storyPoints!: number;
|
||||
finishedStoryPoints!: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per day of the sprint: tickets in it (cancelled ones aside), how many are
|
||||
* still open and their points. Days still to come are null; `ideal` falls in
|
||||
* a straight line from what was open on the first day to zero on the last.
|
||||
*/
|
||||
export class SprintBurndownReadModel {
|
||||
sprintId!: string;
|
||||
dates!: string[];
|
||||
scope!: (number | null)[];
|
||||
remaining!: (number | null)[];
|
||||
remainingPoints!: (number | null)[];
|
||||
ideal!: number[];
|
||||
}
|
||||
8
libs/api/tracker/domain/src/lib/sprint/sprint-status.ts
Normal file
8
libs/api/tracker/domain/src/lib/sprint/sprint-status.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export enum SprintStatus {
|
||||
/** Being prepared: tickets come and go. */
|
||||
PLANNED = 'PLANNED',
|
||||
/** Under way (one per project at a time). */
|
||||
ACTIVE = 'ACTIVE',
|
||||
/** Over: what was left went to another sprint or back to the backlog. */
|
||||
CLOSED = 'CLOSED',
|
||||
}
|
||||
79
libs/api/tracker/domain/src/lib/sprint/sprint.spec.ts
Normal file
79
libs/api/tracker/domain/src/lib/sprint/sprint.spec.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
BusinessRuleError,
|
||||
ValidationError,
|
||||
} from '@task-stream/api/shared/domain';
|
||||
import { SprintClosedEvent } from '../events/sprint-closed.event';
|
||||
import { SprintFactory } from '../factories/sprint.factory';
|
||||
import { replay } from '../testing';
|
||||
import { Sprint } from './sprint';
|
||||
import { SprintStatus } from './sprint-status';
|
||||
|
||||
const factory = new SprintFactory();
|
||||
const newSprint = (name = 'Sprint 1', projectId = 'p-1') =>
|
||||
factory.create('u-pm', {
|
||||
projectId,
|
||||
name,
|
||||
goal: ' Pagos con tarjeta ',
|
||||
startDate: '2026-10-12',
|
||||
endDate: '2026-10-23',
|
||||
});
|
||||
|
||||
describe('Sprint', () => {
|
||||
it('is planned with tidy details', () => {
|
||||
const sprint = newSprint();
|
||||
expect(sprint.status).toBe(SprintStatus.PLANNED);
|
||||
expect(sprint.goal).toBe('Pagos con tarjeta');
|
||||
});
|
||||
|
||||
it('refuses dates the wrong way round, too long or malformed', () => {
|
||||
const create = (startDate: string, endDate: string) =>
|
||||
factory.create('u', { projectId: 'p', name: 'S', startDate, endDate });
|
||||
expect(() => create('2026-10-23', '2026-10-12')).toThrow(ValidationError);
|
||||
expect(() => create('2026-01-01', '2026-12-31')).toThrow(/at most/);
|
||||
expect(() => create('12/10/2026', '2026-10-23')).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('starts once, only when nothing else is under way, and closes', () => {
|
||||
const sprint = newSprint();
|
||||
const next = newSprint('Sprint 2');
|
||||
expect(() => sprint.start('u', { name: 'Sprint 0' })).toThrow(
|
||||
BusinessRuleError,
|
||||
);
|
||||
sprint.start('u', null);
|
||||
expect(() => sprint.start('u', null)).toThrow(BusinessRuleError);
|
||||
expect(() => sprint.update('u', { startDate: '2026-10-13' })).toThrow(
|
||||
/start date/,
|
||||
);
|
||||
sprint.update('u', { endDate: '2026-10-26' });
|
||||
|
||||
sprint.close('u', next, 3);
|
||||
const closed = sprint
|
||||
.getUncommittedEvents()
|
||||
.find((e) => e instanceof SprintClosedEvent);
|
||||
expect(closed).toMatchObject({ carryOverTo: next.id, unfinishedIssues: 3 });
|
||||
const { rebuilt } = replay(sprint, Sprint);
|
||||
expect(rebuilt.status).toBe(SprintStatus.CLOSED);
|
||||
expect(rebuilt.endDate).toBe('2026-10-26');
|
||||
expect(() => rebuilt.assertAcceptsIssues()).toThrow(BusinessRuleError);
|
||||
});
|
||||
|
||||
it('carries over only to another open sprint of the same project', () => {
|
||||
const sprint = newSprint();
|
||||
sprint.start('u', null);
|
||||
expect(() => sprint.close('u', sprint, 0)).toThrow(BusinessRuleError);
|
||||
expect(() => sprint.close('u', newSprint('X', 'p-2'), 0)).toThrow(
|
||||
BusinessRuleError,
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes planned sprints, and any on cascade', () => {
|
||||
const planned = newSprint();
|
||||
planned.delete('u');
|
||||
expect(planned.deleted).toBe(true);
|
||||
const active = newSprint();
|
||||
active.start('u', null);
|
||||
expect(() => active.delete('u')).toThrow(BusinessRuleError);
|
||||
active.delete('u', true);
|
||||
expect(active.deleted).toBe(true);
|
||||
});
|
||||
});
|
||||
192
libs/api/tracker/domain/src/lib/sprint/sprint.ts
Normal file
192
libs/api/tracker/domain/src/lib/sprint/sprint.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
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<SprintProps, 'name' | 'goal' | 'startDate' | 'endDate'>
|
||||
>;
|
||||
|
||||
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<SprintProps, 'name' | 'startDate' | 'endDate'>> &
|
||||
Pick<SprintProps, 'goal'>,
|
||||
): Required<Pick<SprintProps, 'name' | 'startDate' | 'endDate' | 'goal'>> {
|
||||
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<string, unknown>)[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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user