feat: public projects, project deletion and sagas
Public projects:
- Project.isPublic (create/update, owners and admins). Anyone can read a
public project without a token: project, board, tickets, comments,
history, releases, activity, tracking and statistics, over GraphQL and
REST. Writes stay for members (401 anonymous, 403 outsiders).
- @OptionalAuth() / @ApiOptionalPireonAuth(): anonymous callers allowed,
invalid tokens still rejected. ReadAccess lets anonymous viewers read
public projects only; linked tickets are filtered by visibility.
- publicProjects (GraphQL) and GET /api/explore/projects (REST).
- Web: «Explorar» page, project pages open without a session (read-only
notice, sign-in hints), public badge, visibility switch in settings,
no realtime subscriptions without a session.
Project deletion:
- Owner only, confirming the key (deleteProject / DELETE
/api/projects/{project}?confirm=KEY). The key is freed; the read model
drops the project and members are notified (web goes home).
Sagas (run by the EventsBridge after projections, durable and ordered):
- ProjectDeletionSaga: project deleted → delete its tickets and releases;
ticket deleted → purge its comments and drop links from other tickets.
- ReleaseArchivedSaga: unfinished tickets leave an archived release.
- UserDeactivatedSaga: the Pireon webhook records UserDeactivatedEvent in
the event store (EventRecorder); the tracker unassigns that user's open
tickets as the system actor.
- Internal idempotent commands; domain refusals are logged and skipped,
concurrency conflicts retried. Affected streams come from the event store
(TrackerStreamsRepository).
Tests: domain lifecycle specs, saga specs run like the bridge, facade
tests, e2e for public access, cascade deletion, archived releases and
deactivation webhook. Docs, READMEs, schema.gql/openapi.json and Word/PDF
updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,3 +9,4 @@ export * from './lib/release/release-status';
|
||||
export * from './lib/events';
|
||||
export * from './lib/factories';
|
||||
export * from './lib/read-models';
|
||||
export * from './lib/system-actor';
|
||||
|
||||
@@ -19,3 +19,6 @@ export * from './release-created.event';
|
||||
export * from './release-updated.event';
|
||||
export * from './release-shipped.event';
|
||||
export * from './release-archived.event';
|
||||
export * from './project-deleted.event';
|
||||
export * from './issue-deleted.event';
|
||||
export * from './release-deleted.event';
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||
import { TrackerEvent } from './tracker.event';
|
||||
|
||||
/** The ticket was deleted (its project was). Its comments and the links to it go next. */
|
||||
@AutowiredEvent
|
||||
export class IssueDeletedEvent extends TrackerEvent {
|
||||
constructor(
|
||||
actorId: string,
|
||||
issueId: string,
|
||||
projectId: string,
|
||||
readonly key: string,
|
||||
) {
|
||||
super(actorId, issueId, projectId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||
import { TrackerEvent } from './tracker.event';
|
||||
|
||||
/**
|
||||
* The owner deleted the project. Its tickets and releases are deleted next by
|
||||
* the cascade saga; the key becomes free again.
|
||||
*/
|
||||
@AutowiredEvent
|
||||
export class ProjectDeletedEvent extends TrackerEvent {
|
||||
constructor(
|
||||
actorId: string,
|
||||
projectId: string,
|
||||
readonly key: string,
|
||||
/** Members at the time, so their project lists drop it. */
|
||||
readonly memberIds: string[],
|
||||
) {
|
||||
super(actorId, projectId, projectId);
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,15 @@ import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||
import type { ProjectProps } from '../project/project';
|
||||
import { TrackerEvent } from './tracker.event';
|
||||
|
||||
/** Name or description changed. */
|
||||
/** Name, description or visibility changed. */
|
||||
@AutowiredEvent
|
||||
export class ProjectUpdatedEvent extends TrackerEvent {
|
||||
constructor(
|
||||
actorId: string,
|
||||
projectId: string,
|
||||
readonly changes: Partial<Pick<ProjectProps, 'name' | 'description'>>,
|
||||
readonly changes: Partial<
|
||||
Pick<ProjectProps, 'name' | 'description' | 'isPublic'>
|
||||
>,
|
||||
) {
|
||||
super(actorId, projectId, projectId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||
import { TrackerEvent } from './tracker.event';
|
||||
|
||||
/** The release was deleted (its project was). */
|
||||
@AutowiredEvent
|
||||
export class ReleaseDeletedEvent extends TrackerEvent {
|
||||
constructor(actorId: string, releaseId: string, projectId: string) {
|
||||
super(actorId, releaseId, projectId);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export class ProjectFactory {
|
||||
key: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
isPublic?: boolean;
|
||||
ownerId: string;
|
||||
}): Project {
|
||||
const key = props.key.trim().toUpperCase();
|
||||
@@ -29,6 +30,7 @@ export class ProjectFactory {
|
||||
key,
|
||||
name,
|
||||
description: props.description?.trim() || null,
|
||||
isPublic: props.isPublic ?? false,
|
||||
ownerId: props.ownerId,
|
||||
members: [{ userId: props.ownerId, role: ProjectRole.OWNER }],
|
||||
settings: defaultProjectSettings(),
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from '@task-stream/api/shared/domain';
|
||||
import { IssueAssignedEvent } from '../events/issue-assigned.event';
|
||||
import { IssueCreatedEvent } from '../events/issue-created.event';
|
||||
import { IssueDeletedEvent } from '../events/issue-deleted.event';
|
||||
import { IssueLinkedEvent } from '../events/issue-linked.event';
|
||||
import { IssueReleaseChangedEvent } from '../events/issue-release-changed.event';
|
||||
import { IssueStatusChangedEvent } from '../events/issue-status-changed.event';
|
||||
@@ -83,6 +84,7 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
public links: IssueLink[] = [];
|
||||
public createdAt!: string;
|
||||
public updatedAt!: string;
|
||||
public deleted = false;
|
||||
|
||||
constructor(id: string) {
|
||||
super();
|
||||
@@ -214,6 +216,25 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
);
|
||||
}
|
||||
|
||||
/** Deletes the ticket (its project was deleted). Idempotent. */
|
||||
delete(actorId: string): void {
|
||||
if (this.deleted) return;
|
||||
this.apply(
|
||||
new IssueDeletedEvent(actorId, this.id, this.projectId, this.key),
|
||||
);
|
||||
}
|
||||
|
||||
/** Drops every link to `targetIssueId` (that ticket was deleted). */
|
||||
unlinkAllTo(actorId: string, targetIssueId: string): void {
|
||||
for (const link of this.links.filter(
|
||||
(l) => l.targetIssueId === targetIssueId,
|
||||
)) {
|
||||
this.apply(
|
||||
new IssueUnlinkedEvent(actorId, this.id, this.projectId, { ...link }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- State transitions (also replayed by loadFromHistory) ---- */
|
||||
|
||||
onIssueCreatedEvent({ issue }: IssueCreatedEvent): void {
|
||||
@@ -255,6 +276,10 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
||||
this.updatedAt = event.occurredAt;
|
||||
}
|
||||
|
||||
onIssueDeletedEvent(): void {
|
||||
this.deleted = true;
|
||||
}
|
||||
|
||||
onIssueReleaseChangedEvent(event: IssueReleaseChangedEvent): void {
|
||||
this.releaseId = event.releaseId;
|
||||
this.updatedAt = event.occurredAt;
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
BusinessRuleError,
|
||||
ForbiddenError,
|
||||
} from '@task-stream/api/shared/domain';
|
||||
import { IssueDeletedEvent } from '../events/issue-deleted.event';
|
||||
import { IssueUnlinkedEvent } from '../events/issue-unlinked.event';
|
||||
import { ProjectDeletedEvent } from '../events/project-deleted.event';
|
||||
import { ProjectUpdatedEvent } from '../events/project-updated.event';
|
||||
import { ReleaseDeletedEvent } from '../events/release-deleted.event';
|
||||
import { IssueLinkType } from '../issue/issue.enums';
|
||||
import { IssueFactory } from '../factories/issue.factory';
|
||||
import { ProjectFactory } from '../factories/project.factory';
|
||||
import { ReleaseFactory } from '../factories/release.factory';
|
||||
import { replay } from '../testing';
|
||||
import { Project } from './project';
|
||||
import { ProjectRole } from './project-role';
|
||||
|
||||
const newProject = (isPublic = false) => {
|
||||
const project = new ProjectFactory().create({
|
||||
key: 'web',
|
||||
name: 'Web',
|
||||
ownerId: 'owner',
|
||||
isPublic,
|
||||
});
|
||||
project.addMember('owner', 'admin', ProjectRole.ADMIN);
|
||||
return project;
|
||||
};
|
||||
|
||||
const newIssue = (number: number) =>
|
||||
new IssueFactory().create({
|
||||
projectId: 'p',
|
||||
projectKey: 'WEB',
|
||||
number,
|
||||
title: `Ticket ${number}`,
|
||||
type: 'BUG',
|
||||
reporterId: 'owner',
|
||||
});
|
||||
|
||||
describe('Public projects', () => {
|
||||
it('are private unless created or switched public, also when replayed', () => {
|
||||
const project = newProject();
|
||||
expect(() => project.assertCanView('stranger')).toThrow(ForbiddenError);
|
||||
|
||||
project.updateDetails('admin', { isPublic: true });
|
||||
expect(project.getUncommittedEvents().at(-1)).toBeInstanceOf(
|
||||
ProjectUpdatedEvent,
|
||||
);
|
||||
expect(() => project.assertCanView('stranger')).not.toThrow();
|
||||
// Reading is open; changing tickets still needs membership.
|
||||
expect(() => project.assertCanContribute('stranger')).toThrow(
|
||||
ForbiddenError,
|
||||
);
|
||||
expect(replay(project, Project).rebuilt.isPublic).toBe(true);
|
||||
expect(newProject(true).isPublic).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Deleting a project', () => {
|
||||
it('is for the owner only, confirming the key', () => {
|
||||
const project = newProject();
|
||||
expect(() => project.delete('admin', 'WEB')).toThrow(ForbiddenError);
|
||||
expect(() => project.delete('owner', 'APP')).toThrow(BusinessRuleError);
|
||||
|
||||
project.delete('owner', ' web ');
|
||||
const event = project.getUncommittedEvents().at(-1) as ProjectDeletedEvent;
|
||||
expect(event).toBeInstanceOf(ProjectDeletedEvent);
|
||||
expect(event.memberIds).toEqual(['owner', 'admin']);
|
||||
expect(replay(project, Project).rebuilt.deleted).toBe(true);
|
||||
});
|
||||
|
||||
it('cascades through tickets and releases, idempotently', () => {
|
||||
const issue = newIssue(1);
|
||||
issue.delete('owner');
|
||||
issue.delete('owner');
|
||||
expect(
|
||||
issue
|
||||
.getUncommittedEvents()
|
||||
.filter((e) => e instanceof IssueDeletedEvent),
|
||||
).toHaveLength(1);
|
||||
expect(issue.deleted).toBe(true);
|
||||
|
||||
const release = new ReleaseFactory().create('owner', {
|
||||
projectId: 'p',
|
||||
name: 'v1',
|
||||
});
|
||||
release.delete('owner');
|
||||
release.delete('owner');
|
||||
expect(
|
||||
release
|
||||
.getUncommittedEvents()
|
||||
.filter((e) => e instanceof ReleaseDeletedEvent),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('drops every link to a deleted ticket', () => {
|
||||
const source = newIssue(1);
|
||||
source.linkTo('owner', IssueLinkType.BLOCKS, { id: 't', key: 'WEB-2' });
|
||||
source.linkTo('owner', IssueLinkType.RELATES_TO, { id: 't', key: 'WEB-2' });
|
||||
source.linkTo('owner', IssueLinkType.BLOCKS, { id: 'o', key: 'WEB-3' });
|
||||
|
||||
source.unlinkAllTo('owner', 't');
|
||||
expect(
|
||||
source
|
||||
.getUncommittedEvents()
|
||||
.filter((e) => e instanceof IssueUnlinkedEvent),
|
||||
).toHaveLength(2);
|
||||
expect(source.links.map((l) => l.targetIssueId)).toEqual(['o']);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
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';
|
||||
@@ -29,6 +30,8 @@ export interface ProjectProps {
|
||||
key: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
/** Anyone (even without signing in) can read it. Absent in old projects: private. */
|
||||
isPublic?: boolean;
|
||||
ownerId: string;
|
||||
members: ProjectMember[];
|
||||
/** Absent in projects created before settings existed: defaults apply. */
|
||||
@@ -45,11 +48,14 @@ export class Project extends VersionedAggregateRoot implements ProjectProps {
|
||||
public key!: string;
|
||||
public name!: string;
|
||||
public description: string | null = null;
|
||||
public isPublic = 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();
|
||||
@@ -69,16 +75,18 @@ export class Project extends VersionedAggregateRoot implements ProjectProps {
|
||||
return this.roleOf(userId) !== null;
|
||||
}
|
||||
|
||||
/** Can read the project. */
|
||||
/** Can read the project: members, or anyone if it is public. */
|
||||
assertCanView(userId: string): void {
|
||||
if (!this.isMember(userId)) {
|
||||
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... */
|
||||
/** Can create and modify tickets, comments, links... (members only, even if public). */
|
||||
assertCanContribute(userId: string): void {
|
||||
this.assertCanView(userId);
|
||||
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}`);
|
||||
}
|
||||
@@ -96,10 +104,14 @@ export class Project extends VersionedAggregateRoot implements ProjectProps {
|
||||
|
||||
updateDetails(
|
||||
actorId: string,
|
||||
changes: { name?: string; description?: string | null },
|
||||
changes: { name?: string; description?: string | null; isPublic?: boolean },
|
||||
): void {
|
||||
this.assertCanManage(actorId);
|
||||
const patch: { name?: string; description?: string | null } = {};
|
||||
const patch: {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
isPublic?: boolean;
|
||||
} = {};
|
||||
if (changes.name !== undefined) {
|
||||
const name = changes.name.trim();
|
||||
assertThat(name.length > 0, 'Project name is required');
|
||||
@@ -109,10 +121,35 @@ export class Project extends VersionedAggregateRoot implements ProjectProps {
|
||||
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 (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);
|
||||
@@ -175,6 +212,7 @@ export class Project extends VersionedAggregateRoot implements ProjectProps {
|
||||
this.key = project.key;
|
||||
this.name = project.name;
|
||||
this.description = project.description;
|
||||
this.isPublic = project.isPublic ?? false;
|
||||
this.ownerId = project.ownerId;
|
||||
this.members = project.members.map((m) => ({ ...m }));
|
||||
this.settings = structuredClone(
|
||||
@@ -189,6 +227,10 @@ export class Project extends VersionedAggregateRoot implements ProjectProps {
|
||||
this.updatedAt = event.occurredAt;
|
||||
}
|
||||
|
||||
onProjectDeletedEvent(): void {
|
||||
this.deleted = true;
|
||||
}
|
||||
|
||||
onProjectSettingsChangedEvent(event: ProjectSettingsChangedEvent): void {
|
||||
this.settings = structuredClone(event.settings);
|
||||
this.updatedAt = event.occurredAt;
|
||||
|
||||
@@ -12,6 +12,8 @@ export class ProjectReadModel {
|
||||
key!: string;
|
||||
name!: string;
|
||||
description!: string | null;
|
||||
/** Readable by anyone, also without signing in. */
|
||||
isPublic!: boolean;
|
||||
owner!: UserRefReadModel;
|
||||
members!: ProjectMemberReadModel[];
|
||||
/** Statuses, transitions, ticket types and areas. */
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from '@task-stream/api/shared/domain';
|
||||
import { ReleaseArchivedEvent } from '../events/release-archived.event';
|
||||
import { ReleaseCreatedEvent } from '../events/release-created.event';
|
||||
import { ReleaseDeletedEvent } from '../events/release-deleted.event';
|
||||
import { ReleaseShippedEvent } from '../events/release-shipped.event';
|
||||
import { ReleaseUpdatedEvent } from '../events/release-updated.event';
|
||||
import { ReleaseStatus } from './release-status';
|
||||
@@ -30,6 +31,7 @@ export class Release extends VersionedAggregateRoot implements ReleaseProps {
|
||||
public status: ReleaseStatus = ReleaseStatus.PLANNED;
|
||||
public releasedAt: string | null = null;
|
||||
public createdAt!: string;
|
||||
public deleted = false;
|
||||
|
||||
constructor(id: string) {
|
||||
super();
|
||||
@@ -112,6 +114,12 @@ export class Release extends VersionedAggregateRoot implements ReleaseProps {
|
||||
this.apply(new ReleaseArchivedEvent(actorId, this.id, this.projectId));
|
||||
}
|
||||
|
||||
/** Deletes the release (its project was deleted). Idempotent. */
|
||||
delete(actorId: string): void {
|
||||
if (this.deleted) return;
|
||||
this.apply(new ReleaseDeletedEvent(actorId, this.id, this.projectId));
|
||||
}
|
||||
|
||||
/* ---- State transitions (also replayed by loadFromHistory) ---- */
|
||||
|
||||
onReleaseCreatedEvent({ release }: ReleaseCreatedEvent): void {
|
||||
@@ -130,4 +138,8 @@ export class Release extends VersionedAggregateRoot implements ReleaseProps {
|
||||
onReleaseArchivedEvent(): void {
|
||||
this.status = ReleaseStatus.ARCHIVED;
|
||||
}
|
||||
|
||||
onReleaseDeletedEvent(): void {
|
||||
this.deleted = true;
|
||||
}
|
||||
}
|
||||
|
||||
6
libs/api/tracker/domain/src/lib/system-actor.ts
Normal file
6
libs/api/tracker/domain/src/lib/system-actor.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Actor of changes nobody made by hand (e.g. unassigning the tickets of a
|
||||
* user Pireon deactivated). Shown as "TaskStream" in the activity feed.
|
||||
*/
|
||||
export const SYSTEM_ACTOR_ID = 'system';
|
||||
export const SYSTEM_ACTOR_NAME = 'TaskStream';
|
||||
Reference in New Issue
Block a user