feat: guest role for clients and outsiders
A project member can be a GUEST. In a private project a guest only sees the tickets they take part in (opened, assigned or followed): every other ticket answers 404, also in my work, realtime changes, blockers and linked tickets, and project-wide views (activity, statistics, dashboard, releases, sprints, epics, trash) answer 403. Guests open tickets without assigning or planning them, and comment, attach, follow and vote on their own; they change nothing else. In a public project they see what anyone does. The web gives them only Tablero, Lista and Miembros and loads none of the project-wide data. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1755,4 +1755,101 @@ describe('task-stream (e2e)', () => {
|
|||||||
(await rest('token-ana', 'PUT', `/issues/${id}/presence`, {})).body,
|
(await rest('token-ana', 'PUT', `/issues/${id}/presence`, {})).body,
|
||||||
).toEqual([]);
|
).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,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11060,7 +11060,8 @@
|
|||||||
"OWNER",
|
"OWNER",
|
||||||
"ADMIN",
|
"ADMIN",
|
||||||
"MEMBER",
|
"MEMBER",
|
||||||
"VIEWER"
|
"VIEWER",
|
||||||
|
"GUEST"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"ProjectMemberDto": {
|
"ProjectMemberDto": {
|
||||||
|
|||||||
@@ -1213,6 +1213,7 @@ input ProjectMemberInput {
|
|||||||
|
|
||||||
enum ProjectRole {
|
enum ProjectRole {
|
||||||
ADMIN
|
ADMIN
|
||||||
|
GUEST
|
||||||
MEMBER
|
MEMBER
|
||||||
OWNER
|
OWNER
|
||||||
VIEWER
|
VIEWER
|
||||||
|
|||||||
@@ -1721,7 +1721,7 @@ export interface components {
|
|||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
ProjectRole: "OWNER" | "ADMIN" | "MEMBER" | "VIEWER";
|
ProjectRole: "OWNER" | "ADMIN" | "MEMBER" | "VIEWER" | "GUEST";
|
||||||
ProjectMemberDto: {
|
ProjectMemberDto: {
|
||||||
user: components["schemas"]["UserRefDto"];
|
user: components["schemas"]["UserRefDto"];
|
||||||
role: components["schemas"]["ProjectRole"];
|
role: components["schemas"]["ProjectRole"];
|
||||||
|
|||||||
@@ -38,12 +38,20 @@ classDiagram
|
|||||||
|
|
||||||
### Roles por proyecto
|
### Roles por proyecto
|
||||||
|
|
||||||
| Rol | Ver | Crear/modificar tickets, comentar | Gestionar miembros y releases |
|
| Rol | Ver | Crear/modificar tickets, comentar | Gestionar miembros y releases |
|
||||||
| -------------------------------- | --- | --------------------------------- | -------------------------------------------- |
|
| -------------------------------- | ---------------- | ----------------------------------------------- | -------------------------------------------- |
|
||||||
| `OWNER` (quien crea el proyecto) | ✅ | ✅ | ✅ (no se le puede quitar ni cambiar el rol) |
|
| `OWNER` (quien crea el proyecto) | ✅ | ✅ | ✅ (no se le puede quitar ni cambiar el rol) |
|
||||||
| `ADMIN` | ✅ | ✅ | ✅ |
|
| `ADMIN` | ✅ | ✅ | ✅ |
|
||||||
| `MEMBER` | ✅ | ✅ | ❌ |
|
| `MEMBER` | ✅ | ✅ | ❌ |
|
||||||
| `VIEWER` | ✅ | ❌ | ❌ |
|
| `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
|
Cualquiera puede salir de un proyecto (salvo el propietario). Los no miembros reciben _not found_, para no revelar que
|
||||||
el proyecto existe.
|
el proyecto existe.
|
||||||
|
|||||||
@@ -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/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/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/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
|
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
|
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
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ Nada: la tanda está hecha (ver 10.5).
|
|||||||
vista.
|
vista.
|
||||||
- **Entornos de despliegue**: en qué entorno está cada ticket (pruebas, producción), desde los avisos de la
|
- **Entornos de despliegue**: en qué entorno está cada ticket (pruebas, producción), desde los avisos de la
|
||||||
integración continua.
|
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.
|
- **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.
|
- **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
|
- 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.
|
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.
|
- 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.
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -3,6 +3,7 @@ import {
|
|||||||
IssueReadModel,
|
IssueReadModel,
|
||||||
StatusCategory,
|
StatusCategory,
|
||||||
} from '@task-stream/api/tracker/domain';
|
} from '@task-stream/api/tracker/domain';
|
||||||
|
import { ReadAccess } from './queries/read-access';
|
||||||
import { BlockersService } from './blockers.service';
|
import { BlockersService } from './blockers.service';
|
||||||
import { FindIssuesRepository } from './ports/find-issues.repository';
|
import { FindIssuesRepository } from './ports/find-issues.repository';
|
||||||
|
|
||||||
@@ -30,12 +31,15 @@ describe('BlockersService', () => {
|
|||||||
].map((i) => [i.id, i]),
|
].map((i) => [i.id, i]),
|
||||||
);
|
);
|
||||||
const queries: string[][] = [];
|
const queries: string[][] = [];
|
||||||
const service = new BlockersService({
|
const service = new BlockersService(
|
||||||
findByIds: async (ids: string[]) => {
|
{
|
||||||
queries.push(ids);
|
findByIds: async (ids: string[]) => {
|
||||||
return ids.map((id) => store.get(id)).filter(Boolean);
|
queries.push(ids);
|
||||||
},
|
return ids.map((id) => store.get(id)).filter(Boolean);
|
||||||
} as unknown as FindIssuesRepository);
|
},
|
||||||
|
} as unknown as FindIssuesRepository,
|
||||||
|
{ participantScope: async () => null } as unknown as ReadAccess,
|
||||||
|
);
|
||||||
const link = (
|
const link = (
|
||||||
type: IssueLinkType,
|
type: IssueLinkType,
|
||||||
direction: 'INWARD' | 'OUTWARD',
|
direction: 'INWARD' | 'OUTWARD',
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { involves, ReadAccess } from './queries/read-access';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
IssueLinkType,
|
IssueLinkType,
|
||||||
@@ -31,19 +32,28 @@ export class BlockersService {
|
|||||||
result: Promise<Map<string, IssueReadModel>>;
|
result: Promise<Map<string, IssueReadModel>>;
|
||||||
} | null = null;
|
} | null = null;
|
||||||
|
|
||||||
constructor(private readonly findIssues: FindIssuesRepository) {}
|
constructor(
|
||||||
|
private readonly findIssues: FindIssuesRepository,
|
||||||
|
private readonly access: ReadAccess,
|
||||||
|
) {}
|
||||||
|
|
||||||
async blockedBy(issue: IssueReadModel): Promise<Blocker[]> {
|
/** For `viewerId`: a guest only learns of blockers they take part in. */
|
||||||
|
async blockedBy(
|
||||||
|
issue: IssueReadModel,
|
||||||
|
viewerId: string | null = null,
|
||||||
|
): Promise<Blocker[]> {
|
||||||
const ids = blockerIdsOf(issue);
|
const ids = blockerIdsOf(issue);
|
||||||
if (!ids.length) return [];
|
if (!ids.length) return [];
|
||||||
const found = await this.load(ids);
|
const found = await this.load(ids);
|
||||||
|
const scope = await this.access.participantScope(issue.projectId, viewerId);
|
||||||
return ids
|
return ids
|
||||||
.map((id) => found.get(id))
|
.map((id) => found.get(id))
|
||||||
.filter(
|
.filter(
|
||||||
(blocker): blocker is IssueReadModel =>
|
(blocker): blocker is IssueReadModel =>
|
||||||
!!blocker &&
|
!!blocker &&
|
||||||
!blocker.trashedAt &&
|
!blocker.trashedAt &&
|
||||||
!FINISHED.has(blocker.statusCategory),
|
!FINISHED.has(blocker.statusCategory) &&
|
||||||
|
(!scope || involves(blocker, scope)),
|
||||||
)
|
)
|
||||||
.map(({ id, key, title }) => ({ id, key, title }));
|
.map(({ id, key, title }) => ({ id, key, title }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { guestOnly } from '../queries/read-access';
|
||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
@@ -120,10 +121,14 @@ export class GetCalendarIcsQueryHandler
|
|||||||
const projects = await this.findProjects.findByMember(feed.userId);
|
const projects = await this.findProjects.findByMember(feed.userId);
|
||||||
const byId = new Map(projects.map((p) => [p.id, p]));
|
const byId = new Map(projects.map((p) => [p.id, p]));
|
||||||
const ids = projects.map((p) => p.id);
|
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([
|
const [issues, sprints, releases, me] = await Promise.all([
|
||||||
this.findIssues.findDatedFor(feed.userId, ids),
|
this.findIssues.findDatedFor(feed.userId, ids),
|
||||||
Promise.all(ids.map((id) => this.findSprints.findByProject(id))),
|
Promise.all(planned.map((id) => this.findSprints.findByProject(id))),
|
||||||
Promise.all(ids.map((id) => this.findReleases.findByProject(id))),
|
Promise.all(planned.map((id) => this.findReleases.findByProject(id))),
|
||||||
this.users.findById(feed.userId),
|
this.users.findById(feed.userId),
|
||||||
]);
|
]);
|
||||||
const link = linkTo(query.webUrl);
|
const link = linkTo(query.webUrl);
|
||||||
|
|||||||
@@ -12,6 +12,17 @@ import {
|
|||||||
} from './ports/change-feed';
|
} from './ports/change-feed';
|
||||||
import { CanViewProjectQuery } from './queries/can-view-project.query';
|
import { CanViewProjectQuery } from './queries/can-view-project.query';
|
||||||
import { GetIssueQuery } from './queries/get-issue.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<IssueChange>,
|
||||||
|
userId: string,
|
||||||
|
): AsyncIterableIterator<IssueChange> {
|
||||||
|
for await (const change of changes)
|
||||||
|
if (involves(change.issue, userId)) yield change;
|
||||||
|
}
|
||||||
|
|
||||||
/** Realtime subscriptions, authorised at subscribe time. */
|
/** Realtime subscriptions, authorised at subscribe time. */
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -26,7 +37,12 @@ export class ChangesService {
|
|||||||
projectId: string,
|
projectId: string,
|
||||||
): Promise<AsyncIterableIterator<IssueChange>> {
|
): Promise<AsyncIterableIterator<IssueChange>> {
|
||||||
await this.assertCanView(viewerId, projectId);
|
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(
|
async watchComments(
|
||||||
@@ -41,7 +57,7 @@ export class ChangesService {
|
|||||||
viewerId: string,
|
viewerId: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
): Promise<AsyncIterableIterator<ActivityReadModel>> {
|
): Promise<AsyncIterableIterator<ActivityReadModel>> {
|
||||||
await this.assertCanView(viewerId, projectId);
|
await this.assertCanView(viewerId, projectId, true);
|
||||||
return this.changeFeed.activity(projectId);
|
return this.changeFeed.activity(projectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +65,7 @@ export class ChangesService {
|
|||||||
viewerId: string,
|
viewerId: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
): Promise<AsyncIterableIterator<ReleaseChange>> {
|
): Promise<AsyncIterableIterator<ReleaseChange>> {
|
||||||
await this.assertCanView(viewerId, projectId);
|
await this.assertCanView(viewerId, projectId, true);
|
||||||
return this.changeFeed.releases(projectId);
|
return this.changeFeed.releases(projectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +73,7 @@ export class ChangesService {
|
|||||||
viewerId: string,
|
viewerId: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
): Promise<AsyncIterableIterator<SprintChange>> {
|
): Promise<AsyncIterableIterator<SprintChange>> {
|
||||||
await this.assertCanView(viewerId, projectId);
|
await this.assertCanView(viewerId, projectId, true);
|
||||||
return this.changeFeed.sprints(projectId);
|
return this.changeFeed.sprints(projectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,9 +93,10 @@ export class ChangesService {
|
|||||||
private async assertCanView(
|
private async assertCanView(
|
||||||
viewerId: string,
|
viewerId: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
|
wholeProject = false,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const allowed = await this.queryBus.execute(
|
const allowed = await this.queryBus.execute(
|
||||||
new CanViewProjectQuery(viewerId, projectId),
|
new CanViewProjectQuery(viewerId, projectId, wholeProject),
|
||||||
);
|
);
|
||||||
if (!allowed) throw new NotFoundError('Project', projectId);
|
if (!allowed) throw new NotFoundError('Project', projectId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export class AddCommentCommandHandler
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(command: AddCommentCommand): Promise<CommandResult> {
|
async execute(command: AddCommentCommand): Promise<CommandResult> {
|
||||||
const { issue } = await this.access.issueForContributor(
|
const { issue } = await this.access.issueForReporter(
|
||||||
command.issueId,
|
command.issueId,
|
||||||
command.actorId,
|
command.actorId,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export class AttachFileCommandHandler
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(command: AttachFileCommand): Promise<AttachmentResult> {
|
async execute(command: AttachFileCommand): Promise<AttachmentResult> {
|
||||||
const { issue } = await this.access.issueForContributor(
|
const { issue } = await this.access.issueForReporter(
|
||||||
command.issueId,
|
command.issueId,
|
||||||
command.actorId,
|
command.actorId,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs';
|
import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs';
|
||||||
|
import { ForbiddenError } from '@task-stream/api/shared/domain';
|
||||||
import {
|
import {
|
||||||
IssueFactory,
|
IssueFactory,
|
||||||
|
ProjectRole,
|
||||||
SYSTEM_ACTOR_ID,
|
SYSTEM_ACTOR_ID,
|
||||||
assertCanNest,
|
assertCanNest,
|
||||||
} from '@task-stream/api/tracker/domain';
|
} from '@task-stream/api/tracker/domain';
|
||||||
@@ -27,8 +29,21 @@ export class CreateIssueCommandHandler
|
|||||||
const project = await this.access.project(command.projectId);
|
const project = await this.access.project(command.projectId);
|
||||||
// TaskStream itself (proposals from the public form) is no member; only
|
// TaskStream itself (proposals from the public form) is no member; only
|
||||||
// internal commands act as it, never a request from outside.
|
// internal commands act as it, never a request from outside.
|
||||||
if (command.actorId !== SYSTEM_ACTOR_ID)
|
if (command.actorId !== SYSTEM_ACTOR_ID) {
|
||||||
project.assertCanContribute(command.actorId);
|
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.
|
// Before taking a number: an invalid type or area must not burn one.
|
||||||
const workflow = project.workflow;
|
const workflow = project.workflow;
|
||||||
workflow.assertActiveType(command.type);
|
workflow.assertActiveType(command.type);
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ export class ReactToCommentCommandHandler
|
|||||||
throw new ForbiddenError(
|
throw new ForbiddenError(
|
||||||
`You are not a member of project ${project.key}`,
|
`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);
|
comment.react(command.actorId, command.emoji, command.on);
|
||||||
await comment.commitAndWait();
|
await comment.commitAndWait();
|
||||||
return ack(comment);
|
return ack(comment);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export class VoteIssueCommandHandler
|
|||||||
`You are not a member of project ${project.key}`,
|
`You are not a member of project ${project.key}`,
|
||||||
);
|
);
|
||||||
project.assertNotArchived();
|
project.assertNotArchived();
|
||||||
|
this.access.assertSees(project, issue, command.actorId);
|
||||||
if (issue.trashedAt)
|
if (issue.trashedAt)
|
||||||
throw new BusinessRuleError(`${issue.key} is in the trash`);
|
throw new BusinessRuleError(`${issue.key} is in the trash`);
|
||||||
issue.vote(command.actorId, command.on);
|
issue.vote(command.actorId, command.on);
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export class WatchIssueCommandHandler
|
|||||||
`You are not a member of project ${project.key}`,
|
`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);
|
if (command.watching) issue.watch(command.actorId, command.userId);
|
||||||
else issue.unwatch(command.actorId, command.userId);
|
else issue.unwatch(command.actorId, command.userId);
|
||||||
await issue.commitAndWait();
|
await issue.commitAndWait();
|
||||||
|
|||||||
@@ -74,6 +74,31 @@ export class WriteAccess {
|
|||||||
return { issue, project };
|
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. */
|
/** Release of the same project that still accepts tickets. */
|
||||||
async openReleaseOf(project: Project, releaseId: string): Promise<Release> {
|
async openReleaseOf(project: Project, releaseId: string): Promise<Release> {
|
||||||
const release = await this.release(releaseId);
|
const release = await this.release(releaseId);
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ const ROLE: Record<ProjectRole, string> = {
|
|||||||
[ProjectRole.ADMIN]: 'admin',
|
[ProjectRole.ADMIN]: 'admin',
|
||||||
[ProjectRole.MEMBER]: 'miembro',
|
[ProjectRole.MEMBER]: 'miembro',
|
||||||
[ProjectRole.VIEWER]: 'lector',
|
[ProjectRole.VIEWER]: 'lector',
|
||||||
|
[ProjectRole.GUEST]: 'invitado',
|
||||||
};
|
};
|
||||||
|
|
||||||
const FIELD: Record<string, string> = {
|
const FIELD: Record<string, string> = {
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ export interface IssueFilter {
|
|||||||
watching?: boolean;
|
watching?: boolean;
|
||||||
/** Only the ones this person follows. */
|
/** Only the ones this person follows. */
|
||||||
watcherId?: string;
|
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. */
|
/** RECENT (default): last updated first. RANK: backlog order. */
|
||||||
sort?: IssueSort;
|
sort?: IssueSort;
|
||||||
search?: string;
|
search?: string;
|
||||||
@@ -63,6 +65,9 @@ export interface CrossProjectIssueFilter {
|
|||||||
statuses?: string[];
|
statuses?: string[];
|
||||||
categories?: StatusCategory[];
|
categories?: StatusCategory[];
|
||||||
priorities?: IssuePriority[];
|
priorities?: IssuePriority[];
|
||||||
|
/** Of these, only the tickets `participantId` opened, has or follows. */
|
||||||
|
guestProjectIds?: string[];
|
||||||
|
participantId?: string;
|
||||||
/** Project id → the only type ids taken there. */
|
/** Project id → the only type ids taken there. */
|
||||||
typesByProject?: Record<string, string[]>;
|
typesByProject?: Record<string, string[]>;
|
||||||
/** Due before this day (`YYYY-MM-DD`, excluded). */
|
/** Due before this day (`YYYY-MM-DD`, excluded). */
|
||||||
|
|||||||
@@ -29,9 +29,8 @@ const issues = {
|
|||||||
: null,
|
: null,
|
||||||
} as unknown as FindIssuesRepository;
|
} as unknown as FindIssuesRepository;
|
||||||
const access = {
|
const access = {
|
||||||
assertCanView: async (_projectId: string, viewerId: string) => {
|
assertCanViewIssue: async (_issue: IssueReadModel, viewerId: string) => {
|
||||||
if (viewerId === 'eve') throw new ForbiddenError('No');
|
if (viewerId === 'eve') throw new ForbiddenError('No');
|
||||||
return 'MEMBER';
|
|
||||||
},
|
},
|
||||||
} as unknown as ReadAccess;
|
} as unknown as ReadAccess;
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,6 @@ export class PresenceService {
|
|||||||
private async assertCanView(viewerId: string, issueId: string) {
|
private async assertCanView(viewerId: string, issueId: string) {
|
||||||
const issue = await this.findIssues.findById(issueId);
|
const issue = await this.findIssues.findById(issueId);
|
||||||
if (!issue || issue.trashedAt) throw new NotFoundError('Issue', issueId);
|
if (!issue || issue.trashedAt) throw new NotFoundError('Issue', issueId);
|
||||||
await this.access.assertCanView(issue.projectId, viewerId);
|
await this.access.assertCanViewIssue(issue, viewerId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ export class CanViewProjectQueryHandler
|
|||||||
constructor(private readonly access: ReadAccess) {}
|
constructor(private readonly access: ReadAccess) {}
|
||||||
|
|
||||||
async execute(query: CanViewProjectQuery): Promise<boolean> {
|
async execute(query: CanViewProjectQuery): Promise<boolean> {
|
||||||
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))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export class CanViewProjectQuery extends Query<boolean> {
|
|||||||
/** Null: anonymous (public projects only). */
|
/** Null: anonymous (public projects only). */
|
||||||
public readonly viewerId: string | null,
|
public readonly viewerId: string | null,
|
||||||
public readonly projectId: string,
|
public readonly projectId: string,
|
||||||
|
/** The whole project (activity, releases…), not only a guest's tickets. */
|
||||||
|
public readonly wholeProject = false,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||||
import { ActivityReadModel } from '@task-stream/api/tracker/domain';
|
import { ActivityReadModel } from '@task-stream/api/tracker/domain';
|
||||||
import { FindActivityRepository } from '../ports/find-activity.repository';
|
import { FindActivityRepository } from '../ports/find-activity.repository';
|
||||||
@@ -11,10 +13,23 @@ export class GetActivityQueryHandler
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly findActivity: FindActivityRepository,
|
private readonly findActivity: FindActivityRepository,
|
||||||
private readonly access: ReadAccess,
|
private readonly access: ReadAccess,
|
||||||
|
private readonly findIssues: FindIssuesRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(query: GetActivityQuery): Promise<ActivityReadModel[]> {
|
async execute(query: GetActivityQuery): Promise<ActivityReadModel[]> {
|
||||||
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({
|
return this.findActivity.find({
|
||||||
projectId: query.projectId,
|
projectId: query.projectId,
|
||||||
issueId: query.issueId,
|
issueId: query.issueId,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export class GetAttachmentQueryHandler
|
|||||||
'Attachment',
|
'Attachment',
|
||||||
query.attachmentId,
|
query.attachmentId,
|
||||||
);
|
);
|
||||||
await this.access.assertCanView(issue.projectId, query.viewerId, {
|
await this.access.assertCanViewIssue(issue, query.viewerId, {
|
||||||
entity: 'Attachment',
|
entity: 'Attachment',
|
||||||
ref: query.attachmentId,
|
ref: query.attachmentId,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ export class GetBoardQueryHandler
|
|||||||
*/
|
*/
|
||||||
async execute(query: GetBoardQuery): Promise<BoardColumn[]> {
|
async execute(query: GetBoardQuery): Promise<BoardColumn[]> {
|
||||||
await this.access.assertCanView(query.filter.projectId, query.viewerId);
|
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 project = await this.findProjects.findById(query.filter.projectId);
|
||||||
const statuses = (
|
const statuses = (
|
||||||
project?.settings ?? DEFAULT_PROJECT_SETTINGS
|
project?.settings ?? DEFAULT_PROJECT_SETTINGS
|
||||||
@@ -35,6 +40,7 @@ export class GetBoardQueryHandler
|
|||||||
statuses.map(async (status) => {
|
statuses.map(async (status) => {
|
||||||
const page = await this.findIssues.search({
|
const page = await this.findIssues.search({
|
||||||
...query.filter,
|
...query.filter,
|
||||||
|
participantId,
|
||||||
statuses: [status.id],
|
statuses: [status.id],
|
||||||
// Each column follows the backlog order.
|
// Each column follows the backlog order.
|
||||||
sort: IssueSort.RANK,
|
sort: IssueSort.RANK,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export class GetCommentsQueryHandler
|
|||||||
'Issue',
|
'Issue',
|
||||||
query.issueId,
|
query.issueId,
|
||||||
);
|
);
|
||||||
await this.access.assertCanView(issue.projectId, query.viewerId, {
|
await this.access.assertCanViewIssue(issue, query.viewerId, {
|
||||||
entity: 'Issue',
|
entity: 'Issue',
|
||||||
ref: query.issueId,
|
ref: query.issueId,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export class GetEpicsQueryHandler
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(query: GetEpicsQuery): Promise<EpicReadModel[]> {
|
async execute(query: GetEpicsQuery): Promise<EpicReadModel[]> {
|
||||||
await this.access.assertCanView(query.projectId, query.viewerId);
|
await this.access.assertSeesWholeProject(query.projectId, query.viewerId);
|
||||||
return this.findIssues.epics(query.projectId);
|
return this.findIssues.epics(query.projectId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export class GetIssueQueryHandler
|
|||||||
? await this.findIssues.findById(query.idOrKey)
|
? await this.findIssues.findById(query.idOrKey)
|
||||||
: await this.findIssues.findByKey(query.idOrKey.toUpperCase());
|
: await this.findIssues.findByKey(query.idOrKey.toUpperCase());
|
||||||
const found = this.access.assertFound(issue, 'Issue', query.idOrKey);
|
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',
|
entity: 'Issue',
|
||||||
ref: query.idOrKey,
|
ref: query.idOrKey,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ export class GetIssuesByIdsQueryHandler
|
|||||||
await this.access.canView(projectId, query.viewerId),
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export class GetProjectAnalyticsQueryHandler
|
|||||||
async execute(
|
async execute(
|
||||||
query: GetProjectAnalyticsQuery,
|
query: GetProjectAnalyticsQuery,
|
||||||
): Promise<ProjectAnalyticsReadModel> {
|
): Promise<ProjectAnalyticsReadModel> {
|
||||||
await this.access.assertCanView(query.projectId, query.viewerId);
|
await this.access.assertSeesWholeProject(query.projectId, query.viewerId);
|
||||||
const [project, issues, timeline] = await Promise.all([
|
const [project, issues, timeline] = await Promise.all([
|
||||||
this.findProjects.findById(query.projectId),
|
this.findProjects.findById(query.projectId),
|
||||||
this.findIssues.findByProject(query.projectId),
|
this.findIssues.findByProject(query.projectId),
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export class GetProjectInsightsQueryHandler
|
|||||||
async execute(
|
async execute(
|
||||||
query: GetProjectInsightsQuery,
|
query: GetProjectInsightsQuery,
|
||||||
): Promise<ProjectInsightsReadModel> {
|
): Promise<ProjectInsightsReadModel> {
|
||||||
await this.access.assertCanView(query.projectId, query.viewerId);
|
await this.access.assertSeesWholeProject(query.projectId, query.viewerId);
|
||||||
const [project, issues, timeline, sprints, releases, mentions] =
|
const [project, issues, timeline, sprints, releases, mentions] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.findProjects.findById(query.projectId),
|
this.findProjects.findById(query.projectId),
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export class GetProjectStatsQueryHandler
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(query: GetProjectStatsQuery): Promise<ProjectStatsReadModel> {
|
async execute(query: GetProjectStatsQuery): Promise<ProjectStatsReadModel> {
|
||||||
await this.access.assertCanView(query.projectId, query.viewerId);
|
await this.access.assertSeesWholeProject(query.projectId, query.viewerId);
|
||||||
return this.findIssues.stats(query.projectId);
|
return this.findIssues.stats(query.projectId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,10 +24,14 @@ export class GetReleaseBurnupQueryHandler
|
|||||||
'Release',
|
'Release',
|
||||||
query.releaseId,
|
query.releaseId,
|
||||||
);
|
);
|
||||||
await this.access.assertCanView(release.projectId, query.viewerId, {
|
await this.access.assertSeesWholeProject(
|
||||||
entity: 'Release',
|
release.projectId,
|
||||||
ref: query.releaseId,
|
query.viewerId,
|
||||||
});
|
{
|
||||||
|
entity: 'Release',
|
||||||
|
ref: query.releaseId,
|
||||||
|
},
|
||||||
|
);
|
||||||
const [issues, timeline] = await Promise.all([
|
const [issues, timeline] = await Promise.all([
|
||||||
this.findIssues.findByProject(release.projectId),
|
this.findIssues.findByProject(release.projectId),
|
||||||
this.findTimeline.findByProject(release.projectId),
|
this.findTimeline.findByProject(release.projectId),
|
||||||
|
|||||||
@@ -36,10 +36,14 @@ export class GetReleaseNotesDraftQueryHandler
|
|||||||
'Release',
|
'Release',
|
||||||
query.releaseId,
|
query.releaseId,
|
||||||
);
|
);
|
||||||
await this.access.assertCanView(release.projectId, query.viewerId, {
|
await this.access.assertSeesWholeProject(
|
||||||
entity: 'Release',
|
release.projectId,
|
||||||
ref: query.releaseId,
|
query.viewerId,
|
||||||
});
|
{
|
||||||
|
entity: 'Release',
|
||||||
|
ref: query.releaseId,
|
||||||
|
},
|
||||||
|
);
|
||||||
const [page, project] = await Promise.all([
|
const [page, project] = await Promise.all([
|
||||||
this.findIssues.search({
|
this.findIssues.search({
|
||||||
projectId: release.projectId,
|
projectId: release.projectId,
|
||||||
|
|||||||
@@ -23,10 +23,14 @@ export class GetReleaseProgressQueryHandler
|
|||||||
'Release',
|
'Release',
|
||||||
query.releaseId,
|
query.releaseId,
|
||||||
);
|
);
|
||||||
await this.access.assertCanView(release.projectId, query.viewerId, {
|
await this.access.assertSeesWholeProject(
|
||||||
entity: 'Release',
|
release.projectId,
|
||||||
ref: query.releaseId,
|
query.viewerId,
|
||||||
});
|
{
|
||||||
|
entity: 'Release',
|
||||||
|
ref: query.releaseId,
|
||||||
|
},
|
||||||
|
);
|
||||||
return this.findIssues.releaseProgress(release.id);
|
return this.findIssues.releaseProgress(release.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,10 +19,14 @@ export class GetReleaseQueryHandler
|
|||||||
'Release',
|
'Release',
|
||||||
query.releaseId,
|
query.releaseId,
|
||||||
);
|
);
|
||||||
await this.access.assertCanView(release.projectId, query.viewerId, {
|
await this.access.assertSeesWholeProject(
|
||||||
entity: 'Release',
|
release.projectId,
|
||||||
ref: query.releaseId,
|
query.viewerId,
|
||||||
});
|
{
|
||||||
|
entity: 'Release',
|
||||||
|
ref: query.releaseId,
|
||||||
|
},
|
||||||
|
);
|
||||||
return release;
|
return release;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export class GetReleasesQueryHandler
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(query: GetReleasesQuery): Promise<ReleaseReadModel[]> {
|
async execute(query: GetReleasesQuery): Promise<ReleaseReadModel[]> {
|
||||||
await this.access.assertCanView(query.projectId, query.viewerId);
|
await this.access.assertSeesWholeProject(query.projectId, query.viewerId);
|
||||||
return this.findReleases.findByProject(query.projectId);
|
return this.findReleases.findByProject(query.projectId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<GetTicketScopeQuery, string | null>
|
||||||
|
{
|
||||||
|
constructor(private readonly access: ReadAccess) {}
|
||||||
|
|
||||||
|
execute(query: GetTicketScopeQuery): Promise<string | null> {
|
||||||
|
return this.access.participantScope(query.projectId, query.viewerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string | null> {
|
||||||
|
constructor(
|
||||||
|
public readonly viewerId: string | null,
|
||||||
|
public readonly projectId: string,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ export class GetTrashQueryHandler
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(query: GetTrashQuery): Promise<IssueReadModel[]> {
|
async execute(query: GetTrashQuery): Promise<IssueReadModel[]> {
|
||||||
await this.access.assertCanView(query.projectId, query.viewerId);
|
await this.access.assertSeesWholeProject(query.projectId, query.viewerId);
|
||||||
return this.findIssues.findTrashed(query.projectId);
|
return this.findIssues.findTrashed(query.projectId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { guestOnly } from './read-access';
|
||||||
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||||
import { ReleaseReadModel } from '@task-stream/api/tracker/domain';
|
import { ReleaseReadModel } from '@task-stream/api/tracker/domain';
|
||||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||||
@@ -15,8 +16,11 @@ export class GetUpcomingReleasesQueryHandler
|
|||||||
|
|
||||||
async execute(query: GetUpcomingReleasesQuery): Promise<ReleaseReadModel[]> {
|
async execute(query: GetUpcomingReleasesQuery): Promise<ReleaseReadModel[]> {
|
||||||
const projects = await this.findProjects.findByMember(query.viewerId);
|
const projects = await this.findProjects.findByMember(query.viewerId);
|
||||||
|
// A guest does not see a private project's releases.
|
||||||
return this.findReleases.findUpcoming(
|
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),
|
Math.min(query.limit, 50),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export * from './get-issues-by-ids.query';
|
|||||||
export * from './get-my-projects.query';
|
export * from './get-my-projects.query';
|
||||||
export * from './get-project-analytics.query';
|
export * from './get-project-analytics.query';
|
||||||
export * from './get-project-insights.query';
|
export * from './get-project-insights.query';
|
||||||
|
export * from './get-ticket-scope.query';
|
||||||
export * from './get-project-stats.query';
|
export * from './get-project-stats.query';
|
||||||
export * from './get-release-burnup.query';
|
export * from './get-release-burnup.query';
|
||||||
export * from './get-project.query';
|
export * from './get-project.query';
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { GetMyProjectsQueryHandler } from './get-my-projects.query-handler';
|
|||||||
import { GetProjectQueryHandler } from './get-project.query-handler';
|
import { GetProjectQueryHandler } from './get-project.query-handler';
|
||||||
import { GetProjectAnalyticsQueryHandler } from './get-project-analytics.query-handler';
|
import { GetProjectAnalyticsQueryHandler } from './get-project-analytics.query-handler';
|
||||||
import { GetProjectInsightsQueryHandler } from './get-project-insights.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 { GetProjectStatsQueryHandler } from './get-project-stats.query-handler';
|
||||||
import { GetReleaseBurnupQueryHandler } from './get-release-burnup.query-handler';
|
import { GetReleaseBurnupQueryHandler } from './get-release-burnup.query-handler';
|
||||||
import { GetReleaseQueryHandler } from './get-release.query-handler';
|
import { GetReleaseQueryHandler } from './get-release.query-handler';
|
||||||
@@ -36,6 +37,7 @@ export const QUERY_HANDLERS = [
|
|||||||
GetProjectQueryHandler,
|
GetProjectQueryHandler,
|
||||||
GetProjectAnalyticsQueryHandler,
|
GetProjectAnalyticsQueryHandler,
|
||||||
GetProjectInsightsQueryHandler,
|
GetProjectInsightsQueryHandler,
|
||||||
|
GetTicketScopeQueryHandler,
|
||||||
GetProjectStatsQueryHandler,
|
GetProjectStatsQueryHandler,
|
||||||
GetReleaseBurnupQueryHandler,
|
GetReleaseBurnupQueryHandler,
|
||||||
GetReleaseQueryHandler,
|
GetReleaseQueryHandler,
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { NotFoundError } from '@task-stream/api/shared/domain';
|
import { ForbiddenError, NotFoundError } from '@task-stream/api/shared/domain';
|
||||||
import { ProjectRole } from '@task-stream/api/tracker/domain';
|
import {
|
||||||
|
IssueReadModel,
|
||||||
|
ProjectReadModel,
|
||||||
|
ProjectRole,
|
||||||
|
} from '@task-stream/api/tracker/domain';
|
||||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,6 +39,67 @@ export class ReadAccess {
|
|||||||
return null;
|
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<string | null> {
|
||||||
|
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<ProjectRole | null> {
|
||||||
|
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<void> {
|
||||||
|
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<boolean> {
|
||||||
|
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<boolean> {
|
async canView(projectId: string, viewerId: string | null): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await this.assertCanView(projectId, viewerId);
|
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 =
|
export const UUID =
|
||||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ export class SearchIssuesQueryHandler
|
|||||||
if (watching && !query.viewerId) return { items: [], total: 0 };
|
if (watching && !query.viewerId) return { items: [], total: 0 };
|
||||||
return this.findIssues.search({
|
return this.findIssues.search({
|
||||||
...filter,
|
...filter,
|
||||||
|
participantId:
|
||||||
|
(await this.access.participantScope(
|
||||||
|
query.filter.projectId,
|
||||||
|
query.viewerId,
|
||||||
|
)) ?? undefined,
|
||||||
watcherId: watching ? (query.viewerId ?? undefined) : undefined,
|
watcherId: watching ? (query.viewerId ?? undefined) : undefined,
|
||||||
limit: Math.min(query.filter.limit, 200),
|
limit: Math.min(query.filter.limit, 200),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
import { FindIssuesRepository, Page } from '../ports/find-issues.repository';
|
import { FindIssuesRepository, Page } from '../ports/find-issues.repository';
|
||||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||||
import { SearchMyIssuesQuery } from './search-my-issues.query';
|
import { SearchMyIssuesQuery } from './search-my-issues.query';
|
||||||
|
import { guestOnly } from './read-access';
|
||||||
|
|
||||||
@QueryHandler(SearchMyIssuesQuery)
|
@QueryHandler(SearchMyIssuesQuery)
|
||||||
export class SearchMyIssuesQueryHandler
|
export class SearchMyIssuesQueryHandler
|
||||||
@@ -41,8 +42,13 @@ export class SearchMyIssuesQueryHandler
|
|||||||
StatusCategory.IN_PROGRESS,
|
StatusCategory.IN_PROGRESS,
|
||||||
StatusCategory.QA,
|
StatusCategory.QA,
|
||||||
];
|
];
|
||||||
|
const guestProjectIds = projects
|
||||||
|
.filter((p) => guestOnly(p, query.viewerId))
|
||||||
|
.map((p) => p.id);
|
||||||
return this.findIssues.searchAcross({
|
return this.findIssues.searchAcross({
|
||||||
projectIds: projects.map((project) => project.id),
|
projectIds: projects.map((project) => project.id),
|
||||||
|
guestProjectIds,
|
||||||
|
participantId: query.viewerId,
|
||||||
priorities: filter.priorities?.length ? filter.priorities : undefined,
|
priorities: filter.priorities?.length ? filter.priorities : undefined,
|
||||||
typesByProject: bugTypes,
|
typesByProject: bugTypes,
|
||||||
dueBefore: filter.overdue
|
dueBefore: filter.overdue
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export class GetSprintsQueryHandler
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(query: GetSprintsQuery): Promise<SprintReadModel[]> {
|
async execute(query: GetSprintsQuery): Promise<SprintReadModel[]> {
|
||||||
await this.access.assertCanView(query.projectId, query.viewerId);
|
await this.access.assertSeesWholeProject(query.projectId, query.viewerId);
|
||||||
return this.findSprints.findByProject(query.projectId);
|
return this.findSprints.findByProject(query.projectId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -200,6 +200,15 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
|||||||
this.apply(new IssueUpdatedEvent(actorId, this.id, this.projectId, patch));
|
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. */
|
/** Moves the ticket along its project's workflow. */
|
||||||
changeStatus(
|
changeStatus(
|
||||||
actorId: string,
|
actorId: string,
|
||||||
|
|||||||
@@ -4,4 +4,9 @@ export enum ProjectRole {
|
|||||||
MEMBER = 'MEMBER',
|
MEMBER = 'MEMBER',
|
||||||
/** Read-only stakeholder (PM, client...). */
|
/** Read-only stakeholder (PM, client...). */
|
||||||
VIEWER = 'VIEWER',
|
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',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,9 +102,31 @@ export class Project extends VersionedAggregateRoot implements ProjectProps {
|
|||||||
if (this.roleOf(userId) === ProjectRole.VIEWER) {
|
if (this.roleOf(userId) === ProjectRole.VIEWER) {
|
||||||
throw new ForbiddenError(`Viewers cannot modify project ${this.key}`);
|
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();
|
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). */
|
/** Can change members, releases, settings and rules (owners and admins). */
|
||||||
assertCanManage(userId: string): void {
|
assertCanManage(userId: string): void {
|
||||||
this.assertManages(userId);
|
this.assertManages(userId);
|
||||||
|
|||||||
@@ -178,6 +178,14 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
|
|||||||
if (filter.dueBefore)
|
if (filter.dueBefore)
|
||||||
query['dueDate'] = { $ne: null, $lt: filter.dueBefore };
|
query['dueDate'] = { $ne: null, $lt: filter.dueBefore };
|
||||||
const and: Record<string, unknown>[] = [];
|
const and: Record<string, unknown>[] = [];
|
||||||
|
// 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)
|
if (filter.typesByProject)
|
||||||
and.push({
|
and.push({
|
||||||
$or: Object.entries(filter.typesByProject).map(
|
$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<IssueDoc> {
|
function toMongoFilter(filter: IssueFilter): FilterQuery<IssueDoc> {
|
||||||
// Tickets in the trash are only listed by the trash itself.
|
// Tickets in the trash are only listed by the trash itself.
|
||||||
const query: FilterQuery<IssueDoc> = {
|
const query: FilterQuery<IssueDoc> = {
|
||||||
@@ -476,6 +491,11 @@ function toMongoFilter(filter: IssueFilter): FilterQuery<IssueDoc> {
|
|||||||
if (filter.unassigned) query['assignee'] = null;
|
if (filter.unassigned) query['assignee'] = null;
|
||||||
else if (filter.assigneeId) query['assignee.id'] = filter.assigneeId;
|
else if (filter.assigneeId) query['assignee.id'] = filter.assigneeId;
|
||||||
if (filter.watcherId) query['watchers.id'] = filter.watcherId;
|
if (filter.watcherId) query['watchers.id'] = filter.watcherId;
|
||||||
|
if (filter.participantId)
|
||||||
|
query.$and = [
|
||||||
|
...(query.$and ?? []),
|
||||||
|
{ $or: participation(filter.participantId) },
|
||||||
|
];
|
||||||
if (filter.search?.trim()) {
|
if (filter.search?.trim()) {
|
||||||
const pattern = new RegExp(escapeRegex(filter.search.trim()), 'i');
|
const pattern = new RegExp(escapeRegex(filter.search.trim()), 'i');
|
||||||
query.$or = [
|
query.$or = [
|
||||||
|
|||||||
@@ -223,8 +223,11 @@ export class IssueResolver {
|
|||||||
description:
|
description:
|
||||||
'Open tickets it waits for: the ones that block it and the ones it depends on',
|
'Open tickets it waits for: the ones that block it and the ones it depends on',
|
||||||
})
|
})
|
||||||
blockedBy(@Parent() issue: IssueReadModel) {
|
blockedBy(
|
||||||
return this.blockers.blockedBy(issue);
|
@CurrentUser() me: User | undefined,
|
||||||
|
@Parent() issue: IssueReadModel,
|
||||||
|
) {
|
||||||
|
return this.blockers.blockedBy(issue, viewerOf(me));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ResolveField(() => Boolean, { description: 'Whether I voted for it' })
|
@ResolveField(() => Boolean, { description: 'Whether I voted for it' })
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export class Refs {
|
|||||||
async issueDto(issue: IssueReadModel, viewerId: string | null) {
|
async issueDto(issue: IssueReadModel, viewerId: string | null) {
|
||||||
const [allowedTransitions, blockedBy] = await Promise.all([
|
const [allowedTransitions, blockedBy] = await Promise.all([
|
||||||
this.workflows.allowedTransitions(issue),
|
this.workflows.allowedTransitions(issue),
|
||||||
this.blockers.blockedBy(issue),
|
this.blockers.blockedBy(issue, viewerId),
|
||||||
]);
|
]);
|
||||||
// Who voted is only for telling someone whether they did.
|
// Who voted is only for telling someone whether they did.
|
||||||
const { voterIds, ...shown } = issue;
|
const { voterIds, ...shown } = issue;
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ export class ProjectFacade {
|
|||||||
readonly canManage = this.store.canManage;
|
readonly canManage = this.store.canManage;
|
||||||
/** Any role but viewer. */
|
/** Any role but viewer. */
|
||||||
readonly canContribute = this.store.canContribute;
|
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). */
|
/** Statuses, ticket types and areas of this project (names, colours, lookups). */
|
||||||
readonly workflow = this.store.workflow;
|
readonly workflow = this.store.workflow;
|
||||||
readonly savingSettings = this.store.savingSettings;
|
readonly savingSettings = this.store.savingSettings;
|
||||||
|
|||||||
@@ -107,11 +107,23 @@ export const ProjectStore = signalStore(
|
|||||||
(myRole() === ProjectRole.OWNER || myRole() === ProjectRole.ADMIN),
|
(myRole() === ProjectRole.OWNER || myRole() === ProjectRole.ADMIN),
|
||||||
),
|
),
|
||||||
canContribute: computed(
|
canContribute: computed(
|
||||||
|
() =>
|
||||||
|
!project()?.archivedAt &&
|
||||||
|
!!myRole() &&
|
||||||
|
myRole() !== ProjectRole.VIEWER &&
|
||||||
|
myRole() !== ProjectRole.GUEST,
|
||||||
|
),
|
||||||
|
/** Opens tickets and comments: members and guests, not viewers. */
|
||||||
|
canReport: computed(
|
||||||
() =>
|
() =>
|
||||||
!project()?.archivedAt &&
|
!project()?.archivedAt &&
|
||||||
!!myRole() &&
|
!!myRole() &&
|
||||||
myRole() !== ProjectRole.VIEWER,
|
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 ?? []),
|
members: computed(() => project()?.members ?? []),
|
||||||
/** Statuses, types and areas of the project, with lookups. */
|
/** Statuses, types and areas of the project, with lookups. */
|
||||||
workflow: computed(() => new Workflow(project()?.settings)),
|
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(
|
details$: loaded$.pipe(
|
||||||
switchMap(({ payload }) =>
|
switchMap(({ payload }) =>
|
||||||
merge(
|
store.seesOnlyOwnTickets()
|
||||||
projects
|
? []
|
||||||
.stats(payload.id)
|
: merge(
|
||||||
.pipe(map((stats) => projectApiEvents.statsLoaded(stats))),
|
projects
|
||||||
reloadReleases(payload.id),
|
.stats(payload.id)
|
||||||
reloadSprints(payload.id),
|
.pipe(map((stats) => projectApiEvents.statsLoaded(stats))),
|
||||||
activity
|
reloadReleases(payload.id),
|
||||||
.list(payload.id)
|
reloadSprints(payload.id),
|
||||||
.pipe(map((list) => projectApiEvents.activityLoaded(list))),
|
activity
|
||||||
),
|
.list(payload.id)
|
||||||
|
.pipe(map((list) => projectApiEvents.activityLoaded(list))),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
live$: loaded$.pipe(
|
live$: loaded$.pipe(
|
||||||
switchMap(({ payload }) =>
|
switchMap(({ payload }) =>
|
||||||
merge(
|
store.seesOnlyOwnTickets()
|
||||||
realtime
|
? realtime
|
||||||
.projectChanges(payload.id)
|
.projectChanges(payload.id)
|
||||||
.pipe(
|
.pipe(
|
||||||
map(({ project, deleted }) =>
|
map(({ project, deleted }) =>
|
||||||
deleted
|
deleted
|
||||||
? projectRealtimeEvents.projectDeleted(project)
|
? projectRealtimeEvents.projectDeleted(project)
|
||||||
: projectRealtimeEvents.projectChanged(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). */
|
/** Activity means tickets moved: refresh the dashboard (debounced). */
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ export enum ProjectRole {
|
|||||||
ADMIN = 'ADMIN',
|
ADMIN = 'ADMIN',
|
||||||
MEMBER = 'MEMBER',
|
MEMBER = 'MEMBER',
|
||||||
VIEWER = 'VIEWER',
|
VIEWER = 'VIEWER',
|
||||||
|
/** From outside (a client): only the tickets they take part in. */
|
||||||
|
GUEST = 'GUEST',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ReleaseStatus {
|
export enum ReleaseStatus {
|
||||||
|
|||||||
@@ -272,7 +272,7 @@ export type ProjectMemberInput = {
|
|||||||
userId: string;
|
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. */
|
/** The whole configuration (replaces the previous one). Statuses go in board order. */
|
||||||
export type ProjectSettingsInput = {
|
export type ProjectSettingsInput = {
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ const NOBODY = '__nobody__';
|
|||||||
><strong class="ts-mono">{{ board.total() }}</strong> tickets</span
|
><strong class="ts-mono">{{ board.total() }}</strong> tickets</span
|
||||||
>
|
>
|
||||||
<span class="ts-spacer"></span>
|
<span class="ts-spacer"></span>
|
||||||
@if (project.canContribute()) {
|
@if (project.canReport()) {
|
||||||
<pi-action-button
|
<pi-action-button
|
||||||
color="primary"
|
color="primary"
|
||||||
text="Nuevo ticket"
|
text="Nuevo ticket"
|
||||||
@@ -385,20 +385,22 @@ const NOBODY = '__nobody__';
|
|||||||
<pi-label>Estimación</pi-label>
|
<pi-label>Estimación</pi-label>
|
||||||
</pi-select-field>
|
</pi-select-field>
|
||||||
</div>
|
</div>
|
||||||
<div class="ts-form__row">
|
@if (project.canContribute()) {
|
||||||
<pi-select-field
|
<div class="ts-form__row">
|
||||||
[formField]="f.assigneeId"
|
<pi-select-field
|
||||||
[options]="assigneeOptions()"
|
[formField]="f.assigneeId"
|
||||||
>
|
[options]="assigneeOptions()"
|
||||||
<pi-label>Asignar a</pi-label>
|
>
|
||||||
</pi-select-field>
|
<pi-label>Asignar a</pi-label>
|
||||||
<pi-select-field
|
</pi-select-field>
|
||||||
[formField]="f.releaseId"
|
<pi-select-field
|
||||||
[options]="releaseOptions()"
|
[formField]="f.releaseId"
|
||||||
>
|
[options]="releaseOptions()"
|
||||||
<pi-label>Release</pi-label>
|
>
|
||||||
</pi-select-field>
|
<pi-label>Release</pi-label>
|
||||||
</div>
|
</pi-select-field>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<pi-action-button
|
<pi-action-button
|
||||||
size="w100"
|
size="w100"
|
||||||
color="primary"
|
color="primary"
|
||||||
@@ -913,8 +915,9 @@ export class BoardPage {
|
|||||||
priority: v.priority as IssuePriority,
|
priority: v.priority as IssuePriority,
|
||||||
area: v.area || null,
|
area: v.area || null,
|
||||||
storyPoints: v.storyPoints === '' ? null : Number(v.storyPoints),
|
storyPoints: v.storyPoints === '' ? null : Number(v.storyPoints),
|
||||||
assigneeId: v.assigneeId || null,
|
// Planning it is for the team: a guest only opens it.
|
||||||
releaseId: v.releaseId || null,
|
assigneeId: this.project.canContribute() ? v.assigneeId || null : null,
|
||||||
|
releaseId: this.project.canContribute() ? v.releaseId || null : null,
|
||||||
});
|
});
|
||||||
this.drawer.closeAll();
|
this.drawer.closeAll();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -463,10 +463,10 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
} @else if (!project.canContribute()) {
|
} @else if (!project.canReport()) {
|
||||||
<p class="ts-muted">Sin adjuntos.</p>
|
<p class="ts-muted">Sin adjuntos.</p>
|
||||||
}
|
}
|
||||||
@if (project.canContribute()) {
|
@if (project.canReport()) {
|
||||||
<div class="ts-form">
|
<div class="ts-form">
|
||||||
<pi-file-upload
|
<pi-file-upload
|
||||||
label="Capturas, logs, vídeos…"
|
label="Capturas, logs, vídeos…"
|
||||||
@@ -721,9 +721,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
} @else if (
|
} @else if (project.canReport() && !thread.comment.deleted) {
|
||||||
project.canContribute() && !thread.comment.deleted
|
|
||||||
) {
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="ts-plain-link ts-reply__open"
|
class="ts-plain-link ts-reply__open"
|
||||||
@@ -738,7 +736,7 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (project.canContribute()) {
|
@if (project.canReport()) {
|
||||||
<form
|
<form
|
||||||
class="ts-form"
|
class="ts-form"
|
||||||
(submit)="$event.preventDefault(); addComment()"
|
(submit)="$event.preventDefault(); addComment()"
|
||||||
|
|||||||
@@ -231,6 +231,7 @@ export class MembersPage {
|
|||||||
ProjectRole.ADMIN,
|
ProjectRole.ADMIN,
|
||||||
ProjectRole.MEMBER,
|
ProjectRole.MEMBER,
|
||||||
ProjectRole.VIEWER,
|
ProjectRole.VIEWER,
|
||||||
|
ProjectRole.GUEST,
|
||||||
].map((value) => ({ value, label: ROLE[value] }));
|
].map((value) => ({ value, label: ROLE[value] }));
|
||||||
protected readonly candidates = computed(() => {
|
protected readonly candidates = computed(() => {
|
||||||
const members = new Set(this.facade.members().map((m) => m.user.id));
|
const members = new Set(this.facade.members().map((m) => m.user.id));
|
||||||
|
|||||||
@@ -91,15 +91,20 @@ import { projectTone, PUBLIC_TONE, ROLE } from '../ui/copy';
|
|||||||
<nav class="ts-tabs" aria-label="Secciones del proyecto">
|
<nav class="ts-tabs" aria-label="Secciones del proyecto">
|
||||||
<a routerLink="board" routerLinkActive="is-active">Tablero</a>
|
<a routerLink="board" routerLinkActive="is-active">Tablero</a>
|
||||||
<a routerLink="list" routerLinkActive="is-active">Lista</a>
|
<a routerLink="list" routerLinkActive="is-active">Lista</a>
|
||||||
<a routerLink="backlog" routerLinkActive="is-active">Backlog</a>
|
<!-- A guest sees only their tickets: no project-wide views. -->
|
||||||
<a routerLink="sprints" routerLinkActive="is-active">Sprints</a>
|
@if (!facade.seesOnlyOwnTickets()) {
|
||||||
<a routerLink="calendar" routerLinkActive="is-active">Calendario</a>
|
<a routerLink="backlog" routerLinkActive="is-active">Backlog</a>
|
||||||
<a routerLink="epics" routerLinkActive="is-active">Épicas</a>
|
<a routerLink="sprints" routerLinkActive="is-active">Sprints</a>
|
||||||
<a routerLink="roadmap" routerLinkActive="is-active">Hoja de ruta</a>
|
<a routerLink="calendar" routerLinkActive="is-active">Calendario</a>
|
||||||
<a routerLink="releases" routerLinkActive="is-active">Releases</a>
|
<a routerLink="epics" routerLinkActive="is-active">Épicas</a>
|
||||||
<a routerLink="dashboard" routerLinkActive="is-active">Seguimiento</a>
|
<a routerLink="roadmap" routerLinkActive="is-active">Hoja de ruta</a>
|
||||||
<a routerLink="analytics" routerLinkActive="is-active">Estadísticas</a>
|
<a routerLink="releases" routerLinkActive="is-active">Releases</a>
|
||||||
<a routerLink="activity" routerLinkActive="is-active">Actividad</a>
|
<a routerLink="dashboard" routerLinkActive="is-active">Seguimiento</a>
|
||||||
|
<a routerLink="analytics" routerLinkActive="is-active"
|
||||||
|
>Estadísticas</a
|
||||||
|
>
|
||||||
|
<a routerLink="activity" routerLinkActive="is-active">Actividad</a>
|
||||||
|
}
|
||||||
<a routerLink="members" routerLinkActive="is-active">Miembros</a>
|
<a routerLink="members" routerLinkActive="is-active">Miembros</a>
|
||||||
@if (facade.canManage() || facade.isOwner()) {
|
@if (facade.canManage() || facade.isOwner()) {
|
||||||
<a routerLink="settings" routerLinkActive="is-active"
|
<a routerLink="settings" routerLinkActive="is-active"
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ export const ROLE_TONE: Record<ProjectRole, string> = {
|
|||||||
[ProjectRole.ADMIN]: 'var(--ts-s-review)',
|
[ProjectRole.ADMIN]: 'var(--ts-s-review)',
|
||||||
[ProjectRole.MEMBER]: 'var(--ts-s-todo)',
|
[ProjectRole.MEMBER]: 'var(--ts-s-todo)',
|
||||||
[ProjectRole.VIEWER]: 'var(--ts-s-cancelled)',
|
[ProjectRole.VIEWER]: 'var(--ts-s-cancelled)',
|
||||||
|
[ProjectRole.GUEST]: 'var(--ts-s-qa)',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ROLE: Record<ProjectRole, string> = {
|
export const ROLE: Record<ProjectRole, string> = {
|
||||||
@@ -157,6 +158,7 @@ export const ROLE: Record<ProjectRole, string> = {
|
|||||||
[ProjectRole.ADMIN]: 'Admin',
|
[ProjectRole.ADMIN]: 'Admin',
|
||||||
[ProjectRole.MEMBER]: 'Miembro',
|
[ProjectRole.MEMBER]: 'Miembro',
|
||||||
[ProjectRole.VIEWER]: 'Lector',
|
[ProjectRole.VIEWER]: 'Lector',
|
||||||
|
[ProjectRole.GUEST]: 'Invitado',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const RELEASE_STATUS: Record<
|
export const RELEASE_STATUS: Record<
|
||||||
|
|||||||
Reference in New Issue
Block a user