import { BusinessRuleError, ForbiddenError, VersionedAggregateRoot, assertThat, } from '@task-stream/api/shared/domain'; import { ProjectCreatedEvent } from '../events/project-created.event'; import { ProjectDeletedEvent } from '../events/project-deleted.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 { ProjectSettingsChangedEvent } from '../events/project-settings-changed.event'; import { ProjectUpdatedEvent } from '../events/project-updated.event'; import { ProjectRole } from './project-role'; import { defaultProjectSettings, ProjectSettings, ProjectWorkflow, validateProjectSettings, } from './project-settings'; 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; /** Anyone (even without signing in) can read it. Absent in old projects: private. */ isPublic?: boolean; /** * Takes bug reports and ideas from anyone, through a public form (only * while it is public). Absent in old projects: no. */ acceptsProposals?: boolean; ownerId: string; members: ProjectMember[]; /** Absent in projects created before settings existed: defaults apply. */ settings?: ProjectSettings; createdAt: string; updatedAt: string; } /** * Event-sourced aggregate: create it with ProjectFactory, load it with the * AggregateRehydrator. State only changes in the `on` handlers. */ export class Project extends VersionedAggregateRoot implements ProjectProps { public key!: string; public name!: string; public description: string | null = null; public isPublic = false; public acceptsProposals = false; public ownerId!: string; public members: ProjectMember[] = []; public settings: ProjectSettings = defaultProjectSettings(); public createdAt!: string; public updatedAt!: string; /** Set once deleted: the project can no longer be loaded for commands. */ public deleted = false; constructor(id: string) { super(); this.id = id; } /** Statuses, transitions, types and areas of this project. */ get workflow(): ProjectWorkflow { return new ProjectWorkflow(this.settings); } 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: members, or anyone if it is public. */ assertCanView(userId: string): void { if (!this.isPublic && !this.isMember(userId)) { throw new ForbiddenError(`You are not a member of project ${this.key}`); } } /** Can create and modify tickets, comments, links... (members only, even if public). */ assertCanContribute(userId: string): void { if (!this.isMember(userId)) { throw new ForbiddenError(`You are not a member of project ${this.key}`); } 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; isPublic?: boolean; acceptsProposals?: boolean; }, ): void { this.assertCanManage(actorId); const patch: { name?: string; description?: string | null; isPublic?: boolean; acceptsProposals?: boolean; } = {}; 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 (changes.isPublic !== undefined && changes.isPublic !== this.isPublic) patch.isPublic = changes.isPublic; if ( changes.acceptsProposals !== undefined && changes.acceptsProposals !== this.acceptsProposals ) patch.acceptsProposals = changes.acceptsProposals; if (Object.keys(patch).length === 0) return; this.apply(new ProjectUpdatedEvent(actorId, this.id, patch)); } /** * Only the owner deletes a project, confirming its key. Tickets, comments * and releases follow (cascade saga); the events stay in the store. */ delete(actorId: string, confirmKey: string): void { if (actorId !== this.ownerId) { throw new ForbiddenError(`Only the owner can delete project ${this.key}`); } if (confirmKey.trim().toUpperCase() !== this.key) { throw new BusinessRuleError( `Type the project key (${this.key}) to confirm the deletion`, ); } this.apply( new ProjectDeletedEvent( actorId, this.id, this.key, this.members.map((m) => m.userId), ), ); } /** Owners and admins customise statuses, transitions, types and areas. */ updateSettings(actorId: string, next: ProjectSettings): void { this.assertCanManage(actorId); const settings = validateProjectSettings(next, this.settings); if (JSON.stringify(settings) === JSON.stringify(this.settings)) return; this.apply(new ProjectSettingsChangedEvent(actorId, this.id, settings)); } 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.isPublic = project.isPublic ?? false; this.acceptsProposals = project.acceptsProposals ?? false; this.ownerId = project.ownerId; this.members = project.members.map((m) => ({ ...m })); this.settings = structuredClone( project.settings ?? defaultProjectSettings(), ); this.createdAt = project.createdAt; this.updatedAt = project.updatedAt; } onProjectUpdatedEvent(event: ProjectUpdatedEvent): void { Object.assign(this, event.changes); this.updatedAt = event.occurredAt; } onProjectDeletedEvent(): void { this.deleted = true; } onProjectSettingsChangedEvent(event: ProjectSettingsChangedEvent): void { this.settings = structuredClone(event.settings); 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; } }