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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user