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,6 +19,7 @@ import {
import { User } from '@task-stream/api/identity/domain';
import {
AccessToken,
ApiOptionalPireonAuth,
ApiPireonAuth,
CurrentUser,
} from '@task-stream/api/identity/presenters';
@@ -37,6 +38,7 @@ import {
BoardQueryDto,
ChangeMemberRoleDto,
CreateProjectDto,
DeleteProjectQueryDto,
ProjectSettingsBodyDto,
UpdateProjectDto,
} from './dto/requests.dto';
@@ -49,6 +51,7 @@ import {
ProjectStatsDto,
} from './dto/responses.dto';
import { fromSettingsPayload } from '../common/settings';
import { viewerOf } from '../common/viewer';
import { Refs, toProjectDto } from './refs';
const PROJECT = ApiParam({
@@ -85,17 +88,23 @@ export class ProjectsController {
}
@Get(':project')
@ApiOptionalPireonAuth()
@PROJECT
@ApiOperation({ summary: 'A project with its members and settings' })
@ApiOkResponse({ type: ProjectDto })
@ApiReadErrors()
async findOne(@CurrentUser() me: User, @Param('project') project: string) {
return toProjectDto(await this.projects.findOne(me.id, project));
async findOne(
@CurrentUser() me: User | undefined,
@Param('project') project: string,
) {
return toProjectDto(await this.projects.findOne(viewerOf(me), project));
}
@Patch(':project')
@PROJECT
@ApiOperation({ summary: 'Rename or describe a project (owner/admin)' })
@ApiOperation({
summary: 'Rename, describe or make public/private a project (owner/admin)',
})
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
async update(
@@ -110,6 +119,29 @@ export class ProjectsController {
);
}
@Delete(':project')
@PROJECT
@ApiOperation({
summary: 'Delete a project (owner only)',
description:
'Deletes the project and, right after (asynchronously, through a saga), its tickets, comments and releases; ' +
'links from tickets of other projects are removed and the key becomes free. Cannot be undone from the API. ' +
'`confirm` must be the project key.',
})
@ApiOkResponse({ type: CommandResultDto })
@ApiCommandErrors()
async remove(
@CurrentUser() me: User,
@Param('project') project: string,
@Query() query: DeleteProjectQueryDto,
) {
return this.projects.delete(
me.id,
await this.refs.projectId(me.id, project),
query.confirm,
);
}
@Put(':project/settings')
@PROJECT
@ApiOperation({
@@ -133,19 +165,20 @@ export class ProjectsController {
}
@Get(':project/board')
@ApiOptionalPireonAuth()
@PROJECT
@ApiOperation({ summary: 'Kanban board: one column per status' })
@ApiOkResponse({ type: [BoardColumnDto] })
@ApiReadErrors()
async board(
@CurrentUser() me: User,
@CurrentUser() me: User | undefined,
@Param('project') project: string,
@Query() query: BoardQueryDto,
) {
const columns = await this.projects.board(
me.id,
viewerOf(me),
{
projectId: await this.refs.projectId(me.id, project),
projectId: await this.refs.projectId(viewerOf(me), project),
releaseId: query.releaseId,
assigneeId: query.assigneeId,
areas: query.area ? [query.area] : undefined,
@@ -161,20 +194,25 @@ export class ProjectsController {
}
@Get(':project/stats')
@ApiOptionalPireonAuth()
@PROJECT
@ApiOperation({
summary: 'Tracking dashboard: counts by status, type, person…',
})
@ApiOkResponse({ type: ProjectStatsDto })
@ApiReadErrors()
async stats(@CurrentUser() me: User, @Param('project') project: string) {
async stats(
@CurrentUser() me: User | undefined,
@Param('project') project: string,
) {
return this.projects.stats(
me.id,
await this.refs.projectId(me.id, project),
viewerOf(me),
await this.refs.projectId(viewerOf(me), project),
);
}
@Get(':project/analytics')
@ApiOptionalPireonAuth()
@PROJECT
@ApiOperation({
summary: 'Statistics of the last `days` days',
@@ -184,29 +222,30 @@ export class ProjectsController {
@ApiOkResponse({ type: ProjectAnalyticsDto })
@ApiReadErrors()
async analytics(
@CurrentUser() me: User,
@CurrentUser() me: User | undefined,
@Param('project') project: string,
@Query() query: AnalyticsQueryDto,
) {
return this.projects.analytics(
me.id,
await this.refs.projectId(me.id, project),
viewerOf(me),
await this.refs.projectId(viewerOf(me), project),
query.days,
);
}
@Get(':project/activity')
@ApiOptionalPireonAuth()
@PROJECT
@ApiOperation({ summary: 'Activity feed (newest first)' })
@ApiOkResponse({ type: [ActivityDto] })
@ApiReadErrors()
async activity(
@CurrentUser() me: User,
@CurrentUser() me: User | undefined,
@Param('project') project: string,
@Query() query: ActivityQueryDto,
) {
return this.issues.activity(me.id, {
projectId: await this.refs.projectId(me.id, project),
return this.issues.activity(viewerOf(me), {
projectId: await this.refs.projectId(viewerOf(me), project),
issueId: query.issueId,
limit: query.limit,
before: query.before,