diff --git a/apps/api/e2e/tracker.e2e-spec.ts b/apps/api/e2e/tracker.e2e-spec.ts index 056c1fd..8c90e1f 100644 --- a/apps/api/e2e/tracker.e2e-spec.ts +++ b/apps/api/e2e/tracker.e2e-spec.ts @@ -1755,4 +1755,101 @@ describe('task-stream (e2e)', () => { (await rest('token-ana', 'PUT', `/issues/${id}/presence`, {})).body, ).toEqual([]); }); + + it('lets a guest see and talk about only their own tickets', async () => { + const key = 'G' + 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, + ); + // Quinn, a client, joins as a guest. + expect((await rest('token-qa', 'GET', '/users/me')).status).toBe(200); + expect( + ( + await rest('token-ana', 'POST', `/projects/${key}/members`, { + userId: '103', + role: 'GUEST', + }) + ).status, + ).toBe(201); + await eventually( + () => rest('token-qa', 'GET', `/projects/${key}`), + (r) => r.status === 200, + ); + const internal = ( + await rest('token-ana', 'POST', `/projects/${key}/issues`, { + type: 'TASK', + title: 'Solo del equipo', + }) + ).body['key']; + + // A guest opens a ticket, but does not plan it. + expect( + ( + await rest('token-qa', 'POST', `/projects/${key}/issues`, { + type: 'BUG', + title: 'Con asignación', + assigneeId: '101', + }) + ).status, + ).toBe(403); + const mine = ( + await rest('token-qa', 'POST', `/projects/${key}/issues`, { + type: 'BUG', + title: 'No puedo pagar', + }) + ).body['key']; + await eventually( + () => rest('token-qa', 'GET', `/issues/${mine}`), + (r) => r.status === 200, + ); + + const list = await eventually( + () => rest('token-ana', 'GET', `/projects/${key}/issues`), + (r) => r.body?.['total'] === 2, + ); + expect(list.body['total']).toBe(2); + const guestList = await rest('token-qa', 'GET', `/projects/${key}/issues`); + expect(guestList.body['items'].map((i: { key: string }) => i.key)).toEqual([ + mine, + ]); + expect((await rest('token-qa', 'GET', `/issues/${internal}`)).status).toBe( + 404, + ); + expect( + (await rest('token-qa', 'PUT', `/issues/${internal}/watch`)).status, + ).toBe(404); + // Project-wide views are not theirs. + expect( + (await rest('token-qa', 'GET', `/projects/${key}/activity`)).status, + ).toBe(403); + expect( + (await rest('token-qa', 'GET', `/projects/${key}/stats`)).status, + ).toBe(403); + + // They talk on their ticket, but do not move it. + expect( + ( + await rest('token-qa', 'POST', `/issues/${mine}/comments`, { + body: '¿Hay novedades?', + }) + ).status, + ).toBe(201); + expect( + ( + await rest('token-qa', 'PUT', `/issues/${mine}/status`, { + status: 'TODO', + }) + ).status, + ).toBe(403); + // Once the team adds them to a ticket, they see it. + await rest('token-ana', 'PUT', `/issues/${internal}/assignee`, { + assigneeId: '103', + }); + await eventually( + () => rest('token-qa', 'GET', `/issues/${internal}`), + (r) => r.status === 200, + ); + }); }); diff --git a/apps/api/openapi.json b/apps/api/openapi.json index a0e7656..2e35072 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -11060,7 +11060,8 @@ "OWNER", "ADMIN", "MEMBER", - "VIEWER" + "VIEWER", + "GUEST" ] }, "ProjectMemberDto": { diff --git a/apps/api/schema.gql b/apps/api/schema.gql index ccc372e..a2b17a4 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -1213,6 +1213,7 @@ input ProjectMemberInput { enum ProjectRole { ADMIN + GUEST MEMBER OWNER VIEWER diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index 66bfed5..1572893 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -1721,7 +1721,7 @@ export interface components { name: string; }; /** @enum {string} */ - ProjectRole: "OWNER" | "ADMIN" | "MEMBER" | "VIEWER"; + ProjectRole: "OWNER" | "ADMIN" | "MEMBER" | "VIEWER" | "GUEST"; ProjectMemberDto: { user: components["schemas"]["UserRefDto"]; role: components["schemas"]["ProjectRole"]; diff --git a/docs/03-api.md b/docs/03-api.md index c53240b..f03ad7d 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -38,12 +38,20 @@ classDiagram ### Roles por proyecto -| Rol | Ver | Crear/modificar tickets, comentar | Gestionar miembros y releases | -| -------------------------------- | --- | --------------------------------- | -------------------------------------------- | -| `OWNER` (quien crea el proyecto) | ✅ | ✅ | ✅ (no se le puede quitar ni cambiar el rol) | -| `ADMIN` | ✅ | ✅ | ✅ | -| `MEMBER` | ✅ | ✅ | ❌ | -| `VIEWER` | ✅ | ❌ | ❌ | +| Rol | Ver | Crear/modificar tickets, comentar | Gestionar miembros y releases | +| -------------------------------- | ---------------- | ----------------------------------------------- | -------------------------------------------- | +| `OWNER` (quien crea el proyecto) | ✅ | ✅ | ✅ (no se le puede quitar ni cambiar el rol) | +| `ADMIN` | ✅ | ✅ | ✅ | +| `MEMBER` | ✅ | ✅ | ❌ | +| `VIEWER` | ✅ | ❌ | ❌ | +| `GUEST` | Solo sus tickets | Abrir tickets, comentar y adjuntar en los suyos | ❌ | + +Un **invitado** (`GUEST`, para clientes o gente de fuera) de un proyecto privado solo ve los tickets en los que +participa: los que abrió, los que tiene asignados y los que sigue. El resto no existen para él (`404`, también en +«Mi trabajo», en las suscripciones en tiempo real, en «bloqueado por» y en los enlazados), y las vistas de todo el +proyecto (actividad, estadísticas, panel, releases, sprints, épicas, papelera) le responden `403`. Abre tickets sin +asignarlos ni planificarlos, comenta y adjunta en los suyos; no cambia su estado ni sus campos. En un proyecto público +ve lo mismo que cualquiera. Cualquiera puede salir de un proyecto (salvo el propietario). Los no miembros reciben _not found_, para no revelar que el proyecto existe. diff --git a/docs/04-cliente-web.md b/docs/04-cliente-web.md index b9a60fa..3296d35 100644 --- a/docs/04-cliente-web.md +++ b/docs/04-cliente-web.md @@ -296,7 +296,7 @@ visualización de la plataforma: | `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) | | `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto): estados (orden, categoría, color, motivo), transiciones, tipos, áreas, equipos (con sus miembros), definición de «Hecho» y campos personalizados; archivar en lugar de borrar | | `/p/:key/activity` | Actividad en vivo | -| `/p/:key/members` | Miembros | +| `/p/:key/members` | Miembros, con su rol (propietario, admin, miembro, lector o invitado). Un invitado de un proyecto privado solo tiene Tablero, Lista y Miembros, ve solo sus tickets y abre tickets sin asignarlos ni planificarlos | 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 diff --git a/docs/10-futuro.md b/docs/10-futuro.md index 9f35165..1ee3c79 100644 --- a/docs/10-futuro.md +++ b/docs/10-futuro.md @@ -39,7 +39,6 @@ Nada: la tanda está hecha (ver 10.5). vista. - **Entornos de despliegue**: en qué entorno está cada ticket (pruebas, producción), desde los avisos de la integración continua. -- **Rol «invitado»**: solo ve los tickets en los que participa (clientes, externos). - **Página pública de novedades**: las notas de versión publicadas en una página por proyecto, con RSS. - **Vista de cartera**: progreso de épicas y releases de todos los proyectos en una página. @@ -121,3 +120,5 @@ Mucho trabajo para lo que aportan hoy; se pueden recuperar si alguien los necesi - Equipos dentro del proyecto: grupos de miembros en Configuración; los tickets se asignan a uno, la lista filtra por equipo y «@equipo» avisa a todos sus miembros. - Presencia: en cada ticket se ve quién más lo está viendo o editando en ese momento. +- Rol «invitado»: para clientes o gente de fuera; en un proyecto privado solo ve los tickets en los que participa, + abre tickets y comenta en los suyos. diff --git a/docs/TaskStream-Documentacion.docx b/docs/TaskStream-Documentacion.docx index cecba35..c563615 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 507ae63..07ac1c4 100644 Binary files a/docs/TaskStream-Documentacion.pdf and b/docs/TaskStream-Documentacion.pdf differ diff --git a/libs/api/tracker/application/src/lib/blockers.service.spec.ts b/libs/api/tracker/application/src/lib/blockers.service.spec.ts index 791dc1a..bf2a5dd 100644 --- a/libs/api/tracker/application/src/lib/blockers.service.spec.ts +++ b/libs/api/tracker/application/src/lib/blockers.service.spec.ts @@ -3,6 +3,7 @@ import { IssueReadModel, StatusCategory, } from '@task-stream/api/tracker/domain'; +import { ReadAccess } from './queries/read-access'; import { BlockersService } from './blockers.service'; import { FindIssuesRepository } from './ports/find-issues.repository'; @@ -30,12 +31,15 @@ describe('BlockersService', () => { ].map((i) => [i.id, i]), ); const queries: string[][] = []; - const service = new BlockersService({ - findByIds: async (ids: string[]) => { - queries.push(ids); - return ids.map((id) => store.get(id)).filter(Boolean); - }, - } as unknown as FindIssuesRepository); + const service = new BlockersService( + { + findByIds: async (ids: string[]) => { + queries.push(ids); + return ids.map((id) => store.get(id)).filter(Boolean); + }, + } as unknown as FindIssuesRepository, + { participantScope: async () => null } as unknown as ReadAccess, + ); const link = ( type: IssueLinkType, direction: 'INWARD' | 'OUTWARD', diff --git a/libs/api/tracker/application/src/lib/blockers.service.ts b/libs/api/tracker/application/src/lib/blockers.service.ts index 0bf3253..b19bf3f 100644 --- a/libs/api/tracker/application/src/lib/blockers.service.ts +++ b/libs/api/tracker/application/src/lib/blockers.service.ts @@ -1,3 +1,4 @@ +import { involves, ReadAccess } from './queries/read-access'; import { Injectable } from '@nestjs/common'; import { IssueLinkType, @@ -31,19 +32,28 @@ export class BlockersService { result: Promise>; } | null = null; - constructor(private readonly findIssues: FindIssuesRepository) {} + constructor( + private readonly findIssues: FindIssuesRepository, + private readonly access: ReadAccess, + ) {} - async blockedBy(issue: IssueReadModel): Promise { + /** For `viewerId`: a guest only learns of blockers they take part in. */ + async blockedBy( + issue: IssueReadModel, + viewerId: string | null = null, + ): Promise { const ids = blockerIdsOf(issue); if (!ids.length) return []; const found = await this.load(ids); + const scope = await this.access.participantScope(issue.projectId, viewerId); return ids .map((id) => found.get(id)) .filter( (blocker): blocker is IssueReadModel => !!blocker && !blocker.trashedAt && - !FINISHED.has(blocker.statusCategory), + !FINISHED.has(blocker.statusCategory) && + (!scope || involves(blocker, scope)), ) .map(({ id, key, title }) => ({ id, key, title })); } diff --git a/libs/api/tracker/application/src/lib/calendar/calendar.ts b/libs/api/tracker/application/src/lib/calendar/calendar.ts index b000274..4c4619d 100644 --- a/libs/api/tracker/application/src/lib/calendar/calendar.ts +++ b/libs/api/tracker/application/src/lib/calendar/calendar.ts @@ -1,3 +1,4 @@ +import { guestOnly } from '../queries/read-access'; import { randomBytes } from 'node:crypto'; import { Command, @@ -120,10 +121,14 @@ export class GetCalendarIcsQueryHandler const projects = await this.findProjects.findByMember(feed.userId); const byId = new Map(projects.map((p) => [p.id, p])); const ids = projects.map((p) => p.id); + // A guest's calendar has their tickets, not the project's plans. + const planned = projects + .filter((p) => !guestOnly(p, feed.userId)) + .map((p) => p.id); const [issues, sprints, releases, me] = await Promise.all([ this.findIssues.findDatedFor(feed.userId, ids), - Promise.all(ids.map((id) => this.findSprints.findByProject(id))), - Promise.all(ids.map((id) => this.findReleases.findByProject(id))), + Promise.all(planned.map((id) => this.findSprints.findByProject(id))), + Promise.all(planned.map((id) => this.findReleases.findByProject(id))), this.users.findById(feed.userId), ]); const link = linkTo(query.webUrl); diff --git a/libs/api/tracker/application/src/lib/changes.service.ts b/libs/api/tracker/application/src/lib/changes.service.ts index 1e6b995..a671260 100644 --- a/libs/api/tracker/application/src/lib/changes.service.ts +++ b/libs/api/tracker/application/src/lib/changes.service.ts @@ -12,6 +12,17 @@ import { } from './ports/change-feed'; import { CanViewProjectQuery } from './queries/can-view-project.query'; import { GetIssueQuery } from './queries/get-issue.query'; +import { GetTicketScopeQuery } from './queries/get-ticket-scope.query'; +import { involves } from './queries/read-access'; + +/** The changes of the tickets `userId` opened, has or follows. */ +async function* onlyInvolving( + changes: AsyncIterableIterator, + userId: string, +): AsyncIterableIterator { + for await (const change of changes) + if (involves(change.issue, userId)) yield change; +} /** Realtime subscriptions, authorised at subscribe time. */ @Injectable() @@ -26,7 +37,12 @@ export class ChangesService { projectId: string, ): Promise> { await this.assertCanView(viewerId, projectId); - return this.changeFeed.issues(projectId); + const changes = this.changeFeed.issues(projectId); + const scope: string | null = await this.queryBus.execute( + new GetTicketScopeQuery(viewerId, projectId), + ); + // A guest hears only about the tickets they take part in. + return scope ? onlyInvolving(changes, scope) : changes; } async watchComments( @@ -41,7 +57,7 @@ export class ChangesService { viewerId: string, projectId: string, ): Promise> { - await this.assertCanView(viewerId, projectId); + await this.assertCanView(viewerId, projectId, true); return this.changeFeed.activity(projectId); } @@ -49,7 +65,7 @@ export class ChangesService { viewerId: string, projectId: string, ): Promise> { - await this.assertCanView(viewerId, projectId); + await this.assertCanView(viewerId, projectId, true); return this.changeFeed.releases(projectId); } @@ -57,7 +73,7 @@ export class ChangesService { viewerId: string, projectId: string, ): Promise> { - await this.assertCanView(viewerId, projectId); + await this.assertCanView(viewerId, projectId, true); return this.changeFeed.sprints(projectId); } @@ -77,9 +93,10 @@ export class ChangesService { private async assertCanView( viewerId: string, projectId: string, + wholeProject = false, ): Promise { const allowed = await this.queryBus.execute( - new CanViewProjectQuery(viewerId, projectId), + new CanViewProjectQuery(viewerId, projectId, wholeProject), ); if (!allowed) throw new NotFoundError('Project', projectId); } diff --git a/libs/api/tracker/application/src/lib/commands/add-comment.command-handler.ts b/libs/api/tracker/application/src/lib/commands/add-comment.command-handler.ts index a04fb39..25155ef 100644 --- a/libs/api/tracker/application/src/lib/commands/add-comment.command-handler.ts +++ b/libs/api/tracker/application/src/lib/commands/add-comment.command-handler.ts @@ -16,7 +16,7 @@ export class AddCommentCommandHandler ) {} async execute(command: AddCommentCommand): Promise { - const { issue } = await this.access.issueForContributor( + const { issue } = await this.access.issueForReporter( command.issueId, command.actorId, ); diff --git a/libs/api/tracker/application/src/lib/commands/attach-file.command-handler.ts b/libs/api/tracker/application/src/lib/commands/attach-file.command-handler.ts index 31d9fdd..c338ad5 100644 --- a/libs/api/tracker/application/src/lib/commands/attach-file.command-handler.ts +++ b/libs/api/tracker/application/src/lib/commands/attach-file.command-handler.ts @@ -20,7 +20,7 @@ export class AttachFileCommandHandler ) {} async execute(command: AttachFileCommand): Promise { - const { issue } = await this.access.issueForContributor( + const { issue } = await this.access.issueForReporter( command.issueId, command.actorId, ); diff --git a/libs/api/tracker/application/src/lib/commands/create-issue.command-handler.ts b/libs/api/tracker/application/src/lib/commands/create-issue.command-handler.ts index 45f57b5..2c55ec9 100644 --- a/libs/api/tracker/application/src/lib/commands/create-issue.command-handler.ts +++ b/libs/api/tracker/application/src/lib/commands/create-issue.command-handler.ts @@ -1,7 +1,9 @@ import { randomUUID } from 'node:crypto'; import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs'; +import { ForbiddenError } from '@task-stream/api/shared/domain'; import { IssueFactory, + ProjectRole, SYSTEM_ACTOR_ID, assertCanNest, } from '@task-stream/api/tracker/domain'; @@ -27,8 +29,21 @@ export class CreateIssueCommandHandler const project = await this.access.project(command.projectId); // TaskStream itself (proposals from the public form) is no member; only // internal commands act as it, never a request from outside. - if (command.actorId !== SYSTEM_ACTOR_ID) - project.assertCanContribute(command.actorId); + if (command.actorId !== SYSTEM_ACTOR_ID) { + project.assertCanReport(command.actorId); + // A guest opens a ticket; planning it is for the team. + if ( + project.roleOf(command.actorId) === ProjectRole.GUEST && + (command.assigneeId || + command.releaseId || + command.parentId || + command.initialStatus || + command.details.team) + ) + throw new ForbiddenError( + 'Un invitado abre el ticket; asignarlo, planificarlo o colgarlo de otro lo hace el equipo', + ); + } // Before taking a number: an invalid type or area must not burn one. const workflow = project.workflow; workflow.assertActiveType(command.type); diff --git a/libs/api/tracker/application/src/lib/commands/react-to-comment.command-handler.ts b/libs/api/tracker/application/src/lib/commands/react-to-comment.command-handler.ts index c2becf9..29b54f4 100644 --- a/libs/api/tracker/application/src/lib/commands/react-to-comment.command-handler.ts +++ b/libs/api/tracker/application/src/lib/commands/react-to-comment.command-handler.ts @@ -18,6 +18,11 @@ export class ReactToCommentCommandHandler throw new ForbiddenError( `You are not a member of project ${project.key}`, ); + this.access.assertSees( + project, + await this.access.issue(comment.issueId), + command.actorId, + ); comment.react(command.actorId, command.emoji, command.on); await comment.commitAndWait(); return ack(comment); diff --git a/libs/api/tracker/application/src/lib/commands/vote-issue.command-handler.ts b/libs/api/tracker/application/src/lib/commands/vote-issue.command-handler.ts index 7d83fa9..64e4661 100644 --- a/libs/api/tracker/application/src/lib/commands/vote-issue.command-handler.ts +++ b/libs/api/tracker/application/src/lib/commands/vote-issue.command-handler.ts @@ -25,6 +25,7 @@ export class VoteIssueCommandHandler `You are not a member of project ${project.key}`, ); project.assertNotArchived(); + this.access.assertSees(project, issue, command.actorId); if (issue.trashedAt) throw new BusinessRuleError(`${issue.key} is in the trash`); issue.vote(command.actorId, command.on); diff --git a/libs/api/tracker/application/src/lib/commands/watch-issue.command-handler.ts b/libs/api/tracker/application/src/lib/commands/watch-issue.command-handler.ts index 2e5710e..9fc353d 100644 --- a/libs/api/tracker/application/src/lib/commands/watch-issue.command-handler.ts +++ b/libs/api/tracker/application/src/lib/commands/watch-issue.command-handler.ts @@ -25,6 +25,8 @@ export class WatchIssueCommandHandler `You are not a member of project ${project.key}`, ); } + // Following would show a guest a ticket they are not part of. + this.access.assertSees(project, issue, command.userId); if (command.watching) issue.watch(command.actorId, command.userId); else issue.unwatch(command.actorId, command.userId); await issue.commitAndWait(); 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 43902c9..316571e 100644 --- a/libs/api/tracker/application/src/lib/commands/write-access.ts +++ b/libs/api/tracker/application/src/lib/commands/write-access.ts @@ -74,6 +74,31 @@ export class WriteAccess { return { issue, project }; } + /** + * Issue + its project, for opening a conversation on it (comments, files, + * following): members and the guests who take part in the ticket. + */ + async issueForReporter( + issueId: string, + actorId: string, + ): Promise<{ issue: Issue; project: Project }> { + const issue = await this.issue(issueId); + const project = await this.project(issue.projectId); + project.assertCanReport(actorId); + this.assertSees(project, issue, actorId); + if (issue.trashedAt) + throw new BusinessRuleError( + `${issue.key} is in the trash: restore it to change it`, + ); + return { issue, project }; + } + + /** A guest knows no ticket they do not take part in. */ + assertSees(project: Project, issue: Issue, userId: string): void { + if (project.seesOnlyOwnTickets(userId) && !issue.involves(userId)) + throw new NotFoundError('Issue', issue.key); + } + /** Release of the same project that still accepts tickets. */ async openReleaseOf(project: Project, releaseId: string): Promise { const release = await this.release(releaseId); diff --git a/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts b/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts index af5382e..bc78b73 100644 --- a/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts +++ b/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts @@ -79,6 +79,7 @@ const ROLE: Record = { [ProjectRole.ADMIN]: 'admin', [ProjectRole.MEMBER]: 'miembro', [ProjectRole.VIEWER]: 'lector', + [ProjectRole.GUEST]: 'invitado', }; const FIELD: Record = { 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 55153a3..927d68c 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 interface IssueFilter { watching?: boolean; /** Only the ones this person follows. */ watcherId?: string; + /** Only the ones this person opened, has or follows (a guest's view). */ + participantId?: string; /** RECENT (default): last updated first. RANK: backlog order. */ sort?: IssueSort; search?: string; @@ -63,6 +65,9 @@ export interface CrossProjectIssueFilter { statuses?: string[]; categories?: StatusCategory[]; priorities?: IssuePriority[]; + /** Of these, only the tickets `participantId` opened, has or follows. */ + guestProjectIds?: string[]; + participantId?: string; /** Project id → the only type ids taken there. */ typesByProject?: Record; /** Due before this day (`YYYY-MM-DD`, excluded). */ diff --git a/libs/api/tracker/application/src/lib/presence.service.spec.ts b/libs/api/tracker/application/src/lib/presence.service.spec.ts index fa701a9..0595100 100644 --- a/libs/api/tracker/application/src/lib/presence.service.spec.ts +++ b/libs/api/tracker/application/src/lib/presence.service.spec.ts @@ -29,9 +29,8 @@ const issues = { : null, } as unknown as FindIssuesRepository; const access = { - assertCanView: async (_projectId: string, viewerId: string) => { + assertCanViewIssue: async (_issue: IssueReadModel, viewerId: string) => { if (viewerId === 'eve') throw new ForbiddenError('No'); - return 'MEMBER'; }, } as unknown as ReadAccess; diff --git a/libs/api/tracker/application/src/lib/presence.service.ts b/libs/api/tracker/application/src/lib/presence.service.ts index fec11a6..64ae21a 100644 --- a/libs/api/tracker/application/src/lib/presence.service.ts +++ b/libs/api/tracker/application/src/lib/presence.service.ts @@ -57,6 +57,6 @@ export class PresenceService { private async assertCanView(viewerId: string, issueId: string) { const issue = await this.findIssues.findById(issueId); if (!issue || issue.trashedAt) throw new NotFoundError('Issue', issueId); - await this.access.assertCanView(issue.projectId, viewerId); + await this.access.assertCanViewIssue(issue, viewerId); } } diff --git a/libs/api/tracker/application/src/lib/queries/can-view-project.query-handler.ts b/libs/api/tracker/application/src/lib/queries/can-view-project.query-handler.ts index 8a9fd9b..39b7e36 100644 --- a/libs/api/tracker/application/src/lib/queries/can-view-project.query-handler.ts +++ b/libs/api/tracker/application/src/lib/queries/can-view-project.query-handler.ts @@ -9,6 +9,11 @@ export class CanViewProjectQueryHandler constructor(private readonly access: ReadAccess) {} async execute(query: CanViewProjectQuery): Promise { - return this.access.canView(query.projectId, query.viewerId); + if (!(await this.access.canView(query.projectId, query.viewerId))) + return false; + return ( + !query.wholeProject || + !(await this.access.participantScope(query.projectId, query.viewerId)) + ); } } 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 e8b585e..de89fa1 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 @@ -5,6 +5,8 @@ export class CanViewProjectQuery extends Query { /** Null: anonymous (public projects only). */ public readonly viewerId: string | null, public readonly projectId: string, + /** The whole project (activity, releases…), not only a guest's tickets. */ + public readonly wholeProject = false, ) { super(); } diff --git a/libs/api/tracker/application/src/lib/queries/get-activity.query-handler.ts b/libs/api/tracker/application/src/lib/queries/get-activity.query-handler.ts index b1791fd..7e211ae 100644 --- a/libs/api/tracker/application/src/lib/queries/get-activity.query-handler.ts +++ b/libs/api/tracker/application/src/lib/queries/get-activity.query-handler.ts @@ -1,3 +1,5 @@ +import { NotFoundError } from '@task-stream/api/shared/domain'; +import { FindIssuesRepository } from '../ports/find-issues.repository'; import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; import { ActivityReadModel } from '@task-stream/api/tracker/domain'; import { FindActivityRepository } from '../ports/find-activity.repository'; @@ -11,10 +13,23 @@ export class GetActivityQueryHandler constructor( private readonly findActivity: FindActivityRepository, private readonly access: ReadAccess, + private readonly findIssues: FindIssuesRepository, ) {} async execute(query: GetActivityQuery): Promise { - await this.access.assertCanView(query.projectId, query.viewerId); + // A ticket's history goes with the ticket; the project's feed is for + // whoever sees the whole project (not a guest). + if (query.issueId) { + const issue = this.access.assertFound( + await this.findIssues.findById(query.issueId), + 'Issue', + query.issueId, + ); + if (issue.projectId !== query.projectId) + throw new NotFoundError('Issue', query.issueId); + await this.access.assertCanViewIssue(issue, query.viewerId); + } else + await this.access.assertSeesWholeProject(query.projectId, query.viewerId); return this.findActivity.find({ projectId: query.projectId, issueId: query.issueId, diff --git a/libs/api/tracker/application/src/lib/queries/get-attachment.query-handler.ts b/libs/api/tracker/application/src/lib/queries/get-attachment.query-handler.ts index e85f88b..ff394d7 100644 --- a/libs/api/tracker/application/src/lib/queries/get-attachment.query-handler.ts +++ b/libs/api/tracker/application/src/lib/queries/get-attachment.query-handler.ts @@ -21,7 +21,7 @@ export class GetAttachmentQueryHandler 'Attachment', query.attachmentId, ); - await this.access.assertCanView(issue.projectId, query.viewerId, { + await this.access.assertCanViewIssue(issue, query.viewerId, { entity: 'Attachment', ref: query.attachmentId, }); diff --git a/libs/api/tracker/application/src/lib/queries/get-board.query-handler.ts b/libs/api/tracker/application/src/lib/queries/get-board.query-handler.ts index 97630f4..441c9f0 100644 --- a/libs/api/tracker/application/src/lib/queries/get-board.query-handler.ts +++ b/libs/api/tracker/application/src/lib/queries/get-board.query-handler.ts @@ -27,6 +27,11 @@ export class GetBoardQueryHandler */ async execute(query: GetBoardQuery): Promise { await this.access.assertCanView(query.filter.projectId, query.viewerId); + const participantId = + (await this.access.participantScope( + query.filter.projectId, + query.viewerId, + )) ?? undefined; const project = await this.findProjects.findById(query.filter.projectId); const statuses = ( project?.settings ?? DEFAULT_PROJECT_SETTINGS @@ -35,6 +40,7 @@ export class GetBoardQueryHandler statuses.map(async (status) => { const page = await this.findIssues.search({ ...query.filter, + participantId, statuses: [status.id], // Each column follows the backlog order. sort: IssueSort.RANK, diff --git a/libs/api/tracker/application/src/lib/queries/get-comments.query-handler.ts b/libs/api/tracker/application/src/lib/queries/get-comments.query-handler.ts index 1e3a158..dc177be 100644 --- a/libs/api/tracker/application/src/lib/queries/get-comments.query-handler.ts +++ b/libs/api/tracker/application/src/lib/queries/get-comments.query-handler.ts @@ -21,7 +21,7 @@ export class GetCommentsQueryHandler 'Issue', query.issueId, ); - await this.access.assertCanView(issue.projectId, query.viewerId, { + await this.access.assertCanViewIssue(issue, query.viewerId, { entity: 'Issue', ref: query.issueId, }); diff --git a/libs/api/tracker/application/src/lib/queries/get-epics.query-handler.ts b/libs/api/tracker/application/src/lib/queries/get-epics.query-handler.ts index 5f3a54a..9f39570 100644 --- a/libs/api/tracker/application/src/lib/queries/get-epics.query-handler.ts +++ b/libs/api/tracker/application/src/lib/queries/get-epics.query-handler.ts @@ -14,7 +14,7 @@ export class GetEpicsQueryHandler ) {} async execute(query: GetEpicsQuery): Promise { - await this.access.assertCanView(query.projectId, query.viewerId); + await this.access.assertSeesWholeProject(query.projectId, query.viewerId); return this.findIssues.epics(query.projectId); } } diff --git a/libs/api/tracker/application/src/lib/queries/get-issue.query-handler.ts b/libs/api/tracker/application/src/lib/queries/get-issue.query-handler.ts index 6ab4b8e..8b86c03 100644 --- a/libs/api/tracker/application/src/lib/queries/get-issue.query-handler.ts +++ b/libs/api/tracker/application/src/lib/queries/get-issue.query-handler.ts @@ -18,7 +18,7 @@ export class GetIssueQueryHandler ? await this.findIssues.findById(query.idOrKey) : await this.findIssues.findByKey(query.idOrKey.toUpperCase()); const found = this.access.assertFound(issue, 'Issue', query.idOrKey); - await this.access.assertCanView(found.projectId, query.viewerId, { + await this.access.assertCanViewIssue(found, query.viewerId, { entity: 'Issue', ref: query.idOrKey, }); 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 99c99fc..e9f397b 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 @@ -23,6 +23,13 @@ export class GetIssuesByIdsQueryHandler await this.access.canView(projectId, query.viewerId), ); } - return issues.filter((issue) => visible.get(issue.projectId)); + const shown: IssueReadModel[] = []; + for (const issue of issues) + if ( + visible.get(issue.projectId) && + (await this.access.canViewIssue(issue, query.viewerId)) + ) + shown.push(issue); + return shown; } } diff --git a/libs/api/tracker/application/src/lib/queries/get-project-analytics.query-handler.ts b/libs/api/tracker/application/src/lib/queries/get-project-analytics.query-handler.ts index 684bd88..ecf9053 100644 --- a/libs/api/tracker/application/src/lib/queries/get-project-analytics.query-handler.ts +++ b/libs/api/tracker/application/src/lib/queries/get-project-analytics.query-handler.ts @@ -26,7 +26,7 @@ export class GetProjectAnalyticsQueryHandler async execute( query: GetProjectAnalyticsQuery, ): Promise { - await this.access.assertCanView(query.projectId, query.viewerId); + await this.access.assertSeesWholeProject(query.projectId, query.viewerId); const [project, issues, timeline] = await Promise.all([ this.findProjects.findById(query.projectId), this.findIssues.findByProject(query.projectId), diff --git a/libs/api/tracker/application/src/lib/queries/get-project-insights.query-handler.ts b/libs/api/tracker/application/src/lib/queries/get-project-insights.query-handler.ts index fc93cc0..853abfc 100644 --- a/libs/api/tracker/application/src/lib/queries/get-project-insights.query-handler.ts +++ b/libs/api/tracker/application/src/lib/queries/get-project-insights.query-handler.ts @@ -32,7 +32,7 @@ export class GetProjectInsightsQueryHandler async execute( query: GetProjectInsightsQuery, ): Promise { - await this.access.assertCanView(query.projectId, query.viewerId); + await this.access.assertSeesWholeProject(query.projectId, query.viewerId); const [project, issues, timeline, sprints, releases, mentions] = await Promise.all([ this.findProjects.findById(query.projectId), diff --git a/libs/api/tracker/application/src/lib/queries/get-project-stats.query-handler.ts b/libs/api/tracker/application/src/lib/queries/get-project-stats.query-handler.ts index 85b7824..0af3410 100644 --- a/libs/api/tracker/application/src/lib/queries/get-project-stats.query-handler.ts +++ b/libs/api/tracker/application/src/lib/queries/get-project-stats.query-handler.ts @@ -14,7 +14,7 @@ export class GetProjectStatsQueryHandler ) {} async execute(query: GetProjectStatsQuery): Promise { - await this.access.assertCanView(query.projectId, query.viewerId); + await this.access.assertSeesWholeProject(query.projectId, query.viewerId); return this.findIssues.stats(query.projectId); } } diff --git a/libs/api/tracker/application/src/lib/queries/get-release-burnup.query-handler.ts b/libs/api/tracker/application/src/lib/queries/get-release-burnup.query-handler.ts index fc4e426..e6577d8 100644 --- a/libs/api/tracker/application/src/lib/queries/get-release-burnup.query-handler.ts +++ b/libs/api/tracker/application/src/lib/queries/get-release-burnup.query-handler.ts @@ -24,10 +24,14 @@ export class GetReleaseBurnupQueryHandler 'Release', query.releaseId, ); - await this.access.assertCanView(release.projectId, query.viewerId, { - entity: 'Release', - ref: query.releaseId, - }); + await this.access.assertSeesWholeProject( + release.projectId, + query.viewerId, + { + entity: 'Release', + ref: query.releaseId, + }, + ); const [issues, timeline] = await Promise.all([ this.findIssues.findByProject(release.projectId), this.findTimeline.findByProject(release.projectId), diff --git a/libs/api/tracker/application/src/lib/queries/get-release-notes-draft.query.ts b/libs/api/tracker/application/src/lib/queries/get-release-notes-draft.query.ts index cd7b973..e621402 100644 --- a/libs/api/tracker/application/src/lib/queries/get-release-notes-draft.query.ts +++ b/libs/api/tracker/application/src/lib/queries/get-release-notes-draft.query.ts @@ -36,10 +36,14 @@ export class GetReleaseNotesDraftQueryHandler 'Release', query.releaseId, ); - await this.access.assertCanView(release.projectId, query.viewerId, { - entity: 'Release', - ref: query.releaseId, - }); + await this.access.assertSeesWholeProject( + release.projectId, + query.viewerId, + { + entity: 'Release', + ref: query.releaseId, + }, + ); const [page, project] = await Promise.all([ this.findIssues.search({ projectId: release.projectId, diff --git a/libs/api/tracker/application/src/lib/queries/get-release-progress.query-handler.ts b/libs/api/tracker/application/src/lib/queries/get-release-progress.query-handler.ts index c3b7d8a..0a17699 100644 --- a/libs/api/tracker/application/src/lib/queries/get-release-progress.query-handler.ts +++ b/libs/api/tracker/application/src/lib/queries/get-release-progress.query-handler.ts @@ -23,10 +23,14 @@ export class GetReleaseProgressQueryHandler 'Release', query.releaseId, ); - await this.access.assertCanView(release.projectId, query.viewerId, { - entity: 'Release', - ref: query.releaseId, - }); + await this.access.assertSeesWholeProject( + release.projectId, + query.viewerId, + { + entity: 'Release', + ref: query.releaseId, + }, + ); return this.findIssues.releaseProgress(release.id); } } diff --git a/libs/api/tracker/application/src/lib/queries/get-release.query-handler.ts b/libs/api/tracker/application/src/lib/queries/get-release.query-handler.ts index 93ecce0..1c059d8 100644 --- a/libs/api/tracker/application/src/lib/queries/get-release.query-handler.ts +++ b/libs/api/tracker/application/src/lib/queries/get-release.query-handler.ts @@ -19,10 +19,14 @@ export class GetReleaseQueryHandler 'Release', query.releaseId, ); - await this.access.assertCanView(release.projectId, query.viewerId, { - entity: 'Release', - ref: query.releaseId, - }); + await this.access.assertSeesWholeProject( + release.projectId, + query.viewerId, + { + entity: 'Release', + ref: query.releaseId, + }, + ); return release; } } diff --git a/libs/api/tracker/application/src/lib/queries/get-releases.query-handler.ts b/libs/api/tracker/application/src/lib/queries/get-releases.query-handler.ts index f222e22..d428344 100644 --- a/libs/api/tracker/application/src/lib/queries/get-releases.query-handler.ts +++ b/libs/api/tracker/application/src/lib/queries/get-releases.query-handler.ts @@ -14,7 +14,7 @@ export class GetReleasesQueryHandler ) {} async execute(query: GetReleasesQuery): Promise { - await this.access.assertCanView(query.projectId, query.viewerId); + await this.access.assertSeesWholeProject(query.projectId, query.viewerId); return this.findReleases.findByProject(query.projectId); } } diff --git a/libs/api/tracker/application/src/lib/queries/get-ticket-scope.query-handler.ts b/libs/api/tracker/application/src/lib/queries/get-ticket-scope.query-handler.ts new file mode 100644 index 0000000..0601f32 --- /dev/null +++ b/libs/api/tracker/application/src/lib/queries/get-ticket-scope.query-handler.ts @@ -0,0 +1,14 @@ +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; +import { GetTicketScopeQuery } from './get-ticket-scope.query'; +import { ReadAccess } from './read-access'; + +@QueryHandler(GetTicketScopeQuery) +export class GetTicketScopeQueryHandler + implements IQueryHandler +{ + constructor(private readonly access: ReadAccess) {} + + execute(query: GetTicketScopeQuery): Promise { + return this.access.participantScope(query.projectId, query.viewerId); + } +} diff --git a/libs/api/tracker/application/src/lib/queries/get-ticket-scope.query.ts b/libs/api/tracker/application/src/lib/queries/get-ticket-scope.query.ts new file mode 100644 index 0000000..ddec756 --- /dev/null +++ b/libs/api/tracker/application/src/lib/queries/get-ticket-scope.query.ts @@ -0,0 +1,14 @@ +import { Query } from '@nestjs/cqrs'; + +/** + * Whose tickets the viewer sees of a project: null for all; their own id + * for a guest of a private project (only the tickets they take part in). + */ +export class GetTicketScopeQuery extends Query { + constructor( + public readonly viewerId: string | null, + public readonly projectId: string, + ) { + super(); + } +} diff --git a/libs/api/tracker/application/src/lib/queries/get-trash.query.ts b/libs/api/tracker/application/src/lib/queries/get-trash.query.ts index 5ec982f..4e3f762 100644 --- a/libs/api/tracker/application/src/lib/queries/get-trash.query.ts +++ b/libs/api/tracker/application/src/lib/queries/get-trash.query.ts @@ -23,7 +23,7 @@ export class GetTrashQueryHandler ) {} async execute(query: GetTrashQuery): Promise { - await this.access.assertCanView(query.projectId, query.viewerId); + await this.access.assertSeesWholeProject(query.projectId, query.viewerId); return this.findIssues.findTrashed(query.projectId); } } diff --git a/libs/api/tracker/application/src/lib/queries/get-upcoming-releases.query-handler.ts b/libs/api/tracker/application/src/lib/queries/get-upcoming-releases.query-handler.ts index a797d4c..bb62b79 100644 --- a/libs/api/tracker/application/src/lib/queries/get-upcoming-releases.query-handler.ts +++ b/libs/api/tracker/application/src/lib/queries/get-upcoming-releases.query-handler.ts @@ -1,3 +1,4 @@ +import { guestOnly } from './read-access'; import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; import { ReleaseReadModel } from '@task-stream/api/tracker/domain'; import { FindProjectsRepository } from '../ports/find-projects.repository'; @@ -15,8 +16,11 @@ export class GetUpcomingReleasesQueryHandler async execute(query: GetUpcomingReleasesQuery): Promise { const projects = await this.findProjects.findByMember(query.viewerId); + // A guest does not see a private project's releases. return this.findReleases.findUpcoming( - projects.map((project) => project.id), + projects + .filter((project) => !guestOnly(project, query.viewerId)) + .map((project) => project.id), Math.min(query.limit, 50), ); } diff --git a/libs/api/tracker/application/src/lib/queries/index.ts b/libs/api/tracker/application/src/lib/queries/index.ts index a12bae4..7a133f4 100644 --- a/libs/api/tracker/application/src/lib/queries/index.ts +++ b/libs/api/tracker/application/src/lib/queries/index.ts @@ -7,6 +7,7 @@ export * from './get-issues-by-ids.query'; export * from './get-my-projects.query'; export * from './get-project-analytics.query'; export * from './get-project-insights.query'; +export * from './get-ticket-scope.query'; export * from './get-project-stats.query'; export * from './get-release-burnup.query'; export * from './get-project.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 6371ca1..059bee2 100644 --- a/libs/api/tracker/application/src/lib/queries/query-handlers.ts +++ b/libs/api/tracker/application/src/lib/queries/query-handlers.ts @@ -10,6 +10,7 @@ import { GetMyProjectsQueryHandler } from './get-my-projects.query-handler'; import { GetProjectQueryHandler } from './get-project.query-handler'; import { GetProjectAnalyticsQueryHandler } from './get-project-analytics.query-handler'; import { GetProjectInsightsQueryHandler } from './get-project-insights.query-handler'; +import { GetTicketScopeQueryHandler } from './get-ticket-scope.query-handler'; import { GetProjectStatsQueryHandler } from './get-project-stats.query-handler'; import { GetReleaseBurnupQueryHandler } from './get-release-burnup.query-handler'; import { GetReleaseQueryHandler } from './get-release.query-handler'; @@ -36,6 +37,7 @@ export const QUERY_HANDLERS = [ GetProjectQueryHandler, GetProjectAnalyticsQueryHandler, GetProjectInsightsQueryHandler, + GetTicketScopeQueryHandler, GetProjectStatsQueryHandler, GetReleaseBurnupQueryHandler, GetReleaseQueryHandler, 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 6087e4a..545efba 100644 --- a/libs/api/tracker/application/src/lib/queries/read-access.ts +++ b/libs/api/tracker/application/src/lib/queries/read-access.ts @@ -1,6 +1,10 @@ import { Injectable } from '@nestjs/common'; -import { NotFoundError } from '@task-stream/api/shared/domain'; -import { ProjectRole } from '@task-stream/api/tracker/domain'; +import { ForbiddenError, NotFoundError } from '@task-stream/api/shared/domain'; +import { + IssueReadModel, + ProjectReadModel, + ProjectRole, +} from '@task-stream/api/tracker/domain'; import { FindProjectsRepository } from '../ports/find-projects.repository'; /** @@ -35,6 +39,67 @@ export class ReadAccess { return null; } + /** + * Whose tickets the viewer sees of the project: null for all of them; for + * a guest of a private project, their own id (only the tickets they take + * part in). + */ + async participantScope( + projectId: string, + viewerId: string | null, + ): Promise { + if (!viewerId) return null; + const role = await this.findProjects.findRole(projectId, viewerId); + if (role !== ProjectRole.GUEST) return null; + const project = await this.findProjects.findById(projectId); + return project?.isPublic ? null : viewerId; + } + + /** + * Views of the whole project (activity, statistics, releases, sprints…): + * not for a guest, who only sees their own tickets. + */ + async assertSeesWholeProject( + projectId: string, + viewerId: string | null, + asked?: { entity: string; ref: string }, + ): Promise { + const role = await this.assertCanView(projectId, viewerId, asked); + if (await this.participantScope(projectId, viewerId)) + throw new ForbiddenError( + 'Un invitado solo ve los tickets en los que participa', + ); + return role; + } + + /** The ticket, if the viewer may read it (a guest: only theirs). */ + async assertCanViewIssue( + issue: IssueReadModel, + viewerId: string | null, + asked: { entity: string; ref: string } = { + entity: 'Issue', + ref: issue.key, + }, + ): Promise { + await this.assertCanView(issue.projectId, viewerId, asked); + const scope = await this.participantScope(issue.projectId, viewerId); + if (scope && !involves(issue, scope)) + throw new NotFoundError(asked.entity, asked.ref); + } + + async canViewIssue( + issue: IssueReadModel, + viewerId: string | null, + ): Promise { + try { + await this.assertCanViewIssue(issue, viewerId); + return true; + } catch (error) { + if (error instanceof NotFoundError) return false; + throw error; + } + } + async canView(projectId: string, viewerId: string | null): Promise { try { await this.assertCanView(projectId, viewerId); @@ -51,5 +116,24 @@ export class ReadAccess { } } +/** Opened it, has it or follows it. */ +export function involves(issue: IssueReadModel, userId: string): boolean { + return ( + issue.reporter?.id === userId || + issue.assignee?.id === userId || + (issue.watchers ?? []).some((w) => w.id === userId) + ); +} + +/** A guest of this private project: sees only the tickets they take part in. */ +export function guestOnly(project: ProjectReadModel, userId: string): boolean { + return ( + !project.isPublic && + project.members.some( + (m) => m.user.id === userId && m.role === ProjectRole.GUEST, + ) + ); +} + export const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; diff --git a/libs/api/tracker/application/src/lib/queries/search-issues.query-handler.ts b/libs/api/tracker/application/src/lib/queries/search-issues.query-handler.ts index 30db508..b42eb86 100644 --- a/libs/api/tracker/application/src/lib/queries/search-issues.query-handler.ts +++ b/libs/api/tracker/application/src/lib/queries/search-issues.query-handler.ts @@ -20,6 +20,11 @@ export class SearchIssuesQueryHandler if (watching && !query.viewerId) return { items: [], total: 0 }; return this.findIssues.search({ ...filter, + participantId: + (await this.access.participantScope( + query.filter.projectId, + query.viewerId, + )) ?? undefined, watcherId: watching ? (query.viewerId ?? undefined) : undefined, limit: Math.min(query.filter.limit, 200), }); diff --git a/libs/api/tracker/application/src/lib/queries/search-my-issues.query-handler.ts b/libs/api/tracker/application/src/lib/queries/search-my-issues.query-handler.ts index 6aa8a4f..ddd77bf 100644 --- a/libs/api/tracker/application/src/lib/queries/search-my-issues.query-handler.ts +++ b/libs/api/tracker/application/src/lib/queries/search-my-issues.query-handler.ts @@ -7,6 +7,7 @@ import { import { FindIssuesRepository, Page } from '../ports/find-issues.repository'; import { FindProjectsRepository } from '../ports/find-projects.repository'; import { SearchMyIssuesQuery } from './search-my-issues.query'; +import { guestOnly } from './read-access'; @QueryHandler(SearchMyIssuesQuery) export class SearchMyIssuesQueryHandler @@ -41,8 +42,13 @@ export class SearchMyIssuesQueryHandler StatusCategory.IN_PROGRESS, StatusCategory.QA, ]; + const guestProjectIds = projects + .filter((p) => guestOnly(p, query.viewerId)) + .map((p) => p.id); return this.findIssues.searchAcross({ projectIds: projects.map((project) => project.id), + guestProjectIds, + participantId: query.viewerId, priorities: filter.priorities?.length ? filter.priorities : undefined, typesByProject: bugTypes, dueBefore: filter.overdue diff --git a/libs/api/tracker/application/src/lib/queries/sprint.queries.ts b/libs/api/tracker/application/src/lib/queries/sprint.queries.ts index e0e8f2f..065f1e7 100644 --- a/libs/api/tracker/application/src/lib/queries/sprint.queries.ts +++ b/libs/api/tracker/application/src/lib/queries/sprint.queries.ts @@ -76,7 +76,7 @@ export class GetSprintsQueryHandler ) {} async execute(query: GetSprintsQuery): Promise { - await this.access.assertCanView(query.projectId, query.viewerId); + await this.access.assertSeesWholeProject(query.projectId, query.viewerId); return this.findSprints.findByProject(query.projectId); } } diff --git a/libs/api/tracker/domain/src/lib/issue/issue.ts b/libs/api/tracker/domain/src/lib/issue/issue.ts index 95517cf..be172ff 100644 --- a/libs/api/tracker/domain/src/lib/issue/issue.ts +++ b/libs/api/tracker/domain/src/lib/issue/issue.ts @@ -200,6 +200,15 @@ export class Issue extends VersionedAggregateRoot implements IssueProps { this.apply(new IssueUpdatedEvent(actorId, this.id, this.projectId, patch)); } + /** Opened it, has it, or follows it: what a guest may see. */ + involves(userId: string): boolean { + return ( + this.reporterId === userId || + this.assigneeId === userId || + this.watchers.includes(userId) + ); + } + /** Moves the ticket along its project's workflow. */ changeStatus( actorId: string, diff --git a/libs/api/tracker/domain/src/lib/project/project-role.ts b/libs/api/tracker/domain/src/lib/project/project-role.ts index a29d745..fd12089 100644 --- a/libs/api/tracker/domain/src/lib/project/project-role.ts +++ b/libs/api/tracker/domain/src/lib/project/project-role.ts @@ -4,4 +4,9 @@ export enum ProjectRole { MEMBER = 'MEMBER', /** Read-only stakeholder (PM, client...). */ VIEWER = 'VIEWER', + /** + * Someone from outside (a client): in a private project, sees only the + * tickets they take part in, opens tickets and comments on theirs. + */ + GUEST = 'GUEST', } diff --git a/libs/api/tracker/domain/src/lib/project/project.ts b/libs/api/tracker/domain/src/lib/project/project.ts index 766139f..6c3439b 100644 --- a/libs/api/tracker/domain/src/lib/project/project.ts +++ b/libs/api/tracker/domain/src/lib/project/project.ts @@ -102,9 +102,31 @@ export class Project extends VersionedAggregateRoot implements ProjectProps { if (this.roleOf(userId) === ProjectRole.VIEWER) { throw new ForbiddenError(`Viewers cannot modify project ${this.key}`); } + if (this.roleOf(userId) === ProjectRole.GUEST) { + throw new ForbiddenError( + `Los invitados solo pueden abrir tickets en ${this.key} y comentar en los suyos`, + ); + } this.assertNotArchived(); } + /** Can open tickets and comment: members and guests (not viewers). */ + assertCanReport(userId: string): void { + const role = this.roleOf(userId); + if (!role) { + throw new ForbiddenError(`You are not a member of project ${this.key}`); + } + if (role === ProjectRole.VIEWER) { + throw new ForbiddenError(`Viewers cannot modify project ${this.key}`); + } + this.assertNotArchived(); + } + + /** A guest of a private project: they only see the tickets they take part in. */ + seesOnlyOwnTickets(userId: string): boolean { + return !this.isPublic && this.roleOf(userId) === ProjectRole.GUEST; + } + /** Can change members, releases, settings and rules (owners and admins). */ assertCanManage(userId: string): void { this.assertManages(userId); 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 6d2e5b5..4df56fc 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts @@ -178,6 +178,14 @@ export class MongoFindIssuesRepository extends FindIssuesRepository { if (filter.dueBefore) query['dueDate'] = { $ne: null, $lt: filter.dueBefore }; const and: Record[] = []; + // Projects where the viewer is a guest: only the tickets they are part of. + if (filter.guestProjectIds?.length && filter.participantId) + and.push({ + $or: [ + { projectId: { $nin: filter.guestProjectIds } }, + { $or: participation(filter.participantId) }, + ], + }); if (filter.typesByProject) and.push({ $or: Object.entries(filter.typesByProject).map( @@ -440,6 +448,13 @@ export class MongoFindIssuesRepository extends FindIssuesRepository { } } +/** Opened it, has it or follows it. */ +const participation = (userId: string) => [ + { 'reporter.id': userId }, + { 'assignee.id': userId }, + { 'watchers.id': userId }, +]; + function toMongoFilter(filter: IssueFilter): FilterQuery { // Tickets in the trash are only listed by the trash itself. const query: FilterQuery = { @@ -476,6 +491,11 @@ function toMongoFilter(filter: IssueFilter): FilterQuery { if (filter.unassigned) query['assignee'] = null; else if (filter.assigneeId) query['assignee.id'] = filter.assigneeId; if (filter.watcherId) query['watchers.id'] = filter.watcherId; + if (filter.participantId) + query.$and = [ + ...(query.$and ?? []), + { $or: participation(filter.participantId) }, + ]; if (filter.search?.trim()) { const pattern = new RegExp(escapeRegex(filter.search.trim()), 'i'); query.$or = [ 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 c7d21ee..d6c4785 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts @@ -223,8 +223,11 @@ export class IssueResolver { description: 'Open tickets it waits for: the ones that block it and the ones it depends on', }) - blockedBy(@Parent() issue: IssueReadModel) { - return this.blockers.blockedBy(issue); + blockedBy( + @CurrentUser() me: User | undefined, + @Parent() issue: IssueReadModel, + ) { + return this.blockers.blockedBy(issue, viewerOf(me)); } @ResolveField(() => Boolean, { description: 'Whether I voted for it' }) diff --git a/libs/api/tracker/presenters/src/lib/http/refs.ts b/libs/api/tracker/presenters/src/lib/http/refs.ts index ab9182d..1b34505 100644 --- a/libs/api/tracker/presenters/src/lib/http/refs.ts +++ b/libs/api/tracker/presenters/src/lib/http/refs.ts @@ -44,7 +44,7 @@ export class Refs { async issueDto(issue: IssueReadModel, viewerId: string | null) { const [allowedTransitions, blockedBy] = await Promise.all([ this.workflows.allowedTransitions(issue), - this.blockers.blockedBy(issue), + this.blockers.blockedBy(issue, viewerId), ]); // Who voted is only for telling someone whether they did. const { voterIds, ...shown } = issue; diff --git a/libs/web/tracker/application/src/lib/facades/project.facade.ts b/libs/web/tracker/application/src/lib/facades/project.facade.ts index 18e7ed5..0fe14f9 100644 --- a/libs/web/tracker/application/src/lib/facades/project.facade.ts +++ b/libs/web/tracker/application/src/lib/facades/project.facade.ts @@ -66,6 +66,10 @@ export class ProjectFacade { readonly canManage = this.store.canManage; /** Any role but viewer. */ readonly canContribute = this.store.canContribute; + /** Opens tickets and comments (members and guests). */ + readonly canReport = this.store.canReport; + /** A guest of a private project: only their own tickets, no project-wide views. */ + readonly seesOnlyOwnTickets = this.store.seesOnlyOwnTickets; /** Statuses, ticket types and areas of this project (names, colours, lookups). */ readonly workflow = this.store.workflow; readonly savingSettings = this.store.savingSettings; diff --git a/libs/web/tracker/application/src/lib/stores/project.store.ts b/libs/web/tracker/application/src/lib/stores/project.store.ts index 476a569..a0874c8 100644 --- a/libs/web/tracker/application/src/lib/stores/project.store.ts +++ b/libs/web/tracker/application/src/lib/stores/project.store.ts @@ -107,11 +107,23 @@ export const ProjectStore = signalStore( (myRole() === ProjectRole.OWNER || myRole() === ProjectRole.ADMIN), ), canContribute: computed( + () => + !project()?.archivedAt && + !!myRole() && + myRole() !== ProjectRole.VIEWER && + myRole() !== ProjectRole.GUEST, + ), + /** Opens tickets and comments: members and guests, not viewers. */ + canReport: computed( () => !project()?.archivedAt && !!myRole() && myRole() !== ProjectRole.VIEWER, ), + /** A guest of a private project: sees only their own tickets. */ + seesOnlyOwnTickets: computed( + () => !project()?.isPublic && myRole() === ProjectRole.GUEST, + ), members: computed(() => project()?.members ?? []), /** Statuses, types and areas of the project, with lookups. */ workflow: computed(() => new Workflow(project()?.settings)), @@ -262,42 +274,55 @@ export const ProjectStore = signalStore( ), ), ), + // A guest of a private project gets none of the project-wide views. details$: loaded$.pipe( switchMap(({ payload }) => - merge( - projects - .stats(payload.id) - .pipe(map((stats) => projectApiEvents.statsLoaded(stats))), - reloadReleases(payload.id), - reloadSprints(payload.id), - activity - .list(payload.id) - .pipe(map((list) => projectApiEvents.activityLoaded(list))), - ), + store.seesOnlyOwnTickets() + ? [] + : merge( + projects + .stats(payload.id) + .pipe(map((stats) => projectApiEvents.statsLoaded(stats))), + reloadReleases(payload.id), + reloadSprints(payload.id), + activity + .list(payload.id) + .pipe(map((list) => projectApiEvents.activityLoaded(list))), + ), ), ), live$: loaded$.pipe( switchMap(({ payload }) => - merge( - realtime - .projectChanges(payload.id) - .pipe( - map(({ project, deleted }) => - deleted - ? projectRealtimeEvents.projectDeleted(project) - : projectRealtimeEvents.projectChanged(project), - ), + store.seesOnlyOwnTickets() + ? realtime + .projectChanges(payload.id) + .pipe( + map(({ project, deleted }) => + deleted + ? projectRealtimeEvents.projectDeleted(project) + : projectRealtimeEvents.projectChanged(project), + ), + ) + : merge( + realtime + .projectChanges(payload.id) + .pipe( + map(({ project, deleted }) => + deleted + ? projectRealtimeEvents.projectDeleted(project) + : projectRealtimeEvents.projectChanged(project), + ), + ), + realtime + .activity(payload.id) + .pipe(map((a) => projectRealtimeEvents.activityAdded(a))), + realtime + .releaseChanges(payload.id) + .pipe(map((r) => projectRealtimeEvents.releaseChanged(r))), + realtime + .sprintChanges(payload.id) + .pipe(map((c) => projectRealtimeEvents.sprintChanged(c))), ), - realtime - .activity(payload.id) - .pipe(map((a) => projectRealtimeEvents.activityAdded(a))), - realtime - .releaseChanges(payload.id) - .pipe(map((r) => projectRealtimeEvents.releaseChanged(r))), - realtime - .sprintChanges(payload.id) - .pipe(map((c) => projectRealtimeEvents.sprintChanged(c))), - ), ), ), /** Activity means tickets moved: refresh the dashboard (debounced). */ diff --git a/libs/web/tracker/domain/src/lib/models/enums.ts b/libs/web/tracker/domain/src/lib/models/enums.ts index fc1be61..2b0997b 100644 --- a/libs/web/tracker/domain/src/lib/models/enums.ts +++ b/libs/web/tracker/domain/src/lib/models/enums.ts @@ -41,6 +41,8 @@ export enum ProjectRole { ADMIN = 'ADMIN', MEMBER = 'MEMBER', VIEWER = 'VIEWER', + /** From outside (a client): only the tickets they take part in. */ + GUEST = 'GUEST', } export enum ReleaseStatus { diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index dcf0a5a..0e3bdc6 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -272,7 +272,7 @@ export type ProjectMemberInput = { userId: string; }; -export type ProjectRole = 'ADMIN' | 'MEMBER' | 'OWNER' | 'VIEWER'; +export type ProjectRole = 'ADMIN' | 'GUEST' | 'MEMBER' | 'OWNER' | 'VIEWER'; /** The whole configuration (replaces the previous one). Statuses go in board order. */ export type ProjectSettingsInput = { diff --git a/libs/web/tracker/presenters/src/lib/pages/board.page.ts b/libs/web/tracker/presenters/src/lib/pages/board.page.ts index 774c5df..9da6797 100644 --- a/libs/web/tracker/presenters/src/lib/pages/board.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/board.page.ts @@ -197,7 +197,7 @@ const NOBODY = '__nobody__'; >{{ board.total() }} tickets - @if (project.canContribute()) { + @if (project.canReport()) { Estimación -
- - Asignar a - - - Release - -
+ @if (project.canContribute()) { +
+ + Asignar a + + + Release + +
+ } } - } @else if (!project.canContribute()) { + } @else if (!project.canReport()) {

Sin adjuntos.

} - @if (project.canContribute()) { + @if (project.canReport()) {
- } @else if ( - project.canContribute() && !thread.comment.deleted - ) { + } @else if (project.canReport() && !thread.comment.deleted) {