/** * T-162/T-163 — Review workflow service (pages module). * * The page's `reviewState` machine (A-003: explicit reviewer user-ids, never * roles): * * published ──submit──▶ in_review ──approve────────▶ published * ▲ │ ▲ * │ │ └── submit (editor resubmits after fixing) * └──request_changes───┘ * cancel (editor) withdraws an in-flight review → published. * * A page has AT MOST ONE open Review row (`closedAt == null`); `state` * mirrors `page.reviewState` while open (denormalized for queries). Legacy * pages default to `published` — no review row, no banner, no gate (no * behavior change for pre-feature content). * * Authorization (denied-by-default): * - read (GET /pages/:id/review): page VIEW * - `submit` / `cancel`: page EDIT (the editor of record — any EDIT grantee) * - `approve` / `request_changes`: membership in the OPEN review's * `reviewerIds` (a page EDIT grant is NOT enough — the explicit list is * the gate; negative test) * - non-member of the space: 403 (page-level); unknown page: 404 (no-leak) */ import { Inject, Injectable } from "@nestjs/common"; import type { Request } from "express"; import { Prisma } from "@prisma/client"; import { PrismaService } from "../../common/prisma.service.js"; import { AuditService } from "../../common/audit.service.js"; import { EventBus } from "../../common/event-bus.js"; import { NotificationsService } from "../notifications/notifications.service.js"; import { ApiError } from "../../common/errors.js"; import { assertCan } from "../../lib/authorize.js"; import { EVENTS } from "../../constants.js"; import { REVIEW_ACTIONS, type ReviewAction, type ReviewState } from "./review.dto.js"; const REVIEW_SELECT = { id: true, pageId: true, state: true, reviewerIds: true, requestedBy: true, comment: true, deadline: true, decision: true, decidedAt: true, decidedBy: true, openedAt: true, closedAt: true } as const; const openReview = (prisma: PrismaService, pageId: string) => prisma.review.findFirst({ where: { pageId, closedAt: null }, orderBy: { openedAt: "desc" }, select: REVIEW_SELECT }); @Injectable() export class ReviewService { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(AuditService) private readonly audit: AuditService, @Inject(EventBus) private readonly events: EventBus, @Inject(NotificationsService) private readonly notifications: NotificationsService ) {} /** `GET /pages/:id/review` — the banner state (review chip + open review). */ async reviewState(pageId: string, userId: string) { const page = await this.prisma.page.findUnique({ where: { id: pageId }, select: { id: true, workspaceId: true, reviewState: true, trashedAt: true } }); if (!page) throw ApiError.notFound("Page not found."); if (page.trashedAt) throw ApiError.notFound("Page not found."); await assertCan(this.prisma, { workspaceId: page.workspaceId, userId }, "VIEW"); const current = await openReview(this.prisma, pageId); const last = await this.prisma.review.findFirst({ where: { pageId, closedAt: { not: null } }, orderBy: { closedAt: "desc" }, select: { id: true, reviewerIds: true, decision: true, comment: true } }); return { state: page.reviewState, review: current ? { id: current.id, state: current.state, reviewerIds: current.reviewerIds, requestedBy: current.requestedBy, comment: current.comment, deadline: current.deadline, openedAt: current.openedAt } : null, lastReview: last ? { id: last.id, reviewerIds: last.reviewerIds, decision: last.decision, comment: last.comment } : null }; } /** `POST /pages/:id/review` — perform a review action (the matrix). */ async act(pageId: string, userId: string, input: { action: ReviewAction; reviewerIds?: string[]; comment?: string; deadline?: string | null }, req: Request) { const page = await this.prisma.page.findUnique({ where: { id: pageId }, select: { id: true, workspaceId: true, reviewState: true, trashedAt: true } }); if (!page) throw ApiError.notFound("Page not found."); if (page.trashedAt) throw ApiError.notFound("Page not found."); const { workspaceId, reviewState } = page; await assertCan(this.prisma, { workspaceId, userId }, "EDIT"); // baseline for editors const spec = REVIEW_ACTIONS[input.action]; if (!spec.from.includes(reviewState as ReviewState)) { throw new ApiError("VALIDATION_ERROR", 400, `Illegal review transition: ${reviewState} → ${input.action}.`, [ { field: "action", issue: "illegal_transition", from: reviewState, allowedFrom: spec.from } ]); } let review: Prisma.ReviewGetPayload<{ select: typeof REVIEW_SELECT }>; if (input.action === "submit") { // A-003: the explicit reviewer list is REQUIRED on submit (≥1 member). const reviewerIds = input.reviewerIds ?? []; if (!reviewerIds.length) { throw ApiError.validation("submit requires reviewerIds (≥1 workspace member).", [{ field: "reviewerIds", issue: "required" }]); } const members = await this.prisma.workspaceMember.findMany({ where: { workspaceId, userId: { in: reviewerIds } }, select: { userId: true } }); const known = new Set(members.map((m) => m.userId)); const bad = reviewerIds.filter((id) => !known.has(id)); if (bad.length) { throw new ApiError("VALIDATION_ERROR", 400, "reviewerIds must be workspace members.", [ { field: "reviewerIds", issue: "not_workspace_member", received: bad.join(",") } ]); } const existing = await openReview(this.prisma, pageId); const deadline = input.deadline ? new Date(input.deadline) : existing?.deadline ?? null; review = existing ? await this.prisma.review.update({ where: { id: existing.id }, data: { state: "in_review", reviewerIds, comment: input.comment ?? null, deadline }, select: REVIEW_SELECT }) : await this.prisma.review.create({ data: { pageId, state: "in_review", reviewerIds, requestedBy: userId, comment: input.comment ?? null, deadline }, select: REVIEW_SELECT }); await this.prisma.page.update({ where: { id: pageId }, data: { reviewState: "in_review" } }); await this.audit.audit(req, req?.auth, { action: "review.submitted", resourceType: "page", resourceId: pageId, workspaceId, pageId, metadata: { reviewId: review.id, reviewerIds, from: reviewState, to: "in_review" } }); // REVIEW_REQUESTED → each reviewer (not the submitter). for (const rid of reviewerIds) { if (rid === userId) continue; try { await this.notifications.notify({ workspaceId, userId: rid, type: "REVIEW_REQUESTED", title: `Review requested: ${pageId}`, body: input.comment ?? undefined, link: { type: "page", id: pageId, pageId }, actorId: userId }); } catch { /* best-effort */ } } return { state: "in_review", review: this.shape(review) }; } // approve / request_changes / cancel need the OPEN review. const current = await openReview(this.prisma, pageId); if (!current) { throw new ApiError("VALIDATION_ERROR", 400, `No open review on this page (state ${reviewState}).`, [ { field: "action", issue: "no_open_review" } ]); } if (spec.by === "reviewer") { // A-003: the EXPLICIT reviewer list is the gate — an EDIT grant is not enough. if (!current.reviewerIds.includes(userId)) { throw new ApiError("FORBIDDEN", 403, "Only a reviewer of record may decide this review.", [ { field: "action", issue: "not_reviewer" } ]); } } const now = new Date(); if (input.action === "cancel") { // Only the REQUESTER may withdraw the review (not a reviewer, not a // third editor) — no decision is recorded. if (current.requestedBy !== userId) { throw new ApiError("FORBIDDEN", 403, "Only the requester may cancel this review.", [ { field: "action", issue: "not_requester" } ]); } review = await this.prisma.review.update({ where: { id: current.id }, data: { state: "cancelled", closedAt: now, comment: input.comment ?? current.comment }, select: REVIEW_SELECT }); await this.prisma.page.update({ where: { id: pageId }, data: { reviewState: "published" } }); await this.audit.audit(req, req?.auth, { action: "review.cancelled", resourceType: "page", resourceId: pageId, workspaceId, pageId, metadata: { reviewId: review.id, from: reviewState, to: "published" } }); return { state: "published", review: this.shape(review) }; } const decision = input.action === "approve" ? "approved" : "changes_requested"; const nextState = input.action === "approve" ? "published" : "changes_requested"; review = await this.prisma.review.update({ where: { id: current.id }, data: { state: nextState, decision, decidedBy: userId, decidedAt: now, closedAt: now, comment: input.comment ?? current.comment }, select: REVIEW_SELECT }); await this.prisma.page.update({ where: { id: pageId }, data: { reviewState: nextState } }); await this.audit.audit(req, req?.auth, { action: input.action === "approve" ? "review.approved" : "review.changes_requested", resourceType: "page", resourceId: pageId, workspaceId, pageId, metadata: { reviewId: review.id, from: reviewState, to: nextState, comment: input.comment ?? null } }); await this.events.publish(EVENTS.PAGE_UPDATED, { workspaceId, pageId, fields: ["reviewState"] }); // APPROVAL_DECISION → the requester (not the decider). if (current.requestedBy !== userId) { try { await this.notifications.notify({ workspaceId, userId: current.requestedBy, type: "APPROVAL_DECISION", title: input.action === "approve" ? "Your review was approved" : "Changes requested on your review", body: input.comment ?? undefined, link: { type: "page", id: pageId, pageId }, actorId: userId }); } catch { /* best-effort */ } } return { state: nextState, review: this.shape(review) }; } private shape(review: Prisma.ReviewGetPayload<{ select: typeof REVIEW_SELECT }>) { return { id: review.id, state: review.state, reviewerIds: review.reviewerIds, requestedBy: review.requestedBy, comment: review.comment, deadline: review.deadline, decision: review.decision, decidedAt: review.decidedAt, decidedBy: review.decidedBy, openedAt: review.openedAt, closedAt: review.closedAt }; } }