diff --git a/apps/api/e2e/tracker.e2e-spec.ts b/apps/api/e2e/tracker.e2e-spec.ts
index 8850357..67c73e4 100644
--- a/apps/api/e2e/tracker.e2e-spec.ts
+++ b/apps/api/e2e/tracker.e2e-spec.ts
@@ -6,6 +6,7 @@ import {
} from '@task-stream/api/identity/application';
import { User, UserFactory } from '@task-stream/api/identity/domain';
import { Client, createClient } from 'graphql-ws';
+import { createHmac } from 'node:crypto';
import WebSocket from 'ws';
process.env['POSTGRES_URL'] ??=
@@ -17,6 +18,7 @@ process.env['EVENT_STREAM_KEY'] = `task-stream:e2e:${Date.now()}`;
process.env['GRAPHQL_SCHEMA_FILE'] = '';
process.env['OPENAPI_FILE'] = '';
process.env['AUTH_SESSION_TTL_SECONDS'] = '0';
+process.env['PIREON_WEBHOOK_SECRET'] = 'e2e-webhook-secret';
const { AppModule } = require('../src/app/app.module');
const { configureApp } = require('../src/app/configure');
@@ -30,6 +32,7 @@ const people: Record = {
'token-bob': person('102', 'Bob (frontend)', 'bob@x.dev'),
'token-qa': person('103', 'Quinn (QA)', 'qa@x.dev'),
'token-eve': person('104', 'Eve (outsider)', 'eve@x.dev'),
+ 'token-dan': person('105', 'Dan (leaving)', 'dan@x.dev'),
};
const byId = (id: string) =>
Object.values(people).find((p) => p.id === id) ?? null;
@@ -874,4 +877,288 @@ describe('task-stream (e2e)', () => {
(await rest('token-eve', 'GET', `/projects/${key}/analytics`)).status,
).toBe(404);
});
+
+ it('opens public projects to anyone, read-only', async () => {
+ const key = 'P' + Date.now().toString(36).slice(-5).toUpperCase();
+ const created = await rest('token-ana', 'POST', '/projects', {
+ key,
+ name: 'Open roadmap',
+ isPublic: true,
+ });
+ expect(created.status).toBe(201);
+ const projectId = created.body['id'] as string;
+ await eventually(
+ () => rest(null, 'GET', `/projects/${key}`),
+ (r) => r.status === 200,
+ );
+ const issue = (
+ await rest('token-ana', 'POST', `/projects/${key}/issues`, {
+ type: 'FEATURE',
+ title: 'Dark mode',
+ })
+ ).body['key'] as string;
+ await eventually(
+ () => rest('token-ana', 'GET', `/issues/${issue}`),
+ (r) => r.status === 200,
+ );
+ await rest('token-ana', 'POST', `/issues/${issue}/comments`, {
+ body: 'Planned for Q4',
+ });
+
+ // REST without a token: project, tickets, comments, statistics, discovery.
+ const anonymous = await eventually(
+ () => rest(null, 'GET', `/issues/${issue}/comments`),
+ (r) => r.body?.['total'] === 1,
+ );
+ expect(anonymous.status).toBe(200);
+ expect((await rest(null, 'GET', `/projects/${key}`)).body).toMatchObject({
+ key,
+ isPublic: true,
+ });
+ expect((await rest(null, 'GET', `/issues/${issue}`)).status).toBe(200);
+ expect((await rest(null, 'GET', `/projects/${key}/analytics`)).status).toBe(
+ 200,
+ );
+ const explore = await rest(null, 'GET', '/explore/projects');
+ expect(explore.body.map((p: { key: string }) => p.key)).toContain(key);
+
+ // GraphQL without a token, nested fields included.
+ const viaGraphql = await fetch(`${url}/graphql`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({
+ query: `query($k: String!) {
+ publicProjects { key }
+ project(idOrKey: $k) { key isPublic board { status total } stats { total } releases { name } }
+ issues(filter: { projectId: "${projectId}" }) { total items { key comments { total } linkedIssues { key } } }
+ }`,
+ variables: { k: key },
+ }),
+ }).then((r) => r.json());
+ expect(viaGraphql.errors).toBeUndefined();
+ expect(viaGraphql.data.project).toMatchObject({
+ key,
+ isPublic: true,
+ stats: { total: 1 },
+ });
+ expect(viaGraphql.data.issues.items[0]).toMatchObject({
+ key: issue,
+ comments: { total: 1 },
+ });
+
+ // Reading is open, writing is not: anonymous → 401, outsiders → 403.
+ expect(
+ (
+ await rest(null, 'POST', `/projects/${key}/issues`, {
+ type: 'BUG',
+ title: 'x',
+ })
+ ).status,
+ ).toBe(401);
+ expect(
+ (
+ await rest('token-eve', 'POST', `/projects/${key}/issues`, {
+ type: 'BUG',
+ title: 'x',
+ })
+ ).status,
+ ).toBe(403);
+ expect((await rest('token-eve', 'GET', `/issues/${issue}`)).status).toBe(
+ 200,
+ );
+ // A bad token is still rejected, even where none is needed.
+ expect((await rest('bad-token', 'GET', `/projects/${key}`)).status).toBe(
+ 401,
+ );
+
+ // Back to private: anonymous readers get 404 again.
+ await rest('token-ana', 'PATCH', `/projects/${key}`, { isPublic: false });
+ await eventually(
+ () => rest(null, 'GET', `/projects/${key}`),
+ (r) => r.status === 404,
+ );
+ expect((await rest(null, 'GET', `/issues/${issue}`)).status).toBe(404);
+ });
+
+ it('deletes a project with everything in it (cascade saga) and frees its key', async () => {
+ const key = 'D' + Date.now().toString(36).slice(-5).toUpperCase();
+ const other = 'O' + Date.now().toString(36).slice(-5).toUpperCase();
+ for (const k of [key, other])
+ await rest('token-ana', 'POST', '/projects', { key: k, name: k });
+ await eventually(
+ () => rest('token-ana', 'GET', `/projects/${other}`),
+ (r) => r.status === 200,
+ );
+ await eventually(
+ () => rest('token-ana', 'GET', `/projects/${key}`),
+ (r) => r.status === 200,
+ );
+ await rest('token-ana', 'POST', `/projects/${key}/members`, {
+ userId: '102',
+ role: 'ADMIN',
+ });
+ const doomed = (
+ await rest('token-ana', 'POST', `/projects/${key}/issues`, {
+ type: 'BUG',
+ title: 'Doomed',
+ })
+ ).body['key'] as string;
+ const survivor = (
+ await rest('token-ana', 'POST', `/projects/${other}/issues`, {
+ type: 'TASK',
+ title: 'Survivor',
+ })
+ ).body['key'] as string;
+ await eventually(
+ () => rest('token-ana', 'GET', `/issues/${survivor}`),
+ (r) => r.status === 200,
+ );
+ await eventually(
+ () => rest('token-ana', 'GET', `/issues/${doomed}`),
+ (r) => r.status === 200,
+ );
+ await rest('token-ana', 'POST', `/issues/${doomed}/comments`, {
+ body: 'bye',
+ });
+ await rest('token-ana', 'POST', `/projects/${key}/releases`, {
+ name: 'v1.0.0',
+ });
+ await rest('token-ana', 'POST', `/issues/${survivor}/links`, {
+ type: 'DEPENDS_ON',
+ targetIssue: doomed,
+ });
+ await eventually(
+ () => rest('token-ana', 'GET', `/issues/${survivor}`),
+ (r) => r.body?.['links']?.length === 1,
+ );
+
+ // Owner only, confirming the key.
+ expect(
+ (await rest('token-bob', 'DELETE', `/projects/${key}?confirm=${key}`))
+ .status,
+ ).toBe(403);
+ expect(
+ (await rest('token-ana', 'DELETE', `/projects/${key}?confirm=NOPE`))
+ .status,
+ ).toBe(422);
+ const deleted = await gql(
+ 'token-ana',
+ `mutation($p: ID!, $k: String!) { deleteProject(projectId: $p, confirmKey: $k) { version } }`,
+ {
+ p: (await rest('token-ana', 'GET', `/projects/${key}`)).body['id'],
+ k: key.toLowerCase(),
+ },
+ );
+ expect(deleted.deleteProject.version).toBeGreaterThan(1);
+
+ await eventually(
+ () => rest('token-ana', 'GET', `/projects/${key}`),
+ (r) => r.status === 404,
+ );
+ // The saga deletes the tickets and the link from the other project.
+ await eventually(
+ () => rest('token-ana', 'GET', `/issues/${doomed}`),
+ (r) => r.status === 404,
+ );
+ await eventually(
+ () => rest('token-ana', 'GET', `/issues/${survivor}`),
+ (r) => r.body?.['links']?.length === 0,
+ );
+ const mine = await rest('token-bob', 'GET', '/projects');
+ expect(mine.body.map((p: { key: string }) => p.key)).not.toContain(key);
+ // The key can be used again.
+ expect(
+ (await rest('token-ana', 'POST', '/projects', { key, name: 'Reborn' }))
+ .status,
+ ).toBe(201);
+ });
+
+ it('archiving a release lets its unfinished tickets go (saga)', async () => {
+ const key = 'A' + Date.now().toString(36).slice(-5).toUpperCase();
+ await rest('token-ana', 'POST', '/projects', { key, name: key });
+ await eventually(
+ () => rest('token-ana', 'GET', `/projects/${key}`),
+ (r) => r.status === 200,
+ );
+ const release = (
+ await rest('token-ana', 'POST', `/projects/${key}/releases`, {
+ name: 'v2.0.0',
+ })
+ ).body['id'] as string;
+ await eventually(
+ () => rest('token-ana', 'GET', `/releases/${release}`),
+ (r) => r.status === 200,
+ );
+ const open = (
+ await rest('token-ana', 'POST', `/projects/${key}/issues`, {
+ type: 'TASK',
+ title: 'Still open',
+ releaseId: release,
+ })
+ ).body['key'] as string;
+ await eventually(
+ () => rest('token-ana', 'GET', `/issues/${open}`),
+ (r) => r.body?.['release']?.id === release,
+ );
+
+ expect(
+ (await rest('token-ana', 'POST', `/releases/${release}/archive`)).status,
+ ).toBe(200);
+ const released = await eventually(
+ () => rest('token-ana', 'GET', `/issues/${open}`),
+ (r) => r.body?.['release'] === null,
+ );
+ expect(released.body['release']).toBeNull();
+ });
+
+ it('unassigns the open tickets of a user Pireon deactivates (saga)', async () => {
+ const key = 'U' + Date.now().toString(36).slice(-5).toUpperCase();
+ await rest('token-ana', 'POST', '/projects', { key, name: key });
+ await eventually(
+ () => rest('token-ana', 'GET', `/projects/${key}`),
+ (r) => r.status === 200,
+ );
+ // Dan signs in once (so the identity context knows him) and joins.
+ expect((await rest('token-dan', 'GET', '/users/me')).status).toBe(200);
+ await rest('token-ana', 'POST', `/projects/${key}/members`, {
+ userId: '105',
+ });
+ const ticket = (
+ await rest('token-ana', 'POST', `/projects/${key}/issues`, {
+ type: 'TASK',
+ title: 'Dan has it',
+ assigneeId: '105',
+ })
+ ).body['key'] as string;
+ await eventually(
+ () => rest('token-ana', 'GET', `/issues/${ticket}`),
+ (r) => r.body?.['assignee']?.id === '105',
+ );
+
+ const body = JSON.stringify({
+ event: 'user.deactivated',
+ data: { user_id: 105 },
+ });
+ const webhook = await fetch(`${url}/api/webhooks/pireon`, {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ 'x-webhook-signature': createHmac('sha256', 'e2e-webhook-secret')
+ .update(body)
+ .digest('hex'),
+ },
+ body,
+ });
+ expect(webhook.status).toBe(204);
+
+ const unassigned = await eventually(
+ () => rest('token-ana', 'GET', `/issues/${ticket}`),
+ (r) => r.status === 200 && r.body['assignee'] === null,
+ );
+ expect(unassigned.body['assignee']).toBeNull();
+ const history = await rest('token-ana', 'GET', `/issues/${ticket}/history`);
+ expect(
+ history.body.map((h: { actor: { name: string } }) => h.actor.name),
+ ).toContain('TaskStream');
+ });
});
diff --git a/apps/api/openapi.json b/apps/api/openapi.json
index 36e24ec..57b7e90 100644
--- a/apps/api/openapi.json
+++ b/apps/api/openapi.json
@@ -632,7 +632,8 @@
},
{
"pireon": []
- }
+ },
+ {}
],
"summary": "A project with its members and settings",
"tags": [
@@ -743,7 +744,117 @@
"pireon": []
}
],
- "summary": "Rename or describe a project (owner/admin)",
+ "summary": "Rename, describe or make public/private a project (owner/admin)",
+ "tags": [
+ "Projects"
+ ]
+ },
+ "delete": {
+ "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.",
+ "operationId": "ProjectsController_remove",
+ "parameters": [
+ {
+ "name": "project",
+ "required": true,
+ "in": "path",
+ "description": "Project id or key",
+ "schema": {
+ "example": "WEB",
+ "type": "string"
+ }
+ },
+ {
+ "name": "confirm",
+ "required": true,
+ "in": "query",
+ "description": "The project key, to confirm the deletion",
+ "schema": {
+ "example": "WEB",
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CommandResultDto"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid input",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiErrorDto"
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Missing or rejected Pireon token",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiErrorDto"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "Not a member of the project (or not allowed)",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiErrorDto"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Not found",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiErrorDto"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Someone changed it at the same time: retry",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiErrorDto"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "Business rule (e.g. a transition the workflow forbids)",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ApiErrorDto"
+ }
+ }
+ }
+ }
+ },
+ "security": [
+ {
+ "pireon-oauth": []
+ },
+ {
+ "pireon": []
+ }
+ ],
+ "summary": "Delete a project (owner only)",
"tags": [
"Projects"
]
@@ -981,7 +1092,8 @@
},
{
"pireon": []
- }
+ },
+ {}
],
"summary": "Kanban board: one column per status",
"tags": [
@@ -1062,7 +1174,8 @@
},
{
"pireon": []
- }
+ },
+ {}
],
"summary": "Tracking dashboard: counts by status, type, person…",
"tags": [
@@ -1159,7 +1272,8 @@
},
{
"pireon": []
- }
+ },
+ {}
],
"summary": "Statistics of the last `days` days",
"tags": [
@@ -1278,7 +1392,8 @@
},
{
"pireon": []
- }
+ },
+ {}
],
"summary": "Activity feed (newest first)",
"tags": [
@@ -1970,7 +2085,8 @@
},
{
"pireon": []
- }
+ },
+ {}
],
"summary": "Search the tickets of a project",
"tags": [
@@ -2161,7 +2277,8 @@
},
{
"pireon": []
- }
+ },
+ {}
],
"summary": "A ticket",
"tags": [
@@ -2944,7 +3061,8 @@
},
{
"pireon": []
- }
+ },
+ {}
],
"summary": "Comments of the ticket (oldest first)",
"tags": [
@@ -3166,7 +3284,8 @@
},
{
"pireon": []
- }
+ },
+ {}
],
"summary": "History of the ticket (newest first)",
"tags": [
@@ -3545,7 +3664,8 @@
},
{
"pireon": []
- }
+ },
+ {}
],
"summary": "Releases of a project",
"tags": [
@@ -3733,7 +3853,8 @@
},
{
"pireon": []
- }
+ },
+ {}
],
"summary": "A release",
"tags": [
@@ -3919,7 +4040,8 @@
},
{
"pireon": []
- }
+ },
+ {}
],
"summary": "Finished tickets out of the total",
"tags": [
@@ -3998,7 +4120,8 @@
},
{
"pireon": []
- }
+ },
+ {}
],
"summary": "Burnup: tickets in the release and finished ones, day by day",
"tags": [
@@ -4106,7 +4229,8 @@
},
{
"pireon": []
- }
+ },
+ {}
],
"summary": "Tickets included in the release",
"tags": [
@@ -4322,6 +4446,33 @@
"Releases"
]
}
+ },
+ "/api/explore/projects": {
+ "get": {
+ "description": "Their board, tickets, comments, releases, activity and statistics can be read without a token too.",
+ "operationId": "ExploreController_publicProjects",
+ "parameters": [],
+ "responses": {
+ "200": {
+ "description": "",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/ProjectDto"
+ }
+ }
+ }
+ }
+ }
+ },
+ "summary": "Public projects (no token needed)",
+ "tags": [
+ "Explore"
+ ],
+ "security": []
+ }
}
},
"info": {
@@ -4875,6 +5026,10 @@
"type": "string",
"nullable": true
},
+ "isPublic": {
+ "type": "boolean",
+ "description": "Readable by anyone, also without a token"
+ },
"owner": {
"$ref": "#/components/schemas/UserRefDto"
},
@@ -4905,6 +5060,7 @@
"key",
"name",
"description",
+ "isPublic",
"owner",
"members",
"settings",
@@ -4930,6 +5086,11 @@
"type": "string",
"nullable": true,
"maxLength": 5000
+ },
+ "isPublic": {
+ "type": "boolean",
+ "default": false,
+ "description": "Anyone can read it without signing in (board, tickets, comments, releases, activity, statistics)"
}
},
"required": [
@@ -4971,6 +5132,11 @@
"type": "string",
"nullable": true,
"maxLength": 5000
+ },
+ "isPublic": {
+ "type": "boolean",
+ "default": false,
+ "description": "Anyone can read it without signing in (board, tickets, comments, releases, activity, statistics)"
}
}
},
diff --git a/apps/api/schema.gql b/apps/api/schema.gql
index eb2c7be..22ce2e9 100644
--- a/apps/api/schema.gql
+++ b/apps/api/schema.gql
@@ -118,6 +118,9 @@ input CreateIssueInput {
input CreateProjectInput {
description: String
+ """Readable by anyone, also without signing in"""
+ isPublic: Boolean
+
"""2-10 chars, e.g. "TS" → tickets TS-1, TS-2..."""
key: String!
name: String!
@@ -312,6 +315,11 @@ type Mutation {
"""Author or project admin"""
deleteComment(commentId: ID!): CommandResult!
+
+ """
+ Delete the project (owner only), with its tickets, comments and releases. `confirmKey` must be the project key.
+ """
+ deleteProject(confirmKey: String!, projectId: ID!): CommandResult!
editComment(body: String!, commentId: ID!): CommandResult!
"""Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE"""
@@ -347,6 +355,9 @@ type Project {
createdAt: String!
description: String
id: ID!
+
+ """Anyone can read it, also without signing in"""
+ isPublic: Boolean!
key: String!
members: [ProjectMember!]!
name: String!
@@ -467,10 +478,15 @@ type Query {
"""Projects where I am a member"""
myProjects: [Project!]!
+
+ """A project of mine, or any public one (no sign-in needed)"""
project(
"""Project id or key (e.g. "TS")"""
idOrKey: String!
): Project!
+
+ """Projects anyone can read, also without signing in"""
+ publicProjects: [Project!]!
release(id: ID!): Release!
"""Planned releases of all my projects, soonest target date first"""
@@ -593,6 +609,7 @@ input UpdateIssueInput {
input UpdateProjectInput {
description: String
+ isPublic: Boolean
name: String
projectId: ID!
}
diff --git a/apps/web/src/app/app.routes.spec.ts b/apps/web/src/app/app.routes.spec.ts
index bf05a6e..3062b4d 100644
--- a/apps/web/src/app/app.routes.spec.ts
+++ b/apps/web/src/app/app.routes.spec.ts
@@ -2,17 +2,28 @@ import { authGuard } from '@task-stream/web/identity/presenters';
import { appRoutes } from './app.routes';
describe('appRoutes', () => {
- it('keeps sign-in public and everything else behind the auth guard', () => {
+ it('keeps home behind the auth guard, and public projects and «Explorar» open', () => {
const paths = appRoutes.map((route) => route.path);
- expect(paths).toEqual(expect.arrayContaining(['login', 'auth/callback', '', '**']));
+ expect(paths).toEqual(
+ expect.arrayContaining(['login', 'auth/callback', '', '**']),
+ );
const app = appRoutes.find((route) => route.path === '' && route.children);
- expect(app?.canActivate).toContain(authGuard);
- expect(app?.children?.map((route) => route.path)).toEqual(['', 'p/:key']);
+ const children = app?.children ?? [];
+ expect(children.map((route) => route.path)).toEqual([
+ '',
+ 'explorar',
+ 'p/:key',
+ ]);
+ expect(children[0].canActivate).toContain(authGuard);
+ expect(children[1].canActivate).toBeUndefined();
+ expect(children[2].canActivate).toBeUndefined();
});
it('exposes every project section', () => {
- const project = appRoutes.find((route) => route.path === '')?.children?.find((route) => route.path === 'p/:key');
+ const project = appRoutes
+ .find((route) => route.path === '')
+ ?.children?.find((route) => route.path === 'p/:key');
expect(project?.children?.map((route) => route.path)).toEqual([
'',
'board',
diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts
index 50e8683..210e6f5 100644
--- a/apps/web/src/app/app.routes.ts
+++ b/apps/web/src/app/app.routes.ts
@@ -1,12 +1,10 @@
import { Route } from '@angular/router';
-import {
- authGuard,
- identityRoutes,
-} from '@task-stream/web/identity/presenters';
+import { identityRoutes } from '@task-stream/web/identity/presenters';
import { trackerRoutes } from '@task-stream/web/tracker/presenters';
export const appRoutes: Route[] = [
...identityRoutes,
- { path: '', canActivate: [authGuard], children: trackerRoutes },
+ // Not all behind the guard: public projects are readable without a session.
+ { path: '', children: trackerRoutes },
{ path: '**', redirectTo: '' },
];
diff --git a/apps/web/src/app/app.ts b/apps/web/src/app/app.ts
index c860374..4f35f92 100644
--- a/apps/web/src/app/app.ts
+++ b/apps/web/src/app/app.ts
@@ -1,5 +1,13 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
-import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
+import { toSignal } from '@angular/core/rxjs-interop';
+import {
+ NavigationEnd,
+ Router,
+ RouterLink,
+ RouterLinkActive,
+ RouterOutlet,
+} from '@angular/router';
+import { filter, map } from 'rxjs';
import { AuthFacade } from '@task-stream/web/identity/application';
import { UserMenuComponent } from '@task-stream/web/identity/presenters';
import { AppShellComponent } from '@task-stream/web/shared/ui';
@@ -27,8 +35,20 @@ import { AppShellComponent } from '@task-stream/web/shared/ui';
Proyectos
}
+
+ Explorar
+
@if (auth.isAuthenticated()) {
+ } @else if (auth.settled() && !onLogin()) {
+ Iniciar sesión
}
@@ -36,4 +56,13 @@ import { AppShellComponent } from '@task-stream/web/shared/ui';
})
export class App {
protected readonly auth = inject(AuthFacade);
+ private readonly router = inject(Router);
+ /** The login page has its own form: no "Iniciar sesión" button there. */
+ protected readonly onLogin = toSignal(
+ this.router.events.pipe(
+ filter((event) => event instanceof NavigationEnd),
+ map(() => this.router.url.startsWith('/login')),
+ ),
+ { initialValue: false },
+ );
}
diff --git a/apps/web/src/styles.scss b/apps/web/src/styles.scss
index 084847f..95cc0e3 100644
--- a/apps/web/src/styles.scss
+++ b/apps/web/src/styles.scss
@@ -482,6 +482,18 @@ h3 {
font-weight: 600;
background: var(--pi-color-primary-50);
}
+
+ // "Iniciar sesión" for visitors of public projects.
+ &--cta {
+ color: #fff;
+ font-weight: 600;
+ background: var(--ts-brand);
+
+ &:hover {
+ color: #fff;
+ filter: brightness(1.08);
+ }
+ }
}
@media (prefers-reduced-motion: reduce) {
diff --git a/docs/01-vision-general.md b/docs/01-vision-general.md
index 8980721..0f0da3f 100644
--- a/docs/01-vision-general.md
+++ b/docs/01-vision-general.md
@@ -2,21 +2,23 @@
## Qué resuelve
-| Necesidad | Cómo se resuelve |
-| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
-| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas |
-| 🧩 Configuración | Cada proyecto tiene sus estados (con categoría, color y motivo obligatorio), transiciones, tipos de ticket y áreas; lo usado se archiva, no se borra |
-| 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) |
-| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo |
-| 🧪 QA | Por defecto, `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo |
-| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) |
-| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
-| 💬 Comentarios | Decisiones y explicaciones dentro del ticket; el autor edita, el admin modera |
-| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) |
-| 🏠 Inicio | «Mi trabajo»: mis tickets en todos los proyectos, por categoría (QA fallidos primero), lo que espera QA, próximas releases e «Ir a…» por clave |
-| 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket |
-| 📈 Estadísticas | Creados vs. resueltos, flujo acumulado, burnup de releases, tiempos de ciclo y entrega, tasa de fallo en QA y resueltos por persona |
-| ⚡ Tiempo real | Tablero, comentarios, actividad, releases y membresías se actualizan solos (GraphQL subscriptions) |
+| Necesidad | Cómo se resuelve |
+| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas |
+| 🌍 Públicos | Un proyecto público se puede leer sin iniciar sesión (web, GraphQL y REST): tablero, tickets, comentarios, releases, actividad y estadísticas; «Explorar» los lista |
+| 🗑️ Borrar | El propietario borra un proyecto confirmando su clave; tickets, comentarios, releases y enlaces desde otros proyectos se borran en cascada (sagas) y la clave queda libre |
+| 🧩 Configuración | Cada proyecto tiene sus estados (con categoría, color y motivo obligatorio), transiciones, tipos de ticket y áreas; lo usado se archiva, no se borra |
+| 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) |
+| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo |
+| 🧪 QA | Por defecto, `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo |
+| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) |
+| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
+| 💬 Comentarios | Decisiones y explicaciones dentro del ticket; el autor edita, el admin modera |
+| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) |
+| 🏠 Inicio | «Mi trabajo»: mis tickets en todos los proyectos, por categoría (QA fallidos primero), lo que espera QA, próximas releases e «Ir a…» por clave |
+| 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket |
+| 📈 Estadísticas | Creados vs. resueltos, flujo acumulado, burnup de releases, tiempos de ciclo y entrega, tasa de fallo en QA y resueltos por persona |
+| ⚡ Tiempo real | Tablero, comentarios, actividad, releases y membresías se actualizan solos (GraphQL subscriptions) |
Los **usuarios no se gestionan aquí**: vienen del servidor de identidad **Pireon**
([Swagger](https://oauth.pireon.dev/api/documentation)). TaskStream gestiona proyectos, membresías y roles por
diff --git a/docs/02-arquitectura.md b/docs/02-arquitectura.md
index b412ebd..f24fee3 100644
--- a/docs/02-arquitectura.md
+++ b/docs/02-arquitectura.md
@@ -112,6 +112,7 @@ sequenceDiagram
`@AutowiredEvent`) y lo entrega a los `@EventsHandler` registrados para su clase. A diferencia de
`eventBus.subject$.next()`, **espera** a cada handler y procesa los eventos en orden; solo hace `XACK` cuando todos
terminan. Si uno falla, el evento queda pendiente y se reintenta (y `XAUTOCLAIM` recupera los de instancias caídas).
+- Después de los handlers, el bridge ejecuta también las **sagas** (ver abajo).
### Lectura: MongoDB
@@ -128,6 +129,34 @@ Tras actualizar Mongo, el handler publica en Redis pub/sub (`ChangeFeed`). Las s
canales (uno por proyecto, ticket o usuario), así que funcionan con varias instancias de la API. Como se publica
**después** de proyectar, quien recibe el aviso siempre lee el estado nuevo.
+### Sagas: reacciones entre agregados
+
+Cuando un evento de un agregado debe cambiar otros agregados, lo hace una saga (`application/sagas`), con el `@Saga()`
+estándar de `@nestjs/cqrs`: un observable que filtra eventos (`ofType`) y emite comandos. Como el `EventBus` en memoria
+nunca ve los eventos guardados (su publisher es el event store), las sagas las ejecuta el **`EventsBridge`**: a cada
+evento, tras las proyecciones, pasa ese evento solo (`of(event)`) por cada saga y ejecuta los comandos que emite, uno a
+uno y esperando. Así heredan las garantías del bridge: orden, reintento y nada se pierde si la API se cae a mitad.
+
+| Saga | Evento | Comandos |
+| --------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
+| `ProjectDeletionSaga` | `ProjectDeletedEvent` | `DeleteIssueCommand` por cada ticket y `DeleteReleaseCommand` por cada release del proyecto |
+| | `IssueDeletedEvent` | `PurgeCommentCommand` por cada comentario y `DropLinksToIssueCommand` en los tickets que lo enlazaban |
+| `ReleaseArchivedSaga` | `ReleaseArchivedEvent` | `DetachIssueFromReleaseCommand` para sus tickets sin terminar (los terminados se quedan) |
+| `UserDeactivatedSaga` | `UserDeactivatedEvent` (contexto identity) | `UnassignIssueCommand` para sus tickets abiertos en todos los proyectos, como actor `system` («TaskStream») |
+
+Reglas que cumplen:
+
+- **A quién afecta sale del event store**, no del read model (`TrackerStreamsRepository`, `ReleaseIssuesRepository`),
+ salvo la saga de usuarios, que busca tickets asignados ahora mismo en Mongo.
+- **Comandos idempotentes**: un evento puede procesarse dos veces, así que cada comando comprueba si aún hay algo que
+ hacer (`WriteAccess.live()` devuelve `null` si el agregado no existe o ya está borrado; desasignar solo si sigue
+ asignado a esa persona…). Son comandos internos: ningún presenter los expone.
+- **Nada bloquea el stream**: si el dominio rechaza un comando (no encontrado, regla de negocio…), el bridge lo registra
+ y sigue; los conflictos de concurrencia se reintentan hasta 5 veces y, si persisten, el evento queda pendiente.
+- **Autoría**: los cambios se atribuyen a quien provocó el evento (quien borró el proyecto o archivó la release).
+- **Entre contextos**: el webhook de Pireon guarda `UserDeactivatedEvent` en el event store (`EventRecorder`, stream
+ `identity-user-`), y la saga del tracker reacciona a él sin que los contextos se llamen entre sí.
+
### Consistencia eventual
Las mutations devuelven `CommandResult { id, key, version }` en cuanto los eventos están guardados; Mongo se actualiza
diff --git a/docs/03-api.md b/docs/03-api.md
index 6627430..bef190c 100644
--- a/docs/03-api.md
+++ b/docs/03-api.md
@@ -48,6 +48,26 @@ classDiagram
Cualquiera puede salir de un proyecto (salvo el propietario). Los no miembros reciben _not found_, para no revelar que
el proyecto existe.
+### Proyectos públicos
+
+Con `isPublic` (al crear, o después con `updateProject` / `PATCH /api/projects/{project}`, propietario o admin),
+**cualquiera puede leer** el proyecto, también sin token: el proyecto y sus miembros, tablero, tickets, comentarios,
+historial, releases (progreso y burnup), actividad, seguimiento y estadísticas. Escribir sigue siendo cosa de los
+miembros según su rol (sin token: 401; con token pero sin ser miembro: 403). Un usuario con sesión que no es miembro
+lee igual que un anónimo. Las subscriptions exigen sesión; con ella, también se pueden seguir proyectos públicos.
+
+`publicProjects` (GraphQL) y `GET /api/explore/projects` (REST, sin token) los listan. Al volver a privado, quien no es
+miembro vuelve a recibir _not found_. Los enlaces a tickets de otros proyectos solo se muestran si el visitante puede
+leer esos proyectos.
+
+### Borrar un proyecto
+
+Solo el **propietario**, escribiendo la clave del proyecto para confirmar (`deleteProject(projectId, confirmKey)` o
+`DELETE /api/projects/{project}?confirm=CLAVE`). El proyecto desaparece al momento y, a continuación, una saga borra
+sus tickets (y sus comentarios), sus releases y los enlaces que tickets de otros proyectos tenían hacia los suyos (ver
+[Arquitectura](02-arquitectura.md)). La clave queda libre para un proyecto nuevo. Los eventos se quedan en el event
+store como auditoría, pero desde la API no se puede deshacer.
+
### Configuración por proyecto
Cada proyecto tiene sus **estados**, **transiciones**, **tipos de ticket** y **áreas** (`Project.settings`). Todos
@@ -130,12 +150,12 @@ stateDiagram-v2
Todo cambio es un evento guardado en el event store (stream = id del agregado). Llevan `actorId`, `occurredAt`,
`aggregateId`, `projectId` y `aggregateVersion` (la posición en el stream).
-| Agregado | Eventos |
-| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectSettingsChangedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent` |
-| Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent` |
-| Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` |
-| Release | `ReleaseCreatedEvent`, `ReleaseUpdatedEvent`, `ReleaseShippedEvent`, `ReleaseArchivedEvent` |
+| Agregado | Eventos |
+| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectSettingsChangedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent`, `ProjectDeletedEvent` |
+| Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent`, `IssueDeletedEvent` |
+| Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` |
+| Release | `ReleaseCreatedEvent`, `ReleaseUpdatedEvent`, `ReleaseShippedEvent`, `ReleaseArchivedEvent`, `ReleaseDeletedEvent` |
`IssueStatusChangedEvent` lleva además `meta` con el nombre y la categoría de los dos estados en el momento del
cambio, para que el historial y las estadísticas no cambien si luego se renombra o recategoriza un estado.
@@ -161,34 +181,37 @@ reconstruye el read model (ver [Despliegue](07-despliegue.md)).
## 3.2 GraphQL
-Todas las operaciones requieren `Authorization: Bearer ` (en subscriptions, en el `connection_init`).
+Las operaciones requieren `Authorization: Bearer ` (en subscriptions, en el `connection_init`), salvo
+las lecturas de proyectos públicos (`project`, `issue`, `issues`, `release`, `activity`, `publicProjects` y sus campos),
+que funcionan también sin token.
### Queries
-| Query | Devuelve |
-| ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
-| `me` | El usuario autenticado, con sus roles en Pireon |
-| `users(search, limit)` | Usuarios de Pireon (para asignar o añadir miembros) |
-| `user(id)` | Un usuario |
-| `myProjects` | Proyectos de los que soy miembro |
-| `myIssues(assignedToMe, statuses, categories, limit, offset)` | «Mi trabajo»: tickets de todos mis proyectos, asignados a mí (por defecto) o de cualquiera (p. ej. `categories: [QA]`) |
-| `upcomingReleases(limit)` | Releases planificadas de mis proyectos, por fecha objetivo |
-| `project(idOrKey)` | Un proyecto con su `settings`. Campos calculados: `board(releaseId, assigneeId, area, perColumn)`, `stats`, `analytics(days)`, `releases` |
-| `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, texto) |
-| `issue(idOrKey)` | Un ticket. Campos calculados: `allowedTransitions`, `linkedIssues`, `comments`, `history` |
-| `release(id)` | Una release. Campos calculados: `progress`, `burnup`, `issues` |
-| `activity(projectId, issueId?, limit, before?)` | Feed de actividad, del más reciente al más antiguo |
+| Query | Devuelve |
+| ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `me` | El usuario autenticado, con sus roles en Pireon |
+| `users(search, limit)` | Usuarios de Pireon (para asignar o añadir miembros) |
+| `user(id)` | Un usuario |
+| `myProjects` | Proyectos de los que soy miembro |
+| `myIssues(assignedToMe, statuses, categories, limit, offset)` | «Mi trabajo»: tickets de todos mis proyectos, asignados a mí (por defecto) o de cualquiera (p. ej. `categories: [QA]`) |
+| `upcomingReleases(limit)` | Releases planificadas de mis proyectos, por fecha objetivo |
+| `publicProjects` | Proyectos públicos (sin token) |
+| `project(idOrKey)` | Un proyecto (mío, o público) con su `settings`. Campos calculados: `board(releaseId, assigneeId, area, perColumn)`, `stats`, `analytics(days)`, `releases` |
+| `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, texto) |
+| `issue(idOrKey)` | Un ticket. Campos calculados: `allowedTransitions`, `linkedIssues`, `comments`, `history` |
+| `release(id)` | Una release. Campos calculados: `progress`, `burnup`, `issues` |
+| `activity(projectId, issueId?, limit, before?)` | Feed de actividad, del más reciente al más antiguo |
### Mutations
Todas devuelven `CommandResult { id, key, version }`.
-| Ámbito | Mutations |
-| ----------- | ------------------------------------------------------------------------------------------------------------------------------- |
-| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
-| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease` |
-| Comentarios | `addComment`, `editComment`, `deleteComment` |
-| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `archiveRelease` |
+| Ámbito | Mutations |
+| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
+| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease` |
+| Comentarios | `addComment`, `editComment`, `deleteComment` |
+| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `archiveRelease` |
### Subscriptions
@@ -367,9 +390,10 @@ la consuman o generen un cliente. Son otro adaptador de entrada (`presenters/htt
### Autenticación: siempre a través de Pireon
-Cualquier app o usuario que la consuma pasa por el OAuth de Pireon: todas las rutas, salvo `health`, `auth` y el
-webhook, exigen `Authorization: Bearer ` (el `AuthGuard` global, igual que en GraphQL). El documento
-declara dos esquemas:
+Cualquier app o usuario que la consuma pasa por el OAuth de Pireon: todas las rutas, salvo `health`, `auth`, el
+webhook, `explore` y las lecturas de proyectos públicos, exigen `Authorization: Bearer ` (el
+`AuthGuard` global, igual que en GraphQL). En el documento OpenAPI, las rutas donde el token es opcional lo indican con
+un requisito de seguridad vacío (`{}`). Declara dos esquemas:
- **`pireon-oauth`** (OAuth2): _Authorization Code + PKCE_ contra `oauth.pireon.dev/oauth/authorize` y, si
`PIREON_PASSWORD_GRANT=true`, _password_. El intercambio de tokens va por `POST /api/auth/token` (Pireon no tiene
@@ -386,10 +410,11 @@ Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
| Recurso | Rutas |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
-| Proyectos | `GET/POST /api/projects`, `GET/PATCH /api/projects/{project}`, `PUT …/settings`, `…/board`, `…/stats`, `…/analytics?days=`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
+| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `PUT …/settings`, `…/board`, `…/stats`, `…/analytics?days=`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
| Tickets | `GET /api/me/issues` (mi trabajo), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history` |
| Comentarios | `PATCH/DELETE /api/comments/{commentId}` |
| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` |
+| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) |
| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` |
Los filtros de tickets aceptan `status` (ids del proyecto) y `category` (`?category=QA`), repetidos o separados por
diff --git a/docs/04-cliente-web.md b/docs/04-cliente-web.md
index 732316f..3946f7a 100644
--- a/docs/04-cliente-web.md
+++ b/docs/04-cliente-web.md
@@ -114,6 +114,7 @@ Qué gana la página:
| `AuthFacade` | `AuthStore` | raíz | Sesión, usuario, login (PKCE o email y contraseña de Pireon), renovación del token, restaurar al arrancar |
| `ProjectsFacade` | `ProjectsStore` | raíz | Mis proyectos; se actualiza cuando me añaden o quitan (`myProjectsChanged`) |
| `HomeFacade` | `HomeStore` | raíz | «Mi trabajo» en la portada: mis tickets abiertos por categoría (QA fallidos primero), esperando QA y próximas releases; se recarga al volver a la pestaña |
+| `ExploreFacade` | `ExploreStore` | raíz | Proyectos públicos («Explorar»), también sin sesión |
| `ProjectFacade` | `ProjectStore` | ruta `/p/:key` | Proyecto, miembros, stats, releases, actividad en vivo, búsqueda de usuarios, configuración (`workflow`, `saveSettings`) y estadísticas (`loadAnalytics`, `loadBurnup`) |
| `BoardFacade` | `BoardStore` | ruta `board` | Tablero kanban, filtros, movimiento optimista, nuevo ticket, cambios en vivo |
| `IssueFacade` | `IssueStore` | ruta `issues/:issueKey` | Ticket, comentarios en vivo, historial y todas sus acciones |
@@ -223,6 +224,18 @@ visualización de la plataforma:
la tabla, para que nada dependa del color ni del ratón.
- Mientras se recalcula un periodo, se queda la gráfica anterior atenuada.
+### Proyectos públicos y visitantes sin sesión
+
+- Solo la portada (`/`) exige sesión. `/explorar` y las páginas de proyecto (`/p/:key/…`) no: la API decide qué ve
+ cada visitante, y las páginas ya ocultan lo que no puede cambiar (`canContribute`, `canManage`).
+- Sin sesión, la cabecera muestra «Explorar» e «Iniciar sesión»; dentro de un proyecto público aparece un aviso de modo
+ lectura, y en uno privado (o inexistente), el enlace para iniciar sesión volviendo a esa página (`returnUrl`).
+- El tiempo real necesita sesión: `GraphqlRealtimeGateway` no abre subscriptions sin token (si lo hiciera, la API
+ cerraría el WebSocket y el cliente reintentaría sin fin). Los visitantes anónimos ven los datos al cargar la página.
+- Configuración tiene el interruptor «Público» (propietario y admins) y, solo para el propietario, «Eliminar el
+ proyecto», que exige escribir la clave. Al borrarlo se vuelve a la portada; si otro lo borra mientras lo tienes
+ abierto, la subscription avisa (`ProjectChanged.deleted`) y también se vuelve a la portada.
+
## 4.6 Rutas
| Ruta | Página |
@@ -230,6 +243,7 @@ visualización de la plataforma:
| `/login` | Inicio de sesión (solo sin sesión) |
| `/auth/callback` | Retorno de Pireon (PKCE) |
| `/` | Inicio: «Ir a…» (`WEB-12`), mi trabajo (`ts-my-work`) y mis proyectos |
+| `/explorar` | Proyectos públicos (sin sesión) |
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros) |
| `/p/:key/issues/:issueKey` | Ticket |
| `/p/:key/releases` | Releases |
@@ -239,5 +253,6 @@ visualización de la plataforma:
| `/p/:key/activity` | Actividad en vivo |
| `/p/:key/members` | Miembros |
-Todo salvo `/login` y `/auth/callback` está protegido por `authGuard`. Los parámetros de ruta llegan como inputs de
+Solo `/` está protegida por `authGuard` (redirige a `/login?returnUrl=…`); `/login` y `/auth/callback` son para quien
+no tiene sesión, y el resto se puede abrir sin ella si el proyecto es público. Los parámetros de ruta llegan como inputs de
los componentes (`withComponentInputBinding`).
diff --git a/docs/05-autenticacion.md b/docs/05-autenticacion.md
index af01926..0c8f420 100644
--- a/docs/05-autenticacion.md
+++ b/docs/05-autenticacion.md
@@ -29,7 +29,11 @@ sequenceDiagram
- Se cachea el **hash SHA-256** del token, nunca el token en claro.
- Si Pireon marca al usuario como inactivo, el token se rechaza.
- El guard es global: todo requiere token salvo lo marcado con `@Public()` (health, configuración de login, proxy de
- tokens y webhook).
+ tokens, webhook y `GET /api/explore/projects`).
+- `@OptionalAuth()` (o `@ApiOptionalPireonAuth()` en REST) deja pasar sin token: es para las lecturas que permiten los
+ proyectos públicos (proyecto, tablero, tickets, comentarios, releases, actividad, estadísticas). Sin token, el
+ usuario es `undefined` y el visitante es anónimo; un token presente pero inválido sigue dando 401. Las escrituras, las
+ subscriptions y lo «mío» (`myProjects`, `myIssues`, `/api/me/…`) siguen exigiendo sesión.
- En subscriptions, el token se valida una vez al abrir el WebSocket (`connection_init`).
- `@RequireAppRoles('…')` permite exigir roles del cliente OAuth en Pireon (los admins globales de Pireon los tienen todos).
@@ -120,7 +124,9 @@ caduque la caché):
2. Pon el secret que devuelve en `PIREON_WEBHOOK_SECRET`.
La API verifica la firma `X-Webhook-Signature` (HMAC-SHA256 del cuerpo), marca al usuario como inactivo y borra sus
-sesiones cacheadas. Sin `PIREON_WEBHOOK_SECRET`, el endpoint rechaza todas las llamadas.
+sesiones cacheadas. Además guarda `UserDeactivatedEvent` en el event store, y una saga del tracker deja sin asignar sus
+tickets abiertos en todos los proyectos (ver [Arquitectura](02-arquitectura.md)). Sin `PIREON_WEBHOOK_SECRET`, el
+endpoint rechaza todas las llamadas.
## 5.4 Usuarios en TaskStream
diff --git a/docs/TaskStream-Documentacion.docx b/docs/TaskStream-Documentacion.docx
index d009759..a24e69e 100644
Binary files a/docs/TaskStream-Documentacion.docx and b/docs/TaskStream-Documentacion.docx differ
diff --git a/docs/TaskStream-Documentacion.pdf b/docs/TaskStream-Documentacion.pdf
index dd0bb99..6488179 100644
Binary files a/docs/TaskStream-Documentacion.pdf and b/docs/TaskStream-Documentacion.pdf differ
diff --git a/libs/api/identity/application/README.md b/libs/api/identity/application/README.md
index 429369e..d7efac1 100644
--- a/libs/api/identity/application/README.md
+++ b/libs/api/identity/application/README.md
@@ -9,7 +9,8 @@ Casos de uso de identidad.
- `IdentityService`: punto de entrada para los presenters.
- Queries: `ResolveSessionQuery` (token → usuario, con caché), `FindUserQuery`, `FindUsersByIdsQuery`, `SearchUsersQuery`, `GetAuthorizationConfigQuery`.
-- Commands: `IssueTokenCommand` (login / refresh vía Pireon), `HandleIdentityWebhookCommand`.
+- Commands: `IssueTokenCommand` (login / refresh vía Pireon), `HandleIdentityWebhookCommand` (al desactivar a un
+ usuario, guarda `UserDeactivatedEvent` en el event store para que el tracker reaccione).
- Puertos: `IdentityProvider`, `SessionCache`, `UserRegistry`.
Ver [Autenticación](../../../../docs/05-autenticacion.md).
diff --git a/libs/api/identity/application/project.json b/libs/api/identity/application/project.json
index 9e53fbc..f99f025 100644
--- a/libs/api/identity/application/project.json
+++ b/libs/api/identity/application/project.json
@@ -3,10 +3,7 @@
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/api/identity/application/src",
"projectType": "library",
- "tags": [
- "scope:api-identity",
- "type:application"
- ],
+ "tags": ["scope:api-identity", "type:application"],
"// targets": "to see all targets run: nx show project api-identity-application --web",
"targets": {}
}
diff --git a/libs/api/identity/application/src/lib/commands/handle-identity-webhook.command.ts b/libs/api/identity/application/src/lib/commands/handle-identity-webhook.command.ts
index 305453d..6fd5aa9 100644
--- a/libs/api/identity/application/src/lib/commands/handle-identity-webhook.command.ts
+++ b/libs/api/identity/application/src/lib/commands/handle-identity-webhook.command.ts
@@ -1,5 +1,7 @@
import { Logger } from '@nestjs/common';
import { Command, CommandHandler, ICommandHandler } from '@nestjs/cqrs';
+import { EventRecorder } from '@task-stream/api/shared/application';
+import { UserDeactivatedEvent } from '@task-stream/api/identity/domain';
import { SessionCache } from '../ports/session-cache.port';
import { UserRegistry } from '../ports/user-registry.port';
@@ -22,6 +24,7 @@ export class HandleIdentityWebhookHandler
constructor(
private readonly cache: SessionCache,
private readonly registry: UserRegistry,
+ private readonly events: EventRecorder,
) {}
async execute({ event, data }: HandleIdentityWebhookCommand): Promise {
@@ -50,8 +53,14 @@ export class HandleIdentityWebhookHandler
private async deactivate(userId: string): Promise {
const user = await this.registry.findById(userId);
- if (!user) return;
+ if (!user?.isActive) return;
user.deactivate();
await this.registry.upsert(user);
+ // Stored so other contexts react (the tracker unassigns their open tickets).
+ for (const event of user.getUncommittedEvents()) {
+ if (event instanceof UserDeactivatedEvent)
+ await this.events.record(`identity-user-${userId}`, event);
+ }
+ user.uncommit();
}
}
diff --git a/libs/api/identity/domain/project.json b/libs/api/identity/domain/project.json
index 71d7b69..0cefeb8 100644
--- a/libs/api/identity/domain/project.json
+++ b/libs/api/identity/domain/project.json
@@ -3,10 +3,7 @@
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/api/identity/domain/src",
"projectType": "library",
- "tags": [
- "scope:api-identity",
- "type:domain"
- ],
+ "tags": ["scope:api-identity", "type:domain"],
"// targets": "to see all targets run: nx show project api-identity-domain --web",
"targets": {}
}
diff --git a/libs/api/identity/infrastructure/project.json b/libs/api/identity/infrastructure/project.json
index 7825a93..7924335 100644
--- a/libs/api/identity/infrastructure/project.json
+++ b/libs/api/identity/infrastructure/project.json
@@ -3,10 +3,7 @@
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/api/identity/infrastructure/src",
"projectType": "library",
- "tags": [
- "scope:api-identity",
- "type:infrastructure"
- ],
+ "tags": ["scope:api-identity", "type:infrastructure"],
"// targets": "to see all targets run: nx show project api-identity-infrastructure --web",
"targets": {}
}
diff --git a/libs/api/identity/presenters/README.md b/libs/api/identity/presenters/README.md
index 05afdec..fe93b1b 100644
--- a/libs/api/identity/presenters/README.md
+++ b/libs/api/identity/presenters/README.md
@@ -7,7 +7,8 @@ Adaptadores de entrada de identidad.
## Contenido
-- `auth/`: `AuthGuard` global, `@Public()`, `@CurrentUser()`, `@AccessToken()`, `@RequireAppRoles()`, `WsAuthenticator`
+- `auth/`: `AuthGuard` global, `@Public()`, `@OptionalAuth()` / `@ApiOptionalPireonAuth()` (lecturas de proyectos
+ públicos: sin token, el usuario es `undefined`), `@CurrentUser()`, `@AccessToken()`, `@RequireAppRoles()`, `WsAuthenticator`
(subscriptions) y `@ApiPireonAuth()` (esquemas OAuth2 / Bearer de Pireon en OpenAPI).
- `http/`: `AuthController` (`GET /api/auth/config`, `POST /api/auth/token`), `UsersController` (`/api/users/me`,
directorio de Pireon) y `PireonWebhookController` (firma HMAC). Documentados con `@nestjs/swagger`.
diff --git a/libs/api/identity/presenters/project.json b/libs/api/identity/presenters/project.json
index 08ae7dc..201a27a 100644
--- a/libs/api/identity/presenters/project.json
+++ b/libs/api/identity/presenters/project.json
@@ -3,10 +3,7 @@
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/api/identity/presenters/src",
"projectType": "library",
- "tags": [
- "scope:api-identity",
- "type:presenters"
- ],
+ "tags": ["scope:api-identity", "type:presenters"],
"// targets": "to see all targets run: nx show project api-identity-presenters --web",
"targets": {}
}
diff --git a/libs/api/identity/presenters/src/lib/auth/auth.decorators.ts b/libs/api/identity/presenters/src/lib/auth/auth.decorators.ts
index 690afe6..eba8dc8 100644
--- a/libs/api/identity/presenters/src/lib/auth/auth.decorators.ts
+++ b/libs/api/identity/presenters/src/lib/auth/auth.decorators.ts
@@ -4,20 +4,27 @@ import {
applyDecorators,
createParamDecorator,
} from '@nestjs/common';
-import { ApiBearerAuth, ApiOAuth2 } from '@nestjs/swagger';
+import { ApiBearerAuth, ApiOAuth2, ApiSecurity } from '@nestjs/swagger';
import { getAuthRequest } from './auth-request';
export const IS_PUBLIC = 'identity:isPublic';
+export const IS_OPTIONAL_AUTH = 'identity:optionalAuth';
export const REQUIRED_APP_ROLES = 'identity:appRoles';
/** Skips authentication (health checks, webhooks...). */
export const Public = () => SetMetadata(IS_PUBLIC, true);
+/**
+ * Anonymous callers are let through (`@CurrentUser()` is undefined); a token,
+ * if sent, must still be valid. For reads that public projects allow.
+ */
+export const OptionalAuth = () => SetMetadata(IS_OPTIONAL_AUTH, true);
+
/** Requires one of these roles in the task-stream OAuth client (Pireon app roles). */
export const RequireAppRoles = (...roles: string[]) =>
SetMetadata(REQUIRED_APP_ROLES, roles);
-/** The authenticated caller (User). */
+/** The authenticated caller (User); undefined on anonymous `@OptionalAuth()` calls. */
export const CurrentUser = createParamDecorator(
(_: unknown, context: ExecutionContext) => getAuthRequest(context).user,
);
@@ -39,3 +46,10 @@ export const PIREON_BEARER = 'pireon';
*/
export const ApiPireonAuth = () =>
applyDecorators(ApiOAuth2([], PIREON_OAUTH), ApiBearerAuth(PIREON_BEARER));
+
+/**
+ * `@OptionalAuth()` + OpenAPI: adds "no token" to the security requirements
+ * the controller declares with `@ApiPireonAuth()`.
+ */
+export const ApiOptionalPireonAuth = () =>
+ applyDecorators(OptionalAuth(), ApiSecurity({}));
diff --git a/libs/api/identity/presenters/src/lib/auth/auth.guard.ts b/libs/api/identity/presenters/src/lib/auth/auth.guard.ts
index 63b854a..f297bbb 100644
--- a/libs/api/identity/presenters/src/lib/auth/auth.guard.ts
+++ b/libs/api/identity/presenters/src/lib/auth/auth.guard.ts
@@ -11,9 +11,16 @@ import {
InvalidAccessTokenError,
} from '@task-stream/api/identity/application';
import { extractBearerToken, getAuthRequest } from './auth-request';
-import { IS_PUBLIC, REQUIRED_APP_ROLES } from './auth.decorators';
+import {
+ IS_OPTIONAL_AUTH,
+ IS_PUBLIC,
+ REQUIRED_APP_ROLES,
+} from './auth.decorators';
-/** Global guard: every HTTP/GraphQL operation needs a valid Pireon token unless @Public(). */
+/**
+ * Global guard: every HTTP/GraphQL operation needs a valid Pireon token unless
+ * @Public(); with @OptionalAuth() the token may be missing (but not invalid).
+ */
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
@@ -30,7 +37,13 @@ export class AuthGuard implements CanActivate {
// WebSocket subscriptions are authenticated once in connection_init.
if (!req.user) {
const token = extractBearerToken(req.headers['authorization']);
- if (!token) throw new UnauthorizedException('Missing bearer token');
+ if (!token) {
+ if (
+ this.reflector.getAllAndOverride(IS_OPTIONAL_AUTH, targets)
+ )
+ return true;
+ throw new UnauthorizedException('Missing bearer token');
+ }
try {
req.user = await this.identity.resolveSession(token);
req.accessToken = token;
diff --git a/libs/api/shared/application/README.md b/libs/api/shared/application/README.md
index 2fbccd8..7e23594 100644
--- a/libs/api/shared/application/README.md
+++ b/libs/api/shared/application/README.md
@@ -9,7 +9,10 @@ Contrato del event store y reconstrucción de agregados.
- `EventStore` (puerto): `persist` y `getEventsByStreamId`.
- `AggregateRehydrator`: carga los eventos de un stream y devuelve el agregado reconstruido, ya enlazado con el `EventPublisher`.
-- `SharedModule.withInfrastructure(...)`: módulo global que expone el event store y el rehydrator a todos los contextos.
+- `EventRecorder`: guarda un evento en un stream que no es de ningún agregado propio (p. ej. un usuario de Pireon), en
+ la siguiente posición, para que otros contextos reaccionen a él.
+- `SharedModule.withInfrastructure(...)`: módulo global que expone el event store, el rehydrator y el recorder a todos
+ los contextos.
## Comandos
diff --git a/libs/api/shared/application/project.json b/libs/api/shared/application/project.json
index 52a938d..0026890 100644
--- a/libs/api/shared/application/project.json
+++ b/libs/api/shared/application/project.json
@@ -3,10 +3,7 @@
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/api/shared/application/src",
"projectType": "library",
- "tags": [
- "scope:api-shared",
- "type:application"
- ],
+ "tags": ["scope:api-shared", "type:application"],
"// targets": "to see all targets run: nx show project api-shared-application --web",
"targets": {}
}
diff --git a/libs/api/shared/application/src/index.ts b/libs/api/shared/application/src/index.ts
index cb06242..aa46fab 100644
--- a/libs/api/shared/application/src/index.ts
+++ b/libs/api/shared/application/src/index.ts
@@ -1,3 +1,4 @@
export * from './lib/ports/event-store';
export * from './lib/aggregate-rehydrator';
export * from './lib/shared.module';
+export * from './lib/event-recorder';
diff --git a/libs/api/shared/application/src/lib/event-recorder.ts b/libs/api/shared/application/src/lib/event-recorder.ts
new file mode 100644
index 0000000..3732944
--- /dev/null
+++ b/libs/api/shared/application/src/lib/event-recorder.ts
@@ -0,0 +1,35 @@
+import { Injectable } from '@nestjs/common';
+import { ConcurrencyError, DomainEvent } from '@task-stream/api/shared/domain';
+import { EventStore } from './ports/event-store';
+
+/**
+ * Appends a domain event to a stream that no aggregate of ours owns (e.g. a
+ * user, owned by Pireon), at the next position. Like any stored event, it
+ * reaches the event handlers and sagas of every context through the events
+ * bridge.
+ */
+@Injectable()
+export class EventRecorder {
+ constructor(private readonly eventStore: EventStore) {}
+
+ async record(streamId: string, event: DomainEvent): Promise {
+ for (let attempt = 1; ; attempt++) {
+ const position =
+ (await this.eventStore.getEventsByStreamId(streamId)).length + 1;
+ try {
+ await this.eventStore.persist({
+ streamId,
+ position,
+ type: event.constructor.name,
+ data: JSON.parse(
+ JSON.stringify({ ...event, aggregateVersion: position }),
+ ),
+ });
+ return;
+ } catch (error) {
+ // Someone appended meanwhile: take the next position.
+ if (!(error instanceof ConcurrencyError) || attempt === 3) throw error;
+ }
+ }
+ }
+}
diff --git a/libs/api/shared/application/src/lib/shared.module.ts b/libs/api/shared/application/src/lib/shared.module.ts
index 14bd971..d021f2a 100644
--- a/libs/api/shared/application/src/lib/shared.module.ts
+++ b/libs/api/shared/application/src/lib/shared.module.ts
@@ -1,11 +1,12 @@
import { DynamicModule, Global, Module, Type } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { AggregateRehydrator } from './aggregate-rehydrator';
+import { EventRecorder } from './event-recorder';
/**
* Shared building blocks for every bounded context: the event store (from
- * the infrastructure module plugged in by the composition root) and the
- * AggregateRehydrator.
+ * the infrastructure module plugged in by the composition root), the
+ * AggregateRehydrator and the EventRecorder.
*/
@Global()
@Module({})
@@ -16,8 +17,8 @@ export class SharedModule {
return {
module: SharedModule,
imports: [CqrsModule, infrastructureModule],
- providers: [AggregateRehydrator],
- exports: [infrastructureModule, AggregateRehydrator],
+ providers: [AggregateRehydrator, EventRecorder],
+ exports: [infrastructureModule, AggregateRehydrator, EventRecorder],
};
}
}
diff --git a/libs/api/shared/domain/project.json b/libs/api/shared/domain/project.json
index 04d8faa..8ba486c 100644
--- a/libs/api/shared/domain/project.json
+++ b/libs/api/shared/domain/project.json
@@ -3,10 +3,7 @@
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/api/shared/domain/src",
"projectType": "library",
- "tags": [
- "scope:api-shared",
- "type:domain"
- ],
+ "tags": ["scope:api-shared", "type:domain"],
"// targets": "to see all targets run: nx show project api-shared-domain --web",
"targets": {}
}
diff --git a/libs/api/shared/infrastructure/README.md b/libs/api/shared/infrastructure/README.md
index a3e025a..878e46f 100644
--- a/libs/api/shared/infrastructure/README.md
+++ b/libs/api/shared/infrastructure/README.md
@@ -10,7 +10,8 @@ Event store en PostgreSQL y el camino de los eventos hasta la lectura.
- `PostgresEventStore` (tabla `events`, `UNIQUE(stream_id, position)` → `ConcurrencyError`).
- `EventSerializer` / `EventDeserializer` y `EventStorePublisher` (sustituye al publisher del `EventBus`: `commit()` persiste).
- `EventStoreRelay`: copia los eventos nuevos al Redis Stream (`FOR UPDATE SKIP LOCKED`).
-- `EventsBridge`: consume el stream y entrega cada evento a sus `@EventsHandler`, en orden y esperando (ACK al final).
+- `EventsBridge`: consume el stream y entrega cada evento a sus `@EventsHandler`, en orden y esperando (ACK al final);
+ después ejecuta los `@Saga()` (el `EventBus` en memoria nunca ve los eventos guardados) y sus comandos, uno a uno.
- Conexiones Redis (`REDIS_CLIENT`, `GRAPHQL_PUB_SUB`) y `SharedInfrastructureModule.forRootAsync`.
Flujo completo: [Arquitectura](../../../../docs/02-arquitectura.md#22-flujo-completo-de-una-escritura).
diff --git a/libs/api/shared/infrastructure/project.json b/libs/api/shared/infrastructure/project.json
index a80f35e..a6d85e2 100644
--- a/libs/api/shared/infrastructure/project.json
+++ b/libs/api/shared/infrastructure/project.json
@@ -3,10 +3,7 @@
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/api/shared/infrastructure/src",
"projectType": "library",
- "tags": [
- "scope:api-shared",
- "type:infrastructure"
- ],
+ "tags": ["scope:api-shared", "type:infrastructure"],
"// targets": "to see all targets run: nx show project api-shared-infrastructure --web",
"targets": {}
}
diff --git a/libs/api/shared/infrastructure/src/lib/event-store/events-bridge.ts b/libs/api/shared/infrastructure/src/lib/event-store/events-bridge.ts
index 09debd5..0313f37 100644
--- a/libs/api/shared/infrastructure/src/lib/event-store/events-bridge.ts
+++ b/libs/api/shared/infrastructure/src/lib/event-store/events-bridge.ts
@@ -7,9 +7,20 @@ import {
Type,
} from '@nestjs/common';
import { DiscoveryService } from '@nestjs/core';
-import { IEventHandler } from '@nestjs/cqrs';
-import { SerializableEvent } from '@task-stream/api/shared/domain';
+import {
+ CommandBus,
+ ICommand,
+ IEvent,
+ IEventHandler,
+ ISaga,
+} from '@nestjs/cqrs';
+import {
+ ConcurrencyError,
+ DomainError,
+ SerializableEvent,
+} from '@task-stream/api/shared/domain';
import { hostname } from 'node:os';
+import { lastValueFrom, of, toArray } from 'rxjs';
import { EventStreamConsumer } from '../event-stream/event-stream.consumer';
import {
EVENT_STREAM_OPTIONS,
@@ -20,8 +31,12 @@ import { EventDeserializer } from './deserializers/event.deserializer';
export const EVENTS_BRIDGE_ENABLED = Symbol('EVENTS_BRIDGE_ENABLED');
-/** Metadata written by @EventsHandler(...events) (@nestjs/cqrs). */
+/** Metadata written by @EventsHandler(...events) and @Saga() (@nestjs/cqrs). */
const EVENTS_HANDLER_METADATA = '__eventsHandler__';
+const SAGA_METADATA = '__saga__';
+
+/** Attempts of a saga command that keeps hitting concurrent writes. */
+const CONCURRENCY_ATTEMPTS = 5;
/**
* Redis stream → @EventsHandler. Plays the role of the event-store change
@@ -32,6 +47,13 @@ const EVENTS_HANDLER_METADATA = '__eventsHandler__';
* time, so events of an aggregate are projected in order, and the entry is
* acknowledged in Redis only once every handler succeeded (otherwise it
* stays pending and is retried).
+ *
+ * It also runs the `@Saga()`s (the in-memory EventBus never sees stored
+ * events, so it would not): after the handlers, each saga gets the event
+ * alone (`of(event)`), and the commands it emits are executed one by one,
+ * awaited. Commands must be idempotent (an event may be handled twice).
+ * A command refused by the domain (not found, business rule...) is logged
+ * and skipped, so it cannot block the stream; concurrent writes are retried.
*/
@Injectable()
export class EventsBridge
@@ -40,6 +62,7 @@ export class EventsBridge
private readonly logger = new Logger(EventsBridge.name);
private consumer?: EventStreamConsumer;
private readonly handlers = new Map();
+ private readonly sagas: { name: string; saga: ISaga }[] = [];
constructor(
private readonly connections: RedisConnectionFactory,
@@ -47,6 +70,7 @@ export class EventsBridge
@Inject(EVENTS_BRIDGE_ENABLED) private readonly enabled: boolean,
private readonly eventDeserializer: EventDeserializer,
private readonly discovery: DiscoveryService,
+ private readonly commandBus: CommandBus,
) {}
async onApplicationBootstrap(): Promise {
@@ -55,6 +79,7 @@ export class EventsBridge
return;
}
this.registerEventHandlers();
+ this.registerSagas();
this.consumer = new EventStreamConsumer(
this.connections.create('events-bridge'),
{
@@ -66,7 +91,7 @@ export class EventsBridge
);
await this.consumer.start();
this.logger.log(
- `Bridging ${this.stream.streamKey} to ${this.handlers.size} event type(s)`,
+ `Bridging ${this.stream.streamKey} to ${this.handlers.size} event type(s) and ${this.sagas.length} saga(s)`,
);
}
@@ -90,11 +115,66 @@ export class EventsBridge
}
}
+ private registerSagas(): void {
+ for (const wrapper of this.discovery.getProviders()) {
+ if (!wrapper.metatype || !wrapper.instance) continue;
+ const keys: string[] =
+ Reflect.getMetadata(SAGA_METADATA, wrapper.metatype) ?? [];
+ const instance = wrapper.instance as Record;
+ for (const key of keys) {
+ this.sagas.push({
+ name: `${wrapper.metatype.name}.${key}`,
+ saga: instance[key].bind(instance),
+ });
+ }
+ }
+ }
+
private async handleEvent(stored: SerializableEvent): Promise {
const { data: event } = this.eventDeserializer.deserialize(stored);
const eventCls = Object.getPrototypeOf(event).constructor as Type;
+ // Projections first: sagas may read the read model they just updated.
for (const handler of this.handlers.get(eventCls) ?? []) {
await handler.handle(event);
}
+ for (const { name, saga } of this.sagas) {
+ const commands = await lastValueFrom(
+ saga(of(event as IEvent)).pipe(toArray()),
+ );
+ for (const command of commands) {
+ await this.execute(name, event as IEvent, command);
+ }
+ }
+ }
+
+ private async execute(
+ saga: string,
+ event: IEvent,
+ command: ICommand,
+ ): Promise {
+ for (let attempt = 1; ; attempt++) {
+ try {
+ await this.commandBus.execute(command);
+ return;
+ } catch (error) {
+ if (
+ error instanceof ConcurrencyError &&
+ attempt < CONCURRENCY_ATTEMPTS
+ ) {
+ await new Promise((resolve) => setTimeout(resolve, 50 * attempt));
+ continue;
+ }
+ if (
+ error instanceof DomainError &&
+ !(error instanceof ConcurrencyError)
+ ) {
+ this.logger.warn(
+ `${saga}: ${command.constructor.name} after ${event.constructor.name} skipped: ${error.message}`,
+ );
+ return;
+ }
+ throw error;
+ }
+ }
}
}
diff --git a/libs/api/shared/presenters/project.json b/libs/api/shared/presenters/project.json
index 81474db..f9658f4 100644
--- a/libs/api/shared/presenters/project.json
+++ b/libs/api/shared/presenters/project.json
@@ -3,10 +3,7 @@
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/api/shared/presenters/src",
"projectType": "library",
- "tags": [
- "scope:api-shared",
- "type:presenters"
- ],
+ "tags": ["scope:api-shared", "type:presenters"],
"// targets": "to see all targets run: nx show project api-shared-presenters --web",
"targets": {}
}
diff --git a/libs/api/tracker/application/README.md b/libs/api/tracker/application/README.md
index ba545e8..6a10e2c 100644
--- a/libs/api/tracker/application/README.md
+++ b/libs/api/tracker/application/README.md
@@ -10,10 +10,14 @@ Casos de uso del tracker (CQRS).
- Services: `ProjectsService`, `IssuesService`, `CommentsService`, `ReleasesService`, `ChangesService` (subscriptions),
`WorkflowsService` (workflow de cada proyecto para presentar tickets, con una caché de 2 s).
- `commands/`: un fichero por command y otro por handler; cargan con `WriteAccess` (rehydrator) y guardan con `commitAndWait()`.
-- `queries/`: un fichero por query y otro por handler; leen de los puertos `Find*Repository` y comprueban la membresía (`ReadAccess`);
+- `queries/`: un fichero por query y otro por handler; leen de los puertos `Find*Repository` y comprueban el acceso
+ (`ReadAccess`: miembros, o cualquiera si el proyecto es público; `viewerId` null = anónimo);
`SearchMyIssuesQuery` y `GetUpcomingReleasesQuery` cruzan todos los proyectos del usuario («Mi trabajo»).
- `event-handlers/`: proyecciones (`@EventsHandler`) hacia el read model (incluida la línea temporal de cada ticket),
notificaciones en tiempo real y feed de actividad.
+- `sagas/`: `ProjectDeletionSaga` (cascada al borrar un proyecto y sus tickets), `ReleaseArchivedSaga` (los tickets sin
+ terminar salen de una release archivada) y `UserDeactivatedSaga` (desasigna los tickets abiertos de un usuario que
+ Pireon desactiva). Las ejecuta el `EventsBridge`; sus comandos son internos e idempotentes. Tests en `sagas.spec.ts`.
- `analytics/`: estadísticas y burnup calculados con funciones puras sobre la línea temporal (con tests).
- `ports/`: lookups de escritura, `Find*`, `UpsertMaterialized*`, `ReleaseIssuesRepository`, `ChangeFeed`, `UserDirectory`.
- `TrackerApplicationModule.withInfrastructure(...)`.
diff --git a/libs/api/tracker/application/src/lib/commands/command-handlers.ts b/libs/api/tracker/application/src/lib/commands/command-handlers.ts
index fd9e25e..21997fc 100644
--- a/libs/api/tracker/application/src/lib/commands/command-handlers.ts
+++ b/libs/api/tracker/application/src/lib/commands/command-handlers.ts
@@ -18,6 +18,13 @@ import { CreateReleaseCommandHandler } from './create-release.command-handler';
import { UpdateReleaseCommandHandler } from './update-release.command-handler';
import { PublishReleaseCommandHandler } from './publish-release.command-handler';
import { ArchiveReleaseCommandHandler } from './archive-release.command-handler';
+import { DeleteProjectCommandHandler } from './delete-project.command-handler';
+import { DeleteIssueCommandHandler } from './delete-issue.command-handler';
+import { DeleteReleaseCommandHandler } from './delete-release.command-handler';
+import { PurgeCommentCommandHandler } from './purge-comment.command-handler';
+import { DropLinksToIssueCommandHandler } from './drop-links-to-issue.command-handler';
+import { DetachIssueFromReleaseCommandHandler } from './detach-issue-from-release.command-handler';
+import { UnassignIssueCommandHandler } from './unassign-issue.command-handler';
export const COMMAND_HANDLERS = [
CreateProjectCommandHandler,
@@ -40,4 +47,12 @@ export const COMMAND_HANDLERS = [
UpdateReleaseCommandHandler,
PublishReleaseCommandHandler,
ArchiveReleaseCommandHandler,
+ DeleteProjectCommandHandler,
+ // Emitted by sagas only (cascades and reactions to other aggregates).
+ DeleteIssueCommandHandler,
+ DeleteReleaseCommandHandler,
+ PurgeCommentCommandHandler,
+ DropLinksToIssueCommandHandler,
+ DetachIssueFromReleaseCommandHandler,
+ UnassignIssueCommandHandler,
];
diff --git a/libs/api/tracker/application/src/lib/commands/create-project.command-handler.ts b/libs/api/tracker/application/src/lib/commands/create-project.command-handler.ts
index 7fca1b7..3e4b8fe 100644
--- a/libs/api/tracker/application/src/lib/commands/create-project.command-handler.ts
+++ b/libs/api/tracker/application/src/lib/commands/create-project.command-handler.ts
@@ -19,6 +19,7 @@ export class CreateProjectCommandHandler
key: command.key,
name: command.name,
description: command.description,
+ isPublic: command.isPublic,
ownerId: command.actorId,
});
await this.projectKeys.reserve(project.key, project.id);
diff --git a/libs/api/tracker/application/src/lib/commands/create-project.command.ts b/libs/api/tracker/application/src/lib/commands/create-project.command.ts
index 42e5604..f12e942 100644
--- a/libs/api/tracker/application/src/lib/commands/create-project.command.ts
+++ b/libs/api/tracker/application/src/lib/commands/create-project.command.ts
@@ -7,6 +7,7 @@ export class CreateProjectCommand extends Command {
public readonly key: string,
public readonly name: string,
public readonly description?: string | null,
+ public readonly isPublic = false,
) {
super();
}
diff --git a/libs/api/tracker/application/src/lib/commands/delete-issue.command-handler.ts b/libs/api/tracker/application/src/lib/commands/delete-issue.command-handler.ts
new file mode 100644
index 0000000..c281789
--- /dev/null
+++ b/libs/api/tracker/application/src/lib/commands/delete-issue.command-handler.ts
@@ -0,0 +1,18 @@
+import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
+import { Issue } from '@task-stream/api/tracker/domain';
+import { DeleteIssueCommand } from './delete-issue.command';
+import { WriteAccess } from './write-access';
+
+@CommandHandler(DeleteIssueCommand)
+export class DeleteIssueCommandHandler
+ implements ICommandHandler
+{
+ constructor(private readonly access: WriteAccess) {}
+
+ async execute(command: DeleteIssueCommand): Promise {
+ const issue = await this.access.live(command.issueId, Issue);
+ if (!issue) return;
+ issue.delete(command.actorId);
+ await issue.commitAndWait();
+ }
+}
diff --git a/libs/api/tracker/application/src/lib/commands/delete-issue.command.ts b/libs/api/tracker/application/src/lib/commands/delete-issue.command.ts
new file mode 100644
index 0000000..f2be888
--- /dev/null
+++ b/libs/api/tracker/application/src/lib/commands/delete-issue.command.ts
@@ -0,0 +1,11 @@
+import { Command } from '@nestjs/cqrs';
+
+/** Saga only: deletes a ticket of a deleted project. */
+export class DeleteIssueCommand extends Command {
+ constructor(
+ public readonly actorId: string,
+ public readonly issueId: string,
+ ) {
+ super();
+ }
+}
diff --git a/libs/api/tracker/application/src/lib/commands/delete-project.command-handler.ts b/libs/api/tracker/application/src/lib/commands/delete-project.command-handler.ts
new file mode 100644
index 0000000..ea69fa7
--- /dev/null
+++ b/libs/api/tracker/application/src/lib/commands/delete-project.command-handler.ts
@@ -0,0 +1,25 @@
+import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
+import { ProjectKeysRepository } from '../ports/project-keys.repository';
+import { CommandResult, ack } from './command-result';
+import { DeleteProjectCommand } from './delete-project.command';
+import { WriteAccess } from './write-access';
+
+/** Tickets, comments and releases follow through ProjectDeletionSaga. */
+@CommandHandler(DeleteProjectCommand)
+export class DeleteProjectCommandHandler
+ implements ICommandHandler
+{
+ constructor(
+ private readonly access: WriteAccess,
+ private readonly projectKeys: ProjectKeysRepository,
+ ) {}
+
+ async execute(command: DeleteProjectCommand): Promise {
+ const project = await this.access.project(command.projectId);
+ project.delete(command.actorId, command.confirmKey);
+ await project.commitAndWait();
+ // The key can be used again by a new project.
+ await this.projectKeys.release(project.key, project.id);
+ return ack(project);
+ }
+}
diff --git a/libs/api/tracker/application/src/lib/commands/delete-project.command.ts b/libs/api/tracker/application/src/lib/commands/delete-project.command.ts
new file mode 100644
index 0000000..82a8b1b
--- /dev/null
+++ b/libs/api/tracker/application/src/lib/commands/delete-project.command.ts
@@ -0,0 +1,13 @@
+import { Command } from '@nestjs/cqrs';
+import { CommandResult } from './command-result';
+
+/** The owner deletes a project, typing its key to confirm. */
+export class DeleteProjectCommand extends Command {
+ constructor(
+ public readonly actorId: string,
+ public readonly projectId: string,
+ public readonly confirmKey: string,
+ ) {
+ super();
+ }
+}
diff --git a/libs/api/tracker/application/src/lib/commands/delete-release.command-handler.ts b/libs/api/tracker/application/src/lib/commands/delete-release.command-handler.ts
new file mode 100644
index 0000000..95db4b8
--- /dev/null
+++ b/libs/api/tracker/application/src/lib/commands/delete-release.command-handler.ts
@@ -0,0 +1,18 @@
+import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
+import { Release } from '@task-stream/api/tracker/domain';
+import { DeleteReleaseCommand } from './delete-release.command';
+import { WriteAccess } from './write-access';
+
+@CommandHandler(DeleteReleaseCommand)
+export class DeleteReleaseCommandHandler
+ implements ICommandHandler
+{
+ constructor(private readonly access: WriteAccess) {}
+
+ async execute(command: DeleteReleaseCommand): Promise {
+ const release = await this.access.live(command.releaseId, Release);
+ if (!release) return;
+ release.delete(command.actorId);
+ await release.commitAndWait();
+ }
+}
diff --git a/libs/api/tracker/application/src/lib/commands/delete-release.command.ts b/libs/api/tracker/application/src/lib/commands/delete-release.command.ts
new file mode 100644
index 0000000..3cf1217
--- /dev/null
+++ b/libs/api/tracker/application/src/lib/commands/delete-release.command.ts
@@ -0,0 +1,11 @@
+import { Command } from '@nestjs/cqrs';
+
+/** Saga only: deletes a release of a deleted project. */
+export class DeleteReleaseCommand extends Command {
+ constructor(
+ public readonly actorId: string,
+ public readonly releaseId: string,
+ ) {
+ super();
+ }
+}
diff --git a/libs/api/tracker/application/src/lib/commands/detach-issue-from-release.command-handler.ts b/libs/api/tracker/application/src/lib/commands/detach-issue-from-release.command-handler.ts
new file mode 100644
index 0000000..7d4388c
--- /dev/null
+++ b/libs/api/tracker/application/src/lib/commands/detach-issue-from-release.command-handler.ts
@@ -0,0 +1,21 @@
+import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
+import { Issue, Project } from '@task-stream/api/tracker/domain';
+import { DetachIssueFromReleaseCommand } from './detach-issue-from-release.command';
+import { WriteAccess } from './write-access';
+
+@CommandHandler(DetachIssueFromReleaseCommand)
+export class DetachIssueFromReleaseCommandHandler
+ implements ICommandHandler
+{
+ constructor(private readonly access: WriteAccess) {}
+
+ async execute(command: DetachIssueFromReleaseCommand): Promise {
+ const issue = await this.access.live(command.issueId, Issue);
+ if (!issue || issue.releaseId !== command.releaseId) return;
+ const project = await this.access.live(issue.projectId, Project);
+ // Finished tickets stay: they are what the release shipped.
+ if (!project || project.workflow.isFinished(issue.status)) return;
+ issue.setRelease(command.actorId, null);
+ await issue.commitAndWait();
+ }
+}
diff --git a/libs/api/tracker/application/src/lib/commands/detach-issue-from-release.command.ts b/libs/api/tracker/application/src/lib/commands/detach-issue-from-release.command.ts
new file mode 100644
index 0000000..6d59b13
--- /dev/null
+++ b/libs/api/tracker/application/src/lib/commands/detach-issue-from-release.command.ts
@@ -0,0 +1,12 @@
+import { Command } from '@nestjs/cqrs';
+
+/** Saga only: takes an unfinished ticket out of an archived release. */
+export class DetachIssueFromReleaseCommand extends Command {
+ constructor(
+ public readonly actorId: string,
+ public readonly issueId: string,
+ public readonly releaseId: string,
+ ) {
+ super();
+ }
+}
diff --git a/libs/api/tracker/application/src/lib/commands/drop-links-to-issue.command-handler.ts b/libs/api/tracker/application/src/lib/commands/drop-links-to-issue.command-handler.ts
new file mode 100644
index 0000000..62248a6
--- /dev/null
+++ b/libs/api/tracker/application/src/lib/commands/drop-links-to-issue.command-handler.ts
@@ -0,0 +1,19 @@
+import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
+import { Issue, Project } from '@task-stream/api/tracker/domain';
+import { DropLinksToIssueCommand } from './drop-links-to-issue.command';
+import { WriteAccess } from './write-access';
+
+@CommandHandler(DropLinksToIssueCommand)
+export class DropLinksToIssueCommandHandler
+ implements ICommandHandler
+{
+ constructor(private readonly access: WriteAccess) {}
+
+ async execute(command: DropLinksToIssueCommand): Promise {
+ const issue = await this.access.live(command.issueId, Issue);
+ // Gone, or about to be (its project was deleted too): nothing to tidy.
+ if (!issue || !(await this.access.live(issue.projectId, Project))) return;
+ issue.unlinkAllTo(command.actorId, command.targetIssueId);
+ await issue.commitAndWait();
+ }
+}
diff --git a/libs/api/tracker/application/src/lib/commands/drop-links-to-issue.command.ts b/libs/api/tracker/application/src/lib/commands/drop-links-to-issue.command.ts
new file mode 100644
index 0000000..3c65b06
--- /dev/null
+++ b/libs/api/tracker/application/src/lib/commands/drop-links-to-issue.command.ts
@@ -0,0 +1,12 @@
+import { Command } from '@nestjs/cqrs';
+
+/** Saga only: removes the links of `issueId` that point to a deleted ticket. */
+export class DropLinksToIssueCommand extends Command {
+ constructor(
+ public readonly actorId: string,
+ public readonly issueId: string,
+ public readonly targetIssueId: string,
+ ) {
+ super();
+ }
+}
diff --git a/libs/api/tracker/application/src/lib/commands/index.ts b/libs/api/tracker/application/src/lib/commands/index.ts
index 77b0d51..c3fe06f 100644
--- a/libs/api/tracker/application/src/lib/commands/index.ts
+++ b/libs/api/tracker/application/src/lib/commands/index.ts
@@ -19,3 +19,10 @@ export * from './update-issue.command';
export * from './update-project.command';
export * from './update-project-settings.command';
export * from './update-release.command';
+export * from './delete-project.command';
+export * from './delete-issue.command';
+export * from './delete-release.command';
+export * from './purge-comment.command';
+export * from './drop-links-to-issue.command';
+export * from './detach-issue-from-release.command';
+export * from './unassign-issue.command';
diff --git a/libs/api/tracker/application/src/lib/commands/purge-comment.command-handler.ts b/libs/api/tracker/application/src/lib/commands/purge-comment.command-handler.ts
new file mode 100644
index 0000000..4dda52d
--- /dev/null
+++ b/libs/api/tracker/application/src/lib/commands/purge-comment.command-handler.ts
@@ -0,0 +1,18 @@
+import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
+import { Comment } from '@task-stream/api/tracker/domain';
+import { PurgeCommentCommand } from './purge-comment.command';
+import { WriteAccess } from './write-access';
+
+@CommandHandler(PurgeCommentCommand)
+export class PurgeCommentCommandHandler
+ implements ICommandHandler
+{
+ constructor(private readonly access: WriteAccess) {}
+
+ async execute(command: PurgeCommentCommand): Promise {
+ const comment = await this.access.live(command.commentId, Comment);
+ if (!comment || comment.deleted) return;
+ comment.delete(command.actorId, true);
+ await comment.commitAndWait();
+ }
+}
diff --git a/libs/api/tracker/application/src/lib/commands/purge-comment.command.ts b/libs/api/tracker/application/src/lib/commands/purge-comment.command.ts
new file mode 100644
index 0000000..7d1d5ee
--- /dev/null
+++ b/libs/api/tracker/application/src/lib/commands/purge-comment.command.ts
@@ -0,0 +1,11 @@
+import { Command } from '@nestjs/cqrs';
+
+/** Saga only: deletes a comment of a deleted ticket. */
+export class PurgeCommentCommand extends Command {
+ constructor(
+ public readonly actorId: string,
+ public readonly commentId: string,
+ ) {
+ super();
+ }
+}
diff --git a/libs/api/tracker/application/src/lib/commands/unassign-issue.command-handler.ts b/libs/api/tracker/application/src/lib/commands/unassign-issue.command-handler.ts
new file mode 100644
index 0000000..d431091
--- /dev/null
+++ b/libs/api/tracker/application/src/lib/commands/unassign-issue.command-handler.ts
@@ -0,0 +1,19 @@
+import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
+import { Issue } from '@task-stream/api/tracker/domain';
+import { UnassignIssueCommand } from './unassign-issue.command';
+import { WriteAccess } from './write-access';
+
+@CommandHandler(UnassignIssueCommand)
+export class UnassignIssueCommandHandler
+ implements ICommandHandler
+{
+ constructor(private readonly access: WriteAccess) {}
+
+ async execute(command: UnassignIssueCommand): Promise {
+ const issue = await this.access.live(command.issueId, Issue);
+ // Someone may have reassigned it meanwhile: only undo our user's assignment.
+ if (!issue || issue.assigneeId !== command.userId) return;
+ issue.assign(command.actorId, null);
+ await issue.commitAndWait();
+ }
+}
diff --git a/libs/api/tracker/application/src/lib/commands/unassign-issue.command.ts b/libs/api/tracker/application/src/lib/commands/unassign-issue.command.ts
new file mode 100644
index 0000000..460469c
--- /dev/null
+++ b/libs/api/tracker/application/src/lib/commands/unassign-issue.command.ts
@@ -0,0 +1,12 @@
+import { Command } from '@nestjs/cqrs';
+
+/** Saga only: unassigns a ticket if it is still assigned to `userId` (deactivated in Pireon). */
+export class UnassignIssueCommand extends Command {
+ constructor(
+ public readonly actorId: string,
+ public readonly issueId: string,
+ public readonly userId: string,
+ ) {
+ super();
+ }
+}
diff --git a/libs/api/tracker/application/src/lib/commands/update-project.command.ts b/libs/api/tracker/application/src/lib/commands/update-project.command.ts
index a38682f..3a61f60 100644
--- a/libs/api/tracker/application/src/lib/commands/update-project.command.ts
+++ b/libs/api/tracker/application/src/lib/commands/update-project.command.ts
@@ -5,7 +5,11 @@ export class UpdateProjectCommand extends Command {
constructor(
public readonly actorId: string,
public readonly projectId: string,
- public readonly changes: { name?: string; description?: string | null },
+ public readonly changes: {
+ name?: string;
+ description?: string | null;
+ isPublic?: boolean;
+ },
) {
super();
}
diff --git a/libs/api/tracker/application/src/lib/commands/write-access.ts b/libs/api/tracker/application/src/lib/commands/write-access.ts
index 97e6c91..4d8668e 100644
--- a/libs/api/tracker/application/src/lib/commands/write-access.ts
+++ b/libs/api/tracker/application/src/lib/commands/write-access.ts
@@ -13,6 +13,11 @@ import {
} from '@task-stream/api/tracker/domain';
import { UserDirectory } from '../ports/user-directory';
+type Aggregate = VersionedAggregateRoot & {
+ createdAt?: string;
+ deleted?: boolean;
+};
+
/** Loads aggregates from the event store, failing with domain errors. */
@Injectable()
export class WriteAccess {
@@ -78,13 +83,31 @@ export class WriteAccess {
}
}
- private async load(
+ /**
+ * For saga commands, which must be idempotent: the aggregate, or null when
+ * it does not exist or was deleted (nothing left to do).
+ */
+ async live(
+ id: string,
+ AggregateCls: Type,
+ ): Promise {
+ try {
+ return await this.load(id, AggregateCls);
+ } catch (error) {
+ if (error instanceof NotFoundError) return null;
+ throw error;
+ }
+ }
+
+ private async load(
id: string,
AggregateCls: Type,
): Promise {
const aggregate = await this.rehydrator.rehydrate(id, AggregateCls);
- // The stream exists but belongs to another kind of aggregate.
- if (!aggregate.createdAt) throw new NotFoundError(AggregateCls.name, id);
+ // The stream exists but belongs to another kind of aggregate, or the
+ // aggregate was deleted: same answer, it is not there.
+ if (!aggregate.createdAt || aggregate.deleted)
+ throw new NotFoundError(AggregateCls.name, id);
return aggregate;
}
}
diff --git a/libs/api/tracker/application/src/lib/comments.service.ts b/libs/api/tracker/application/src/lib/comments.service.ts
index eb3f9ba..6b9354b 100644
--- a/libs/api/tracker/application/src/lib/comments.service.ts
+++ b/libs/api/tracker/application/src/lib/comments.service.ts
@@ -38,7 +38,7 @@ export class CommentsService {
}
list(
- viewerId: string,
+ viewerId: string | null,
issueId: string,
limit?: number,
offset?: number,
diff --git a/libs/api/tracker/application/src/lib/event-handlers/activity.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/activity.event-handler.ts
index b5275f6..ebab9db 100644
--- a/libs/api/tracker/application/src/lib/event-handlers/activity.event-handler.ts
+++ b/libs/api/tracker/application/src/lib/event-handlers/activity.event-handler.ts
@@ -22,6 +22,8 @@ import {
ReleaseUpdatedEvent,
TrackerEvent,
DEFAULT_PROJECT_SETTINGS,
+ SYSTEM_ACTOR_ID,
+ SYSTEM_ACTOR_NAME,
ProjectSettingsChangedEvent,
ProjectWorkflow,
} from '@task-stream/api/tracker/domain';
@@ -80,12 +82,16 @@ export class ActivityEventHandler implements IEventHandler {
? await this.findReleases.findById(releaseId)
: null;
const project = await this.findProjects.findById(event.projectId);
+ // The project was deleted (cascade in progress): no feed to write to.
+ if (!project) return;
const mentioned = [
event.actorId,
(event as { userId?: string }).userId,
(event as { assigneeId?: string }).assigneeId,
];
const users = await this.users.refs(mentioned);
+ const actorOf = (id: string) =>
+ id === SYSTEM_ACTOR_ID ? { id, name: SYSTEM_ACTOR_NAME } : users.of(id);
const activity: ActivityReadModel = {
id: `${event.aggregateId}:${versionOf(event)}`,
@@ -93,9 +99,9 @@ export class ActivityEventHandler implements IEventHandler {
projectId: event.projectId,
issueId,
issueKey,
- actor: users.of(event.actorId),
+ actor: actorOf(event.actorId),
summary: describeEvent(event, {
- actor: users.of(event.actorId).name,
+ actor: actorOf(event.actorId).name,
user: (id) => users.ofNullable(id)?.name ?? '?',
issueKey: issueKey ?? '',
projectKey: project?.key ?? '',
diff --git a/libs/api/tracker/application/src/lib/event-handlers/event-handlers.ts b/libs/api/tracker/application/src/lib/event-handlers/event-handlers.ts
index ade25b0..8627447 100644
--- a/libs/api/tracker/application/src/lib/event-handlers/event-handlers.ts
+++ b/libs/api/tracker/application/src/lib/event-handlers/event-handlers.ts
@@ -21,10 +21,16 @@ import { ReleaseUpdatedEventHandler } from './release-updated.event-handler';
import { ReadModelNotifier } from './read-model-notifier';
import { IssueTimelineEventHandler } from './issue-timeline.event-handler';
import { ProjectSettingsChangedEventHandler } from './project-settings-changed.event-handler';
+import { ProjectDeletedEventHandler } from './project-deleted.event-handler';
+import { IssueDeletedEventHandler } from './issue-deleted.event-handler';
+import { ReleaseDeletedEventHandler } from './release-deleted.event-handler';
/** Read-model projections (MongoDB) + realtime, fed by the EventsBridge. */
export const EVENT_HANDLERS = [
ActivityEventHandler,
+ ProjectDeletedEventHandler,
+ IssueDeletedEventHandler,
+ ReleaseDeletedEventHandler,
CommentAddedEventHandler,
CommentDeletedEventHandler,
CommentEditedEventHandler,
diff --git a/libs/api/tracker/application/src/lib/event-handlers/issue-deleted.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/issue-deleted.event-handler.ts
new file mode 100644
index 0000000..e72b9ec
--- /dev/null
+++ b/libs/api/tracker/application/src/lib/event-handlers/issue-deleted.event-handler.ts
@@ -0,0 +1,17 @@
+import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
+import { IssueDeletedEvent } from '@task-stream/api/tracker/domain';
+import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
+
+/** Removes the ticket, its timeline and the links other tickets show to it. */
+@EventsHandler(IssueDeletedEvent)
+export class IssueDeletedEventHandler
+ implements IEventHandler
+{
+ constructor(
+ private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
+ ) {}
+
+ async handle(event: IssueDeletedEvent) {
+ await this.upsertMaterializedIssueRepository.remove(event.aggregateId);
+ }
+}
diff --git a/libs/api/tracker/application/src/lib/event-handlers/project-created.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/project-created.event-handler.ts
index 63e9aa8..e7dcd4c 100644
--- a/libs/api/tracker/application/src/lib/event-handlers/project-created.event-handler.ts
+++ b/libs/api/tracker/application/src/lib/event-handlers/project-created.event-handler.ts
@@ -25,6 +25,7 @@ export class ProjectCreatedEventHandler
key: project.key,
name: project.name,
description: project.description,
+ isPublic: project.isPublic ?? false,
owner: users.of(project.ownerId),
members: project.members.map((m) => ({
user: users.of(m.userId),
diff --git a/libs/api/tracker/application/src/lib/event-handlers/project-deleted.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/project-deleted.event-handler.ts
new file mode 100644
index 0000000..1b8c730
--- /dev/null
+++ b/libs/api/tracker/application/src/lib/event-handlers/project-deleted.event-handler.ts
@@ -0,0 +1,37 @@
+import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
+import { ProjectDeletedEvent } from '@task-stream/api/tracker/domain';
+import { ChangeFeed } from '../ports/change-feed';
+import { FindProjectsRepository } from '../ports/find-projects.repository';
+import { UpsertMaterializedProjectRepository } from '../ports/upsert-materialized-project.repository';
+import { versionOf } from './read-model-notifier';
+
+/**
+ * Removes the project (and its activity and timeline) from the read model,
+ * and tells its members, whose project lists drop it. Tickets, comments and
+ * releases go as the cascade saga deletes them.
+ */
+@EventsHandler(ProjectDeletedEvent)
+export class ProjectDeletedEventHandler
+ implements IEventHandler
+{
+ constructor(
+ private readonly findProjects: FindProjectsRepository,
+ private readonly upsertMaterializedProjectRepository: UpsertMaterializedProjectRepository,
+ private readonly changeFeed: ChangeFeed,
+ ) {}
+
+ async handle(event: ProjectDeletedEvent) {
+ const project = await this.findProjects.findById(event.projectId);
+ await this.upsertMaterializedProjectRepository.remove(event.projectId);
+ if (!project) return; // Already removed (event handled twice).
+ await this.changeFeed.publishProject(
+ {
+ action: event.constructor.name,
+ version: versionOf(event),
+ actorId: event.actorId,
+ project: { ...project, version: versionOf(event) },
+ },
+ event.memberIds,
+ );
+ }
+}
diff --git a/libs/api/tracker/application/src/lib/event-handlers/release-deleted.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/release-deleted.event-handler.ts
new file mode 100644
index 0000000..0c41ae9
--- /dev/null
+++ b/libs/api/tracker/application/src/lib/event-handlers/release-deleted.event-handler.ts
@@ -0,0 +1,16 @@
+import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
+import { ReleaseDeletedEvent } from '@task-stream/api/tracker/domain';
+import { UpsertMaterializedReleaseRepository } from '../ports/upsert-materialized-release.repository';
+
+@EventsHandler(ReleaseDeletedEvent)
+export class ReleaseDeletedEventHandler
+ implements IEventHandler
+{
+ constructor(
+ private readonly upsertMaterializedReleaseRepository: UpsertMaterializedReleaseRepository,
+ ) {}
+
+ async handle(event: ReleaseDeletedEvent) {
+ await this.upsertMaterializedReleaseRepository.remove(event.aggregateId);
+ }
+}
diff --git a/libs/api/tracker/application/src/lib/issues.service.ts b/libs/api/tracker/application/src/lib/issues.service.ts
index 7eba6ec..0b48e74 100644
--- a/libs/api/tracker/application/src/lib/issues.service.ts
+++ b/libs/api/tracker/application/src/lib/issues.service.ts
@@ -126,15 +126,18 @@ export class IssuesService {
);
}
- findOne(viewerId: string, idOrKey: string): Promise {
+ findOne(viewerId: string | null, idOrKey: string): Promise {
return this.queryBus.execute(new GetIssueQuery(viewerId, idOrKey));
}
- findMany(viewerId: string, ids: string[]): Promise {
+ findMany(viewerId: string | null, ids: string[]): Promise {
return this.queryBus.execute(new GetIssuesByIdsQuery(viewerId, ids));
}
- search(viewerId: string, filter: IssueFilter): Promise> {
+ search(
+ viewerId: string | null,
+ filter: IssueFilter,
+ ): Promise> {
return this.queryBus.execute(new SearchIssuesQuery(viewerId, filter));
}
@@ -147,7 +150,7 @@ export class IssuesService {
}
activity(
- viewerId: string,
+ viewerId: string | null,
params: {
projectId: string;
issueId?: string;
diff --git a/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts b/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts
index f059bd7..f4b1436 100644
--- a/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts
+++ b/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts
@@ -47,6 +47,8 @@ export abstract class FindIssuesRepository {
filter: CrossProjectIssueFilter,
): Promise>;
abstract stats(projectId: string): Promise;
+ /** Open tickets (TODO, IN_PROGRESS or QA category) assigned to someone, in any project. */
+ abstract findOpenAssignedTo(userId: string): Promise;
/** Every ticket of a project (statistics). */
abstract findByProject(projectId: string): Promise;
abstract releaseProgress(
diff --git a/libs/api/tracker/application/src/lib/ports/find-projects.repository.ts b/libs/api/tracker/application/src/lib/ports/find-projects.repository.ts
index 68a0437..7216078 100644
--- a/libs/api/tracker/application/src/lib/ports/find-projects.repository.ts
+++ b/libs/api/tracker/application/src/lib/ports/find-projects.repository.ts
@@ -8,4 +8,6 @@ export abstract class FindProjectsRepository {
projectId: string,
userId: string,
): Promise;
+ /** Projects anyone can read, by name. */
+ abstract findPublic(): Promise;
}
diff --git a/libs/api/tracker/application/src/lib/ports/index.ts b/libs/api/tracker/application/src/lib/ports/index.ts
index 0ab91d1..07bb9d3 100644
--- a/libs/api/tracker/application/src/lib/ports/index.ts
+++ b/libs/api/tracker/application/src/lib/ports/index.ts
@@ -16,3 +16,4 @@ export * from './user-directory';
export * from './release-issues.repository';
export * from './upsert-materialized-timeline.repository';
export * from './find-timeline.repository';
+export * from './tracker-streams.repository';
diff --git a/libs/api/tracker/application/src/lib/ports/project-keys.repository.ts b/libs/api/tracker/application/src/lib/ports/project-keys.repository.ts
index 2a20f26..3c02f72 100644
--- a/libs/api/tracker/application/src/lib/ports/project-keys.repository.ts
+++ b/libs/api/tracker/application/src/lib/ports/project-keys.repository.ts
@@ -2,4 +2,6 @@
export abstract class ProjectKeysRepository {
/** Throws BusinessRuleError if the key is already taken. */
abstract reserve(key: string, projectId: string): Promise;
+ /** Frees the key of a deleted project (only if still held by it). */
+ abstract release(key: string, projectId: string): Promise;
}
diff --git a/libs/api/tracker/application/src/lib/ports/tracker-streams.repository.ts b/libs/api/tracker/application/src/lib/ports/tracker-streams.repository.ts
new file mode 100644
index 0000000..d360def
--- /dev/null
+++ b/libs/api/tracker/application/src/lib/ports/tracker-streams.repository.ts
@@ -0,0 +1,13 @@
+/**
+ * Write-side lookups over the event store (the source of truth, unlike the
+ * eventually-consistent read model): which streams belong to a project or a
+ * ticket. The cascade saga uses them to delete everything that hangs off a
+ * deleted project.
+ */
+export abstract class TrackerStreamsRepository {
+ abstract issueIdsOfProject(projectId: string): Promise;
+ abstract releaseIdsOfProject(projectId: string): Promise;
+ abstract commentIdsOfIssue(issueId: string): Promise;
+ /** Tickets that were ever linked to `issueId` (they may have unlinked since). */
+ abstract issueIdsLinkingTo(issueId: string): Promise;
+}
diff --git a/libs/api/tracker/application/src/lib/ports/upsert-materialized-issue.repository.ts b/libs/api/tracker/application/src/lib/ports/upsert-materialized-issue.repository.ts
index f60aeeb..56c3f4e 100644
--- a/libs/api/tracker/application/src/lib/ports/upsert-materialized-issue.repository.ts
+++ b/libs/api/tracker/application/src/lib/ports/upsert-materialized-issue.repository.ts
@@ -25,6 +25,8 @@ export abstract class UpsertMaterializedIssueRepository {
projectId: string,
categories: Record,
): Promise;
+ /** The ticket was deleted: its view, its timeline and the links shown on other tickets go. */
+ abstract remove(issueId: string): Promise;
/** Keeps the denormalised release name of every issue in sync. */
abstract renameRelease(releaseId: string, name: string): Promise;
}
diff --git a/libs/api/tracker/application/src/lib/ports/upsert-materialized-project.repository.ts b/libs/api/tracker/application/src/lib/ports/upsert-materialized-project.repository.ts
index 4b20a15..80ea5e7 100644
--- a/libs/api/tracker/application/src/lib/ports/upsert-materialized-project.repository.ts
+++ b/libs/api/tracker/application/src/lib/ports/upsert-materialized-project.repository.ts
@@ -6,4 +6,6 @@ export abstract class UpsertMaterializedProjectRepository {
project: Pick &
Partial,
): Promise;
+ /** The project was deleted: its view, activity and timeline go. */
+ abstract remove(projectId: string): Promise;
}
diff --git a/libs/api/tracker/application/src/lib/ports/upsert-materialized-release.repository.ts b/libs/api/tracker/application/src/lib/ports/upsert-materialized-release.repository.ts
index 9fc19dc..989b405 100644
--- a/libs/api/tracker/application/src/lib/ports/upsert-materialized-release.repository.ts
+++ b/libs/api/tracker/application/src/lib/ports/upsert-materialized-release.repository.ts
@@ -6,4 +6,5 @@ export abstract class UpsertMaterializedReleaseRepository {
release: Pick &
Partial,
): Promise;
+ abstract remove(releaseId: string): Promise;
}
diff --git a/libs/api/tracker/application/src/lib/projects.service.ts b/libs/api/tracker/application/src/lib/projects.service.ts
index cdfceaf..f9928da 100644
--- a/libs/api/tracker/application/src/lib/projects.service.ts
+++ b/libs/api/tracker/application/src/lib/projects.service.ts
@@ -12,6 +12,7 @@ import { AddProjectMemberCommand } from './commands/add-project-member.command';
import { ChangeProjectMemberRoleCommand } from './commands/change-project-member-role.command';
import { CommandResult } from './commands/command-result';
import { CreateProjectCommand } from './commands/create-project.command';
+import { DeleteProjectCommand } from './commands/delete-project.command';
import { RemoveProjectMemberCommand } from './commands/remove-project-member.command';
import { UpdateProjectCommand } from './commands/update-project.command';
import { UpdateProjectSettingsCommand } from './commands/update-project-settings.command';
@@ -20,6 +21,7 @@ import { BoardColumn, GetBoardQuery } from './queries/get-board.query';
import { GetMyProjectsQuery } from './queries/get-my-projects.query';
import { GetProjectAnalyticsQuery } from './queries/get-project-analytics.query';
import { GetProjectStatsQuery } from './queries/get-project-stats.query';
+import { GetPublicProjectsQuery } from './queries/get-public-projects.query';
import { GetProjectQuery } from './queries/get-project.query';
import { GetReleasesQuery } from './queries/get-releases.query';
@@ -32,7 +34,12 @@ export class ProjectsService {
create(
actorId: string,
- input: { key: string; name: string; description?: string | null },
+ input: {
+ key: string;
+ name: string;
+ description?: string | null;
+ isPublic?: boolean;
+ },
): Promise {
return this.commandBus.execute(
new CreateProjectCommand(
@@ -40,6 +47,7 @@ export class ProjectsService {
input.key,
input.name,
input.description,
+ input.isPublic ?? false,
),
);
}
@@ -47,13 +55,32 @@ export class ProjectsService {
update(
actorId: string,
projectId: string,
- changes: { name?: string; description?: string | null },
+ changes: { name?: string; description?: string | null; isPublic?: boolean },
): Promise {
return this.commandBus.execute(
new UpdateProjectCommand(actorId, projectId, changes),
);
}
+ /**
+ * Owner only, typing the key to confirm. Tickets, comments and releases
+ * are deleted right after by ProjectDeletionSaga; the key is freed.
+ */
+ delete(
+ actorId: string,
+ projectId: string,
+ confirmKey: string,
+ ): Promise {
+ return this.commandBus.execute(
+ new DeleteProjectCommand(actorId, projectId, confirmKey),
+ );
+ }
+
+ /** Projects anyone can read, also without signing in. */
+ findPublic(): Promise {
+ return this.queryBus.execute(new GetPublicProjectsQuery());
+ }
+
/** Statuses, transitions, ticket types and areas (owners and admins). */
updateSettings(
actorId: string,
@@ -109,16 +136,19 @@ export class ProjectsService {
return this.queryBus.execute(new GetMyProjectsQuery(viewerId));
}
- findOne(viewerId: string, idOrKey: string): Promise {
+ findOne(viewerId: string | null, idOrKey: string): Promise {
return this.queryBus.execute(new GetProjectQuery(viewerId, idOrKey));
}
- stats(viewerId: string, projectId: string): Promise {
+ stats(
+ viewerId: string | null,
+ projectId: string,
+ ): Promise {
return this.queryBus.execute(new GetProjectStatsQuery(viewerId, projectId));
}
board(
- viewerId: string,
+ viewerId: string | null,
filter: Omit,
perColumn?: number,
): Promise {
@@ -127,13 +157,16 @@ export class ProjectsService {
);
}
- releases(viewerId: string, projectId: string): Promise {
+ releases(
+ viewerId: string | null,
+ projectId: string,
+ ): Promise {
return this.queryBus.execute(new GetReleasesQuery(viewerId, projectId));
}
/** Statistics over the last `days` days (created vs resolved, flow, times, QA). */
analytics(
- viewerId: string,
+ viewerId: string | null,
projectId: string,
days: number,
): Promise {
diff --git a/libs/api/tracker/application/src/lib/queries/can-view-project.query.ts b/libs/api/tracker/application/src/lib/queries/can-view-project.query.ts
index f73ce92..e8b585e 100644
--- a/libs/api/tracker/application/src/lib/queries/can-view-project.query.ts
+++ b/libs/api/tracker/application/src/lib/queries/can-view-project.query.ts
@@ -2,7 +2,8 @@ import { Query } from '@nestjs/cqrs';
export class CanViewProjectQuery extends Query {
constructor(
- public readonly viewerId: string,
+ /** Null: anonymous (public projects only). */
+ public readonly viewerId: string | null,
public readonly projectId: string,
) {
super();
diff --git a/libs/api/tracker/application/src/lib/queries/get-activity.query.ts b/libs/api/tracker/application/src/lib/queries/get-activity.query.ts
index 4ba152c..ad97cbb 100644
--- a/libs/api/tracker/application/src/lib/queries/get-activity.query.ts
+++ b/libs/api/tracker/application/src/lib/queries/get-activity.query.ts
@@ -3,7 +3,8 @@ import { ActivityReadModel } from '@task-stream/api/tracker/domain';
export class GetActivityQuery extends Query {
constructor(
- public readonly viewerId: string,
+ /** Null: anonymous (public projects only). */
+ public readonly viewerId: string | null,
public readonly projectId: string,
public readonly issueId?: string,
public readonly limit = 50,
diff --git a/libs/api/tracker/application/src/lib/queries/get-board.query.ts b/libs/api/tracker/application/src/lib/queries/get-board.query.ts
index d9ce1c6..4612c3d 100644
--- a/libs/api/tracker/application/src/lib/queries/get-board.query.ts
+++ b/libs/api/tracker/application/src/lib/queries/get-board.query.ts
@@ -12,7 +12,8 @@ export interface BoardColumn {
/** Kanban board: one column per status of the project (cancelled ones aside). */
export class GetBoardQuery extends Query {
constructor(
- public readonly viewerId: string,
+ /** Null: anonymous (public projects only). */
+ public readonly viewerId: string | null,
public readonly filter: Omit,
public readonly perColumn = 50,
) {
diff --git a/libs/api/tracker/application/src/lib/queries/get-comments.query.ts b/libs/api/tracker/application/src/lib/queries/get-comments.query.ts
index bc7873f..ce15259 100644
--- a/libs/api/tracker/application/src/lib/queries/get-comments.query.ts
+++ b/libs/api/tracker/application/src/lib/queries/get-comments.query.ts
@@ -4,7 +4,8 @@ import { Page } from '../ports/find-issues.repository';
export class GetCommentsQuery extends Query> {
constructor(
- public readonly viewerId: string,
+ /** Null: anonymous (public projects only). */
+ public readonly viewerId: string | null,
public readonly issueId: string,
public readonly limit = 50,
public readonly offset = 0,
diff --git a/libs/api/tracker/application/src/lib/queries/get-issue.query.ts b/libs/api/tracker/application/src/lib/queries/get-issue.query.ts
index f841960..736d310 100644
--- a/libs/api/tracker/application/src/lib/queries/get-issue.query.ts
+++ b/libs/api/tracker/application/src/lib/queries/get-issue.query.ts
@@ -3,7 +3,8 @@ import { IssueReadModel } from '@task-stream/api/tracker/domain';
export class GetIssueQuery extends Query {
constructor(
- public readonly viewerId: string,
+ /** Null: anonymous (public projects only). */
+ public readonly viewerId: string | null,
/** Issue id or key ("TS-42"). */
public readonly idOrKey: string,
) {
diff --git a/libs/api/tracker/application/src/lib/queries/get-issues-by-ids.query-handler.ts b/libs/api/tracker/application/src/lib/queries/get-issues-by-ids.query-handler.ts
index 7678f6a..99c99fc 100644
--- a/libs/api/tracker/application/src/lib/queries/get-issues-by-ids.query-handler.ts
+++ b/libs/api/tracker/application/src/lib/queries/get-issues-by-ids.query-handler.ts
@@ -1,7 +1,7 @@
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { IssueReadModel } from '@task-stream/api/tracker/domain';
import { FindIssuesRepository } from '../ports/find-issues.repository';
-import { FindProjectsRepository } from '../ports/find-projects.repository';
+import { ReadAccess } from './read-access';
import { GetIssuesByIdsQuery } from './get-issues-by-ids.query';
@QueryHandler(GetIssuesByIdsQuery)
@@ -10,14 +10,19 @@ export class GetIssuesByIdsQueryHandler
{
constructor(
private readonly findIssues: FindIssuesRepository,
- private readonly findProjects: FindProjectsRepository,
+ private readonly access: ReadAccess,
) {}
async execute(query: GetIssuesByIdsQuery): Promise {
const issues = await this.findIssues.findByIds([...new Set(query.ids)]);
- const visible = new Set(
- (await this.findProjects.findByMember(query.viewerId)).map((p) => p.id),
- );
- return issues.filter((issue) => visible.has(issue.projectId));
+ // Linked tickets may sit in other projects: keep the ones the viewer can read.
+ const visible = new Map();
+ for (const projectId of new Set(issues.map((issue) => issue.projectId))) {
+ visible.set(
+ projectId,
+ await this.access.canView(projectId, query.viewerId),
+ );
+ }
+ return issues.filter((issue) => visible.get(issue.projectId));
}
}
diff --git a/libs/api/tracker/application/src/lib/queries/get-issues-by-ids.query.ts b/libs/api/tracker/application/src/lib/queries/get-issues-by-ids.query.ts
index 6fd4678..da68c0a 100644
--- a/libs/api/tracker/application/src/lib/queries/get-issues-by-ids.query.ts
+++ b/libs/api/tracker/application/src/lib/queries/get-issues-by-ids.query.ts
@@ -3,7 +3,8 @@ import { IssueReadModel } from '@task-stream/api/tracker/domain';
export class GetIssuesByIdsQuery extends Query {
constructor(
- public readonly viewerId: string,
+ /** Null: anonymous (public projects only). */
+ public readonly viewerId: string | null,
public readonly ids: string[],
) {
super();
diff --git a/libs/api/tracker/application/src/lib/queries/get-project-analytics.query.ts b/libs/api/tracker/application/src/lib/queries/get-project-analytics.query.ts
index 4872d2e..f611618 100644
--- a/libs/api/tracker/application/src/lib/queries/get-project-analytics.query.ts
+++ b/libs/api/tracker/application/src/lib/queries/get-project-analytics.query.ts
@@ -4,7 +4,8 @@ import { ProjectAnalyticsReadModel } from '@task-stream/api/tracker/domain';
/** Statistics of a project over the last `days` days. */
export class GetProjectAnalyticsQuery extends Query {
constructor(
- public readonly viewerId: string,
+ /** Null: anonymous (public projects only). */
+ public readonly viewerId: string | null,
public readonly projectId: string,
public readonly days: number,
) {
diff --git a/libs/api/tracker/application/src/lib/queries/get-project-stats.query.ts b/libs/api/tracker/application/src/lib/queries/get-project-stats.query.ts
index ba0027e..adf7301 100644
--- a/libs/api/tracker/application/src/lib/queries/get-project-stats.query.ts
+++ b/libs/api/tracker/application/src/lib/queries/get-project-stats.query.ts
@@ -3,7 +3,8 @@ import { ProjectStatsReadModel } from '@task-stream/api/tracker/domain';
export class GetProjectStatsQuery extends Query {
constructor(
- public readonly viewerId: string,
+ /** Null: anonymous (public projects only). */
+ public readonly viewerId: string | null,
public readonly projectId: string,
) {
super();
diff --git a/libs/api/tracker/application/src/lib/queries/get-project.query.ts b/libs/api/tracker/application/src/lib/queries/get-project.query.ts
index b296340..8ef8ee4 100644
--- a/libs/api/tracker/application/src/lib/queries/get-project.query.ts
+++ b/libs/api/tracker/application/src/lib/queries/get-project.query.ts
@@ -3,7 +3,8 @@ import { ProjectReadModel } from '@task-stream/api/tracker/domain';
export class GetProjectQuery extends Query {
constructor(
- public readonly viewerId: string,
+ /** Null: anonymous (public projects only). */
+ public readonly viewerId: string | null,
/** Project id or key ("TS"). */
public readonly idOrKey: string,
) {
diff --git a/libs/api/tracker/application/src/lib/queries/get-public-projects.query-handler.ts b/libs/api/tracker/application/src/lib/queries/get-public-projects.query-handler.ts
new file mode 100644
index 0000000..8608a1e
--- /dev/null
+++ b/libs/api/tracker/application/src/lib/queries/get-public-projects.query-handler.ts
@@ -0,0 +1,15 @@
+import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
+import { ProjectReadModel } from '@task-stream/api/tracker/domain';
+import { FindProjectsRepository } from '../ports/find-projects.repository';
+import { GetPublicProjectsQuery } from './get-public-projects.query';
+
+@QueryHandler(GetPublicProjectsQuery)
+export class GetPublicProjectsQueryHandler
+ implements IQueryHandler
+{
+ constructor(private readonly findProjects: FindProjectsRepository) {}
+
+ execute(): Promise {
+ return this.findProjects.findPublic();
+ }
+}
diff --git a/libs/api/tracker/application/src/lib/queries/get-public-projects.query.ts b/libs/api/tracker/application/src/lib/queries/get-public-projects.query.ts
new file mode 100644
index 0000000..4777957
--- /dev/null
+++ b/libs/api/tracker/application/src/lib/queries/get-public-projects.query.ts
@@ -0,0 +1,5 @@
+import { Query } from '@nestjs/cqrs';
+import { ProjectReadModel } from '@task-stream/api/tracker/domain';
+
+/** Projects anyone can read (no sign-in needed). */
+export class GetPublicProjectsQuery extends Query {}
diff --git a/libs/api/tracker/application/src/lib/queries/get-release-burnup.query.ts b/libs/api/tracker/application/src/lib/queries/get-release-burnup.query.ts
index 7cc3afb..15bd676 100644
--- a/libs/api/tracker/application/src/lib/queries/get-release-burnup.query.ts
+++ b/libs/api/tracker/application/src/lib/queries/get-release-burnup.query.ts
@@ -4,7 +4,8 @@ import { ReleaseBurnupReadModel } from '@task-stream/api/tracker/domain';
/** Scope vs finished tickets of a release, day by day. */
export class GetReleaseBurnupQuery extends Query {
constructor(
- public readonly viewerId: string,
+ /** Null: anonymous (public projects only). */
+ public readonly viewerId: string | null,
public readonly releaseId: string,
) {
super();
diff --git a/libs/api/tracker/application/src/lib/queries/get-release-progress.query.ts b/libs/api/tracker/application/src/lib/queries/get-release-progress.query.ts
index d4d6074..f104c60 100644
--- a/libs/api/tracker/application/src/lib/queries/get-release-progress.query.ts
+++ b/libs/api/tracker/application/src/lib/queries/get-release-progress.query.ts
@@ -3,7 +3,8 @@ import { ReleaseProgressReadModel } from '@task-stream/api/tracker/domain';
export class GetReleaseProgressQuery extends Query {
constructor(
- public readonly viewerId: string,
+ /** Null: anonymous (public projects only). */
+ public readonly viewerId: string | null,
public readonly releaseId: string,
) {
super();
diff --git a/libs/api/tracker/application/src/lib/queries/get-release.query.ts b/libs/api/tracker/application/src/lib/queries/get-release.query.ts
index d8245f0..98077a8 100644
--- a/libs/api/tracker/application/src/lib/queries/get-release.query.ts
+++ b/libs/api/tracker/application/src/lib/queries/get-release.query.ts
@@ -3,7 +3,8 @@ import { ReleaseReadModel } from '@task-stream/api/tracker/domain';
export class GetReleaseQuery extends Query {
constructor(
- public readonly viewerId: string,
+ /** Null: anonymous (public projects only). */
+ public readonly viewerId: string | null,
public readonly releaseId: string,
) {
super();
diff --git a/libs/api/tracker/application/src/lib/queries/get-releases.query.ts b/libs/api/tracker/application/src/lib/queries/get-releases.query.ts
index 4c97185..b1981e4 100644
--- a/libs/api/tracker/application/src/lib/queries/get-releases.query.ts
+++ b/libs/api/tracker/application/src/lib/queries/get-releases.query.ts
@@ -3,7 +3,8 @@ import { ReleaseReadModel } from '@task-stream/api/tracker/domain';
export class GetReleasesQuery extends Query {
constructor(
- public readonly viewerId: string,
+ /** Null: anonymous (public projects only). */
+ public readonly viewerId: string | null,
public readonly projectId: string,
) {
super();
diff --git a/libs/api/tracker/application/src/lib/queries/index.ts b/libs/api/tracker/application/src/lib/queries/index.ts
index 618f960..776d13c 100644
--- a/libs/api/tracker/application/src/lib/queries/index.ts
+++ b/libs/api/tracker/application/src/lib/queries/index.ts
@@ -15,3 +15,4 @@ export * from './get-releases.query';
export * from './get-upcoming-releases.query';
export * from './search-issues.query';
export * from './search-my-issues.query';
+export * from './get-public-projects.query';
diff --git a/libs/api/tracker/application/src/lib/queries/query-handlers.ts b/libs/api/tracker/application/src/lib/queries/query-handlers.ts
index 4cde03e..b83871e 100644
--- a/libs/api/tracker/application/src/lib/queries/query-handlers.ts
+++ b/libs/api/tracker/application/src/lib/queries/query-handlers.ts
@@ -1,3 +1,4 @@
+import { GetPublicProjectsQueryHandler } from './get-public-projects.query-handler';
import { CanViewProjectQueryHandler } from './can-view-project.query-handler';
import { GetActivityQueryHandler } from './get-activity.query-handler';
import { GetBoardQueryHandler } from './get-board.query-handler';
@@ -17,6 +18,7 @@ import { SearchIssuesQueryHandler } from './search-issues.query-handler';
import { SearchMyIssuesQueryHandler } from './search-my-issues.query-handler';
export const QUERY_HANDLERS = [
+ GetPublicProjectsQueryHandler,
CanViewProjectQueryHandler,
GetActivityQueryHandler,
GetBoardQueryHandler,
diff --git a/libs/api/tracker/application/src/lib/queries/read-access.ts b/libs/api/tracker/application/src/lib/queries/read-access.ts
index 4d26928..ca8baf5 100644
--- a/libs/api/tracker/application/src/lib/queries/read-access.ts
+++ b/libs/api/tracker/application/src/lib/queries/read-access.ts
@@ -3,23 +3,38 @@ import { NotFoundError } from '@task-stream/api/shared/domain';
import { ProjectRole } from '@task-stream/api/tracker/domain';
import { FindProjectsRepository } from '../ports/find-projects.repository';
-/** Authorisation for the read side, based on the projected memberships. */
+/**
+ * Authorisation for the read side, based on the projected memberships:
+ * members read their projects; anyone (also anonymous, `viewerId` null)
+ * reads public ones.
+ */
@Injectable()
export class ReadAccess {
constructor(private readonly findProjects: FindProjectsRepository) {}
+ /** The viewer's role, or null when they read a public project as an outsider. */
async assertCanView(
projectId: string,
- viewerId: string,
- ): Promise {
- const role = await this.findProjects.findRole(projectId, viewerId);
+ viewerId: string | null,
+ ): Promise {
+ const role = viewerId
+ ? await this.findProjects.findRole(projectId, viewerId)
+ : null;
+ if (role) return role;
+ const project = await this.findProjects.findById(projectId);
// Same answer for "doesn't exist" and "not yours": don't leak projects.
- if (!role) throw new NotFoundError('Project', projectId);
- return role;
+ if (!project?.isPublic) throw new NotFoundError('Project', projectId);
+ return null;
}
- async canView(projectId: string, viewerId: string): Promise {
- return (await this.findProjects.findRole(projectId, viewerId)) !== null;
+ async canView(projectId: string, viewerId: string | null): Promise {
+ try {
+ await this.assertCanView(projectId, viewerId);
+ return true;
+ } catch (error) {
+ if (error instanceof NotFoundError) return false;
+ throw error;
+ }
}
assertFound(value: T | null, entity: string, id: string): T {
diff --git a/libs/api/tracker/application/src/lib/queries/search-issues.query.ts b/libs/api/tracker/application/src/lib/queries/search-issues.query.ts
index b9c31f9..037ece9 100644
--- a/libs/api/tracker/application/src/lib/queries/search-issues.query.ts
+++ b/libs/api/tracker/application/src/lib/queries/search-issues.query.ts
@@ -4,7 +4,8 @@ import { IssueFilter, Page } from '../ports/find-issues.repository';
export class SearchIssuesQuery extends Query> {
constructor(
- public readonly viewerId: string,
+ /** Null: anonymous (public projects only). */
+ public readonly viewerId: string | null,
public readonly filter: IssueFilter,
) {
super();
diff --git a/libs/api/tracker/application/src/lib/releases.service.ts b/libs/api/tracker/application/src/lib/releases.service.ts
index 2aef50e..67a3542 100644
--- a/libs/api/tracker/application/src/lib/releases.service.ts
+++ b/libs/api/tracker/application/src/lib/releases.service.ts
@@ -70,7 +70,10 @@ export class ReleasesService {
);
}
- findOne(viewerId: string, releaseId: string): Promise {
+ findOne(
+ viewerId: string | null,
+ releaseId: string,
+ ): Promise {
return this.queryBus.execute(new GetReleaseQuery(viewerId, releaseId));
}
@@ -80,7 +83,7 @@ export class ReleasesService {
}
progress(
- viewerId: string,
+ viewerId: string | null,
releaseId: string,
): Promise {
return this.queryBus.execute(
@@ -89,7 +92,10 @@ export class ReleasesService {
}
/** Scope vs finished tickets of the release, day by day. */
- burnup(viewerId: string, releaseId: string): Promise {
+ burnup(
+ viewerId: string | null,
+ releaseId: string,
+ ): Promise {
return this.queryBus.execute(
new GetReleaseBurnupQuery(viewerId, releaseId),
);
diff --git a/libs/api/tracker/application/src/lib/sagas/project-deletion.saga.ts b/libs/api/tracker/application/src/lib/sagas/project-deletion.saga.ts
new file mode 100644
index 0000000..da73c67
--- /dev/null
+++ b/libs/api/tracker/application/src/lib/sagas/project-deletion.saga.ts
@@ -0,0 +1,86 @@
+import { Injectable } from '@nestjs/common';
+import { ICommand, IEvent, ofType, Saga } from '@nestjs/cqrs';
+import {
+ IssueDeletedEvent,
+ ProjectDeletedEvent,
+} from '@task-stream/api/tracker/domain';
+import { from, mergeMap, Observable } from 'rxjs';
+import { DeleteIssueCommand } from '../commands/delete-issue.command';
+import { DeleteReleaseCommand } from '../commands/delete-release.command';
+import { DropLinksToIssueCommand } from '../commands/drop-links-to-issue.command';
+import { PurgeCommentCommand } from '../commands/purge-comment.command';
+import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
+
+/**
+ * Deleting a project cascades, one aggregate per command (each stream ends
+ * with its own "deleted" event):
+ *
+ * ProjectDeleted → DeleteIssue (each ticket) + DeleteRelease (each release)
+ * IssueDeleted → PurgeComment (each comment) + DropLinksToIssue (tickets
+ * of other projects that pointed to it)
+ *
+ * Who to delete comes from the event store, not the read model. The changes
+ * are attributed to whoever deleted the project.
+ */
+@Injectable()
+export class ProjectDeletionSaga {
+ constructor(private readonly streams: TrackerStreamsRepository) {}
+
+ @Saga()
+ projectDeleted = (events$: Observable): Observable =>
+ events$.pipe(
+ ofType(ProjectDeletedEvent),
+ mergeMap((event) =>
+ from(this.contentsOf(event.projectId)).pipe(
+ mergeMap(({ issueIds, releaseIds }) => [
+ ...issueIds.map((id) => new DeleteIssueCommand(event.actorId, id)),
+ ...releaseIds.map(
+ (id) => new DeleteReleaseCommand(event.actorId, id),
+ ),
+ ]),
+ ),
+ ),
+ );
+
+ @Saga()
+ issueDeleted = (events$: Observable): Observable =>
+ events$.pipe(
+ ofType(IssueDeletedEvent),
+ mergeMap((event) =>
+ from(this.dependentsOf(event.aggregateId)).pipe(
+ mergeMap(({ commentIds, linkingIds }) => [
+ ...commentIds.map(
+ (id) => new PurgeCommentCommand(event.actorId, id),
+ ),
+ ...linkingIds.map(
+ (id) =>
+ new DropLinksToIssueCommand(
+ event.actorId,
+ id,
+ event.aggregateId,
+ ),
+ ),
+ ]),
+ ),
+ ),
+ );
+
+ private async contentsOf(projectId: string) {
+ const [issueIds, releaseIds] = await Promise.all([
+ this.streams.issueIdsOfProject(projectId),
+ this.streams.releaseIdsOfProject(projectId),
+ ]);
+ return { issueIds, releaseIds };
+ }
+
+ private async dependentsOf(issueId: string) {
+ const [commentIds, linkingIds] = await Promise.all([
+ this.streams.commentIdsOfIssue(issueId),
+ this.streams.issueIdsLinkingTo(issueId),
+ ]);
+ return {
+ commentIds,
+ linkingIds: linkingIds.filter((id) => id !== issueId),
+ };
+ }
+}
diff --git a/libs/api/tracker/application/src/lib/sagas/release-archived.saga.ts b/libs/api/tracker/application/src/lib/sagas/release-archived.saga.ts
new file mode 100644
index 0000000..f90b76d
--- /dev/null
+++ b/libs/api/tracker/application/src/lib/sagas/release-archived.saga.ts
@@ -0,0 +1,36 @@
+import { Injectable } from '@nestjs/common';
+import { ICommand, IEvent, ofType, Saga } from '@nestjs/cqrs';
+import { ReleaseArchivedEvent } from '@task-stream/api/tracker/domain';
+import { from, mergeMap, Observable } from 'rxjs';
+import { DetachIssueFromReleaseCommand } from '../commands/detach-issue-from-release.command';
+import { ReleaseIssuesRepository } from '../ports/release-issues.repository';
+
+/**
+ * An archived release no longer accepts changes, so its unfinished tickets
+ * would be stuck in it: they leave it (back to "no release"), attributed to
+ * whoever archived it. Finished tickets stay, as the record of what shipped.
+ */
+@Injectable()
+export class ReleaseArchivedSaga {
+ constructor(private readonly releaseIssues: ReleaseIssuesRepository) {}
+
+ @Saga()
+ releaseArchived = (events$: Observable): Observable =>
+ events$.pipe(
+ ofType(ReleaseArchivedEvent),
+ mergeMap((event) =>
+ from(this.releaseIssues.findIssueIds(event.aggregateId)).pipe(
+ mergeMap((issueIds) =>
+ issueIds.map(
+ (issueId) =>
+ new DetachIssueFromReleaseCommand(
+ event.actorId,
+ issueId,
+ event.aggregateId,
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+}
diff --git a/libs/api/tracker/application/src/lib/sagas/sagas.spec.ts b/libs/api/tracker/application/src/lib/sagas/sagas.spec.ts
new file mode 100644
index 0000000..c18b77e
--- /dev/null
+++ b/libs/api/tracker/application/src/lib/sagas/sagas.spec.ts
@@ -0,0 +1,237 @@
+import {
+ CommandBus,
+ CqrsModule,
+ EventBus,
+ ICommand,
+ ISaga,
+} from '@nestjs/cqrs';
+import { Test } from '@nestjs/testing';
+import {
+ AggregateRehydrator,
+ EventStore,
+} from '@task-stream/api/shared/application';
+import { ForbiddenError, NotFoundError } from '@task-stream/api/shared/domain';
+import { UserDeactivatedEvent } from '@task-stream/api/identity/domain';
+import {
+ Comment,
+ Issue,
+ IssueLinkType,
+ ProjectRole,
+ Release,
+ TRACKER_FACTORIES,
+} from '@task-stream/api/tracker/domain';
+import { lastValueFrom, of, toArray } from 'rxjs';
+import {
+ FakeUserDirectory,
+ InMemoryEventStore,
+ InMemoryEventStorePublisher,
+ InMemoryIssueNumbers,
+ InMemoryProjectKeys,
+ InMemoryReleaseIssues,
+ InMemoryReleaseNames,
+ InMemoryTrackerStreams,
+} from '../../testing/in-memory';
+import { AddCommentCommand } from '../commands/add-comment.command';
+import { AddProjectMemberCommand } from '../commands/add-project-member.command';
+import { ArchiveReleaseCommand } from '../commands/archive-release.command';
+import { ChangeIssueStatusCommand } from '../commands/change-issue-status.command';
+import { COMMAND_HANDLERS } from '../commands/command-handlers';
+import { CommandResult } from '../commands/command-result';
+import { CreateIssueCommand } from '../commands/create-issue.command';
+import { CreateProjectCommand } from '../commands/create-project.command';
+import { CreateReleaseCommand } from '../commands/create-release.command';
+import { DeleteProjectCommand } from '../commands/delete-project.command';
+import { LinkIssuesCommand } from '../commands/link-issues.command';
+import { AssignIssueCommand } from '../commands/assign-issue.command';
+import { WriteAccess } from '../commands/write-access';
+import { FindIssuesRepository } from '../ports/find-issues.repository';
+import { IssueNumbersRepository } from '../ports/issue-numbers.repository';
+import { ProjectKeysRepository } from '../ports/project-keys.repository';
+import { ReleaseIssuesRepository } from '../ports/release-issues.repository';
+import { ReleaseNamesRepository } from '../ports/release-names.repository';
+import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
+import { UserDirectory } from '../ports/user-directory';
+import { ProjectDeletionSaga } from './project-deletion.saga';
+import { ReleaseArchivedSaga } from './release-archived.saga';
+import { UserDeactivatedSaga } from './user-deactivated.saga';
+
+async function setup() {
+ const store = new InMemoryEventStore();
+ const users = new FakeUserDirectory().add('owner').add('dev').add('qa');
+ // Read model double for the user saga: open tickets are tracked by the test.
+ const openAssigned = new Map();
+ const moduleRef = await Test.createTestingModule({
+ imports: [CqrsModule.forRoot()],
+ providers: [
+ ...COMMAND_HANDLERS,
+ ...TRACKER_FACTORIES,
+ ProjectDeletionSaga,
+ ReleaseArchivedSaga,
+ UserDeactivatedSaga,
+ WriteAccess,
+ AggregateRehydrator,
+ { provide: EventStore, useValue: store },
+ { provide: UserDirectory, useValue: users },
+ { provide: ProjectKeysRepository, useClass: InMemoryProjectKeys },
+ { provide: IssueNumbersRepository, useClass: InMemoryIssueNumbers },
+ { provide: ReleaseNamesRepository, useClass: InMemoryReleaseNames },
+ {
+ provide: ReleaseIssuesRepository,
+ useValue: new InMemoryReleaseIssues(store),
+ },
+ {
+ provide: TrackerStreamsRepository,
+ useValue: new InMemoryTrackerStreams(store),
+ },
+ {
+ provide: FindIssuesRepository,
+ useValue: {
+ findOpenAssignedTo: async (userId: string) =>
+ (openAssigned.get(userId) ?? []).map((id) => ({ id })),
+ },
+ },
+ ],
+ }).compile();
+ await moduleRef.init();
+ InMemoryEventStorePublisher.install(moduleRef.get(EventBus), store);
+
+ const commandBus = moduleRef.get(CommandBus);
+ const run = (command: object): Promise =>
+ commandBus.execute(command as never);
+ const sagas: ISaga[] = [
+ ...Object.values(moduleRef.get(ProjectDeletionSaga)),
+ ...Object.values(moduleRef.get(ReleaseArchivedSaga)),
+ ...Object.values(moduleRef.get(UserDeactivatedSaga)),
+ ].filter((value): value is ISaga => typeof value === 'function');
+
+ /** What the events bridge does: every stored event through every saga, until quiet. */
+ let cursor = 0;
+ const react = async (event: object) => {
+ for (const saga of sagas) {
+ const commands = await lastValueFrom(saga(of(event)).pipe(toArray()));
+ for (const command of commands)
+ await commandBus.execute(command as ICommand);
+ }
+ };
+ const drain = async () => {
+ while (cursor < store.events.length) {
+ const stored = store.events[cursor++];
+ const [event] = (await store.getEventsByStreamId(stored.streamId))
+ .filter((e) => e.position === stored.position)
+ .map((e) => e.data);
+ await react(event);
+ }
+ };
+
+ const access = moduleRef.get(WriteAccess);
+ const rehydrator = moduleRef.get(AggregateRehydrator);
+ return { run, drain, react, access, rehydrator, openAssigned };
+}
+
+const ticket = (projectId: string, title: string) =>
+ new CreateIssueCommand('owner', projectId, 'TASK', title, {});
+
+describe('sagas', () => {
+ it('deleting a project cascades to its tickets, comments, releases and links', async () => {
+ const { run, drain, access, rehydrator } = await setup();
+ const web = (await run(new CreateProjectCommand('owner', 'WEB', 'Web'))).id;
+ const api = (await run(new CreateProjectCommand('owner', 'API', 'Api'))).id;
+ const a = (await run(ticket(web, 'A'))).id;
+ const b = (await run(ticket(web, 'B'))).id;
+ const c = (await run(ticket(api, 'C'))).id;
+ const comments = [
+ (await run(new AddCommentCommand('owner', a, 'one'))).id,
+ (await run(new AddCommentCommand('owner', a, 'two'))).id,
+ ];
+ await run(new LinkIssuesCommand('owner', a, IssueLinkType.BLOCKS, b));
+ await run(new LinkIssuesCommand('owner', c, IssueLinkType.DEPENDS_ON, a));
+ const release = (
+ await run(new CreateReleaseCommand('owner', web, 'v1', null, null))
+ ).id;
+
+ await expect(
+ run(new DeleteProjectCommand('dev', web, 'WEB')),
+ ).rejects.toThrow(ForbiddenError);
+ await run(new DeleteProjectCommand('owner', web, 'web'));
+ await drain();
+
+ for (const [id, Cls] of [
+ [a, Issue],
+ [b, Issue],
+ [release, Release],
+ [comments[0], Comment],
+ [comments[1], Comment],
+ ] as const) {
+ expect(await access.live(id, Cls as never)).toBeNull();
+ }
+ // The ticket of the other project no longer points to the deleted one.
+ expect((await rehydrator.rehydrate(c, Issue)).links).toEqual([]);
+ // Commands on the deleted project fail as if it never existed.
+ await expect(run(ticket(web, 'late'))).rejects.toThrow(NotFoundError);
+ // And its key is free again.
+ await expect(
+ run(new CreateProjectCommand('owner', 'WEB', 'Web again')),
+ ).resolves.toMatchObject({ key: 'WEB' });
+ });
+
+ it('only the owner deletes a project', async () => {
+ const { run } = await setup();
+ const web = (await run(new CreateProjectCommand('owner', 'WEB', 'Web'))).id;
+ await expect(
+ run(new DeleteProjectCommand('owner', web, 'NOPE')),
+ ).rejects.toThrow(/WEB/);
+ await run(
+ new AddProjectMemberCommand('owner', web, 'dev', ProjectRole.ADMIN),
+ );
+ await expect(
+ run(new DeleteProjectCommand('dev', web, 'WEB')),
+ ).rejects.toThrow(ForbiddenError);
+ });
+
+ it('archiving a release lets its unfinished tickets go', async () => {
+ const { run, drain, rehydrator } = await setup();
+ const web = (await run(new CreateProjectCommand('owner', 'WEB', 'Web'))).id;
+ const release = (
+ await run(new CreateReleaseCommand('owner', web, 'v1', null, null))
+ ).id;
+ const open = (
+ await run(
+ new CreateIssueCommand('owner', web, 'TASK', 'open', {}, null, release),
+ )
+ ).id;
+ const done = (
+ await run(
+ new CreateIssueCommand('owner', web, 'TASK', 'done', {}, null, release),
+ )
+ ).id;
+ for (const status of ['TODO', 'IN_PROGRESS', 'IN_REVIEW', 'DONE'])
+ await run(new ChangeIssueStatusCommand('owner', done, status));
+
+ await run(new ArchiveReleaseCommand('owner', release));
+ await drain();
+
+ expect((await rehydrator.rehydrate(open, Issue)).releaseId).toBeNull();
+ expect((await rehydrator.rehydrate(done, Issue)).releaseId).toBe(release);
+ });
+
+ it('a user deactivated in Pireon leaves their open tickets unassigned', async () => {
+ const { run, react, rehydrator, openAssigned } = await setup();
+ const web = (await run(new CreateProjectCommand('owner', 'WEB', 'Web'))).id;
+ for (const user of ['dev', 'qa'])
+ await run(
+ new AddProjectMemberCommand('owner', web, user, ProjectRole.MEMBER),
+ );
+ const mine = (await run(ticket(web, 'mine'))).id;
+ const moved = (await run(ticket(web, 'moved'))).id;
+ await run(new AssignIssueCommand('owner', mine, 'dev'));
+ // The read model still lists it as dev's, but it was reassigned meanwhile.
+ await run(new AssignIssueCommand('owner', moved, 'qa'));
+ openAssigned.set('dev', [mine, moved]);
+
+ await react(new UserDeactivatedEvent('dev'));
+
+ const unassigned = await rehydrator.rehydrate(mine, Issue);
+ expect(unassigned.assigneeId).toBeNull();
+ expect((await rehydrator.rehydrate(moved, Issue)).assigneeId).toBe('qa');
+ });
+});
diff --git a/libs/api/tracker/application/src/lib/sagas/sagas.ts b/libs/api/tracker/application/src/lib/sagas/sagas.ts
new file mode 100644
index 0000000..7c9f4c0
--- /dev/null
+++ b/libs/api/tracker/application/src/lib/sagas/sagas.ts
@@ -0,0 +1,10 @@
+import { ProjectDeletionSaga } from './project-deletion.saga';
+import { ReleaseArchivedSaga } from './release-archived.saga';
+import { UserDeactivatedSaga } from './user-deactivated.saga';
+
+/** Reactions to events that change other aggregates (run by the events bridge). */
+export const SAGAS = [
+ ProjectDeletionSaga,
+ ReleaseArchivedSaga,
+ UserDeactivatedSaga,
+];
diff --git a/libs/api/tracker/application/src/lib/sagas/user-deactivated.saga.ts b/libs/api/tracker/application/src/lib/sagas/user-deactivated.saga.ts
new file mode 100644
index 0000000..27c1c85
--- /dev/null
+++ b/libs/api/tracker/application/src/lib/sagas/user-deactivated.saga.ts
@@ -0,0 +1,37 @@
+import { Injectable } from '@nestjs/common';
+import { ICommand, IEvent, ofType, Saga } from '@nestjs/cqrs';
+import { UserDeactivatedEvent } from '@task-stream/api/identity/domain';
+import { SYSTEM_ACTOR_ID } from '@task-stream/api/tracker/domain';
+import { from, mergeMap, Observable } from 'rxjs';
+import { UnassignIssueCommand } from '../commands/unassign-issue.command';
+import { FindIssuesRepository } from '../ports/find-issues.repository';
+
+/**
+ * Pireon deactivated a user (identity context, `user.deactivated` webhook):
+ * their open tickets in every project become unassigned, so nobody waits on
+ * someone who is gone. Done by the system actor ("TaskStream").
+ */
+@Injectable()
+export class UserDeactivatedSaga {
+ constructor(private readonly findIssues: FindIssuesRepository) {}
+
+ @Saga()
+ userDeactivated = (events$: Observable): Observable =>
+ events$.pipe(
+ ofType(UserDeactivatedEvent),
+ mergeMap((event) =>
+ from(this.findIssues.findOpenAssignedTo(event.userId)).pipe(
+ mergeMap((issues) =>
+ issues.map(
+ (issue) =>
+ new UnassignIssueCommand(
+ SYSTEM_ACTOR_ID,
+ issue.id,
+ event.userId,
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+}
diff --git a/libs/api/tracker/application/src/lib/tracker-application.module.ts b/libs/api/tracker/application/src/lib/tracker-application.module.ts
index 1270696..569032a 100644
--- a/libs/api/tracker/application/src/lib/tracker-application.module.ts
+++ b/libs/api/tracker/application/src/lib/tracker-application.module.ts
@@ -14,6 +14,7 @@ import { ProjectsService } from './projects.service';
import { QUERY_HANDLERS } from './queries/query-handlers';
import { ReadAccess } from './queries/read-access';
import { ReleasesService } from './releases.service';
+import { SAGAS } from './sagas/sagas';
import { WorkflowsService } from './workflows.service';
const SERVICES = [
@@ -41,6 +42,7 @@ const SERVICES = [
...COMMAND_HANDLERS,
...QUERY_HANDLERS,
...EVENT_HANDLERS,
+ ...SAGAS,
],
exports: SERVICES,
})
diff --git a/libs/api/tracker/application/src/testing/in-memory.ts b/libs/api/tracker/application/src/testing/in-memory.ts
index 53b116b..6194e3d 100644
--- a/libs/api/tracker/application/src/testing/in-memory.ts
+++ b/libs/api/tracker/application/src/testing/in-memory.ts
@@ -11,6 +11,7 @@ import { IssueNumbersRepository } from '../lib/ports/issue-numbers.repository';
import { ProjectKeysRepository } from '../lib/ports/project-keys.repository';
import { ReleaseIssuesRepository } from '../lib/ports/release-issues.repository';
import { ReleaseNamesRepository } from '../lib/ports/release-names.repository';
+import { TrackerStreamsRepository } from '../lib/ports/tracker-streams.repository';
import {
DirectoryUser,
UserDirectory,
@@ -128,6 +129,9 @@ export class InMemoryProjectKeys extends ProjectKeysRepository {
throw new BusinessRuleError(`Project key ${key} is already taken`);
this.keys.add(key);
}
+ async release(key: string) {
+ this.keys.delete(key);
+ }
}
export class InMemoryIssueNumbers extends IssueNumbersRepository {
@@ -189,3 +193,54 @@ export class InMemoryReleaseIssues extends ReleaseIssuesRepository {
];
}
}
+
+/** Same lookups as the Postgres adapter, over the in-memory events. */
+export class InMemoryTrackerStreams extends TrackerStreamsRepository {
+ constructor(private readonly store: InMemoryEventStore) {
+ super();
+ }
+
+ private streams(
+ type: string,
+ matches: (data: Record) => boolean,
+ ): string[] {
+ return [
+ ...new Set(
+ this.store.events
+ .filter(
+ (e) =>
+ e.type === type && matches(e.data as Record),
+ )
+ .map((e) => e.streamId),
+ ),
+ ];
+ }
+
+ async issueIdsOfProject(projectId: string) {
+ return this.streams(
+ 'IssueCreatedEvent',
+ (d) => d['projectId'] === projectId,
+ );
+ }
+
+ async releaseIdsOfProject(projectId: string) {
+ return this.streams(
+ 'ReleaseCreatedEvent',
+ (d) => d['projectId'] === projectId,
+ );
+ }
+
+ async commentIdsOfIssue(issueId: string) {
+ return this.streams(
+ 'CommentAddedEvent',
+ (d) => (d['comment'] as { issueId: string }).issueId === issueId,
+ );
+ }
+
+ async issueIdsLinkingTo(issueId: string) {
+ return this.streams(
+ 'IssueLinkedEvent',
+ (d) => (d['link'] as { targetIssueId: string }).targetIssueId === issueId,
+ );
+ }
+}
diff --git a/libs/api/tracker/domain/README.md b/libs/api/tracker/domain/README.md
index 732c858..762fcab 100644
--- a/libs/api/tracker/domain/README.md
+++ b/libs/api/tracker/domain/README.md
@@ -9,6 +9,8 @@ El modelo del producto: proyectos, tickets, comentarios y releases.
- Agregados event-sourced: `Project`, `Issue`, `Comment`, `Release`. Validan en sus métodos y cambian de estado en `on`.
- `events/`: un fichero por evento (`IssueCreatedEvent`, `IssueStatusChangedEvent`…), todos con `@AutowiredEvent`.
+- Proyectos públicos (`Project.isPublic`) y borrado: `Project.delete` (propietario, confirmando la clave),
+ `Issue.delete`, `Release.delete` (los usa la cascada); `SYSTEM_ACTOR_ID` para cambios que no hace una persona.
- `factories/`: `ProjectFactory`, `IssueFactory`, `CommentFactory`, `ReleaseFactory` (`@Injectable()`).
- `project/project-settings.ts`: configuración por proyecto (estados con categoría, transiciones, tipos, áreas), sus
valores por defecto, `ProjectWorkflow` (lo que consultan tickets y queries) y `validateProjectSettings` (archivar,
diff --git a/libs/api/tracker/domain/src/index.ts b/libs/api/tracker/domain/src/index.ts
index 27c1d1d..51d3530 100644
--- a/libs/api/tracker/domain/src/index.ts
+++ b/libs/api/tracker/domain/src/index.ts
@@ -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';
diff --git a/libs/api/tracker/domain/src/lib/events/index.ts b/libs/api/tracker/domain/src/lib/events/index.ts
index 05cbc90..54b5afb 100644
--- a/libs/api/tracker/domain/src/lib/events/index.ts
+++ b/libs/api/tracker/domain/src/lib/events/index.ts
@@ -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';
diff --git a/libs/api/tracker/domain/src/lib/events/issue-deleted.event.ts b/libs/api/tracker/domain/src/lib/events/issue-deleted.event.ts
new file mode 100644
index 0000000..8a691ce
--- /dev/null
+++ b/libs/api/tracker/domain/src/lib/events/issue-deleted.event.ts
@@ -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);
+ }
+}
diff --git a/libs/api/tracker/domain/src/lib/events/project-deleted.event.ts b/libs/api/tracker/domain/src/lib/events/project-deleted.event.ts
new file mode 100644
index 0000000..0011c32
--- /dev/null
+++ b/libs/api/tracker/domain/src/lib/events/project-deleted.event.ts
@@ -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);
+ }
+}
diff --git a/libs/api/tracker/domain/src/lib/events/project-updated.event.ts b/libs/api/tracker/domain/src/lib/events/project-updated.event.ts
index 513933a..9b9329f 100644
--- a/libs/api/tracker/domain/src/lib/events/project-updated.event.ts
+++ b/libs/api/tracker/domain/src/lib/events/project-updated.event.ts
@@ -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>,
+ readonly changes: Partial<
+ Pick
+ >,
) {
super(actorId, projectId, projectId);
}
diff --git a/libs/api/tracker/domain/src/lib/events/release-deleted.event.ts b/libs/api/tracker/domain/src/lib/events/release-deleted.event.ts
new file mode 100644
index 0000000..db76c77
--- /dev/null
+++ b/libs/api/tracker/domain/src/lib/events/release-deleted.event.ts
@@ -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);
+ }
+}
diff --git a/libs/api/tracker/domain/src/lib/factories/project.factory.ts b/libs/api/tracker/domain/src/lib/factories/project.factory.ts
index 892778f..3c3540d 100644
--- a/libs/api/tracker/domain/src/lib/factories/project.factory.ts
+++ b/libs/api/tracker/domain/src/lib/factories/project.factory.ts
@@ -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(),
diff --git a/libs/api/tracker/domain/src/lib/issue/issue.ts b/libs/api/tracker/domain/src/lib/issue/issue.ts
index c8fd5e9..4b70320 100644
--- a/libs/api/tracker/domain/src/lib/issue/issue.ts
+++ b/libs/api/tracker/domain/src/lib/issue/issue.ts
@@ -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;
diff --git a/libs/api/tracker/domain/src/lib/project/project-lifecycle.spec.ts b/libs/api/tracker/domain/src/lib/project/project-lifecycle.spec.ts
new file mode 100644
index 0000000..25f50cc
--- /dev/null
+++ b/libs/api/tracker/domain/src/lib/project/project-lifecycle.spec.ts
@@ -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']);
+ });
+});
diff --git a/libs/api/tracker/domain/src/lib/project/project.ts b/libs/api/tracker/domain/src/lib/project/project.ts
index 8503641..78f74d7 100644
--- a/libs/api/tracker/domain/src/lib/project/project.ts
+++ b/libs/api/tracker/domain/src/lib/project/project.ts
@@ -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;
diff --git a/libs/api/tracker/domain/src/lib/read-models/project.read-model.ts b/libs/api/tracker/domain/src/lib/read-models/project.read-model.ts
index 420da1f..ad011fd 100644
--- a/libs/api/tracker/domain/src/lib/read-models/project.read-model.ts
+++ b/libs/api/tracker/domain/src/lib/read-models/project.read-model.ts
@@ -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. */
diff --git a/libs/api/tracker/domain/src/lib/release/release.ts b/libs/api/tracker/domain/src/lib/release/release.ts
index 7b61010..b8efdd9 100644
--- a/libs/api/tracker/domain/src/lib/release/release.ts
+++ b/libs/api/tracker/domain/src/lib/release/release.ts
@@ -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;
+ }
}
diff --git a/libs/api/tracker/domain/src/lib/system-actor.ts b/libs/api/tracker/domain/src/lib/system-actor.ts
new file mode 100644
index 0000000..5143f53
--- /dev/null
+++ b/libs/api/tracker/domain/src/lib/system-actor.ts
@@ -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';
diff --git a/libs/api/tracker/infrastructure/README.md b/libs/api/tracker/infrastructure/README.md
index 72132a3..067f4dd 100644
--- a/libs/api/tracker/infrastructure/README.md
+++ b/libs/api/tracker/infrastructure/README.md
@@ -7,7 +7,9 @@ Adaptadores de salida del tracker.
## Contenido
-- `postgres/`: lookups de escritura (`project_keys`, `issue_sequences`, `release_names`) y `ReleaseIssuesRepository` (consulta el event store).
+- `postgres/`: lookups de escritura (`project_keys`, `issue_sequences`, `release_names`), `ReleaseIssuesRepository` y
+ `TrackerStreamsRepository` (consultan el event store: tickets y releases de un proyecto, comentarios y enlaces de un
+ ticket).
- `mongo/`: esquemas del read model (incluida la línea temporal `issue_timeline`), repositorios `Find*` y
`UpsertMaterialized*` (upsert idempotente por versión) y `StatusCategoryBackfill` (al arrancar, rellena la categoría
del estado en los tickets proyectados antes de existir).
diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts b/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts
index 92993ca..863ac1b 100644
--- a/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts
+++ b/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts
@@ -49,9 +49,10 @@ const FINISHED = [StatusCategory.DONE, StatusCategory.CANCELLED];
function toProject(doc: ProjectDoc): ProjectReadModel {
const { memberIds: _ignored, ...rest } = doc;
- // Projects projected before settings existed use the defaults.
+ // Projects projected before settings (or visibility) existed use the defaults.
return {
...fromDoc(rest),
+ isPublic: rest.isPublic ?? false,
settings: rest.settings ?? defaultProjectSettings(),
};
}
@@ -83,6 +84,14 @@ export class MongoFindProjectsRepository extends FindProjectsRepository {
return docs.map(toProject);
}
+ async findPublic(): Promise {
+ const docs = await this.projects
+ .find({ isPublic: true })
+ .sort({ name: 1 })
+ .lean();
+ return docs.map(toProject);
+ }
+
async findRole(
projectId: string,
userId: string,
@@ -207,6 +216,13 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
};
}
+ async findOpenAssignedTo(userId: string): Promise {
+ const docs = await this.issues
+ .find({ 'assignee.id': userId, statusCategory: { $nin: FINISHED } })
+ .lean();
+ return docs.map(issueFromDoc);
+ }
+
async findByProject(projectId: string): Promise {
const docs = await this.issues.find({ projectId }).lean();
return docs.map(issueFromDoc);
diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts b/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts
index 2ecaf52..a3b38ae 100644
--- a/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts
+++ b/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts
@@ -48,6 +48,7 @@ export const ProjectSchema = new Schema(
{
_id: String,
key: { type: String, unique: true },
+ isPublic: { type: Boolean, index: true },
memberIds: { type: [String], index: true },
owner: userRef,
members: [{ _id: false, user: userRef, role: String }],
diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/upsert-materialized.repositories.ts b/libs/api/tracker/infrastructure/src/lib/mongo/upsert-materialized.repositories.ts
index a994ee5..a40584f 100644
--- a/libs/api/tracker/infrastructure/src/lib/mongo/upsert-materialized.repositories.ts
+++ b/libs/api/tracker/infrastructure/src/lib/mongo/upsert-materialized.repositories.ts
@@ -37,10 +37,22 @@ export class MongoUpsertMaterializedProjectRepository extends UpsertMaterialized
constructor(
@InjectModel(ReadModels.Project)
private readonly projects: Model,
+ @InjectModel(ReadModels.Activity)
+ private readonly activity: Model,
+ @InjectModel(ReadModels.Timeline)
+ private readonly timeline: Model,
) {
super();
}
+ async remove(projectId: string) {
+ await Promise.all([
+ this.projects.deleteOne({ _id: projectId }),
+ this.activity.deleteMany({ projectId }),
+ this.timeline.deleteMany({ projectId }),
+ ]);
+ }
+
async upsert({
id,
version,
@@ -60,10 +72,28 @@ export class MongoUpsertMaterializedProjectRepository extends UpsertMaterialized
export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIssueRepository {
constructor(
@InjectModel(ReadModels.Issue) private readonly issues: Model,
+ @InjectModel(ReadModels.Timeline)
+ private readonly timeline: Model,
) {
super();
}
+ async remove(issueId: string) {
+ await Promise.all([
+ this.issues.deleteOne({ _id: issueId }),
+ this.issues.updateMany(
+ {
+ $or: [
+ { 'inwardLinks.issueId': issueId },
+ { 'outwardLinks.issueId': issueId },
+ ],
+ },
+ { $pull: { inwardLinks: { issueId }, outwardLinks: { issueId } } },
+ ),
+ this.timeline.deleteMany({ issueId }),
+ ]);
+ }
+
async upsert({
id,
version,
@@ -164,6 +194,10 @@ export class MongoUpsertMaterializedReleaseRepository extends UpsertMaterialized
}: Pick & Partial) {
await upsertIfNewer(this.releases, id, version, fields);
}
+
+ async remove(releaseId: string) {
+ await this.releases.deleteOne({ _id: releaseId });
+ }
}
@Injectable()
diff --git a/libs/api/tracker/infrastructure/src/lib/postgres/lookup.repositories.ts b/libs/api/tracker/infrastructure/src/lib/postgres/lookup.repositories.ts
index f43bff8..62ccd2f 100644
--- a/libs/api/tracker/infrastructure/src/lib/postgres/lookup.repositories.ts
+++ b/libs/api/tracker/infrastructure/src/lib/postgres/lookup.repositories.ts
@@ -29,6 +29,12 @@ export class PostgresProjectKeysRepository extends ProjectKeysRepository {
throw error;
}
}
+
+ async release(key: string, projectId: string): Promise {
+ await this.dataSource
+ .getRepository(ProjectKeyEntity)
+ .delete({ key, projectId });
+ }
}
@Injectable()
diff --git a/libs/api/tracker/infrastructure/src/lib/postgres/tracker-streams.repository.ts b/libs/api/tracker/infrastructure/src/lib/postgres/tracker-streams.repository.ts
new file mode 100644
index 0000000..a50a0fd
--- /dev/null
+++ b/libs/api/tracker/infrastructure/src/lib/postgres/tracker-streams.repository.ts
@@ -0,0 +1,62 @@
+import { Injectable } from '@nestjs/common';
+import { TrackerStreamsRepository } from '@task-stream/api/tracker/application';
+import {
+ CommentAddedEvent,
+ IssueCreatedEvent,
+ IssueLinkedEvent,
+ ReleaseCreatedEvent,
+} from '@task-stream/api/tracker/domain';
+import { DataSource } from 'typeorm';
+
+/** Reads the event store directly: which streams hang off a project or a ticket. */
+@Injectable()
+export class PostgresTrackerStreamsRepository extends TrackerStreamsRepository {
+ constructor(private readonly dataSource: DataSource) {
+ super();
+ }
+
+ issueIdsOfProject(projectId: string): Promise {
+ return this.streams(
+ IssueCreatedEvent.name,
+ `data->>'projectId'`,
+ projectId,
+ );
+ }
+
+ releaseIdsOfProject(projectId: string): Promise {
+ return this.streams(
+ ReleaseCreatedEvent.name,
+ `data->>'projectId'`,
+ projectId,
+ );
+ }
+
+ commentIdsOfIssue(issueId: string): Promise {
+ return this.streams(
+ CommentAddedEvent.name,
+ `data->'comment'->>'issueId'`,
+ issueId,
+ );
+ }
+
+ issueIdsLinkingTo(issueId: string): Promise {
+ return this.streams(
+ IssueLinkedEvent.name,
+ `data->'link'->>'targetIssueId'`,
+ issueId,
+ );
+ }
+
+ /** `field` is one of the fixed JSON paths above, never user input. */
+ private async streams(
+ type: string,
+ field: string,
+ value: string,
+ ): Promise {
+ const rows: { stream_id: string }[] = await this.dataSource.query(
+ `SELECT DISTINCT stream_id FROM events WHERE type = $1 AND ${field} = $2`,
+ [type, value],
+ );
+ return rows.map((row) => row.stream_id);
+ }
+}
diff --git a/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts b/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts
index 33e255b..5654404 100644
--- a/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts
+++ b/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts
@@ -14,6 +14,7 @@ import {
ProjectKeysRepository,
ReleaseIssuesRepository,
ReleaseNamesRepository,
+ TrackerStreamsRepository,
UpsertMaterializedActivityRepository,
UpsertMaterializedCommentRepository,
UpsertMaterializedIssueRepository,
@@ -48,6 +49,7 @@ import {
PostgresReleaseNamesRepository,
} from './postgres/lookup.repositories';
import { PostgresReleaseIssuesRepository } from './postgres/release-issues.repository';
+import { PostgresTrackerStreamsRepository } from './postgres/tracker-streams.repository';
import { RedisChangeFeed } from './realtime/redis-change-feed';
const PORTS = [
@@ -59,6 +61,10 @@ const PORTS = [
provide: ReleaseIssuesRepository,
useClass: PostgresReleaseIssuesRepository,
},
+ {
+ provide: TrackerStreamsRepository,
+ useClass: PostgresTrackerStreamsRepository,
+ },
// Read side → MongoDB
{ provide: FindProjectsRepository, useClass: MongoFindProjectsRepository },
{ provide: FindIssuesRepository, useClass: MongoFindIssuesRepository },
diff --git a/libs/api/tracker/presenters/README.md b/libs/api/tracker/presenters/README.md
index 978b9af..0b44f65 100644
--- a/libs/api/tracker/presenters/README.md
+++ b/libs/api/tracker/presenters/README.md
@@ -12,6 +12,8 @@ API GraphQL y REST (OpenAPI) del tracker.
- `http/`: controladores REST `ProjectsController`, `IssuesController`, `CommentsController`, `ReleasesController`
(mismos services), DTOs con `@nestjs/swagger` y `Refs` (proyectos y tickets por id o clave; añade a cada ticket sus
transiciones permitidas según el workflow de su proyecto).
+- `ExploreController` (`GET /api/explore/projects`, sin token). Las lecturas usan `@OptionalAuth()` y `viewerOf(me)`
+ (null = anónimo) para servir proyectos públicos sin sesión.
- `common/settings.ts`: configuración del proyecto con las transiciones como lista (lo que viaja por GraphQL y REST).
Operaciones disponibles: [GraphQL](../../../../docs/03-api.md#32-graphql) y
diff --git a/libs/api/tracker/presenters/src/lib/common/viewer.ts b/libs/api/tracker/presenters/src/lib/common/viewer.ts
new file mode 100644
index 0000000..b5b16a9
--- /dev/null
+++ b/libs/api/tracker/presenters/src/lib/common/viewer.ts
@@ -0,0 +1,4 @@
+import { User } from '@task-stream/api/identity/domain';
+
+/** Who reads: the user's id, or null when anonymous (public projects only). */
+export const viewerOf = (me: User | undefined): string | null => me?.id ?? null;
diff --git a/libs/api/tracker/presenters/src/lib/graphql/inputs.ts b/libs/api/tracker/presenters/src/lib/graphql/inputs.ts
index 8c2371f..879dd4f 100644
--- a/libs/api/tracker/presenters/src/lib/graphql/inputs.ts
+++ b/libs/api/tracker/presenters/src/lib/graphql/inputs.ts
@@ -41,6 +41,14 @@ export class CreateProjectInput {
@IsString()
@MaxLength(5000)
description?: string | null;
+
+ @Field({
+ nullable: true,
+ description: 'Readable by anyone, also without signing in',
+ })
+ @IsOptional()
+ @IsBoolean()
+ isPublic?: boolean;
}
@InputType()
@@ -57,6 +65,10 @@ export class UpdateProjectInput {
@IsString()
@MaxLength(5000)
description?: string | null;
+ @Field({ nullable: true })
+ @IsOptional()
+ @IsBoolean()
+ isPublic?: boolean;
}
@InputType()
diff --git a/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts b/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts
index 44b40eb..724fda6 100644
--- a/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts
+++ b/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts
@@ -9,7 +9,10 @@ import {
Resolver,
} from '@nestjs/graphql';
import { User } from '@task-stream/api/identity/domain';
-import { CurrentUser } from '@task-stream/api/identity/presenters';
+import {
+ CurrentUser,
+ OptionalAuth,
+} from '@task-stream/api/identity/presenters';
import {
CommentsService,
IssuesService,
@@ -27,6 +30,7 @@ import {
UpdateIssueInput,
} from './inputs';
import { withoutNulls } from '../common/issue-input';
+import { viewerOf } from '../common/viewer';
import {
ActivityGql,
CommandResultGql,
@@ -43,23 +47,29 @@ export class IssueResolver {
private readonly workflows: WorkflowsService,
) {}
+ @OptionalAuth()
@Query(() => IssueGql)
issue(
- @CurrentUser() me: User,
+ @CurrentUser() me: User | undefined,
@Args('idOrKey', { description: 'Issue id or key (e.g. "TS-42")' })
idOrKey: string,
) {
- return this.issuesService.findOne(me.id, idOrKey);
+ return this.issuesService.findOne(viewerOf(me), idOrKey);
}
+ @OptionalAuth()
@Query(() => IssuePageGql, { name: 'issues' })
searchIssues(
- @CurrentUser() me: User,
+ @CurrentUser() me: User | undefined,
@Args('filter') filter: IssueFilterInput,
@Args('limit', { type: () => Int, defaultValue: 50 }) limit: number,
@Args('offset', { type: () => Int, defaultValue: 0 }) offset: number,
) {
- return this.issuesService.search(me.id, { ...filter, limit, offset });
+ return this.issuesService.search(viewerOf(me), {
+ ...filter,
+ limit,
+ offset,
+ });
}
@Query(() => IssuePageGql, {
@@ -85,18 +95,19 @@ export class IssueResolver {
});
}
+ @OptionalAuth()
@Query(() => [ActivityGql], {
description: 'Project activity feed (newest first)',
})
activity(
- @CurrentUser() me: User,
+ @CurrentUser() me: User | undefined,
@Args('projectId', { type: () => ID }) projectId: string,
@Args('issueId', { type: () => ID, nullable: true }) issueId?: string,
@Args('limit', { type: () => Int, defaultValue: 50 }) limit = 50,
@Args('before', { nullable: true, description: 'ISO date cursor' })
before?: string,
) {
- return this.issuesService.activity(me.id, {
+ return this.issuesService.activity(viewerOf(me), {
projectId,
issueId,
limit,
@@ -112,31 +123,34 @@ export class IssueResolver {
}
@ResolveField(() => [IssueGql], { description: 'Linked tickets I can see' })
- linkedIssues(@CurrentUser() me: User, @Parent() issue: IssueReadModel) {
+ linkedIssues(
+ @CurrentUser() me: User | undefined,
+ @Parent() issue: IssueReadModel,
+ ) {
if (issue.links.length === 0) return [];
return this.issuesService.findMany(
- me.id,
+ viewerOf(me),
issue.links.map((l) => l.issueId),
);
}
@ResolveField(() => CommentPageGql)
comments(
- @CurrentUser() me: User,
+ @CurrentUser() me: User | undefined,
@Parent() issue: IssueReadModel,
@Args('limit', { type: () => Int, defaultValue: 50 }) limit: number,
@Args('offset', { type: () => Int, defaultValue: 0 }) offset: number,
) {
- return this.commentsService.list(me.id, issue.id, limit, offset);
+ return this.commentsService.list(viewerOf(me), issue.id, limit, offset);
}
@ResolveField(() => [ActivityGql], { description: 'History of this ticket' })
history(
- @CurrentUser() me: User,
+ @CurrentUser() me: User | undefined,
@Parent() issue: IssueReadModel,
@Args('limit', { type: () => Int, defaultValue: 50 }) limit: number,
) {
- return this.issuesService.activity(me.id, {
+ return this.issuesService.activity(viewerOf(me), {
projectId: issue.projectId,
issueId: issue.id,
limit,
diff --git a/libs/api/tracker/presenters/src/lib/graphql/project.resolver.ts b/libs/api/tracker/presenters/src/lib/graphql/project.resolver.ts
index 7be8887..9826fcd 100644
--- a/libs/api/tracker/presenters/src/lib/graphql/project.resolver.ts
+++ b/libs/api/tracker/presenters/src/lib/graphql/project.resolver.ts
@@ -9,9 +9,14 @@ import {
Resolver,
} from '@nestjs/graphql';
import { User } from '@task-stream/api/identity/domain';
-import { AccessToken, CurrentUser } from '@task-stream/api/identity/presenters';
+import {
+ AccessToken,
+ CurrentUser,
+ OptionalAuth,
+} from '@task-stream/api/identity/presenters';
import { ProjectsService } from '@task-stream/api/tracker/application';
import { ProjectReadModel } from '@task-stream/api/tracker/domain';
+import { viewerOf } from '../common/viewer';
import { fromSettingsPayload, toSettingsPayload } from '../common/settings';
import {
CreateProjectInput,
@@ -38,13 +43,24 @@ export class ProjectResolver {
return this.projects.findMine(me.id);
}
- @Query(() => ProjectGql)
+ @OptionalAuth()
+ @Query(() => ProjectGql, {
+ description: 'A project of mine, or any public one (no sign-in needed)',
+ })
project(
- @CurrentUser() me: User,
+ @CurrentUser() me: User | undefined,
@Args('idOrKey', { description: 'Project id or key (e.g. "TS")' })
idOrKey: string,
) {
- return this.projects.findOne(me.id, idOrKey);
+ return this.projects.findOne(viewerOf(me), idOrKey);
+ }
+
+ @OptionalAuth()
+ @Query(() => [ProjectGql], {
+ description: 'Projects anyone can read, also without signing in',
+ })
+ publicProjects() {
+ return this.projects.findPublic();
}
@ResolveField(() => ProjectSettingsGql)
@@ -57,30 +73,36 @@ export class ProjectResolver {
'Statistics of the last `days` days (7-365): created vs resolved, cumulative flow, cycle/lead time, throughput and QA',
})
analytics(
- @CurrentUser() me: User,
+ @CurrentUser() me: User | undefined,
@Parent() project: ProjectReadModel,
@Args('days', { type: () => Int, defaultValue: 90 }) days: number,
) {
- return this.projects.analytics(me.id, project.id, days);
+ return this.projects.analytics(viewerOf(me), project.id, days);
}
@ResolveField(() => [ReleaseGql])
- releases(@CurrentUser() me: User, @Parent() project: ProjectReadModel) {
- return this.projects.releases(me.id, project.id);
+ releases(
+ @CurrentUser() me: User | undefined,
+ @Parent() project: ProjectReadModel,
+ ) {
+ return this.projects.releases(viewerOf(me), project.id);
}
@ResolveField(() => ProjectStatsGql, {
description: 'Project tracking dashboard',
})
- stats(@CurrentUser() me: User, @Parent() project: ProjectReadModel) {
- return this.projects.stats(me.id, project.id);
+ stats(
+ @CurrentUser() me: User | undefined,
+ @Parent() project: ProjectReadModel,
+ ) {
+ return this.projects.stats(viewerOf(me), project.id);
}
@ResolveField(() => [BoardColumnGql], {
description: 'Kanban board, one column per status',
})
board(
- @CurrentUser() me: User,
+ @CurrentUser() me: User | undefined,
@Parent() project: ProjectReadModel,
@Args('releaseId', { type: () => ID, nullable: true }) releaseId?: string,
@Args('assigneeId', { type: () => ID, nullable: true }) assigneeId?: string,
@@ -88,7 +110,7 @@ export class ProjectResolver {
@Args('perColumn', { type: () => Int, defaultValue: 50 }) perColumn = 50,
) {
return this.projects.board(
- me.id,
+ viewerOf(me),
{
projectId: project.id,
releaseId,
@@ -132,6 +154,18 @@ export class ProjectResolver {
);
}
+ @Mutation(() => CommandResultGql, {
+ description:
+ 'Delete the project (owner only), with its tickets, comments and releases. `confirmKey` must be the project key.',
+ })
+ deleteProject(
+ @CurrentUser() me: User,
+ @Args('projectId', { type: () => ID }) projectId: string,
+ @Args('confirmKey') confirmKey: string,
+ ) {
+ return this.projects.delete(me.id, projectId, confirmKey);
+ }
+
@Mutation(() => CommandResultGql, {
description: 'Add a Pireon user to the project',
})
diff --git a/libs/api/tracker/presenters/src/lib/graphql/release.resolver.ts b/libs/api/tracker/presenters/src/lib/graphql/release.resolver.ts
index 71fc8e6..842980e 100644
--- a/libs/api/tracker/presenters/src/lib/graphql/release.resolver.ts
+++ b/libs/api/tracker/presenters/src/lib/graphql/release.resolver.ts
@@ -10,7 +10,11 @@ import {
} from '@nestjs/graphql';
import { User } from '@task-stream/api/identity/domain';
import { ReleaseReadModel } from '@task-stream/api/tracker/domain';
-import { CurrentUser } from '@task-stream/api/identity/presenters';
+import {
+ CurrentUser,
+ OptionalAuth,
+} from '@task-stream/api/identity/presenters';
+import { viewerOf } from '../common/viewer';
import {
IssuesService,
ReleasesService,
@@ -31,9 +35,13 @@ export class ReleaseResolver {
private readonly issuesService: IssuesService,
) {}
+ @OptionalAuth()
@Query(() => ReleaseGql)
- release(@CurrentUser() me: User, @Args('id', { type: () => ID }) id: string) {
- return this.releases.findOne(me.id, id);
+ release(
+ @CurrentUser() me: User | undefined,
+ @Args('id', { type: () => ID }) id: string,
+ ) {
+ return this.releases.findOne(viewerOf(me), id);
}
@Query(() => [ReleaseGql], {
@@ -48,27 +56,33 @@ export class ReleaseResolver {
}
@ResolveField(() => ReleaseProgressGql)
- progress(@CurrentUser() me: User, @Parent() release: ReleaseReadModel) {
- return this.releases.progress(me.id, release.id);
+ progress(
+ @CurrentUser() me: User | undefined,
+ @Parent() release: ReleaseReadModel,
+ ) {
+ return this.releases.progress(viewerOf(me), release.id);
}
@ResolveField(() => ReleaseBurnupGql, {
description: 'Scope vs finished tickets, day by day',
})
- burnup(@CurrentUser() me: User, @Parent() release: ReleaseReadModel) {
- return this.releases.burnup(me.id, release.id);
+ burnup(
+ @CurrentUser() me: User | undefined,
+ @Parent() release: ReleaseReadModel,
+ ) {
+ return this.releases.burnup(viewerOf(me), release.id);
}
@ResolveField(() => IssuePageGql, {
description: 'Tickets included in this release',
})
issues(
- @CurrentUser() me: User,
+ @CurrentUser() me: User | undefined,
@Parent() release: ReleaseReadModel,
@Args('limit', { type: () => Int, defaultValue: 100 }) limit: number,
@Args('offset', { type: () => Int, defaultValue: 0 }) offset: number,
) {
- return this.issuesService.search(me.id, {
+ return this.issuesService.search(viewerOf(me), {
projectId: release.projectId,
releaseId: release.id,
limit,
diff --git a/libs/api/tracker/presenters/src/lib/graphql/types.ts b/libs/api/tracker/presenters/src/lib/graphql/types.ts
index e69492a..a2d2fa5 100644
--- a/libs/api/tracker/presenters/src/lib/graphql/types.ts
+++ b/libs/api/tracker/presenters/src/lib/graphql/types.ts
@@ -71,6 +71,8 @@ export class ProjectGql {
@Field() key!: string;
@Field() name!: string;
@Field(() => String, { nullable: true }) description!: string | null;
+ @Field({ description: 'Anyone can read it, also without signing in' })
+ isPublic!: boolean;
@Field(() => UserRefGql) owner!: UserRefGql;
@Field(() => [ProjectMemberGql]) members!: ProjectMemberGql[];
@Field() createdAt!: string;
diff --git a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts
index 5697d11..6b4df8c 100644
--- a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts
+++ b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts
@@ -73,12 +73,30 @@ export class CreateProjectDto {
@IsString()
@MaxLength(5000)
description?: string | null;
+
+ @ApiPropertyOptional({
+ default: false,
+ description:
+ 'Anyone can read it without signing in (board, tickets, comments, releases, activity, statistics)',
+ })
+ @IsOptional()
+ @IsBoolean()
+ isPublic?: boolean;
}
export class UpdateProjectDto extends PartialType(
OmitType(CreateProjectDto, ['key'] as const),
) {}
+export class DeleteProjectQueryDto {
+ @ApiProperty({
+ example: 'WEB',
+ description: 'The project key, to confirm the deletion',
+ })
+ @IsString()
+ confirm!: string;
+}
+
export class AddMemberDto {
@ApiProperty({ description: 'Pireon user id (see GET /users)' })
@IsString()
diff --git a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts
index cf810b4..26148d8 100644
--- a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts
+++ b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts
@@ -76,6 +76,8 @@ export class ProjectDto {
key!: string;
@ApiProperty({ example: 'Checkout revamp' }) name!: string;
@ApiProperty({ type: String, nullable: true }) description!: string | null;
+ @ApiProperty({ description: 'Readable by anyone, also without a token' })
+ isPublic!: boolean;
@ApiProperty({ type: UserRefDto }) owner!: UserRefDto;
@ApiProperty({ type: [ProjectMemberDto] }) members!: ProjectMemberDto[];
@ApiProperty({ type: ProjectSettingsDto })
diff --git a/libs/api/tracker/presenters/src/lib/http/explore.controller.ts b/libs/api/tracker/presenters/src/lib/http/explore.controller.ts
new file mode 100644
index 0000000..8dd6929
--- /dev/null
+++ b/libs/api/tracker/presenters/src/lib/http/explore.controller.ts
@@ -0,0 +1,25 @@
+import { Controller, Get } from '@nestjs/common';
+import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
+import { Public } from '@task-stream/api/identity/presenters';
+import { ProjectsService } from '@task-stream/api/tracker/application';
+import { ProjectDto } from './dto/responses.dto';
+import { toProjectDto } from './refs';
+
+/** What anyone can read without signing in. */
+@ApiTags('Explore')
+@Controller('explore')
+export class ExploreController {
+ constructor(private readonly projects: ProjectsService) {}
+
+ @Get('projects')
+ @Public()
+ @ApiOperation({
+ summary: 'Public projects (no token needed)',
+ description:
+ 'Their board, tickets, comments, releases, activity and statistics can be read without a token too.',
+ })
+ @ApiOkResponse({ type: [ProjectDto] })
+ async publicProjects() {
+ return (await this.projects.findPublic()).map(toProjectDto);
+ }
+}
diff --git a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts
index 747cb62..807712c 100644
--- a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts
+++ b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts
@@ -18,6 +18,7 @@ import {
} from '@nestjs/swagger';
import { User } from '@task-stream/api/identity/domain';
import {
+ ApiOptionalPireonAuth,
ApiPireonAuth,
CurrentUser,
} from '@task-stream/api/identity/presenters';
@@ -51,6 +52,7 @@ import {
IssueDto,
IssuePageDto,
} from './dto/responses.dto';
+import { viewerOf } from '../common/viewer';
import { Refs } from './refs';
const PROJECT = ApiParam({
@@ -95,17 +97,18 @@ export class IssuesController {
}
@Get('projects/:project/issues')
+ @ApiOptionalPireonAuth()
@PROJECT
@ApiOperation({ summary: 'Search the tickets of a project' })
@ApiOkResponse({ type: IssuePageDto })
@ApiReadErrors()
async search(
- @CurrentUser() me: User,
+ @CurrentUser() me: User | undefined,
@Param('project') project: string,
@Query() query: IssueSearchQueryDto,
) {
- const page = await this.issues.search(me.id, {
- projectId: await this.refs.projectId(me.id, project),
+ const page = await this.issues.search(viewerOf(me), {
+ projectId: await this.refs.projectId(viewerOf(me), project),
statuses: query.status,
categories: query.category,
types: query.type,
@@ -143,12 +146,16 @@ export class IssuesController {
}
@Get('issues/:issue')
+ @ApiOptionalPireonAuth()
@ISSUE
@ApiOperation({ summary: 'A ticket' })
@ApiOkResponse({ type: IssueDto })
@ApiReadErrors()
- async findOne(@CurrentUser() me: User, @Param('issue') issue: string) {
- return this.refs.issueDto(await this.issues.findOne(me.id, issue));
+ async findOne(
+ @CurrentUser() me: User | undefined,
+ @Param('issue') issue: string,
+ ) {
+ return this.refs.issueDto(await this.issues.findOne(viewerOf(me), issue));
}
@Patch('issues/:issue')
@@ -271,18 +278,19 @@ export class IssuesController {
}
@Get('issues/:issue/comments')
+ @ApiOptionalPireonAuth()
@ISSUE
@ApiOperation({ summary: 'Comments of the ticket (oldest first)' })
@ApiOkResponse({ type: CommentPageDto })
@ApiReadErrors()
async listComments(
- @CurrentUser() me: User,
+ @CurrentUser() me: User | undefined,
@Param('issue') issue: string,
@Query() page: PageQueryDto,
) {
return this.comments.list(
- me.id,
- await this.refs.issueId(me.id, issue),
+ viewerOf(me),
+ await this.refs.issueId(viewerOf(me), issue),
page.limit,
page.offset,
);
@@ -306,17 +314,18 @@ export class IssuesController {
}
@Get('issues/:issue/history')
+ @ApiOptionalPireonAuth()
@ISSUE
@ApiOperation({ summary: 'History of the ticket (newest first)' })
@ApiOkResponse({ type: [ActivityDto] })
@ApiReadErrors()
async history(
- @CurrentUser() me: User,
+ @CurrentUser() me: User | undefined,
@Param('issue') issue: string,
@Query() page: PageQueryDto,
) {
- const found = await this.issues.findOne(me.id, issue);
- return this.issues.activity(me.id, {
+ const found = await this.issues.findOne(viewerOf(me), issue);
+ return this.issues.activity(viewerOf(me), {
projectId: found.projectId,
issueId: found.id,
limit: page.limit,
diff --git a/libs/api/tracker/presenters/src/lib/http/projects.controller.ts b/libs/api/tracker/presenters/src/lib/http/projects.controller.ts
index cc5010f..ae2dce3 100644
--- a/libs/api/tracker/presenters/src/lib/http/projects.controller.ts
+++ b/libs/api/tracker/presenters/src/lib/http/projects.controller.ts
@@ -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,
diff --git a/libs/api/tracker/presenters/src/lib/http/refs.ts b/libs/api/tracker/presenters/src/lib/http/refs.ts
index 69d63a6..f213004 100644
--- a/libs/api/tracker/presenters/src/lib/http/refs.ts
+++ b/libs/api/tracker/presenters/src/lib/http/refs.ts
@@ -25,12 +25,12 @@ export class Refs {
private readonly workflows: WorkflowsService,
) {}
- async projectId(viewerId: string, ref: string): Promise {
+ async projectId(viewerId: string | null, ref: string): Promise {
if (UUID.test(ref)) return ref;
return (await this.projects.findOne(viewerId, ref)).id;
}
- async issueId(viewerId: string, ref: string): Promise {
+ async issueId(viewerId: string | null, ref: string): Promise {
if (UUID.test(ref)) return ref;
return (await this.issues.findOne(viewerId, ref)).id;
}
diff --git a/libs/api/tracker/presenters/src/lib/http/releases.controller.ts b/libs/api/tracker/presenters/src/lib/http/releases.controller.ts
index e8aa333..5298da2 100644
--- a/libs/api/tracker/presenters/src/lib/http/releases.controller.ts
+++ b/libs/api/tracker/presenters/src/lib/http/releases.controller.ts
@@ -17,6 +17,7 @@ import {
} from '@nestjs/swagger';
import { User } from '@task-stream/api/identity/domain';
import {
+ ApiOptionalPireonAuth,
ApiPireonAuth,
CurrentUser,
} from '@task-stream/api/identity/presenters';
@@ -43,6 +44,7 @@ import {
ReleaseBurnupDto,
ReleaseProgressDto,
} from './dto/responses.dto';
+import { viewerOf } from '../common/viewer';
import { Refs } from './refs';
const PROJECT = ApiParam({
@@ -74,14 +76,18 @@ export class ReleasesController {
}
@Get('projects/:project/releases')
+ @ApiOptionalPireonAuth()
@PROJECT
@ApiOperation({ summary: 'Releases of a project' })
@ApiOkResponse({ type: [ReleaseDto] })
@ApiReadErrors()
- async list(@CurrentUser() me: User, @Param('project') project: string) {
+ async list(
+ @CurrentUser() me: User | undefined,
+ @Param('project') project: string,
+ ) {
return this.projects.releases(
- me.id,
- await this.refs.projectId(me.id, project),
+ viewerOf(me),
+ await this.refs.projectId(viewerOf(me), project),
);
}
@@ -102,11 +108,15 @@ export class ReleasesController {
}
@Get('releases/:releaseId')
+ @ApiOptionalPireonAuth()
@ApiOperation({ summary: 'A release' })
@ApiOkResponse({ type: ReleaseDto })
@ApiReadErrors()
- findOne(@CurrentUser() me: User, @Param('releaseId') releaseId: string) {
- return this.releases.findOne(me.id, releaseId);
+ findOne(
+ @CurrentUser() me: User | undefined,
+ @Param('releaseId') releaseId: string,
+ ) {
+ return this.releases.findOne(viewerOf(me), releaseId);
}
@Patch('releases/:releaseId')
@@ -122,34 +132,43 @@ export class ReleasesController {
}
@Get('releases/:releaseId/progress')
+ @ApiOptionalPireonAuth()
@ApiOperation({ summary: 'Finished tickets out of the total' })
@ApiOkResponse({ type: ReleaseProgressDto })
@ApiReadErrors()
- progress(@CurrentUser() me: User, @Param('releaseId') releaseId: string) {
- return this.releases.progress(me.id, releaseId);
+ progress(
+ @CurrentUser() me: User | undefined,
+ @Param('releaseId') releaseId: string,
+ ) {
+ return this.releases.progress(viewerOf(me), releaseId);
}
@Get('releases/:releaseId/burnup')
+ @ApiOptionalPireonAuth()
@ApiOperation({
summary: 'Burnup: tickets in the release and finished ones, day by day',
})
@ApiOkResponse({ type: ReleaseBurnupDto })
@ApiReadErrors()
- burnup(@CurrentUser() me: User, @Param('releaseId') releaseId: string) {
- return this.releases.burnup(me.id, releaseId);
+ burnup(
+ @CurrentUser() me: User | undefined,
+ @Param('releaseId') releaseId: string,
+ ) {
+ return this.releases.burnup(viewerOf(me), releaseId);
}
@Get('releases/:releaseId/issues')
+ @ApiOptionalPireonAuth()
@ApiOperation({ summary: 'Tickets included in the release' })
@ApiOkResponse({ type: IssuePageDto })
@ApiReadErrors()
async issuesOf(
- @CurrentUser() me: User,
+ @CurrentUser() me: User | undefined,
@Param('releaseId') releaseId: string,
@Query() page: PageQueryDto,
) {
- const release = await this.releases.findOne(me.id, releaseId);
- const found = await this.issues.search(me.id, {
+ const release = await this.releases.findOne(viewerOf(me), releaseId);
+ const found = await this.issues.search(viewerOf(me), {
projectId: release.projectId,
releaseId: release.id,
limit: page.limit,
diff --git a/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts b/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts
index db0e385..a3cd1e2 100644
--- a/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts
+++ b/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts
@@ -7,6 +7,7 @@ import { ReleaseResolver } from './graphql/release.resolver';
import { SubscriptionResolver } from './graphql/subscription.resolver';
import { CommentsController } from './http/comments.controller';
import { IssuesController } from './http/issues.controller';
+import { ExploreController } from './http/explore.controller';
import { ProjectsController } from './http/projects.controller';
import { Refs } from './http/refs';
import { ReleasesController } from './http/releases.controller';
@@ -22,6 +23,7 @@ import { ReleasesController } from './http/releases.controller';
IssuesController,
CommentsController,
ReleasesController,
+ ExploreController,
],
providers: [
Refs,
diff --git a/libs/web/identity/presenters/src/lib/pages/login.page.scss b/libs/web/identity/presenters/src/lib/pages/login.page.scss
index 3a85a8f..3257d07 100644
--- a/libs/web/identity/presenters/src/lib/pages/login.page.scss
+++ b/libs/web/identity/presenters/src/lib/pages/login.page.scss
@@ -59,7 +59,7 @@
.ts-login__lead {
max-width: 46ch;
- margin: 0 0 2.25rem;
+ margin: 0 0 0.75rem;
font-size: 1.0625rem;
line-height: 1.55;
color: var(--ts-muted);
@@ -192,3 +192,19 @@
.ts-login__form {
row-gap: 1.75rem;
}
+
+.ts-login__explore {
+ margin: 0 0 2.25rem;
+ font-size: 0.9375rem;
+ color: var(--ts-muted);
+
+ a {
+ font-weight: 600;
+ color: var(--ts-brand);
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+}
diff --git a/libs/web/identity/presenters/src/lib/pages/login.page.ts b/libs/web/identity/presenters/src/lib/pages/login.page.ts
index 089ac30..98359cf 100644
--- a/libs/web/identity/presenters/src/lib/pages/login.page.ts
+++ b/libs/web/identity/presenters/src/lib/pages/login.page.ts
@@ -1,3 +1,4 @@
+import { RouterLink } from '@angular/router';
import {
ChangeDetectionStrategy,
Component,
@@ -85,6 +86,7 @@ const SAMPLE_FEED = [
selector: 'ts-login-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
+ RouterLink,
LogoComponent,
PiActionButtonComponent,
PiDividerComponent,
@@ -109,6 +111,10 @@ const SAMPLE_FEED = [
Bugs, cambios de UI, features y QA de tu equipo en un tablero que se
mueve solo. Front, back y QA, enlazados de punta a punta.
+
+ ¿Solo quieres mirar?
+ Proyectos públicos →
+
@for (event of feed; track $index) {
diff --git a/libs/web/tracker/application/README.md b/libs/web/tracker/application/README.md
index 0fb0838..5611921 100644
--- a/libs/web/tracker/application/README.md
+++ b/libs/web/tracker/application/README.md
@@ -9,7 +9,9 @@ Estado del tracker (NgRx Signals con eventos) detrás de facades.
- Facades (lo que se exporta): `ProjectsFacade` y `HomeFacade` («Mi trabajo») (raíz) y `ProjectFacade`, `BoardFacade`, `IssueFacade` (de ruta, con
`provideProjectFacade()`, `provideBoardFacade()`, `provideIssueFacade()`). Tests en `facades/facades.spec.ts`.
-- `ProjectFacade` también da la configuración del proyecto (`workflow`, `saveSettings`) y sus estadísticas
+- `ExploreFacade` (raíz): proyectos públicos, también sin sesión.
+- `ProjectFacade` también da la visibilidad (`setPublic`) y el borrado (`deleteProject`, vuelve a la portada; y si otro
+ lo borra, también), y la configuración del proyecto (`workflow`, `saveSettings`) y sus estadísticas
(`loadAnalytics`, `loadBurnup`).
- Interno: stores `ProjectsStore`, `ProjectStore`, `HomeStore`, `BoardStore`, `IssueStore` y eventos por origen
(`*PageEvents`, `*ApiEvents`, `*RealtimeEvents`).
diff --git a/libs/web/tracker/application/src/index.ts b/libs/web/tracker/application/src/index.ts
index ec298d5..34b0b83 100644
--- a/libs/web/tracker/application/src/index.ts
+++ b/libs/web/tracker/application/src/index.ts
@@ -4,3 +4,4 @@ export * from './lib/facades/project.facade';
export * from './lib/facades/board.facade';
export * from './lib/facades/issue.facade';
export * from './lib/facades/home.facade';
+export * from './lib/facades/explore.facade';
diff --git a/libs/web/tracker/application/src/lib/events/explore.events.ts b/libs/web/tracker/application/src/lib/events/explore.events.ts
new file mode 100644
index 0000000..bc6bad0
--- /dev/null
+++ b/libs/web/tracker/application/src/lib/events/explore.events.ts
@@ -0,0 +1,18 @@
+import { type } from '@ngrx/signals';
+import { eventGroup } from '@ngrx/signals/events';
+import { Project } from '@task-stream/web/tracker/domain';
+
+export const explorePageEvents = eventGroup({
+ source: 'Explore Page',
+ events: {
+ opened: type
(),
+ },
+});
+
+export const exploreApiEvents = eventGroup({
+ source: 'Explore API',
+ events: {
+ loaded: type(),
+ loadFailed: type(),
+ },
+});
diff --git a/libs/web/tracker/application/src/lib/events/project.events.ts b/libs/web/tracker/application/src/lib/events/project.events.ts
index 5d399a6..a392b0c 100644
--- a/libs/web/tracker/application/src/lib/events/project.events.ts
+++ b/libs/web/tracker/application/src/lib/events/project.events.ts
@@ -30,6 +30,8 @@ export const projectPageEvents = eventGroup({
}>(),
analyticsRequested: type<{ projectId: string; days: number }>(),
burnupRequested: type<{ releaseId: string }>(),
+ visibilityChangeRequested: type<{ projectId: string; isPublic: boolean }>(),
+ deleteRequested: type<{ projectId: string; confirmKey: string }>(),
},
});
@@ -48,6 +50,8 @@ export const projectApiEvents = eventGroup({
analyticsFailed: type(),
burnupLoaded: type(),
settingsSaved: type(),
+ /** I deleted the open project. */
+ deleted: type(),
},
});
@@ -55,6 +59,8 @@ export const projectRealtimeEvents = eventGroup({
source: 'Project Realtime',
events: {
projectChanged: type(),
+ /** Someone else deleted the open project. */
+ projectDeleted: type(),
activityAdded: type(),
releaseChanged: type(),
},
diff --git a/libs/web/tracker/application/src/lib/facades/explore.facade.ts b/libs/web/tracker/application/src/lib/facades/explore.facade.ts
new file mode 100644
index 0000000..c0698af
--- /dev/null
+++ b/libs/web/tracker/application/src/lib/facades/explore.facade.ts
@@ -0,0 +1,22 @@
+import { inject, Injectable } from '@angular/core';
+import { injectDispatch } from '@ngrx/signals/events';
+import { explorePageEvents } from '../events/explore.events';
+import { ExploreStore } from '../stores/explore.store';
+
+/** "Explorar": public projects, for anyone (no sign-in needed). */
+@Injectable({ providedIn: 'root' })
+export class ExploreFacade {
+ private readonly store = inject(ExploreStore);
+ private readonly dispatch = injectDispatch(explorePageEvents);
+
+ /** By name. */
+ readonly projects = this.store.projects;
+ /** False until the first load: show skeletons, not empty states. */
+ readonly loaded = this.store.loaded;
+ readonly loading = this.store.loading;
+ readonly error = this.store.error;
+
+ load(): void {
+ this.dispatch.opened();
+ }
+}
diff --git a/libs/web/tracker/application/src/lib/facades/facades.spec.ts b/libs/web/tracker/application/src/lib/facades/facades.spec.ts
index 6054b1a..4bbf21c 100644
--- a/libs/web/tracker/application/src/lib/facades/facades.spec.ts
+++ b/libs/web/tracker/application/src/lib/facades/facades.spec.ts
@@ -13,7 +13,8 @@ import {
TrackerRealtimeGateway,
UsersGateway,
} from '@task-stream/web/tracker/domain';
-import { NEVER, of } from 'rxjs';
+import { Router } from '@angular/router';
+import { NEVER, of, Subject } from 'rxjs';
import { boardPageEvents } from '../events/board.events';
import { projectPageEvents } from '../events/project.events';
import { Notifications } from '../notifications';
@@ -35,6 +36,8 @@ function gateways() {
stats: vi.fn(() => NEVER),
addMember: vi.fn(() => of(ok)),
updateSettings: vi.fn(() => of(ok)),
+ update: vi.fn(() => of(ok)),
+ delete: vi.fn(() => of(ok)),
analytics: vi.fn(() => of(analytics)),
},
issues: {
@@ -48,7 +51,7 @@ function gateways() {
commentChanges: () => NEVER,
activity: () => NEVER,
releaseChanges: () => NEVER,
- projectChanges: () => NEVER,
+ projectChanges: vi.fn((): unknown => NEVER),
},
};
}
@@ -86,8 +89,12 @@ function setup() {
{ provide: AuthFacade, useValue: { user: signal({ id: 'me' }) } },
],
});
+ const navigate = vi
+ .spyOn(TestBed.inject(Router), 'navigateByUrl')
+ .mockResolvedValue(true);
+ const notifications = TestBed.inject(Notifications);
const host = TestBed.createComponent(Host).componentInstance;
- return { ...host, fakes };
+ return { ...host, fakes, navigate, notifications };
}
describe('BoardFacade', () => {
@@ -152,6 +159,40 @@ describe('ProjectFacade', () => {
expect(project.workflow().status('IN_REVIEW').name).toBe('In review');
});
+ it('makes the project public and deletes it, then goes home', () => {
+ const { project, fakes, navigate, notifications } = setup();
+ project.open('WEB');
+
+ project.setPublic(true);
+ expect(fakes.projects.update).toHaveBeenCalledWith({
+ projectId: 'p1',
+ isPublic: true,
+ });
+
+ project.deleteProject('WEB');
+ expect(fakes.projects.delete).toHaveBeenCalledWith('p1', 'WEB');
+ expect(project.deleting()).toBe(false);
+ expect(notifications.success).toHaveBeenLastCalledWith(
+ 'Proyecto WEB eliminado',
+ );
+ expect(navigate).toHaveBeenCalledWith('/');
+ });
+
+ it('leaves when someone else deletes the open project', () => {
+ const live = new Subject();
+ const { project, fakes, navigate, notifications } = setup();
+ fakes.realtime.projectChanges.mockReturnValue(live);
+ project.open('WEB');
+
+ live.next({ project, deleted: false });
+ expect(navigate).not.toHaveBeenCalled();
+ live.next({ project: { key: 'WEB' }, deleted: true });
+ expect(notifications.success).toHaveBeenCalledWith(
+ 'El proyecto WEB se ha eliminado',
+ );
+ expect(navigate).toHaveBeenCalledWith('/');
+ });
+
it('searches the directory from two characters', () => {
const { project } = setup();
const seen: string[] = [];
diff --git a/libs/web/tracker/application/src/lib/facades/project.facade.ts b/libs/web/tracker/application/src/lib/facades/project.facade.ts
index 8ef05fd..4a4c6c6 100644
--- a/libs/web/tracker/application/src/lib/facades/project.facade.ts
+++ b/libs/web/tracker/application/src/lib/facades/project.facade.ts
@@ -1,4 +1,4 @@
-import { inject, Injectable, Provider } from '@angular/core';
+import { computed, inject, Injectable, Provider } from '@angular/core';
import { injectDispatch } from '@ngrx/signals/events';
import {
CreateReleaseInput,
@@ -41,6 +41,9 @@ export class ProjectFacade {
readonly analyticsError = this.store.analyticsError;
/** Burnup of the release asked for with `loadBurnup`. */
readonly burnup = this.store.burnup;
+ readonly deleting = this.store.deleting;
+ /** Only the owner can delete the project. */
+ readonly isOwner = computed(() => this.store.myRole() === ProjectRole.OWNER);
/** Loads the project (by id or key) and follows its changes. */
open(idOrKey: string): void {
@@ -91,6 +94,22 @@ export class ProjectFacade {
if (projectId) this.dispatch.settingsSaveRequested({ projectId, settings });
}
+ /** Public: anyone (also without signing in) can read it. Owners and admins. */
+ setPublic(isPublic: boolean): void {
+ const projectId = this.projectId();
+ if (projectId)
+ this.dispatch.visibilityChangeRequested({ projectId, isPublic });
+ }
+
+ /**
+ * Owner only, typing the key to confirm. Tickets, comments and releases go
+ * too; on success it navigates home.
+ */
+ deleteProject(confirmKey: string): void {
+ const projectId = this.projectId();
+ if (projectId) this.dispatch.deleteRequested({ projectId, confirmKey });
+ }
+
loadAnalytics(days = this.store.analyticsDays()): void {
const projectId = this.projectId();
if (projectId) this.dispatch.analyticsRequested({ projectId, days });
diff --git a/libs/web/tracker/application/src/lib/stores/explore.store.ts b/libs/web/tracker/application/src/lib/stores/explore.store.ts
new file mode 100644
index 0000000..f8cd096
--- /dev/null
+++ b/libs/web/tracker/application/src/lib/stores/explore.store.ts
@@ -0,0 +1,58 @@
+import { inject } from '@angular/core';
+import { mapResponse } from '@ngrx/operators';
+import { signalStore, withState } from '@ngrx/signals';
+import {
+ Events,
+ on,
+ withEventHandlers,
+ withReducer,
+} from '@ngrx/signals/events';
+import { Project, ProjectsGateway } from '@task-stream/web/tracker/domain';
+import { switchMap } from 'rxjs';
+import { exploreApiEvents, explorePageEvents } from '../events/explore.events';
+import { describeError } from '../notifications';
+
+interface ExploreState {
+ projects: Project[];
+ loaded: boolean;
+ loading: boolean;
+ error: string | null;
+}
+
+/** Public projects, readable by anyone (also without signing in). */
+export const ExploreStore = signalStore(
+ { providedIn: 'root' },
+ withState({
+ projects: [],
+ loaded: false,
+ loading: false,
+ error: null,
+ }),
+ withReducer(
+ on(explorePageEvents.opened, () => ({ loading: true, error: null })),
+ on(exploreApiEvents.loaded, ({ payload }) => ({
+ projects: payload,
+ loaded: true,
+ loading: false,
+ })),
+ on(exploreApiEvents.loadFailed, ({ payload }) => ({
+ loading: false,
+ error: payload,
+ })),
+ ),
+ withEventHandlers(
+ (_store, events = inject(Events), projects = inject(ProjectsGateway)) => ({
+ load$: events.on(explorePageEvents.opened).pipe(
+ switchMap(() =>
+ projects.publicProjects().pipe(
+ mapResponse({
+ next: (list) => exploreApiEvents.loaded(list),
+ error: (error) =>
+ exploreApiEvents.loadFailed(describeError(error)),
+ }),
+ ),
+ ),
+ ),
+ }),
+ ),
+);
diff --git a/libs/web/tracker/application/src/lib/stores/project.store.ts b/libs/web/tracker/application/src/lib/stores/project.store.ts
index 11f5944..ebdca62 100644
--- a/libs/web/tracker/application/src/lib/stores/project.store.ts
+++ b/libs/web/tracker/application/src/lib/stores/project.store.ts
@@ -24,8 +24,11 @@ import {
UsersGateway,
Workflow,
} from '@task-stream/web/tracker/domain';
+import { Router } from '@angular/router';
import {
debounceTime,
+ exhaustMap,
+ filter,
map,
merge,
mergeMap,
@@ -56,6 +59,8 @@ interface ProjectState {
burnup: ReleaseBurnup | null;
/** True while settings are being saved (until the project comes back changed). */
savingSettings: boolean;
+ /** True from asking to delete the project until the answer. */
+ deleting: boolean;
loading: boolean;
error: string | null;
}
@@ -74,6 +79,7 @@ export const ProjectStore = signalStore(
analyticsError: null,
burnup: null,
savingSettings: false,
+ deleting: false,
loading: false,
error: null,
}),
@@ -120,10 +126,12 @@ export const ProjectStore = signalStore(
on(projectPageEvents.settingsSaveRequested, () => ({
savingSettings: true,
})),
+ on(projectPageEvents.deleteRequested, () => ({ deleting: true })),
+ on(projectApiEvents.deleted, () => ({ deleting: false })),
on(
projectApiEvents.commandSucceeded,
projectApiEvents.commandFailed,
- () => ({ savingSettings: false }),
+ () => ({ savingSettings: false, deleting: false }),
),
on(projectPageEvents.analyticsRequested, ({ payload }) => ({
analyticsDays: payload.days,
@@ -171,6 +179,7 @@ export const ProjectStore = signalStore(
realtime = inject(TrackerRealtimeGateway),
notifications = inject(Notifications),
users = inject(UsersGateway),
+ router = inject(Router),
) => {
const loaded$ = events.on(projectApiEvents.loaded);
const reloadReleases = (projectId: string) =>
@@ -218,7 +227,13 @@ export const ProjectStore = signalStore(
merge(
realtime
.projectChanges(payload.id)
- .pipe(map((p) => projectRealtimeEvents.projectChanged(p))),
+ .pipe(
+ map(({ project, deleted }) =>
+ deleted
+ ? projectRealtimeEvents.projectDeleted(project)
+ : projectRealtimeEvents.projectChanged(project),
+ ),
+ ),
realtime
.activity(payload.id)
.pipe(map((a) => projectRealtimeEvents.activityAdded(a))),
@@ -335,6 +350,20 @@ export const ProjectStore = signalStore(
),
),
),
+ events
+ .on(projectPageEvents.visibilityChangeRequested)
+ .pipe(
+ mergeMap(({ payload }) =>
+ runCommand(
+ projects.update(payload),
+ payload.isPublic
+ ? 'Proyecto público: cualquiera puede verlo'
+ : 'Proyecto privado: solo para sus miembros',
+ projectApiEvents.commandSucceeded,
+ projectApiEvents.commandFailed,
+ ),
+ ),
+ ),
events
.on(projectPageEvents.releaseCreateRequested)
.pipe(
@@ -372,6 +401,36 @@ export const ProjectStore = signalStore(
),
),
),
+ delete$: events.on(projectPageEvents.deleteRequested).pipe(
+ exhaustMap(({ payload }) =>
+ projects.delete(payload.projectId, payload.confirmKey).pipe(
+ mapResponse({
+ next: () =>
+ projectApiEvents.deleted(
+ `Proyecto ${store.project()?.key ?? ''} eliminado`,
+ ),
+ error: (error) =>
+ projectApiEvents.commandFailed(describeError(error)),
+ }),
+ ),
+ ),
+ ),
+ /** Gone (by me or by someone else): back to the home page. */
+ leave$: merge(
+ events
+ .on(projectApiEvents.deleted)
+ .pipe(map(({ payload }) => payload)),
+ events.on(projectRealtimeEvents.projectDeleted).pipe(
+ // My own deletion already said so.
+ filter(() => !store.deleting()),
+ map(({ payload }) => `El proyecto ${payload.key} se ha eliminado`),
+ ),
+ ).pipe(
+ tap((message) => {
+ notifications.success(message);
+ void router.navigateByUrl('/');
+ }),
+ ),
feedback$: merge(
events
.on(projectApiEvents.commandSucceeded)
diff --git a/libs/web/tracker/application/src/lib/stores/projects.store.ts b/libs/web/tracker/application/src/lib/stores/projects.store.ts
index b4dac62..06b7afc 100644
--- a/libs/web/tracker/application/src/lib/stores/projects.store.ts
+++ b/libs/web/tracker/application/src/lib/stores/projects.store.ts
@@ -88,12 +88,12 @@ export const ProjectsStore = signalStore(
/** One live subscription for the whole session. */
live$: events.on(projectsPageEvents.opened).pipe(
exhaustMap(() => realtime.myProjectsChanges()),
- map((project) =>
+ map(({ project, deleted }) =>
projectsRealtimeEvents.projectChanged({
project,
- stillMember: project.members.some(
- (m) => m.user.id === auth.user()?.id,
- ),
+ stillMember:
+ !deleted &&
+ project.members.some((m) => m.user.id === auth.user()?.id),
}),
),
),
diff --git a/libs/web/tracker/domain/src/lib/models/inputs.ts b/libs/web/tracker/domain/src/lib/models/inputs.ts
index 9cc9c7c..090f5d3 100644
--- a/libs/web/tracker/domain/src/lib/models/inputs.ts
+++ b/libs/web/tracker/domain/src/lib/models/inputs.ts
@@ -9,6 +9,14 @@ export interface CreateProjectInput {
key: string;
name: string;
description?: string | null;
+ isPublic?: boolean;
+}
+
+export interface UpdateProjectInput {
+ projectId: string;
+ name?: string;
+ description?: string | null;
+ isPublic?: boolean;
}
export interface CreateIssueInput {
diff --git a/libs/web/tracker/domain/src/lib/models/models.ts b/libs/web/tracker/domain/src/lib/models/models.ts
index 91c67fb..cae54be 100644
--- a/libs/web/tracker/domain/src/lib/models/models.ts
+++ b/libs/web/tracker/domain/src/lib/models/models.ts
@@ -22,6 +22,8 @@ export interface Project {
key: string;
name: string;
description: string | null;
+ /** Anyone can read it, also without signing in. */
+ isPublic: boolean;
owner: UserRef;
members: ProjectMember[];
settings: ProjectSettings;
diff --git a/libs/web/tracker/domain/src/lib/ports/gateways.ts b/libs/web/tracker/domain/src/lib/ports/gateways.ts
index 1adc1ab..24acfe1 100644
--- a/libs/web/tracker/domain/src/lib/ports/gateways.ts
+++ b/libs/web/tracker/domain/src/lib/ports/gateways.ts
@@ -25,6 +25,7 @@ import {
MemberInput,
MyIssuesFilter,
UpdateIssueInput,
+ UpdateProjectInput,
} from '../models/inputs';
/*
@@ -34,6 +35,8 @@ import {
export abstract class ProjectsGateway {
abstract myProjects(): Observable;
+ /** Projects anyone can read (no sign-in needed). */
+ abstract publicProjects(): Observable;
abstract project(idOrKey: string): Observable;
abstract stats(projectId: string): Observable;
/** Statistics of the last `days` days. */
@@ -47,6 +50,13 @@ export abstract class ProjectsGateway {
settings: ProjectSettings,
): Observable;
abstract create(input: CreateProjectInput): Observable;
+ /** Name, description or visibility (owners and admins). */
+ abstract update(input: UpdateProjectInput): Observable;
+ /** Owner only; `confirmKey` must be the project key. */
+ abstract delete(
+ projectId: string,
+ confirmKey: string,
+ ): Observable;
abstract addMember(input: MemberInput): Observable;
abstract changeMemberRole(input: MemberInput): Observable;
abstract removeMember(
@@ -118,11 +128,18 @@ export interface CommentChanged {
comment: Comment | null;
}
+/** A project changed, or was deleted (then `project` is its last state). */
+export interface ProjectChanged {
+ project: Project;
+ deleted: boolean;
+}
+
+/** Realtime needs a session: without one, these streams stay silent. */
export abstract class TrackerRealtimeGateway {
abstract issueChanges(projectId: string): Observable;
abstract commentChanges(issueId: string): Observable;
abstract activity(projectId: string): Observable;
abstract releaseChanges(projectId: string): Observable;
- abstract projectChanges(projectId: string): Observable;
- abstract myProjectsChanges(): Observable;
+ abstract projectChanges(projectId: string): Observable;
+ abstract myProjectsChanges(): Observable;
}
diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-projects.gateway.ts b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-projects.gateway.ts
index 9abf01d..1fe6887 100644
--- a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-projects.gateway.ts
+++ b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-projects.gateway.ts
@@ -8,17 +8,21 @@ import {
ProjectSettings,
ProjectsGateway,
ProjectStats,
+ UpdateProjectInput,
} from '@task-stream/web/tracker/domain';
import { map, Observable } from 'rxjs';
import {
AddProjectMemberDocument,
ChangeProjectMemberRoleDocument,
CreateProjectDocument,
+ DeleteProjectDocument,
MyProjectsDocument,
ProjectAnalyticsDocument,
ProjectDocument,
ProjectStatsDocument,
+ PublicProjectsDocument,
RemoveProjectMemberDocument,
+ UpdateProjectDocument,
UpdateProjectSettingsDocument,
} from '../graphql/generated';
import { GraphqlOperations } from './graphql-operations';
@@ -39,6 +43,12 @@ export class GraphqlProjectsGateway extends ProjectsGateway {
.pipe(map((d) => d.myProjects.map(toProject)));
}
+ publicProjects(): Observable {
+ return this.gql
+ .query(PublicProjectsDocument)
+ .pipe(map((d) => d.publicProjects.map(toProject)));
+ }
+
project(idOrKey: string): Observable {
return this.gql
.query(ProjectDocument, { idOrKey })
@@ -75,6 +85,18 @@ export class GraphqlProjectsGateway extends ProjectsGateway {
.pipe(map((d) => toCommandResult(d.createProject)));
}
+ update(input: UpdateProjectInput): Observable {
+ return this.gql
+ .mutate(UpdateProjectDocument, { input })
+ .pipe(map((d) => toCommandResult(d.updateProject)));
+ }
+
+ delete(projectId: string, confirmKey: string): Observable {
+ return this.gql
+ .mutate(DeleteProjectDocument, { projectId, confirmKey })
+ .pipe(map((d) => toCommandResult(d.deleteProject)));
+ }
+
addMember(input: MemberInput): Observable {
return this.gql
.mutate(AddProjectMemberDocument, { input: input as never })
diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-realtime.gateway.ts b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-realtime.gateway.ts
index c9794e7..c2a503e 100644
--- a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-realtime.gateway.ts
+++ b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-realtime.gateway.ts
@@ -1,13 +1,14 @@
import { inject, Injectable } from '@angular/core';
+import { AccessTokenSource } from '@task-stream/web/shared/infrastructure';
import {
Activity,
CommentChanged,
IssueChanged,
- Project,
+ ProjectChanged,
Release,
TrackerRealtimeGateway,
} from '@task-stream/web/tracker/domain';
-import { map, Observable } from 'rxjs';
+import { defer, EMPTY, map, Observable, switchMap } from 'rxjs';
import {
ActivityAddedDocument,
CommentChangedDocument,
@@ -25,53 +26,84 @@ import {
toRelease,
} from './mappers';
-/** GraphQL subscriptions (graphql-ws). Each stream lives while it is subscribed. */
+/**
+ * GraphQL subscriptions (graphql-ws). Each stream lives while it is
+ * subscribed. The API only accepts signed-in sockets, so anonymous visitors
+ * of public projects get no live updates (instead of a socket retrying forever).
+ */
@Injectable()
export class GraphqlRealtimeGateway extends TrackerRealtimeGateway {
private readonly gql = inject(GraphqlOperations);
+ private readonly tokens = inject(AccessTokenSource);
+
+ private signedIn(stream: () => Observable): Observable {
+ return defer(() => this.tokens.accessToken()).pipe(
+ switchMap((token) => (token ? stream() : EMPTY)),
+ );
+ }
issueChanges(projectId: string): Observable {
- return this.gql.subscribe(IssueChangedDocument, { projectId }).pipe(
- map(({ issueChanged }) => ({
- ...issueChanged,
- issue: toIssueSummary(issueChanged.issue),
- })),
+ return this.signedIn(() =>
+ this.gql.subscribe(IssueChangedDocument, { projectId }).pipe(
+ map(({ issueChanged }) => ({
+ ...issueChanged,
+ issue: toIssueSummary(issueChanged.issue),
+ })),
+ ),
);
}
commentChanges(issueId: string): Observable {
- return this.gql.subscribe(CommentChangedDocument, { issueId }).pipe(
- map(({ commentChanged }) => ({
- action: commentChanged.action,
- commentId: commentChanged.commentId,
- comment: commentChanged.comment
- ? toComment(commentChanged.comment)
- : null,
- })),
+ return this.signedIn(() =>
+ this.gql.subscribe(CommentChangedDocument, { issueId }).pipe(
+ map(({ commentChanged }) => ({
+ action: commentChanged.action,
+ commentId: commentChanged.commentId,
+ comment: commentChanged.comment
+ ? toComment(commentChanged.comment)
+ : null,
+ })),
+ ),
);
}
activity(projectId: string): Observable {
- return this.gql
- .subscribe(ActivityAddedDocument, { projectId })
- .pipe(map((d) => toActivity(d.activityAdded)));
+ return this.signedIn(() =>
+ this.gql
+ .subscribe(ActivityAddedDocument, { projectId })
+ .pipe(map((d) => toActivity(d.activityAdded))),
+ );
}
releaseChanges(projectId: string): Observable {
- return this.gql
- .subscribe(ReleaseChangedDocument, { projectId })
- .pipe(map((d) => toRelease(d.releaseChanged.release)));
+ return this.signedIn(() =>
+ this.gql
+ .subscribe(ReleaseChangedDocument, { projectId })
+ .pipe(map((d) => toRelease(d.releaseChanged.release))),
+ );
}
- projectChanges(projectId: string): Observable {
- return this.gql
- .subscribe(ProjectChangedDocument, { projectId })
- .pipe(map((d) => toProject(d.projectChanged.project)));
+ projectChanges(projectId: string): Observable {
+ return this.signedIn(() =>
+ this.gql
+ .subscribe(ProjectChangedDocument, { projectId })
+ .pipe(map((d) => toProjectChanged(d.projectChanged))),
+ );
}
- myProjectsChanges(): Observable {
- return this.gql
- .subscribe(MyProjectsChangedDocument)
- .pipe(map((d) => toProject(d.myProjectsChanged.project)));
+ myProjectsChanges(): Observable {
+ return this.signedIn(() =>
+ this.gql
+ .subscribe(MyProjectsChangedDocument)
+ .pipe(map((d) => toProjectChanged(d.myProjectsChanged))),
+ );
}
}
+
+const toProjectChanged = (change: {
+ action: string;
+ project: Parameters[0];
+}): ProjectChanged => ({
+ project: toProject(change.project),
+ deleted: change.action === 'ProjectDeletedEvent',
+});
diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql
index 5f46787..b114f7e 100644
--- a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql
+++ b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql
@@ -8,6 +8,7 @@ fragment ProjectFields on Project {
key
name
description
+ isPublic
updatedAt
version
owner {
diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts
index a1921b9..bf6ec58 100644
--- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts
+++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts
@@ -41,6 +41,8 @@ export type CreateIssueInput = {
export type CreateProjectInput = {
description?: string | null | undefined;
+ /** Readable by anyone, also without signing in */
+ isPublic?: boolean | null | undefined;
/** 2-10 chars, e.g. "TS" → tickets TS-1, TS-2... */
key: string;
name: string;
@@ -141,6 +143,13 @@ export type UpdateIssueInput = {
type?: string | null | undefined;
};
+export type UpdateProjectInput = {
+ description?: string | null | undefined;
+ isPublic?: boolean | null | undefined;
+ name?: string | null | undefined;
+ projectId: string;
+};
+
export type WorkflowStatusInput = {
archived?: boolean;
category: StatusCategory;
@@ -157,6 +166,7 @@ export type ProjectFieldsFragment = {
key: string;
name: string;
description: string | null;
+ isPublic: boolean;
updatedAt: string;
version: number;
owner: { id: string; name: string };
@@ -504,6 +514,7 @@ export type MyProjectsQuery = {
key: string;
name: string;
description: string | null;
+ isPublic: boolean;
updatedAt: string;
version: number;
owner: { id: string; name: string };
@@ -540,6 +551,7 @@ export type ProjectQuery = {
key: string;
name: string;
description: string | null;
+ isPublic: boolean;
updatedAt: string;
version: number;
owner: { id: string; name: string };
@@ -678,6 +690,58 @@ export type ProjectAnalyticsQuery = {
};
};
+export type PublicProjectsQueryVariables = Exact<{ [key: string]: never }>;
+
+export type PublicProjectsQuery = {
+ publicProjects: Array<{
+ id: string;
+ key: string;
+ name: string;
+ description: string | null;
+ isPublic: boolean;
+ updatedAt: string;
+ version: number;
+ owner: { id: string; name: string };
+ members: Array<{ role: ProjectRole; user: { id: string; name: string } }>;
+ settings: {
+ statuses: Array<{
+ id: string;
+ name: string;
+ category: StatusCategory;
+ color: string;
+ requiresReason: boolean;
+ archived: boolean;
+ }>;
+ transitions: Array<{ from: string; to: Array }>;
+ issueTypes: Array<{
+ id: string;
+ name: string;
+ icon: string;
+ color: string;
+ archived: boolean;
+ }>;
+ areas: Array<{ id: string; name: string; archived: boolean }>;
+ };
+ }>;
+};
+
+export type UpdateProjectMutationVariables = Exact<{
+ input: UpdateProjectInput;
+}>;
+
+export type UpdateProjectMutation = {
+ updateProject: { id: string; key: string | null; version: number };
+};
+
+export type DeleteProjectMutationVariables = Exact<{
+ projectId: string;
+ confirmKey: string;
+}>;
+
+export type DeleteProjectMutation = {
+ deleteProject: { id: string; key: string | null; version: number };
+};
+
export type ReleasesQueryVariables = Exact<{
idOrKey: string;
}>;
@@ -862,11 +926,13 @@ export type ProjectChangedSubscriptionVariables = Exact<{
export type ProjectChangedSubscription = {
projectChanged: {
+ action: string;
project: {
id: string;
key: string;
name: string;
description: string | null;
+ isPublic: boolean;
updatedAt: string;
version: number;
owner: { id: string; name: string };
@@ -900,11 +966,13 @@ export type MyProjectsChangedSubscriptionVariables = Exact<{
export type MyProjectsChangedSubscription = {
myProjectsChanged: {
+ action: string;
project: {
id: string;
key: string;
name: string;
description: string | null;
+ isPublic: boolean;
updatedAt: string;
version: number;
owner: { id: string; name: string };
@@ -969,6 +1037,7 @@ export const ProjectFieldsFragmentDoc = {
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{ kind: 'Field', name: { kind: 'Name', value: 'description' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'isPublic' } },
{ kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
{
@@ -3067,6 +3136,7 @@ export const MyProjectsDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{ kind: 'Field', name: { kind: 'Name', value: 'description' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'isPublic' } },
{ kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
{
@@ -3267,6 +3337,7 @@ export const ProjectDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{ kind: 'Field', name: { kind: 'Name', value: 'description' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'isPublic' } },
{ kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
{
@@ -4263,6 +4334,346 @@ export const ProjectAnalyticsDocument = {
ProjectAnalyticsQuery,
ProjectAnalyticsQueryVariables
>;
+export const PublicProjectsDocument = {
+ kind: 'Document',
+ definitions: [
+ {
+ kind: 'OperationDefinition',
+ operation: 'query',
+ name: { kind: 'Name', value: 'PublicProjects' },
+ selectionSet: {
+ kind: 'SelectionSet',
+ selections: [
+ {
+ kind: 'Field',
+ name: { kind: 'Name', value: 'publicProjects' },
+ selectionSet: {
+ kind: 'SelectionSet',
+ selections: [
+ {
+ kind: 'FragmentSpread',
+ name: { kind: 'Name', value: 'ProjectFields' },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ {
+ kind: 'FragmentDefinition',
+ name: { kind: 'Name', value: 'UserRefFields' },
+ typeCondition: {
+ kind: 'NamedType',
+ name: { kind: 'Name', value: 'UserRef' },
+ },
+ selectionSet: {
+ kind: 'SelectionSet',
+ selections: [
+ { kind: 'Field', name: { kind: 'Name', value: 'id' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'name' } },
+ ],
+ },
+ },
+ {
+ kind: 'FragmentDefinition',
+ name: { kind: 'Name', value: 'ProjectFields' },
+ typeCondition: {
+ kind: 'NamedType',
+ name: { kind: 'Name', value: 'Project' },
+ },
+ selectionSet: {
+ kind: 'SelectionSet',
+ selections: [
+ { kind: 'Field', name: { kind: 'Name', value: 'id' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'key' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'name' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'description' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'isPublic' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'version' } },
+ {
+ kind: 'Field',
+ name: { kind: 'Name', value: 'owner' },
+ selectionSet: {
+ kind: 'SelectionSet',
+ selections: [
+ {
+ kind: 'FragmentSpread',
+ name: { kind: 'Name', value: 'UserRefFields' },
+ },
+ ],
+ },
+ },
+ {
+ kind: 'Field',
+ name: { kind: 'Name', value: 'members' },
+ selectionSet: {
+ kind: 'SelectionSet',
+ selections: [
+ { kind: 'Field', name: { kind: 'Name', value: 'role' } },
+ {
+ kind: 'Field',
+ name: { kind: 'Name', value: 'user' },
+ selectionSet: {
+ kind: 'SelectionSet',
+ selections: [
+ {
+ kind: 'FragmentSpread',
+ name: { kind: 'Name', value: 'UserRefFields' },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ {
+ kind: 'Field',
+ name: { kind: 'Name', value: 'settings' },
+ selectionSet: {
+ kind: 'SelectionSet',
+ selections: [
+ {
+ kind: 'Field',
+ name: { kind: 'Name', value: 'statuses' },
+ selectionSet: {
+ kind: 'SelectionSet',
+ selections: [
+ { kind: 'Field', name: { kind: 'Name', value: 'id' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'name' } },
+ {
+ kind: 'Field',
+ name: { kind: 'Name', value: 'category' },
+ },
+ { kind: 'Field', name: { kind: 'Name', value: 'color' } },
+ {
+ kind: 'Field',
+ name: { kind: 'Name', value: 'requiresReason' },
+ },
+ {
+ kind: 'Field',
+ name: { kind: 'Name', value: 'archived' },
+ },
+ ],
+ },
+ },
+ {
+ kind: 'Field',
+ name: { kind: 'Name', value: 'transitions' },
+ selectionSet: {
+ kind: 'SelectionSet',
+ selections: [
+ { kind: 'Field', name: { kind: 'Name', value: 'from' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'to' } },
+ ],
+ },
+ },
+ {
+ kind: 'Field',
+ name: { kind: 'Name', value: 'issueTypes' },
+ selectionSet: {
+ kind: 'SelectionSet',
+ selections: [
+ { kind: 'Field', name: { kind: 'Name', value: 'id' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'name' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'icon' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'color' } },
+ {
+ kind: 'Field',
+ name: { kind: 'Name', value: 'archived' },
+ },
+ ],
+ },
+ },
+ {
+ kind: 'Field',
+ name: { kind: 'Name', value: 'areas' },
+ selectionSet: {
+ kind: 'SelectionSet',
+ selections: [
+ { kind: 'Field', name: { kind: 'Name', value: 'id' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'name' } },
+ {
+ kind: 'Field',
+ name: { kind: 'Name', value: 'archived' },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ ],
+} as unknown as DocumentNode;
+export const UpdateProjectDocument = {
+ kind: 'Document',
+ definitions: [
+ {
+ kind: 'OperationDefinition',
+ operation: 'mutation',
+ name: { kind: 'Name', value: 'UpdateProject' },
+ variableDefinitions: [
+ {
+ kind: 'VariableDefinition',
+ variable: {
+ kind: 'Variable',
+ name: { kind: 'Name', value: 'input' },
+ },
+ type: {
+ kind: 'NonNullType',
+ type: {
+ kind: 'NamedType',
+ name: { kind: 'Name', value: 'UpdateProjectInput' },
+ },
+ },
+ },
+ ],
+ selectionSet: {
+ kind: 'SelectionSet',
+ selections: [
+ {
+ kind: 'Field',
+ name: { kind: 'Name', value: 'updateProject' },
+ arguments: [
+ {
+ kind: 'Argument',
+ name: { kind: 'Name', value: 'input' },
+ value: {
+ kind: 'Variable',
+ name: { kind: 'Name', value: 'input' },
+ },
+ },
+ ],
+ selectionSet: {
+ kind: 'SelectionSet',
+ selections: [
+ {
+ kind: 'FragmentSpread',
+ name: { kind: 'Name', value: 'CommandResultFields' },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ {
+ kind: 'FragmentDefinition',
+ name: { kind: 'Name', value: 'CommandResultFields' },
+ typeCondition: {
+ kind: 'NamedType',
+ name: { kind: 'Name', value: 'CommandResult' },
+ },
+ selectionSet: {
+ kind: 'SelectionSet',
+ selections: [
+ { kind: 'Field', name: { kind: 'Name', value: 'id' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'key' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'version' } },
+ ],
+ },
+ },
+ ],
+} as unknown as DocumentNode<
+ UpdateProjectMutation,
+ UpdateProjectMutationVariables
+>;
+export const DeleteProjectDocument = {
+ kind: 'Document',
+ definitions: [
+ {
+ kind: 'OperationDefinition',
+ operation: 'mutation',
+ name: { kind: 'Name', value: 'DeleteProject' },
+ variableDefinitions: [
+ {
+ kind: 'VariableDefinition',
+ variable: {
+ kind: 'Variable',
+ name: { kind: 'Name', value: 'projectId' },
+ },
+ type: {
+ kind: 'NonNullType',
+ type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
+ },
+ },
+ {
+ kind: 'VariableDefinition',
+ variable: {
+ kind: 'Variable',
+ name: { kind: 'Name', value: 'confirmKey' },
+ },
+ type: {
+ kind: 'NonNullType',
+ type: {
+ kind: 'NamedType',
+ name: { kind: 'Name', value: 'String' },
+ },
+ },
+ },
+ ],
+ selectionSet: {
+ kind: 'SelectionSet',
+ selections: [
+ {
+ kind: 'Field',
+ name: { kind: 'Name', value: 'deleteProject' },
+ arguments: [
+ {
+ kind: 'Argument',
+ name: { kind: 'Name', value: 'projectId' },
+ value: {
+ kind: 'Variable',
+ name: { kind: 'Name', value: 'projectId' },
+ },
+ },
+ {
+ kind: 'Argument',
+ name: { kind: 'Name', value: 'confirmKey' },
+ value: {
+ kind: 'Variable',
+ name: { kind: 'Name', value: 'confirmKey' },
+ },
+ },
+ ],
+ selectionSet: {
+ kind: 'SelectionSet',
+ selections: [
+ {
+ kind: 'FragmentSpread',
+ name: { kind: 'Name', value: 'CommandResultFields' },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ {
+ kind: 'FragmentDefinition',
+ name: { kind: 'Name', value: 'CommandResultFields' },
+ typeCondition: {
+ kind: 'NamedType',
+ name: { kind: 'Name', value: 'CommandResult' },
+ },
+ selectionSet: {
+ kind: 'SelectionSet',
+ selections: [
+ { kind: 'Field', name: { kind: 'Name', value: 'id' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'key' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'version' } },
+ ],
+ },
+ },
+ ],
+} as unknown as DocumentNode<
+ DeleteProjectMutation,
+ DeleteProjectMutationVariables
+>;
export const ReleasesDocument = {
kind: 'Document',
definitions: [
@@ -5344,6 +5755,7 @@ export const ProjectChangedDocument = {
selectionSet: {
kind: 'SelectionSet',
selections: [
+ { kind: 'Field', name: { kind: 'Name', value: 'action' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'project' },
@@ -5392,6 +5804,7 @@ export const ProjectChangedDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{ kind: 'Field', name: { kind: 'Name', value: 'description' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'isPublic' } },
{ kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
{
@@ -5530,6 +5943,7 @@ export const MyProjectsChangedDocument = {
selectionSet: {
kind: 'SelectionSet',
selections: [
+ { kind: 'Field', name: { kind: 'Name', value: 'action' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'project' },
@@ -5578,6 +5992,7 @@ export const MyProjectsChangedDocument = {
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{ kind: 'Field', name: { kind: 'Name', value: 'description' } },
+ { kind: 'Field', name: { kind: 'Name', value: 'isPublic' } },
{ kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
{
diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/projects.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/projects.graphql
index 1827f6e..db8c203 100644
--- a/libs/web/tracker/infrastructure/src/lib/graphql/projects.graphql
+++ b/libs/web/tracker/infrastructure/src/lib/graphql/projects.graphql
@@ -126,3 +126,21 @@ query ProjectAnalytics($idOrKey: String!, $days: Int!) {
}
}
}
+
+query PublicProjects {
+ publicProjects {
+ ...ProjectFields
+ }
+}
+
+mutation UpdateProject($input: UpdateProjectInput!) {
+ updateProject(input: $input) {
+ ...CommandResultFields
+ }
+}
+
+mutation DeleteProject($projectId: ID!, $confirmKey: String!) {
+ deleteProject(projectId: $projectId, confirmKey: $confirmKey) {
+ ...CommandResultFields
+ }
+}
diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/subscriptions.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/subscriptions.graphql
index 1c3f332..781ae85 100644
--- a/libs/web/tracker/infrastructure/src/lib/graphql/subscriptions.graphql
+++ b/libs/web/tracker/infrastructure/src/lib/graphql/subscriptions.graphql
@@ -35,6 +35,7 @@ subscription ReleaseChanged($projectId: ID!) {
subscription ProjectChanged($projectId: ID!) {
projectChanged(projectId: $projectId) {
+ action
project {
...ProjectFields
}
@@ -43,6 +44,7 @@ subscription ProjectChanged($projectId: ID!) {
subscription MyProjectsChanged {
myProjectsChanged {
+ action
project {
...ProjectFields
}
diff --git a/libs/web/tracker/presenters/README.md b/libs/web/tracker/presenters/README.md
index 380485e..53c77ff 100644
--- a/libs/web/tracker/presenters/README.md
+++ b/libs/web/tracker/presenters/README.md
@@ -8,8 +8,9 @@ Pantallas del tracker con `@pi/ui`.
## Contenido
- Rutas `trackerRoutes`: inicio (proyectos + «Mi trabajo», `pages/my-work.ts`), tablero kanban (drag & drop), ticket,
- releases, seguimiento, estadísticas, actividad, miembros y configuración.
-- `ui/`: `IssueCardComponent`, `AvatarsComponent`, `GoToComponent` («Ir a… WEB-12»), `SeriesChartComponent` (gráficas
+ releases, seguimiento, estadísticas, actividad, miembros y configuración (con visibilidad y borrado), más
+ «Explorar» (`pages/explore.page.ts`). Solo la portada exige sesión.
+- `ui/`: `IssueCardComponent`, `AvatarsComponent`, `GoToComponent` («Ir a… WEB-12»), `ProjectCardComponent`, `SeriesChartComponent` (gráficas
SVG de líneas y áreas apiladas) y `copy.ts` (textos y tonos; estados, tipos y áreas salen del `Workflow` del proyecto).
## Comandos
diff --git a/libs/web/tracker/presenters/src/lib/pages/explore.page.ts b/libs/web/tracker/presenters/src/lib/pages/explore.page.ts
new file mode 100644
index 0000000..6796578
--- /dev/null
+++ b/libs/web/tracker/presenters/src/lib/pages/explore.page.ts
@@ -0,0 +1,94 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ inject,
+ OnInit,
+} from '@angular/core';
+import { RouterLink } from '@angular/router';
+import {
+ PiEmptyStateComponent,
+ PiNoteComponent,
+ PiSkeletonComponent,
+} from '@pi/ui';
+import { AuthFacade } from '@task-stream/web/identity/application';
+import { ExploreFacade } from '@task-stream/web/tracker/application';
+import { ProjectCardComponent } from '../ui/project-card';
+
+/** /explorar — public projects, for anyone (no sign-in needed). */
+@Component({
+ selector: 'ts-explore-page',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [
+ RouterLink,
+ PiEmptyStateComponent,
+ PiNoteComponent,
+ PiSkeletonComponent,
+ ProjectCardComponent,
+ ],
+ template: `
+
+
+ @if (facade.error(); as error) {
+
+ } @else if (!facade.loaded()) {
+
+ @for (i of [1, 2, 3]; track i) {
+
+ }
+
+ } @else if (!facade.projects().length) {
+
+ } @else {
+
+ @for (project of facade.projects(); track project.id) {
+
+ }
+
+ }
+ `,
+ styles: `
+ .ts-strong {
+ font-weight: 600;
+ color: var(--ts-brand);
+ }
+ .ts-projects {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(min(100%, 20rem), 1fr));
+ gap: 1.25rem;
+ }
+ `,
+})
+export class ExplorePage implements OnInit {
+ protected readonly facade = inject(ExploreFacade);
+ protected readonly auth = inject(AuthFacade);
+
+ ngOnInit(): void {
+ this.facade.load();
+ }
+}
diff --git a/libs/web/tracker/presenters/src/lib/pages/project-shell.page.ts b/libs/web/tracker/presenters/src/lib/pages/project-shell.page.ts
index b040bd6..e9cd003 100644
--- a/libs/web/tracker/presenters/src/lib/pages/project-shell.page.ts
+++ b/libs/web/tracker/presenters/src/lib/pages/project-shell.page.ts
@@ -6,14 +6,20 @@ import {
inject,
input,
} from '@angular/core';
-import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
+import {
+ Router,
+ RouterLink,
+ RouterLinkActive,
+ RouterOutlet,
+} from '@angular/router';
+import { AuthFacade } from '@task-stream/web/identity/application';
import { PiNoteComponent, PiSpinnerComponent } from '@pi/ui';
import {
ProjectFacade,
provideProjectFacade,
} from '@task-stream/web/tracker/application';
import { AvatarsComponent } from '../ui/avatars';
-import { projectTone, ROLE } from '../ui/copy';
+import { projectTone, PUBLIC_TONE, ROLE } from '../ui/copy';
/** /p/:key — loads the project once for all its tabs (its store and facade are scoped here). */
@Component({
@@ -33,11 +39,18 @@ import { projectTone, ROLE } from '../ui/copy';
+ @if (!facade.myRole()) {
+
+ Proyecto público: lo estás viendo en modo lectura.
+ @if (!auth.isAuthenticated()) {
+ Inicia sesión
+ si eres del equipo.
+ }
+
+ }
Tablero
Releases
@@ -77,8 +104,26 @@ import { projectTone, ROLE } from '../ui/copy';
appearance="fill"
type="status"
title="No se pudo abrir el proyecto"
- [text]="error"
+ [text]="
+ auth.isAuthenticated()
+ ? error
+ : 'No existe o no es público. Si es de tu equipo, inicia sesión.'
+ "
/>
+ @if (!auth.isAuthenticated()) {
+
+ Iniciar sesión
+ ·
+ Ver proyectos públicos
+
+ }
} @else {
@@ -86,6 +131,33 @@ import { projectTone, ROLE } from '../ui/copy';
}
`,
styles: `
+ .ts-eyebrow .ts-pill {
+ margin-left: 0.5rem;
+ letter-spacing: 0;
+ text-transform: none;
+ vertical-align: middle;
+ }
+ .ts-readonly {
+ margin: 1rem 0 0;
+ padding: 0.625rem 0.875rem;
+ border-radius: 0.625rem;
+ background: color-mix(in srgb, var(--ts-c-teal) 8%, var(--ts-surface));
+ border: 1px solid color-mix(in srgb, var(--ts-c-teal) 25%, transparent);
+ font-size: 0.875rem;
+ color: var(--ts-ink-soft);
+ a {
+ font-weight: 600;
+ color: var(--ts-brand);
+ }
+ }
+ .ts-center-actions {
+ margin: 1rem 0 0;
+ font-size: 0.875rem;
+ a {
+ font-weight: 600;
+ color: var(--ts-brand);
+ }
+ }
.ts-project-header {
display: flex;
align-items: flex-end;
@@ -138,6 +210,11 @@ export class ProjectShellPage {
readonly key = input.required
();
protected readonly facade = inject(ProjectFacade);
+ protected readonly auth = inject(AuthFacade);
+ private readonly router = inject(Router);
+ protected readonly publicTone = PUBLIC_TONE;
+ /** Where to come back after signing in. */
+ protected readonly url = () => this.router.url;
protected readonly roleLabel = (role: keyof typeof ROLE) => ROLE[role];
protected readonly tone = projectTone;
protected readonly memberNames = computed(() =>
diff --git a/libs/web/tracker/presenters/src/lib/pages/projects.page.ts b/libs/web/tracker/presenters/src/lib/pages/projects.page.ts
index 90f0212..ba1d95d 100644
--- a/libs/web/tracker/presenters/src/lib/pages/projects.page.ts
+++ b/libs/web/tracker/presenters/src/lib/pages/projects.page.ts
@@ -15,7 +15,6 @@ import {
pattern,
required,
} from '@angular/forms/signals';
-import { RouterLink } from '@angular/router';
import {
PiActionButtonComponent,
PiEmptyStateComponent,
@@ -27,19 +26,17 @@ import {
import { DrawerService } from '@task-stream/web/shared/ui';
import { ProjectsFacade } from '@task-stream/web/tracker/application';
import { AuthFacade } from '@task-stream/web/identity/application';
-import { AvatarsComponent } from '../ui/avatars';
import { GoToComponent } from '../ui/go-to';
+import { ProjectCardComponent } from '../ui/project-card';
import { MyWorkComponent } from './my-work';
-import { projectTone, timeAgo } from '../ui/copy';
@Component({
selector: 'ts-projects-page',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
- RouterLink,
FormField,
- AvatarsComponent,
GoToComponent,
+ ProjectCardComponent,
MyWorkComponent,
PiActionButtonComponent,
PiEmptyStateComponent,
@@ -101,32 +98,7 @@ import { projectTone, timeAgo } from '../ui/copy';
} @else {
}
@@ -151,6 +123,14 @@ import { projectTone, timeAgo } from '../ui/copy';
>
Descripción
+
+
+
+ Proyecto público
+ Cualquiera, también sin iniciar sesión, podrá verlo (solo lectura).
+ Se puede cambiar después en Configuración.
+
+
>('createForm');
- private readonly model = signal({ key: '', name: '', description: '' });
+ private readonly model = signal({
+ key: '',
+ name: '',
+ description: '',
+ isPublic: false,
+ });
protected readonly f = form(this.model, (p) => {
required(p.key);
pattern(p.key, /^[A-Za-z][A-Za-z0-9]{1,9}$/, {
@@ -276,28 +214,25 @@ export class ProjectsPage implements OnInit {
maxLength(p.name, 120);
});
- protected readonly ago = timeAgo;
- protected readonly tone = projectTone;
protected readonly pad = (n: number) => String(n).padStart(2, '0');
- protected readonly memberNames = (members: { user: { name: string } }[]) =>
- members.map((m) => m.user.name);
ngOnInit(): void {
this.facade.load();
}
protected openCreate(): void {
- this.model.set({ key: '', name: '', description: '' });
+ this.model.set({ key: '', name: '', description: '', isPublic: false });
this.drawer.open('Nuevo proyecto', this.createForm());
}
protected create(): void {
if (this.f().invalid()) return;
- const { key, name, description } = this.model();
+ const { key, name, description, isPublic } = this.model();
this.facade.create({
key: key.toUpperCase(),
name,
description: description || null,
+ isPublic,
});
this.drawer.closeAll();
}
diff --git a/libs/web/tracker/presenters/src/lib/pages/settings.page.ts b/libs/web/tracker/presenters/src/lib/pages/settings.page.ts
index 50094e3..e62436a 100644
--- a/libs/web/tracker/presenters/src/lib/pages/settings.page.ts
+++ b/libs/web/tracker/presenters/src/lib/pages/settings.page.ts
@@ -80,6 +80,38 @@ function idFrom(name: string): string {
/>
}
+ @if (facade.project(); as project) {
+
+
+
Visibilidad
+
+ @if (project.isPublic) {
+ Público: cualquiera, también sin iniciar sesión, ve el tablero,
+ los tickets, los comentarios, las releases, la actividad y las
+ estadísticas (en la web, por GraphQL y por REST). Solo los
+ miembros pueden cambiar algo.
+ } @else {
+ Privado: solo los miembros del proyecto lo ven.
+ }
+
+
+
+
+
+ {{ project.isPublic ? 'Público' : 'Privado' }}
+
+
+ }
+
@if (draft(); as d) {
+
+ @if (facade.isOwner() && facade.project(); as project) {
+
+
+
Eliminar el proyecto
+
+ Se borran sus tickets, comentarios y releases, y los enlaces que
+ tickets de otros proyectos tengan hacia los suyos. La clave
+ {{ project.key }} queda libre. No se puede deshacer.
+
+
+
+
+ Escribe {{ project.key }} para confirmar
+
+
+
+ {{ facade.deleting() ? 'Eliminando…' : 'Eliminar proyecto' }}
+
+
+
+ }
}
`,
styles: `
+ :host {
+ display: grid;
+ gap: 1rem;
+ }
+ .ts-block.ts-visibility,
+ .ts-block.ts-danger {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1.5rem;
+ flex-wrap: wrap;
+ > div {
+ flex: 1 1 26rem;
+ }
+ p {
+ max-width: 46rem;
+ margin: 0.25rem 0 0;
+ font-size: 0.8125rem;
+ }
+ }
+ .ts-switch {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.625rem;
+ font-size: 0.875rem;
+ font-weight: 600;
+ cursor: pointer;
+ input {
+ position: absolute;
+ opacity: 0;
+ width: 1px;
+ height: 1px;
+ }
+ input:focus-visible + .ts-switch__track {
+ outline: 2px solid var(--ts-brand);
+ outline-offset: 2px;
+ }
+ input:disabled ~ * {
+ opacity: 0.6;
+ cursor: default;
+ }
+ }
+ .ts-switch__track {
+ position: relative;
+ width: 2.5rem;
+ height: 1.375rem;
+ border-radius: 999px;
+ background: var(--ts-line-strong);
+ transition: background-color 0.15s ease;
+ &::after {
+ content: '';
+ position: absolute;
+ top: 3px;
+ left: 3px;
+ width: 1rem;
+ height: 1rem;
+ border-radius: 50%;
+ background: var(--ts-surface);
+ box-shadow: var(--ts-shadow);
+ transition: transform 0.15s ease;
+ }
+ }
+ .ts-switch input:checked + .ts-switch__track {
+ background: var(--ts-brand);
+ &::after {
+ transform: translateX(1.125rem);
+ }
+ }
+ .ts-danger {
+ border: 1px solid color-mix(in srgb, var(--ts-c-red) 45%, transparent);
+ h3 {
+ color: var(--ts-c-red);
+ }
+ }
+ .ts-danger__confirm {
+ display: flex;
+ align-items: flex-end;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+ }
+ .ts-danger__label {
+ display: grid;
+ gap: 0.375rem;
+ font-size: 0.8125rem;
+ color: var(--ts-ink-soft);
+ code {
+ font-family: var(--ts-font-mono);
+ font-weight: 600;
+ }
+ .ts-input {
+ width: 12rem;
+ }
+ }
+ .ts-delete {
+ min-width: 0 !important;
+ height: 2.125rem;
+ padding: 0 1rem !important;
+ border: 0 !important;
+ border-radius: 999px !important;
+ background: var(--ts-c-red) !important;
+ color: #fff !important;
+ font: inherit;
+ font-size: 0.8125rem;
+ font-weight: 600;
+ cursor: pointer;
+ &:disabled {
+ opacity: 0.45;
+ cursor: default;
+ }
+ }
.ts-settings {
display: grid;
gap: 1rem;
- padding-bottom: 5rem;
+ padding-bottom: 0;
}
fieldset {
display: grid;
@@ -745,6 +928,8 @@ export class SettingsPage {
() => this.facade.project()?.settings ?? null,
);
protected readonly draft = signal(null);
+ /** The key, typed to confirm deleting the project. */
+ protected readonly confirmKey = signal('');
protected readonly dirty = computed(
() =>
diff --git a/libs/web/tracker/presenters/src/lib/tracker.routes.ts b/libs/web/tracker/presenters/src/lib/tracker.routes.ts
index 5dcda6a..dae71b4 100644
--- a/libs/web/tracker/presenters/src/lib/tracker.routes.ts
+++ b/libs/web/tracker/presenters/src/lib/tracker.routes.ts
@@ -1,13 +1,25 @@
import { Routes } from '@angular/router';
+import { authGuard } from '@task-stream/web/identity/presenters';
-/** Everything behind authentication. */
+/**
+ * The tracker. Home needs a session; project pages and «Explorar» don't:
+ * public projects can be read by anyone (the API decides what each visitor
+ * sees, and pages hide what they cannot change).
+ */
export const trackerRoutes: Routes = [
{
path: '',
+ canActivate: [authGuard],
title: 'Proyectos · TaskStream',
loadComponent: () =>
import('./pages/projects.page').then((m) => m.ProjectsPage),
},
+ {
+ path: 'explorar',
+ title: 'Proyectos públicos · TaskStream',
+ loadComponent: () =>
+ import('./pages/explore.page').then((m) => m.ExplorePage),
+ },
{
path: 'p/:key',
loadComponent: () =>
diff --git a/libs/web/tracker/presenters/src/lib/ui/copy.ts b/libs/web/tracker/presenters/src/lib/ui/copy.ts
index a0bdfd0..b53fd44 100644
--- a/libs/web/tracker/presenters/src/lib/ui/copy.ts
+++ b/libs/web/tracker/presenters/src/lib/ui/copy.ts
@@ -22,6 +22,9 @@ type ChipColor =
/** CSS colour of a settings colour token (`--ts-c-*` in styles.scss). */
export const tone = (color: string) => `var(--ts-c-${color}, var(--ts-muted))`;
+/** Badge of public projects (readable without signing in). */
+export const PUBLIC_TONE = tone('teal');
+
/** A status as the UI shows it: the project's name and colour. */
export function statusLook(workflow: Workflow, id: string) {
const status = workflow.status(id);
diff --git a/libs/web/tracker/presenters/src/lib/ui/project-card.ts b/libs/web/tracker/presenters/src/lib/ui/project-card.ts
new file mode 100644
index 0000000..842f418
--- /dev/null
+++ b/libs/web/tracker/presenters/src/lib/ui/project-card.ts
@@ -0,0 +1,126 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ input,
+} from '@angular/core';
+import { RouterLink } from '@angular/router';
+import { Project } from '@task-stream/web/tracker/domain';
+import { AvatarsComponent } from './avatars';
+import { projectTone, PUBLIC_TONE, timeAgo } from './copy';
+
+/** A project in a list (my projects, public projects): key, name, team. */
+@Component({
+ selector: 'ts-project-card',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [RouterLink, AvatarsComponent],
+ template: `
+
+
+ {{ project().key }}
+ @if (project().isPublic) {
+ Público
+ }
+ {{
+ ago(project().updatedAt)
+ }}
+
+ {{ project().name }}
+ @if (project().description) {
+ {{ project().description }}
+ }
+
+
+ {{ project().members.length }}
+ {{ project().members.length === 1 ? 'persona' : 'personas' }}
+ →
+
+
+ `,
+ styles: `
+ :host {
+ display: block;
+ }
+ .ts-project {
+ display: grid;
+ grid-template-rows: auto auto 1fr auto;
+ gap: 0.375rem;
+ min-height: 12rem;
+ padding: 1.5rem 1.5rem 1.25rem;
+ &:hover .ts-project__go {
+ opacity: 1;
+ transform: none;
+ }
+ }
+ .ts-project__top {
+ display: flex;
+ align-items: center;
+ gap: 0.625rem;
+ }
+ .ts-project__key {
+ font-family: var(--ts-font-mono);
+ font-size: 1.75rem;
+ font-weight: 600;
+ letter-spacing: -0.04em;
+ color: var(--tone);
+ }
+ .ts-project__updated {
+ margin-left: auto;
+ font-size: 0.75rem;
+ color: var(--ts-muted);
+ }
+ .ts-project__name {
+ margin: 0;
+ font-size: 1.25rem;
+ font-weight: 700;
+ line-height: 1.15;
+ }
+ .ts-project__description {
+ margin: 0;
+ color: var(--ts-muted);
+ font-size: 0.9rem;
+ line-height: 1.45;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ }
+ .ts-project__bottom {
+ display: flex;
+ align-items: center;
+ gap: 0.625rem;
+ margin-top: 0.75rem;
+ padding-top: 0.875rem;
+ border-top: 1px dashed var(--ts-line);
+ }
+ .ts-project__count {
+ font-size: 0.75rem;
+ color: var(--ts-muted);
+ }
+ .ts-project__go {
+ margin-left: auto;
+ font-size: 1.125rem;
+ color: var(--tone);
+ opacity: 0;
+ transform: translateX(-4px);
+ transition:
+ opacity 0.18s ease,
+ transform 0.18s ease;
+ }
+ `,
+})
+export class ProjectCardComponent {
+ readonly project = input.required();
+ protected readonly tone = computed(() => projectTone(this.project().key));
+ protected readonly memberNames = computed(() =>
+ this.project().members.map((m) => m.user.name),
+ );
+ protected readonly ago = timeAgo;
+ protected readonly publicTone = PUBLIC_TONE;
+}