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.

+