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:
2026-10-15 18:02:36 +02:00
parent f60d4c8346
commit 8bf7d47b2e
66 changed files with 607 additions and 130 deletions

View File

@@ -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<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> {
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;