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:
@@ -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<IssueChange>,
|
||||
userId: string,
|
||||
): AsyncIterableIterator<IssueChange> {
|
||||
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<AsyncIterableIterator<IssueChange>> {
|
||||
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<AsyncIterableIterator<ActivityReadModel>> {
|
||||
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<AsyncIterableIterator<ReleaseChange>> {
|
||||
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<AsyncIterableIterator<SprintChange>> {
|
||||
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<void> {
|
||||
const allowed = await this.queryBus.execute(
|
||||
new CanViewProjectQuery(viewerId, projectId),
|
||||
new CanViewProjectQuery(viewerId, projectId, wholeProject),
|
||||
);
|
||||
if (!allowed) throw new NotFoundError('Project', projectId);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user