first commit
This commit is contained in:
25
libs/api/tracker/domain/README.md
Normal file
25
libs/api/tracker/domain/README.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# api-tracker-domain
|
||||
|
||||
El modelo del producto: proyectos, tickets, comentarios y releases.
|
||||
|
||||
- **Import**: `@task-stream/api/tracker/domain`
|
||||
- **Capa**: `domain` · **Tags**: `scope:api-tracker`, `type:domain`
|
||||
|
||||
## Contenido
|
||||
|
||||
- Agregados event-sourced: `Project`, `Issue`, `Comment`, `Release`. Validan en sus métodos y cambian de estado en `on<Evento>`.
|
||||
- `events/`: un fichero por evento (`IssueCreatedEvent`, `IssueStatusChangedEvent`…), todos con `@AutowiredEvent`.
|
||||
- `factories/`: `ProjectFactory`, `IssueFactory`, `CommentFactory`, `ReleaseFactory` (`@Injectable()`).
|
||||
- `issue/issue-workflow.ts`: transiciones de estado permitidas y estados que exigen motivo.
|
||||
- `read-models/`: forma de las vistas de lectura (`ProjectReadModel`, `IssueReadModel`…).
|
||||
|
||||
Reglas y workflow: [API](../../../../docs/03-api.md#31-modelo-de-dominio-contexto-tracker).
|
||||
|
||||
## Comandos
|
||||
|
||||
```sh
|
||||
npx nx lint api-tracker-domain
|
||||
npx nx test api-tracker-domain # Jest
|
||||
```
|
||||
|
||||
Reglas de dependencia entre capas: [Arquitectura](../../../../docs/02-arquitectura.md#21-capas-hexagonales).
|
||||
3
libs/api/tracker/domain/eslint.config.mjs
Normal file
3
libs/api/tracker/domain/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from '../../../../eslint.config.mjs';
|
||||
|
||||
export default [...baseConfig];
|
||||
11
libs/api/tracker/domain/jest.config.cts
Normal file
11
libs/api/tracker/domain/jest.config.cts
Normal file
@@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
displayName: 'api-tracker-domain',
|
||||
preset: '../../../../jest.preset.js',
|
||||
testEnvironment: 'node',
|
||||
passWithNoTests: true,
|
||||
transform: {
|
||||
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: '../../../../coverage/libs/api/tracker/domain',
|
||||
};
|
||||
12
libs/api/tracker/domain/project.json
Normal file
12
libs/api/tracker/domain/project.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "api-tracker-domain",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/api/tracker/domain/src",
|
||||
"projectType": "library",
|
||||
"tags": [
|
||||
"scope:api-tracker",
|
||||
"type:domain"
|
||||
],
|
||||
"// targets": "to see all targets run: nx show project api-tracker-domain --web",
|
||||
"targets": {}
|
||||
}
|
||||
11
libs/api/tracker/domain/src/index.ts
Normal file
11
libs/api/tracker/domain/src/index.ts
Normal 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';
|
||||
99
libs/api/tracker/domain/src/lib/comment/comment.ts
Normal file
99
libs/api/tracker/domain/src/lib/comment/comment.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
20
libs/api/tracker/domain/src/lib/events/index.ts
Normal file
20
libs/api/tracker/domain/src/lib/events/index.ts
Normal 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';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
16
libs/api/tracker/domain/src/lib/events/issue-linked.event.ts
Normal file
16
libs/api/tracker/domain/src/lib/events/issue-linked.event.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
12
libs/api/tracker/domain/src/lib/events/tracker.event.ts
Normal file
12
libs/api/tracker/domain/src/lib/events/tracker.event.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
31
libs/api/tracker/domain/src/lib/factories/comment.factory.ts
Normal file
31
libs/api/tracker/domain/src/lib/factories/comment.factory.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
14
libs/api/tracker/domain/src/lib/factories/index.ts
Normal file
14
libs/api/tracker/domain/src/lib/factories/index.ts
Normal 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,
|
||||
];
|
||||
60
libs/api/tracker/domain/src/lib/factories/issue.factory.ts
Normal file
60
libs/api/tracker/domain/src/lib/factories/issue.factory.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
43
libs/api/tracker/domain/src/lib/factories/project.factory.ts
Normal file
43
libs/api/tracker/domain/src/lib/factories/project.factory.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
43
libs/api/tracker/domain/src/lib/factories/release.factory.ts
Normal file
43
libs/api/tracker/domain/src/lib/factories/release.factory.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
37
libs/api/tracker/domain/src/lib/issue/issue-workflow.ts
Normal file
37
libs/api/tracker/domain/src/lib/issue/issue-workflow.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
51
libs/api/tracker/domain/src/lib/issue/issue.enums.ts
Normal file
51
libs/api/tracker/domain/src/lib/issue/issue.enums.ts
Normal 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',
|
||||
}
|
||||
157
libs/api/tracker/domain/src/lib/issue/issue.spec.ts
Normal file
157
libs/api/tracker/domain/src/lib/issue/issue.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
278
libs/api/tracker/domain/src/lib/issue/issue.ts
Normal file
278
libs/api/tracker/domain/src/lib/issue/issue.ts
Normal 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;
|
||||
}
|
||||
7
libs/api/tracker/domain/src/lib/project/project-role.ts
Normal file
7
libs/api/tracker/domain/src/lib/project/project-role.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export enum ProjectRole {
|
||||
OWNER = 'OWNER',
|
||||
ADMIN = 'ADMIN',
|
||||
MEMBER = 'MEMBER',
|
||||
/** Read-only stakeholder (PM, client...). */
|
||||
VIEWER = 'VIEWER',
|
||||
}
|
||||
65
libs/api/tracker/domain/src/lib/project/project.spec.ts
Normal file
65
libs/api/tracker/domain/src/lib/project/project.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
185
libs/api/tracker/domain/src/lib/project/project.ts
Normal file
185
libs/api/tracker/domain/src/lib/project/project.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
7
libs/api/tracker/domain/src/lib/read-models/index.ts
Normal file
7
libs/api/tracker/domain/src/lib/read-models/index.ts
Normal 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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }[];
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Denormalised user reference (name resolved from the identity context). */
|
||||
export class UserRefReadModel {
|
||||
id!: string;
|
||||
name!: string;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum ReleaseStatus {
|
||||
PLANNED = 'PLANNED',
|
||||
RELEASED = 'RELEASED',
|
||||
ARCHIVED = 'ARCHIVED',
|
||||
}
|
||||
27
libs/api/tracker/domain/src/lib/release/release.spec.ts
Normal file
27
libs/api/tracker/domain/src/lib/release/release.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
133
libs/api/tracker/domain/src/lib/release/release.ts
Normal file
133
libs/api/tracker/domain/src/lib/release/release.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
30
libs/api/tracker/domain/src/lib/testing.ts
Normal file
30
libs/api/tracker/domain/src/lib/testing.ts
Normal 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 };
|
||||
}
|
||||
23
libs/api/tracker/domain/tsconfig.json
Normal file
23
libs/api/tracker/domain/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"importHelpers": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noPropertyAccessFromIndexSignature": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
15
libs/api/tracker/domain/tsconfig.lib.json
Normal file
15
libs/api/tracker/domain/tsconfig.lib.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": [
|
||||
"jest.config.ts",
|
||||
"jest.config.cts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts"
|
||||
]
|
||||
}
|
||||
16
libs/api/tracker/domain/tsconfig.spec.json
Normal file
16
libs/api/tracker/domain/tsconfig.spec.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"jest.config.cts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user