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:
@@ -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',
|
||||
|
||||
@@ -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<Map<string, IssueReadModel>>;
|
||||
} | 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);
|
||||
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 }));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export class AddCommentCommandHandler
|
||||
) {}
|
||||
|
||||
async execute(command: AddCommentCommand): Promise<CommandResult> {
|
||||
const { issue } = await this.access.issueForContributor(
|
||||
const { issue } = await this.access.issueForReporter(
|
||||
command.issueId,
|
||||
command.actorId,
|
||||
);
|
||||
|
||||
@@ -20,7 +20,7 @@ export class AttachFileCommandHandler
|
||||
) {}
|
||||
|
||||
async execute(command: AttachFileCommand): Promise<AttachmentResult> {
|
||||
const { issue } = await this.access.issueForContributor(
|
||||
const { issue } = await this.access.issueForReporter(
|
||||
command.issueId,
|
||||
command.actorId,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<Release> {
|
||||
const release = await this.release(releaseId);
|
||||
|
||||
@@ -79,6 +79,7 @@ const ROLE: Record<ProjectRole, string> = {
|
||||
[ProjectRole.ADMIN]: 'admin',
|
||||
[ProjectRole.MEMBER]: 'miembro',
|
||||
[ProjectRole.VIEWER]: 'lector',
|
||||
[ProjectRole.GUEST]: 'invitado',
|
||||
};
|
||||
|
||||
const FIELD: Record<string, string> = {
|
||||
|
||||
@@ -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<string, string[]>;
|
||||
/** Due before this day (`YYYY-MM-DD`, excluded). */
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,11 @@ export class CanViewProjectQueryHandler
|
||||
constructor(private readonly access: ReadAccess) {}
|
||||
|
||||
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). */
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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<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({
|
||||
projectId: query.projectId,
|
||||
issueId: query.issueId,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -27,6 +27,11 @@ export class GetBoardQueryHandler
|
||||
*/
|
||||
async execute(query: GetBoardQuery): Promise<BoardColumn[]> {
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ export class GetEpicsQueryHandler
|
||||
) {}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export class GetProjectAnalyticsQueryHandler
|
||||
async execute(
|
||||
query: GetProjectAnalyticsQuery,
|
||||
): 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([
|
||||
this.findProjects.findById(query.projectId),
|
||||
this.findIssues.findByProject(query.projectId),
|
||||
|
||||
@@ -32,7 +32,7 @@ export class GetProjectInsightsQueryHandler
|
||||
async execute(
|
||||
query: GetProjectInsightsQuery,
|
||||
): 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] =
|
||||
await Promise.all([
|
||||
this.findProjects.findById(query.projectId),
|
||||
|
||||
@@ -14,7 +14,7 @@ export class GetProjectStatsQueryHandler
|
||||
) {}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export class GetReleasesQueryHandler
|
||||
) {}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[]> {
|
||||
await this.access.assertCanView(query.projectId, query.viewerId);
|
||||
await this.access.assertSeesWholeProject(query.projectId, query.viewerId);
|
||||
return this.findIssues.findTrashed(query.projectId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ReleaseReadModel[]> {
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -76,7 +76,7 @@ export class GetSprintsQueryHandler
|
||||
) {}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user