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,11 @@
export * from './lib/project/project';
export * from './lib/project/project-role';
export * from './lib/issue/issue';
export * from './lib/issue/issue.enums';
export * from './lib/issue/issue-workflow';
export * from './lib/comment/comment';
export * from './lib/release/release';
export * from './lib/release/release-status';
export * from './lib/events';
export * from './lib/factories';
export * from './lib/read-models';

View File

@@ -0,0 +1,99 @@
import {
BusinessRuleError,
ForbiddenError,
VersionedAggregateRoot,
assertThat,
} from '@task-stream/api/shared/domain';
import { CommentAddedEvent } from '../events/comment-added.event';
import { CommentDeletedEvent } from '../events/comment-deleted.event';
import { CommentEditedEvent } from '../events/comment-edited.event';
/** Data of a comment (what CommentAddedEvent carries). */
export interface CommentProps {
id: string;
issueId: string;
projectId: string;
authorId: string;
body: string;
deleted: boolean;
createdAt: string;
editedAt: string | null;
}
const MAX_BODY = 20_000;
/** Decisions and explanations inside a ticket. */
export class Comment extends VersionedAggregateRoot implements CommentProps {
public issueId!: string;
public projectId!: string;
public authorId!: string;
public body!: string;
public deleted = false;
public createdAt!: string;
public editedAt: string | null = null;
constructor(id: string) {
super();
this.id = id;
}
edit(actorId: string, body: string): void {
this.assertAlive();
if (actorId !== this.authorId) {
throw new ForbiddenError('Only the author can edit a comment');
}
const next = validCommentBody(body);
if (next === this.body) return;
this.apply(
new CommentEditedEvent(
actorId,
this.id,
this.projectId,
this.issueId,
next,
),
);
}
/** @param canModerate whether the actor manages the project (may delete others' comments). */
delete(actorId: string, canModerate: boolean): void {
this.assertAlive();
if (actorId !== this.authorId && !canModerate) {
throw new ForbiddenError(
'Only the author or a project admin can delete a comment',
);
}
this.apply(
new CommentDeletedEvent(actorId, this.id, this.projectId, this.issueId),
);
}
/* ---- State transitions (also replayed by loadFromHistory) ---- */
onCommentAddedEvent({ comment }: CommentAddedEvent): void {
Object.assign(this, comment);
}
onCommentEditedEvent(event: CommentEditedEvent): void {
this.body = event.body;
this.editedAt = event.occurredAt;
}
onCommentDeletedEvent(): void {
this.deleted = true;
}
private assertAlive(): void {
if (this.deleted) throw new BusinessRuleError('Comment was deleted');
}
}
export function validCommentBody(body: string): string {
const trimmed = body.trim();
assertThat(trimmed.length > 0, 'Comment cannot be empty');
assertThat(
trimmed.length <= MAX_BODY,
`Comment cannot exceed ${MAX_BODY} characters`,
);
return trimmed;
}

View File

@@ -0,0 +1,14 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import type { CommentProps } from '../comment/comment';
import { TrackerEvent } from './tracker.event';
/** Someone commented on a ticket. */
@AutowiredEvent
export class CommentAddedEvent extends TrackerEvent {
constructor(
actorId: string,
readonly comment: CommentProps,
) {
super(actorId, comment.id, comment.projectId);
}
}

View File

@@ -0,0 +1,15 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import { TrackerEvent } from './tracker.event';
/** A comment was deleted. */
@AutowiredEvent
export class CommentDeletedEvent extends TrackerEvent {
constructor(
actorId: string,
commentId: string,
projectId: string,
readonly issueId: string,
) {
super(actorId, commentId, projectId);
}
}

View File

@@ -0,0 +1,16 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import { TrackerEvent } from './tracker.event';
/** The author edited a comment. */
@AutowiredEvent
export class CommentEditedEvent extends TrackerEvent {
constructor(
actorId: string,
commentId: string,
projectId: string,
readonly issueId: string,
readonly body: string,
) {
super(actorId, commentId, projectId);
}
}

View File

@@ -0,0 +1,20 @@
export * from './tracker.event';
export * from './project-created.event';
export * from './project-updated.event';
export * from './project-member-added.event';
export * from './project-member-removed.event';
export * from './project-member-role-changed.event';
export * from './issue-created.event';
export * from './issue-updated.event';
export * from './issue-status-changed.event';
export * from './issue-assigned.event';
export * from './issue-linked.event';
export * from './issue-unlinked.event';
export * from './issue-release-changed.event';
export * from './comment-added.event';
export * from './comment-edited.event';
export * from './comment-deleted.event';
export * from './release-created.event';
export * from './release-updated.event';
export * from './release-shipped.event';
export * from './release-archived.event';

View File

@@ -0,0 +1,16 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import { TrackerEvent } from './tracker.event';
/** A ticket was assigned (or unassigned when assigneeId is null). */
@AutowiredEvent
export class IssueAssignedEvent extends TrackerEvent {
constructor(
actorId: string,
issueId: string,
projectId: string,
readonly assigneeId: string | null,
readonly previousAssigneeId: string | null,
) {
super(actorId, issueId, projectId);
}
}

View File

@@ -0,0 +1,14 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import type { IssueProps } from '../issue/issue';
import { TrackerEvent } from './tracker.event';
/** A ticket was reported (bug, feature, UI change...). */
@AutowiredEvent
export class IssueCreatedEvent extends TrackerEvent {
constructor(
actorId: string,
readonly issue: IssueProps,
) {
super(actorId, issue.id, issue.projectId);
}
}

View File

@@ -0,0 +1,16 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import type { IssueLink } from '../issue/issue';
import { TrackerEvent } from './tracker.event';
/** Two tickets were related (e.g. FE DEPENDS_ON BE). */
@AutowiredEvent
export class IssueLinkedEvent extends TrackerEvent {
constructor(
actorId: string,
issueId: string,
projectId: string,
readonly link: IssueLink,
) {
super(actorId, issueId, projectId);
}
}

View File

@@ -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 release. */
@AutowiredEvent
export class IssueReleaseChangedEvent extends TrackerEvent {
constructor(
actorId: string,
issueId: string,
projectId: string,
readonly releaseId: string | null,
readonly previousReleaseId: string | null,
) {
super(actorId, issueId, projectId);
}
}

View File

@@ -0,0 +1,18 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import { IssueStatus } from '../issue/issue.enums';
import { TrackerEvent } from './tracker.event';
/** A ticket moved through the workflow (incl. QA). */
@AutowiredEvent
export class IssueStatusChangedEvent extends TrackerEvent {
constructor(
actorId: string,
issueId: string,
projectId: string,
readonly from: IssueStatus,
readonly to: IssueStatus,
readonly reason: string | null,
) {
super(actorId, issueId, projectId);
}
}

View File

@@ -0,0 +1,16 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import type { IssueLink } from '../issue/issue';
import { TrackerEvent } from './tracker.event';
/** A relation between tickets was removed. */
@AutowiredEvent
export class IssueUnlinkedEvent extends TrackerEvent {
constructor(
actorId: string,
issueId: string,
projectId: string,
readonly link: IssueLink,
) {
super(actorId, issueId, projectId);
}
}

View File

@@ -0,0 +1,16 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import type { IssueDetails } from '../issue/issue';
import { TrackerEvent } from './tracker.event';
/** Editable fields of a ticket changed. */
@AutowiredEvent
export class IssueUpdatedEvent extends TrackerEvent {
constructor(
actorId: string,
issueId: string,
projectId: string,
readonly changes: IssueDetails,
) {
super(actorId, issueId, projectId);
}
}

View File

@@ -0,0 +1,14 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import type { ProjectProps } from '../project/project';
import { TrackerEvent } from './tracker.event';
/** A project was created. */
@AutowiredEvent
export class ProjectCreatedEvent extends TrackerEvent {
constructor(
actorId: string,
readonly project: ProjectProps,
) {
super(actorId, project.id, project.id);
}
}

View File

@@ -0,0 +1,16 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import { ProjectRole } from '../project/project-role';
import { TrackerEvent } from './tracker.event';
/** A Pireon user joined the project. */
@AutowiredEvent
export class ProjectMemberAddedEvent extends TrackerEvent {
constructor(
actorId: string,
projectId: string,
readonly userId: string,
readonly role: ProjectRole,
) {
super(actorId, projectId, projectId);
}
}

View File

@@ -0,0 +1,14 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import { TrackerEvent } from './tracker.event';
/** A member left or was removed. */
@AutowiredEvent
export class ProjectMemberRemovedEvent extends TrackerEvent {
constructor(
actorId: string,
projectId: string,
readonly userId: string,
) {
super(actorId, projectId, projectId);
}
}

View File

@@ -0,0 +1,17 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import { ProjectRole } from '../project/project-role';
import { TrackerEvent } from './tracker.event';
/** A member got a new role. */
@AutowiredEvent
export class ProjectMemberRoleChangedEvent extends TrackerEvent {
constructor(
actorId: string,
projectId: string,
readonly userId: string,
readonly role: ProjectRole,
readonly previousRole: ProjectRole,
) {
super(actorId, projectId, projectId);
}
}

View File

@@ -0,0 +1,15 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import type { ProjectProps } from '../project/project';
import { TrackerEvent } from './tracker.event';
/** Name or description changed. */
@AutowiredEvent
export class ProjectUpdatedEvent extends TrackerEvent {
constructor(
actorId: string,
projectId: string,
readonly changes: Partial<Pick<ProjectProps, 'name' | 'description'>>,
) {
super(actorId, projectId, projectId);
}
}

View File

@@ -0,0 +1,10 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import { TrackerEvent } from './tracker.event';
/** The release was archived. */
@AutowiredEvent
export class ReleaseArchivedEvent extends TrackerEvent {
constructor(actorId: string, releaseId: string, projectId: string) {
super(actorId, releaseId, projectId);
}
}

View File

@@ -0,0 +1,14 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import type { ReleaseProps } from '../release/release';
import { TrackerEvent } from './tracker.event';
/** A release (version) was planned. */
@AutowiredEvent
export class ReleaseCreatedEvent extends TrackerEvent {
constructor(
actorId: string,
readonly release: ReleaseProps,
) {
super(actorId, release.id, release.projectId);
}
}

View File

@@ -0,0 +1,15 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import { TrackerEvent } from './tracker.event';
/** The release went out. */
@AutowiredEvent
export class ReleaseShippedEvent extends TrackerEvent {
constructor(
actorId: string,
releaseId: string,
projectId: string,
readonly unfinishedIssues: number,
) {
super(actorId, releaseId, projectId);
}
}

View File

@@ -0,0 +1,18 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import type { ReleaseProps } from '../release/release';
import { TrackerEvent } from './tracker.event';
/** Release details changed. */
@AutowiredEvent
export class ReleaseUpdatedEvent extends TrackerEvent {
constructor(
actorId: string,
releaseId: string,
projectId: string,
readonly changes: Partial<
Pick<ReleaseProps, 'name' | 'description' | 'targetDate'>
>,
) {
super(actorId, releaseId, projectId);
}
}

View File

@@ -0,0 +1,12 @@
import { DomainEvent } from '@task-stream/api/shared/domain';
/** Every tracker event says which aggregate changed and in which project. */
export abstract class TrackerEvent extends DomainEvent {
constructor(
actorId: string,
readonly aggregateId: string,
readonly projectId: string,
) {
super(actorId);
}
}

View File

@@ -0,0 +1,31 @@
import { Injectable } from '@nestjs/common';
import { newId } from '@task-stream/api/shared/domain';
import { Comment, CommentProps, validCommentBody } from '../comment/comment';
import { CommentAddedEvent } from '../events/comment-added.event';
@Injectable()
export class CommentFactory {
create(props: {
issueId: string;
projectId: string;
authorId: string;
body: string;
}): Comment {
const data: CommentProps = {
id: newId(),
issueId: props.issueId,
projectId: props.projectId,
authorId: props.authorId,
body: validCommentBody(props.body),
deleted: false,
createdAt: new Date().toISOString(),
editedAt: null,
};
const comment = new Comment(data.id);
Object.assign(comment, { ...data });
comment.apply(new CommentAddedEvent(props.authorId, data), {
skipHandler: true,
});
return comment;
}
}

View File

@@ -0,0 +1,14 @@
import { CommentFactory } from './comment.factory';
import { IssueFactory } from './issue.factory';
import { ProjectFactory } from './project.factory';
import { ReleaseFactory } from './release.factory';
export * from './issue.factory';
export { CommentFactory, ProjectFactory, ReleaseFactory };
export const TRACKER_FACTORIES = [
ProjectFactory,
IssueFactory,
CommentFactory,
ReleaseFactory,
];

View File

@@ -0,0 +1,60 @@
import { Injectable } from '@nestjs/common';
import { assertThat, newId } from '@task-stream/api/shared/domain';
import { IssueCreatedEvent } from '../events/issue-created.event';
import {
Issue,
IssueDetails,
IssueProps,
normalizeIssueDetails,
} from '../issue/issue';
import { IssuePriority, IssueStatus, IssueType } from '../issue/issue.enums';
export interface CreateIssueProps extends IssueDetails {
projectId: string;
projectKey: string;
number: number;
title: string;
type: IssueType;
reporterId: string;
assigneeId?: string | null;
}
@Injectable()
export class IssueFactory {
/** New tickets start in BACKLOG with a project-scoped key (e.g. TS-7). */
create(props: CreateIssueProps): Issue {
const details = normalizeIssueDetails(props);
const title = details.title ?? '';
assertThat(title.length > 0, 'Issue title is required');
const now = new Date().toISOString();
const data: IssueProps = {
id: newId(),
projectId: props.projectId,
number: props.number,
key: `${props.projectKey}-${props.number}`,
type: props.type,
area: props.area ?? null,
title,
description: details.description ?? null,
status: IssueStatus.BACKLOG,
priority: props.priority ?? IssuePriority.MEDIUM,
reporterId: props.reporterId,
assigneeId: props.assigneeId ?? null,
releaseId: null,
labels: details.labels ?? [],
figmaUrl: details.figmaUrl ?? null,
storyPoints: details.storyPoints ?? null,
dueDate: details.dueDate ?? null,
links: [],
createdAt: now,
updatedAt: now,
};
const issue = new Issue(data.id);
Object.assign(issue, structuredClone(data));
issue.apply(new IssueCreatedEvent(props.reporterId, data), {
skipHandler: true,
});
return issue;
}
}

View File

@@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { assertThat, newId } from '@task-stream/api/shared/domain';
import { ProjectCreatedEvent } from '../events/project-created.event';
import { Project, ProjectProps } from '../project/project';
import { ProjectRole } from '../project/project-role';
const KEY_PATTERN = /^[A-Z][A-Z0-9]{1,9}$/;
@Injectable()
export class ProjectFactory {
create(props: {
key: string;
name: string;
description?: string | null;
ownerId: string;
}): Project {
const key = props.key.trim().toUpperCase();
assertThat(
KEY_PATTERN.test(key),
'Project key must be 2-10 uppercase alphanumeric characters starting with a letter',
);
const name = props.name.trim();
assertThat(name.length > 0, 'Project name is required');
const now = new Date().toISOString();
const data: ProjectProps = {
id: newId(),
key,
name,
description: props.description?.trim() || null,
ownerId: props.ownerId,
members: [{ userId: props.ownerId, role: ProjectRole.OWNER }],
createdAt: now,
updatedAt: now,
};
const project = new Project(data.id);
Object.assign(project, structuredClone(data));
project.apply(new ProjectCreatedEvent(props.ownerId, data), {
skipHandler: true,
});
return project;
}
}

View File

@@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { assertThat, newId } from '@task-stream/api/shared/domain';
import { ReleaseCreatedEvent } from '../events/release-created.event';
import { Release, ReleaseProps } from '../release/release';
import { ReleaseStatus } from '../release/release-status';
@Injectable()
export class ReleaseFactory {
create(
actorId: string,
props: {
projectId: string;
name: string;
description?: string | null;
targetDate?: string | null;
},
): Release {
const name = props.name.trim();
assertThat(name.length > 0, 'Release name is required');
if (props.targetDate) {
assertThat(
!Number.isNaN(Date.parse(props.targetDate)),
'targetDate must be an ISO date',
);
}
const data: ReleaseProps = {
id: newId(),
projectId: props.projectId,
name,
description: props.description?.trim() || null,
targetDate: props.targetDate ?? null,
status: ReleaseStatus.PLANNED,
releasedAt: null,
createdAt: new Date().toISOString(),
};
const release = new Release(data.id);
Object.assign(release, { ...data });
release.apply(new ReleaseCreatedEvent(actorId, data), {
skipHandler: true,
});
return release;
}
}

View File

@@ -0,0 +1,37 @@
import { IssueStatus as S } from './issue.enums';
/**
* Allowed status transitions. QA is a first-class step: work goes
* READY_FOR_QA → IN_QA → DONE | QA_FAILED → IN_PROGRESS.
*/
const TRANSITIONS: Record<S, readonly S[]> = {
[S.BACKLOG]: [S.TODO, S.IN_PROGRESS, S.CANCELLED],
[S.TODO]: [S.BACKLOG, S.IN_PROGRESS, S.CANCELLED],
[S.IN_PROGRESS]: [S.TODO, S.IN_REVIEW, S.READY_FOR_QA, S.CANCELLED],
[S.IN_REVIEW]: [S.IN_PROGRESS, S.READY_FOR_QA, S.DONE],
[S.READY_FOR_QA]: [S.IN_QA, S.IN_PROGRESS],
[S.IN_QA]: [S.DONE, S.QA_FAILED],
[S.QA_FAILED]: [S.IN_PROGRESS, S.TODO],
[S.DONE]: [S.TODO, S.IN_PROGRESS],
[S.CANCELLED]: [S.BACKLOG],
};
/** Statuses that require an explanation when entered. */
const REASON_REQUIRED: readonly S[] = [S.QA_FAILED, S.CANCELLED];
export const FINISHED_STATUSES: readonly S[] = [S.DONE, S.CANCELLED];
export const IssueWorkflow = {
canTransition(from: S, to: S): boolean {
return TRANSITIONS[from].includes(to);
},
allowedFrom(from: S): readonly S[] {
return TRANSITIONS[from];
},
requiresReason(to: S): boolean {
return REASON_REQUIRED.includes(to);
},
isFinished(status: S): boolean {
return FINISHED_STATUSES.includes(status);
},
};

View File

@@ -0,0 +1,51 @@
export enum IssueType {
/** Error found by QA or users. */
BUG = 'BUG',
/** New functionality. */
FEATURE = 'FEATURE',
/** Implementation that does not match the Figma design. */
UI_CHANGE = 'UI_CHANGE',
/** Technical or generic work. */
TASK = 'TASK',
/** Test plan / QA verification ticket. */
QA = 'QA',
}
/** Team / layer the ticket belongs to, used to link FE ↔ BE ↔ QA work. */
export enum IssueArea {
FRONTEND = 'FRONTEND',
BACKEND = 'BACKEND',
MOBILE = 'MOBILE',
QA = 'QA',
DESIGN = 'DESIGN',
DEVOPS = 'DEVOPS',
}
export enum IssuePriority {
LOWEST = 'LOWEST',
LOW = 'LOW',
MEDIUM = 'MEDIUM',
HIGH = 'HIGH',
CRITICAL = 'CRITICAL',
}
export enum IssueStatus {
BACKLOG = 'BACKLOG',
TODO = 'TODO',
IN_PROGRESS = 'IN_PROGRESS',
IN_REVIEW = 'IN_REVIEW',
READY_FOR_QA = 'READY_FOR_QA',
IN_QA = 'IN_QA',
QA_FAILED = 'QA_FAILED',
DONE = 'DONE',
CANCELLED = 'CANCELLED',
}
export enum IssueLinkType {
BLOCKS = 'BLOCKS',
DEPENDS_ON = 'DEPENDS_ON',
/** QA ticket that verifies the target ticket. */
TESTS = 'TESTS',
DUPLICATES = 'DUPLICATES',
RELATES_TO = 'RELATES_TO',
}

View File

@@ -0,0 +1,157 @@
import {
BusinessRuleError,
ValidationError,
} from '@task-stream/api/shared/domain';
import { IssueCreatedEvent } from '../events/issue-created.event';
import { IssueStatusChangedEvent } from '../events/issue-status-changed.event';
import { IssueFactory } from '../factories/issue.factory';
import { replay } from '../testing';
import { Issue } from './issue';
import { IssueLinkType, IssueStatus, IssueType } from './issue.enums';
const factory = new IssueFactory();
const newIssue = () =>
factory.create({
projectId: 'p-1',
projectKey: 'TS',
number: 7,
title: ' Login button misaligned ',
type: IssueType.UI_CHANGE,
reporterId: 'u-qa',
figmaUrl: 'https://www.figma.com/file/abc',
labels: ['UI', 'ui ', 'login'],
});
const moveTo = (issue: Issue, ...statuses: IssueStatus[]) =>
statuses.forEach((s) => issue.changeStatus('u-dev', s, 'because'));
describe('Issue', () => {
it('is created in BACKLOG with a project-scoped key and a creation event', () => {
const issue = newIssue();
const events = issue.getUncommittedEvents();
expect(issue.key).toBe('TS-7');
expect(issue.status).toBe(IssueStatus.BACKLOG);
expect(issue.title).toBe('Login button misaligned');
expect(issue.labels).toEqual(['ui', 'login']);
expect(events).toHaveLength(1);
expect(events[0]).toBeInstanceOf(IssueCreatedEvent);
});
it('rejects non-Figma design links', () => {
expect(() =>
factory.create({
projectId: 'p-1',
projectKey: 'TS',
number: 1,
title: 'x',
type: IssueType.UI_CHANGE,
reporterId: 'u',
figmaUrl: 'https://evil.com/x',
}),
).toThrow(ValidationError);
});
it('follows the QA workflow', () => {
const issue = newIssue();
moveTo(
issue,
IssueStatus.TODO,
IssueStatus.IN_PROGRESS,
IssueStatus.READY_FOR_QA,
IssueStatus.IN_QA,
);
issue.changeStatus(
'u-qa',
IssueStatus.QA_FAILED,
'Still 2px off on Safari',
);
moveTo(
issue,
IssueStatus.IN_PROGRESS,
IssueStatus.READY_FOR_QA,
IssueStatus.IN_QA,
IssueStatus.DONE,
);
expect(issue.status).toBe(IssueStatus.DONE);
});
it('forbids skipping QA and requires a reason when QA fails', () => {
const issue = newIssue();
moveTo(issue, IssueStatus.TODO);
expect(() => issue.changeStatus('u', IssueStatus.DONE)).toThrow(
BusinessRuleError,
);
moveTo(
issue,
IssueStatus.IN_PROGRESS,
IssueStatus.READY_FOR_QA,
IssueStatus.IN_QA,
);
expect(() =>
issue.changeStatus('u-qa', IssueStatus.QA_FAILED, ' '),
).toThrow(/reason/);
});
it('links tickets once and never to itself', () => {
const issue = newIssue();
issue.linkTo('u', IssueLinkType.DEPENDS_ON, { id: 'i-be', key: 'BE-3' });
expect(() =>
issue.linkTo('u', IssueLinkType.DEPENDS_ON, { id: 'i-be', key: 'BE-3' }),
).toThrow(BusinessRuleError);
expect(() =>
issue.linkTo('u', IssueLinkType.RELATES_TO, {
id: issue.id,
key: 'TS-7',
}),
).toThrow(BusinessRuleError);
issue.unlink('u', IssueLinkType.DEPENDS_ON, 'i-be');
expect(issue.links).toHaveLength(0);
});
it('records events only for real changes', () => {
const issue = newIssue();
issue.uncommit();
issue.updateDetails('u', {
title: 'Login button misaligned',
labels: ['login', 'ui'],
});
issue.updateDetails('u', { priority: issue.priority });
expect(issue.getUncommittedEvents()).toHaveLength(1);
});
it('is rebuilt from its event history (event sourcing)', () => {
const issue = newIssue();
moveTo(issue, IssueStatus.TODO, IssueStatus.IN_PROGRESS);
issue.assign('u-qa', 'u-dev');
issue.linkTo('u', IssueLinkType.TESTS, { id: 'i-2', key: 'TS-2' });
const { rebuilt, history } = replay(issue, Issue);
expect(history.map((e) => e.type)).toEqual([
'IssueCreatedEvent',
'IssueStatusChangedEvent',
'IssueStatusChangedEvent',
'IssueAssignedEvent',
'IssueLinkedEvent',
]);
expect(history[1].data).toBeInstanceOf(IssueStatusChangedEvent);
expect(rebuilt.version.value).toBe(5);
expect(rebuilt).toMatchObject({
key: 'TS-7',
status: IssueStatus.IN_PROGRESS,
assigneeId: 'u-dev',
labels: ['ui', 'login'],
links: [
{ type: IssueLinkType.TESTS, targetIssueId: 'i-2', targetKey: 'TS-2' },
],
});
expect(rebuilt.getUncommittedEvents()).toHaveLength(0);
});
});

View File

@@ -0,0 +1,278 @@
import {
BusinessRuleError,
VersionedAggregateRoot,
assertThat,
} from '@task-stream/api/shared/domain';
import { IssueAssignedEvent } from '../events/issue-assigned.event';
import { IssueCreatedEvent } from '../events/issue-created.event';
import { IssueLinkedEvent } from '../events/issue-linked.event';
import { IssueReleaseChangedEvent } from '../events/issue-release-changed.event';
import { IssueStatusChangedEvent } from '../events/issue-status-changed.event';
import { IssueUnlinkedEvent } from '../events/issue-unlinked.event';
import { IssueUpdatedEvent } from '../events/issue-updated.event';
import {
IssueArea,
IssueLinkType,
IssuePriority,
IssueStatus,
IssueType,
} from './issue.enums';
import { IssueWorkflow } from './issue-workflow';
export interface IssueLink {
type: IssueLinkType;
targetIssueId: string;
targetKey: string;
}
/** Fields editable after creation. */
export interface IssueDetails {
title?: string;
description?: string | null;
type?: IssueType;
area?: IssueArea | null;
priority?: IssuePriority;
labels?: string[];
figmaUrl?: string | null;
storyPoints?: number | null;
dueDate?: string | null;
}
/** Data of a ticket (what IssueCreatedEvent carries). */
export interface IssueProps {
id: string;
projectId: string;
number: number;
key: string;
type: IssueType;
area: IssueArea | null;
title: string;
description: string | null;
status: IssueStatus;
priority: IssuePriority;
reporterId: string;
assigneeId: string | null;
releaseId: string | null;
labels: string[];
figmaUrl: string | null;
storyPoints: number | null;
dueDate: string | null;
links: IssueLink[];
createdAt: string;
updatedAt: string;
}
/**
* A ticket: bug, feature, UI change, task or QA. Event-sourced: create it
* with IssueFactory, load it with the AggregateRehydrator.
*/
export class Issue extends VersionedAggregateRoot implements IssueProps {
public projectId!: string;
public number!: number;
public key!: string;
public type!: IssueType;
public area: IssueArea | null = null;
public title!: string;
public description: string | null = null;
public status: IssueStatus = IssueStatus.BACKLOG;
public priority: IssuePriority = IssuePriority.MEDIUM;
public reporterId!: string;
public assigneeId: string | null = null;
public releaseId: string | null = null;
public labels: string[] = [];
public figmaUrl: string | null = null;
public storyPoints: number | null = null;
public dueDate: string | null = null;
public links: IssueLink[] = [];
public createdAt!: string;
public updatedAt!: string;
constructor(id: string) {
super();
this.id = id;
}
updateDetails(actorId: string, changes: IssueDetails): void {
const next = normalizeIssueDetails(changes);
const patch: IssueDetails = {};
for (const field of Object.keys(next) as (keyof IssueDetails)[]) {
const value = next[field];
if (value === undefined) continue;
if (JSON.stringify(value) === JSON.stringify(this[field])) continue;
(patch as Record<string, unknown>)[field] = value;
}
if (patch.title !== undefined)
assertThat(patch.title.length > 0, 'Issue title is required');
if (Object.keys(patch).length === 0) return;
this.apply(new IssueUpdatedEvent(actorId, this.id, this.projectId, patch));
}
changeStatus(actorId: string, to: IssueStatus, reason?: string | null): void {
const from = this.status;
if (from === to) return;
if (!IssueWorkflow.canTransition(from, to)) {
throw new BusinessRuleError(
`Cannot move ${this.key} from ${from} to ${to}. Allowed: ${IssueWorkflow.allowedFrom(from).join(', ')}`,
);
}
const trimmedReason = reason?.trim() || null;
if (IssueWorkflow.requiresReason(to) && !trimmedReason) {
throw new BusinessRuleError(
`A reason is required to move ${this.key} to ${to}`,
);
}
this.apply(
new IssueStatusChangedEvent(
actorId,
this.id,
this.projectId,
from,
to,
trimmedReason,
),
);
}
/** @param assigneeId null to unassign */
assign(actorId: string, assigneeId: string | null): void {
if (this.assigneeId === assigneeId) return;
this.apply(
new IssueAssignedEvent(
actorId,
this.id,
this.projectId,
assigneeId,
this.assigneeId,
),
);
}
linkTo(
actorId: string,
type: IssueLinkType,
target: { id: string; key: string },
): void {
if (target.id === this.id)
throw new BusinessRuleError('An issue cannot be linked to itself');
if (
this.links.some((l) => l.type === type && l.targetIssueId === target.id)
) {
throw new BusinessRuleError(`${this.key} already ${type} ${target.key}`);
}
this.apply(
new IssueLinkedEvent(actorId, this.id, this.projectId, {
type,
targetIssueId: target.id,
targetKey: target.key,
}),
);
}
unlink(actorId: string, type: IssueLinkType, targetIssueId: string): void {
const link = this.links.find(
(l) => l.type === type && l.targetIssueId === targetIssueId,
);
if (!link) throw new BusinessRuleError('Link not found');
this.apply(
new IssueUnlinkedEvent(actorId, this.id, this.projectId, { ...link }),
);
}
/** Moves the ticket in or out of a release (null removes it). */
setRelease(actorId: string, releaseId: string | null): void {
if (this.releaseId === releaseId) return;
this.apply(
new IssueReleaseChangedEvent(
actorId,
this.id,
this.projectId,
releaseId,
this.releaseId,
),
);
}
/* ---- State transitions (also replayed by loadFromHistory) ---- */
onIssueCreatedEvent({ issue }: IssueCreatedEvent): void {
Object.assign(this, {
...issue,
labels: [...issue.labels],
links: issue.links.map((l) => ({ ...l })),
});
}
onIssueUpdatedEvent(event: IssueUpdatedEvent): void {
Object.assign(this, event.changes);
this.updatedAt = event.occurredAt;
}
onIssueStatusChangedEvent(event: IssueStatusChangedEvent): void {
this.status = event.to;
this.updatedAt = event.occurredAt;
}
onIssueAssignedEvent(event: IssueAssignedEvent): void {
this.assigneeId = event.assigneeId;
this.updatedAt = event.occurredAt;
}
onIssueLinkedEvent(event: IssueLinkedEvent): void {
this.links = [...this.links, { ...event.link }];
this.updatedAt = event.occurredAt;
}
onIssueUnlinkedEvent(event: IssueUnlinkedEvent): void {
this.links = this.links.filter(
(l) =>
!(
l.type === event.link.type &&
l.targetIssueId === event.link.targetIssueId
),
);
this.updatedAt = event.occurredAt;
}
onIssueReleaseChangedEvent(event: IssueReleaseChangedEvent): void {
this.releaseId = event.releaseId;
this.updatedAt = event.occurredAt;
}
}
/** Trims, deduplicates and validates editable issue fields. */
export function normalizeIssueDetails(details: IssueDetails): IssueDetails {
const out: IssueDetails = { ...details };
if (details.title !== undefined) out.title = details.title.trim();
if (details.description !== undefined)
out.description = details.description?.trim() || null;
if (details.labels !== undefined) {
out.labels = [
...new Set(
details.labels.map((l) => l.trim().toLowerCase()).filter(Boolean),
),
];
}
if (details.figmaUrl !== undefined && details.figmaUrl !== null) {
const url = details.figmaUrl.trim();
assertThat(
/^https:\/\/([\w-]+\.)?figma\.com\//.test(url),
'figmaUrl must be a figma.com link',
);
out.figmaUrl = url;
}
if (details.storyPoints !== undefined && details.storyPoints !== null) {
assertThat(
Number.isInteger(details.storyPoints) &&
details.storyPoints >= 0 &&
details.storyPoints <= 100,
'storyPoints must be an integer between 0 and 100',
);
}
if (details.dueDate !== undefined && details.dueDate !== null) {
assertThat(
!Number.isNaN(Date.parse(details.dueDate)),
'dueDate must be an ISO date',
);
}
return out;
}

View File

@@ -0,0 +1,7 @@
export enum ProjectRole {
OWNER = 'OWNER',
ADMIN = 'ADMIN',
MEMBER = 'MEMBER',
/** Read-only stakeholder (PM, client...). */
VIEWER = 'VIEWER',
}

View File

@@ -0,0 +1,65 @@
import {
BusinessRuleError,
ForbiddenError,
ValidationError,
} from '@task-stream/api/shared/domain';
import { ProjectFactory } from '../factories/project.factory';
import { replay } from '../testing';
import { Project } from './project';
import { ProjectRole } from './project-role';
const factory = new ProjectFactory();
const newProject = () =>
factory.create({ key: 'ts', name: 'Task Stream', ownerId: 'owner' });
describe('Project', () => {
it('normalises the key and makes the creator owner', () => {
const project = newProject();
expect(project.key).toBe('TS');
expect(project.roleOf('owner')).toBe(ProjectRole.OWNER);
});
it('validates the key', () => {
expect(() =>
factory.create({ key: '1x', name: 'n', ownerId: 'o' }),
).toThrow(ValidationError);
});
it('lets admins manage members and viewers only read', () => {
const project = newProject();
project.addMember('owner', 'admin', ProjectRole.ADMIN);
project.addMember('admin', 'viewer', ProjectRole.VIEWER);
expect(() => project.assertCanContribute('viewer')).toThrow(ForbiddenError);
expect(() => project.addMember('viewer', 'x', ProjectRole.MEMBER)).toThrow(
ForbiddenError,
);
expect(() => project.assertCanView('stranger')).toThrow(ForbiddenError);
});
it('protects the owner', () => {
const project = newProject();
project.addMember('owner', 'admin', ProjectRole.ADMIN);
expect(() => project.removeMember('admin', 'owner')).toThrow(
BusinessRuleError,
);
expect(() =>
project.changeMemberRole('admin', 'owner', ProjectRole.MEMBER),
).toThrow(BusinessRuleError);
});
it('rebuilds memberships from history', () => {
const project = newProject();
project.addMember('owner', 'dev', ProjectRole.MEMBER);
project.changeMemberRole('owner', 'dev', ProjectRole.ADMIN);
project.addMember('owner', 'qa', ProjectRole.MEMBER);
project.removeMember('qa', 'qa');
const { rebuilt } = replay(project, Project);
expect(rebuilt.members).toEqual([
{ userId: 'owner', role: ProjectRole.OWNER },
{ userId: 'dev', role: ProjectRole.ADMIN },
]);
expect(rebuilt.version.value).toBe(5);
});
});

View File

@@ -0,0 +1,185 @@
import {
BusinessRuleError,
ForbiddenError,
VersionedAggregateRoot,
assertThat,
} from '@task-stream/api/shared/domain';
import { ProjectCreatedEvent } from '../events/project-created.event';
import { ProjectMemberAddedEvent } from '../events/project-member-added.event';
import { ProjectMemberRemovedEvent } from '../events/project-member-removed.event';
import { ProjectMemberRoleChangedEvent } from '../events/project-member-role-changed.event';
import { ProjectUpdatedEvent } from '../events/project-updated.event';
import { ProjectRole } from './project-role';
export interface ProjectMember {
userId: string;
role: ProjectRole;
}
/** Data of a project (what ProjectCreatedEvent carries). */
export interface ProjectProps {
id: string;
key: string;
name: string;
description: string | null;
ownerId: string;
members: ProjectMember[];
createdAt: string;
updatedAt: string;
}
/**
* Event-sourced aggregate: create it with ProjectFactory, load it with the
* AggregateRehydrator. State only changes in the `on<Event>` handlers.
*/
export class Project extends VersionedAggregateRoot implements ProjectProps {
public key!: string;
public name!: string;
public description: string | null = null;
public ownerId!: string;
public members: ProjectMember[] = [];
public createdAt!: string;
public updatedAt!: string;
constructor(id: string) {
super();
this.id = id;
}
roleOf(userId: string): ProjectRole | null {
return this.members.find((m) => m.userId === userId)?.role ?? null;
}
isMember(userId: string): boolean {
return this.roleOf(userId) !== null;
}
/** Can read the project. */
assertCanView(userId: string): void {
if (!this.isMember(userId)) {
throw new ForbiddenError(`You are not a member of project ${this.key}`);
}
}
/** Can create and modify tickets, comments, links... */
assertCanContribute(userId: string): void {
this.assertCanView(userId);
if (this.roleOf(userId) === ProjectRole.VIEWER) {
throw new ForbiddenError(`Viewers cannot modify project ${this.key}`);
}
}
/** Can manage members, releases and project settings. */
assertCanManage(userId: string): void {
const role = this.roleOf(userId);
if (role !== ProjectRole.OWNER && role !== ProjectRole.ADMIN) {
throw new ForbiddenError(
`Only owners and admins can manage project ${this.key}`,
);
}
}
updateDetails(
actorId: string,
changes: { name?: string; description?: string | null },
): void {
this.assertCanManage(actorId);
const patch: { name?: string; description?: string | null } = {};
if (changes.name !== undefined) {
const name = changes.name.trim();
assertThat(name.length > 0, 'Project 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 (Object.keys(patch).length === 0) return;
this.apply(new ProjectUpdatedEvent(actorId, this.id, patch));
}
addMember(actorId: string, userId: string, role: ProjectRole): void {
this.assertCanManage(actorId);
assertThat(role !== ProjectRole.OWNER, 'A project can only have one owner');
if (this.isMember(userId)) {
throw new BusinessRuleError(
`User ${userId} is already a member of ${this.key}`,
);
}
this.apply(new ProjectMemberAddedEvent(actorId, this.id, userId, role));
}
changeMemberRole(actorId: string, userId: string, role: ProjectRole): void {
this.assertCanManage(actorId);
assertThat(role !== ProjectRole.OWNER, 'A project can only have one owner');
const current = this.roleOf(userId);
if (!current)
throw new BusinessRuleError(
`User ${userId} is not a member of ${this.key}`,
);
if (current === ProjectRole.OWNER) {
throw new BusinessRuleError('The owner role cannot be changed');
}
if (current === role) return;
this.apply(
new ProjectMemberRoleChangedEvent(
actorId,
this.id,
userId,
role,
current,
),
);
}
removeMember(actorId: string, userId: string): void {
// Anyone can leave a project; removing others requires manage rights.
if (actorId !== userId) this.assertCanManage(actorId);
if (!this.isMember(userId)) {
throw new BusinessRuleError(
`User ${userId} is not a member of ${this.key}`,
);
}
if (userId === this.ownerId) {
throw new BusinessRuleError('The project owner cannot be removed');
}
this.apply(new ProjectMemberRemovedEvent(actorId, this.id, userId));
}
/* ---- State transitions (also replayed by loadFromHistory) ---- */
onProjectCreatedEvent({ project }: ProjectCreatedEvent): void {
this.key = project.key;
this.name = project.name;
this.description = project.description;
this.ownerId = project.ownerId;
this.members = project.members.map((m) => ({ ...m }));
this.createdAt = project.createdAt;
this.updatedAt = project.updatedAt;
}
onProjectUpdatedEvent(event: ProjectUpdatedEvent): void {
Object.assign(this, event.changes);
this.updatedAt = event.occurredAt;
}
onProjectMemberAddedEvent(event: ProjectMemberAddedEvent): void {
this.members = [
...this.members,
{ userId: event.userId, role: event.role },
];
this.updatedAt = event.occurredAt;
}
onProjectMemberRoleChangedEvent(event: ProjectMemberRoleChangedEvent): void {
this.members = this.members.map((m) =>
m.userId === event.userId ? { ...m, role: event.role } : m,
);
this.updatedAt = event.occurredAt;
}
onProjectMemberRemovedEvent(event: ProjectMemberRemovedEvent): void {
this.members = this.members.filter((m) => m.userId !== event.userId);
this.updatedAt = event.occurredAt;
}
}

View File

@@ -0,0 +1,16 @@
import { UserRefReadModel } from './user-ref.read-model';
/** One line of the project activity feed (one per domain event). */
export class ActivityReadModel {
/** `${aggregateId}:${aggregateVersion}` */
id!: string;
/** Event class name, e.g. `IssueStatusChangedEvent`. */
type!: string;
projectId!: string;
issueId!: string | null;
issueKey!: string | null;
actor!: UserRefReadModel;
/** Human readable sentence ("Ana moved TS-4 from IN_QA to QA_FAILED: ..."). */
summary!: string;
occurredAt!: string;
}

View File

@@ -0,0 +1,12 @@
import { UserRefReadModel } from './user-ref.read-model';
export class CommentReadModel {
id!: string;
issueId!: string;
projectId!: string;
author!: UserRefReadModel;
body!: string;
createdAt!: string;
editedAt!: string | null;
version!: number;
}

View File

@@ -0,0 +1,7 @@
export * from './user-ref.read-model';
export * from './project.read-model';
export * from './issue.read-model';
export * from './comment.read-model';
export * from './release.read-model';
export * from './activity.read-model';
export * from './project-stats.read-model';

View File

@@ -0,0 +1,45 @@
import {
IssueArea,
IssueLinkType,
IssuePriority,
IssueStatus,
IssueType,
} from '../issue/issue.enums';
import { UserRefReadModel } from './user-ref.read-model';
export type LinkDirection = 'OUTWARD' | 'INWARD';
export class IssueLinkReadModel {
type!: IssueLinkType;
/** OUTWARD: this issue → other (e.g. "blocks"). INWARD: other → this ("is blocked by"). */
direction!: LinkDirection;
issueId!: string;
issueKey!: string;
}
export class IssueReadModel {
id!: string;
projectId!: string;
projectKey!: string;
number!: number;
key!: string;
type!: IssueType;
area!: IssueArea | null;
title!: string;
description!: string | null;
status!: IssueStatus;
priority!: IssuePriority;
reporter!: UserRefReadModel;
assignee!: UserRefReadModel | null;
release!: { id: string; name: string } | null;
labels!: string[];
figmaUrl!: string | null;
storyPoints!: number | null;
dueDate!: string | null;
links!: IssueLinkReadModel[];
commentCount!: number;
createdAt!: string;
updatedAt!: string;
/** Position of the last projected event. */
version!: number;
}

View File

@@ -0,0 +1,19 @@
import { IssuePriority, IssueStatus, IssueType } from '../issue/issue.enums';
import { UserRefReadModel } from './user-ref.read-model';
export class CountBy<TKey = string> {
key!: TKey;
count!: number;
}
/** Tracking dashboard of a project. */
export class ProjectStatsReadModel {
total!: number;
open!: number;
byStatus!: CountBy<IssueStatus>[];
byType!: CountBy<IssueType>[];
byPriority!: CountBy<IssuePriority>[];
byArea!: CountBy<string>[];
/** Open tickets per person (user null = unassigned). */
byAssignee!: { user: UserRefReadModel | null; count: number }[];
}

View File

@@ -0,0 +1,20 @@
import { ProjectRole } from '../project/project-role';
import { UserRefReadModel } from './user-ref.read-model';
export class ProjectMemberReadModel {
user!: UserRefReadModel;
role!: ProjectRole;
}
export class ProjectReadModel {
id!: string;
key!: string;
name!: string;
description!: string | null;
owner!: UserRefReadModel;
members!: ProjectMemberReadModel[];
createdAt!: string;
updatedAt!: string;
/** Position of the last projected event. */
version!: number;
}

View File

@@ -0,0 +1,21 @@
import { IssueStatus } from '../issue/issue.enums';
import { ReleaseStatus } from '../release/release-status';
export class ReleaseReadModel {
id!: string;
projectId!: string;
name!: string;
description!: string | null;
targetDate!: string | null;
status!: ReleaseStatus;
releasedAt!: string | null;
createdAt!: string;
version!: number;
}
export class ReleaseProgressReadModel {
total!: number;
finished!: number;
percent!: number;
byStatus!: { key: IssueStatus; count: number }[];
}

View File

@@ -0,0 +1,5 @@
/** Denormalised user reference (name resolved from the identity context). */
export class UserRefReadModel {
id!: string;
name!: string;
}

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;
}
}

View File

@@ -0,0 +1,30 @@
import {
SerializableEvent,
VersionedAggregateRoot,
} from '@task-stream/api/shared/domain';
/**
* Round-trips uncommitted events like the event store does (JSON + class
* prototype) and rebuilds a fresh aggregate from them.
*/
export function replay<T extends VersionedAggregateRoot>(
aggregate: T,
AggregateCls: new (id: string) => T,
previous: SerializableEvent[] = [],
): { rebuilt: T; history: SerializableEvent[] } {
const history = [
...previous,
...aggregate.getUncommittedEvents().map((event, index) => ({
streamId: aggregate.id,
type: event.constructor.name,
position: previous.length + index + 1,
data: Object.assign(
Object.create(Object.getPrototypeOf(event)),
JSON.parse(JSON.stringify(event)),
),
})),
];
const rebuilt = new AggregateCls(aggregate.id);
rebuilt.loadFromHistory(history);
return { rebuilt, history };
}