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:
2026-10-07 07:04:20 +02:00
parent 0e301ad545
commit e656222b54
164 changed files with 3862 additions and 414 deletions

View File

@@ -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';

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(),

View File

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

View File

@@ -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']);
});
});

View File

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

View File

@@ -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. */

View File

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

View 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';