first commit

This commit is contained in:
2026-10-03 13:40:26 +02:00
parent 732a5b5a16
commit 566f415fa7
555 changed files with 62704 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
export enum ReleaseStatus {
PLANNED = 'PLANNED',
RELEASED = 'RELEASED',
ARCHIVED = 'ARCHIVED',
}

View File

@@ -0,0 +1,27 @@
import { BusinessRuleError } from '@task-stream/api/shared/domain';
import { ReleaseFactory } from '../factories/release.factory';
import { replay } from '../testing';
import { Release } from './release';
import { ReleaseStatus } from './release-status';
describe('Release', () => {
const newRelease = () =>
new ReleaseFactory().create('u', { projectId: 'p-1', name: ' v1.2.0 ' });
it('refuses to ship with unfinished tickets unless forced', () => {
const release = newRelease();
expect(() => release.ship('u', 3)).toThrow(BusinessRuleError);
release.ship('u', 3, true);
expect(release.status).toBe(ReleaseStatus.RELEASED);
});
it('is locked once shipped, also after being rebuilt', () => {
const release = newRelease();
release.ship('u', 0);
const { rebuilt } = replay(release, Release);
expect(rebuilt.name).toBe('v1.2.0');
expect(rebuilt.releasedAt).not.toBeNull();
expect(() => rebuilt.assertAcceptsIssues()).toThrow(BusinessRuleError);
});
});

View File

@@ -0,0 +1,133 @@
import {
BusinessRuleError,
VersionedAggregateRoot,
assertThat,
} from '@task-stream/api/shared/domain';
import { ReleaseArchivedEvent } from '../events/release-archived.event';
import { ReleaseCreatedEvent } from '../events/release-created.event';
import { ReleaseShippedEvent } from '../events/release-shipped.event';
import { ReleaseUpdatedEvent } from '../events/release-updated.event';
import { ReleaseStatus } from './release-status';
/** Data of a release (what ReleaseCreatedEvent carries). */
export interface ReleaseProps {
id: string;
projectId: string;
name: string;
description: string | null;
targetDate: string | null;
status: ReleaseStatus;
releasedAt: string | null;
createdAt: string;
}
/** A version of the product: controls which tickets ship together. */
export class Release extends VersionedAggregateRoot implements ReleaseProps {
public projectId!: string;
public name!: string;
public description: string | null = null;
public targetDate: string | null = null;
public status: ReleaseStatus = ReleaseStatus.PLANNED;
public releasedAt: string | null = null;
public createdAt!: string;
constructor(id: string) {
super();
this.id = id;
}
/** Only planned releases can receive or lose tickets. */
assertAcceptsIssues(): void {
if (this.status !== ReleaseStatus.PLANNED) {
throw new BusinessRuleError(
`Release ${this.name} is ${this.status} and is locked`,
);
}
}
update(
actorId: string,
changes: {
name?: string;
description?: string | null;
targetDate?: string | null;
},
): void {
this.assertAcceptsIssues();
const patch: {
name?: string;
description?: string | null;
targetDate?: string | null;
} = {};
if (changes.name !== undefined) {
const name = changes.name.trim();
assertThat(name.length > 0, 'Release name is required');
if (name !== this.name) patch.name = name;
}
if (changes.description !== undefined) {
const description = changes.description?.trim() || null;
if (description !== this.description) patch.description = description;
}
if (
changes.targetDate !== undefined &&
changes.targetDate !== this.targetDate
) {
if (changes.targetDate) {
assertThat(
!Number.isNaN(Date.parse(changes.targetDate)),
'targetDate must be an ISO date',
);
}
patch.targetDate = changes.targetDate;
}
if (Object.keys(patch).length === 0) return;
this.apply(
new ReleaseUpdatedEvent(actorId, this.id, this.projectId, patch),
);
}
/**
* Ships the release. Unfinished tickets block it unless forced, so nothing
* goes out unnoticed. (Not called `publish`: that name belongs to AggregateRoot.)
*/
ship(actorId: string, unfinishedIssues: number, force = false): void {
this.assertAcceptsIssues();
if (unfinishedIssues > 0 && !force) {
throw new BusinessRuleError(
`Release ${this.name} still has ${unfinishedIssues} unfinished ticket(s); move them out or publish with force`,
);
}
this.apply(
new ReleaseShippedEvent(
actorId,
this.id,
this.projectId,
unfinishedIssues,
),
);
}
archive(actorId: string): void {
if (this.status === ReleaseStatus.ARCHIVED) return;
this.apply(new ReleaseArchivedEvent(actorId, this.id, this.projectId));
}
/* ---- State transitions (also replayed by loadFromHistory) ---- */
onReleaseCreatedEvent({ release }: ReleaseCreatedEvent): void {
Object.assign(this, release);
}
onReleaseUpdatedEvent(event: ReleaseUpdatedEvent): void {
Object.assign(this, event.changes);
}
onReleaseShippedEvent(event: ReleaseShippedEvent): void {
this.status = ReleaseStatus.RELEASED;
this.releasedAt = event.occurredAt;
}
onReleaseArchivedEvent(): void {
this.status = ReleaseStatus.ARCHIVED;
}
}