1
26import { Inject, Injectable } from "@nestjs/common";
27import type { Request } from "express";
28import { Prisma } from "@prisma/client";
29import { PrismaService } from "../../common/prisma.service.js";
30import { AuditService } from "../../common/audit.service.js";
31import { EventBus } from "../../common/event-bus.js";
32import { NotificationsService } from "../notifications/notifications.service.js";
33import { ApiError } from "../../common/errors.js";
34import { assertCan } from "../../lib/authorize.js";
35import { EVENTS } from "../../constants.js";
36import { REVIEW_ACTIONS, type ReviewAction, type ReviewState } from "./review.dto.js";
37
38const REVIEW_SELECT = {
39 id: true,
40 pageId: true,
41 state: true,
42 reviewerIds: true,
43 requestedBy: true,
44 comment: true,
45 deadline: true,
46 decision: true,
47 decidedAt: true,
48 decidedBy: true,
49 openedAt: true,
50 closedAt: true
51} as const;
52
53const openReview = (prisma: PrismaService, pageId: string) =>
54 prisma.review.findFirst({ where: { pageId, closedAt: null }, orderBy: { openedAt: "desc" }, select: REVIEW_SELECT });
55
56@Injectable()
57export class ReviewService {
58 constructor(
59 @Inject(PrismaService) private readonly prisma: PrismaService,
60 @Inject(AuditService) private readonly audit: AuditService,
61 @Inject(EventBus) private readonly events: EventBus,
62 @Inject(NotificationsService) private readonly notifications: NotificationsService
63 ) {}
64
65
66 async reviewState(pageId: string, userId: string) {
67 const page = await this.prisma.page.findUnique({ where: { id: pageId }, select: { id: true, workspaceId: true, reviewState: true, trashedAt: true } });
68 if (!page) throw ApiError.notFound("Page not found.");
69 if (page.trashedAt) throw ApiError.notFound("Page not found.");
70 await assertCan(this.prisma, { workspaceId: page.workspaceId, userId }, "VIEW");
71 const current = await openReview(this.prisma, pageId);
72 const last = await this.prisma.review.findFirst({
73 where: { pageId, closedAt: { not: null } },
74 orderBy: { closedAt: "desc" },
75 select: { id: true, reviewerIds: true, decision: true, comment: true }
76 });
77 return {
78 state: page.reviewState,
79 review: current
80 ? {
81 id: current.id,
82 state: current.state,
83 reviewerIds: current.reviewerIds,
84 requestedBy: current.requestedBy,
85 comment: current.comment,
86 deadline: current.deadline,
87 openedAt: current.openedAt
88 }
89 : null,
90 lastReview: last ? { id: last.id, reviewerIds: last.reviewerIds, decision: last.decision, comment: last.comment } : null
91 };
92 }
93
94
95 async act(pageId: string, userId: string, input: { action: ReviewAction; reviewerIds?: string[]; comment?: string; deadline?: string | null }, req: Request) {
96 const page = await this.prisma.page.findUnique({ where: { id: pageId }, select: { id: true, workspaceId: true, reviewState: true, trashedAt: true } });
97 if (!page) throw ApiError.notFound("Page not found.");
98 if (page.trashedAt) throw ApiError.notFound("Page not found.");
99 const { workspaceId, reviewState } = page;
100 await assertCan(this.prisma, { workspaceId, userId }, "EDIT");
101 const spec = REVIEW_ACTIONS[input.action];
102 if (!spec.from.includes(reviewState as ReviewState)) {
103 throw new ApiError("VALIDATION_ERROR", 400, `Illegal review transition: ${reviewState} → ${input.action}.`, [
104 { field: "action", issue: "illegal_transition", from: reviewState, allowedFrom: spec.from }
105 ]);
106 }
107
108 let review: Prisma.ReviewGetPayload<{ select: typeof REVIEW_SELECT }>;
109 if (input.action === "submit") {
110
111 const reviewerIds = input.reviewerIds ?? [];
112 if (!reviewerIds.length) {
113 throw ApiError.validation("submit requires reviewerIds (≥1 workspace member).", [{ field: "reviewerIds", issue: "required" }]);
114 }
115 const members = await this.prisma.workspaceMember.findMany({
116 where: { workspaceId, userId: { in: reviewerIds } },
117 select: { userId: true }
118 });
119 const known = new Set(members.map((m) => m.userId));
120 const bad = reviewerIds.filter((id) => !known.has(id));
121 if (bad.length) {
122 throw new ApiError("VALIDATION_ERROR", 400, "reviewerIds must be workspace members.", [
123 { field: "reviewerIds", issue: "not_workspace_member", received: bad.join(",") }
124 ]);
125 }
126 const existing = await openReview(this.prisma, pageId);
127 const deadline = input.deadline ? new Date(input.deadline) : existing?.deadline ?? null;
128 review = existing
129 ? await this.prisma.review.update({
130 where: { id: existing.id },
131 data: { state: "in_review", reviewerIds, comment: input.comment ?? null, deadline },
132 select: REVIEW_SELECT
133 })
134 : await this.prisma.review.create({
135 data: { pageId, state: "in_review", reviewerIds, requestedBy: userId, comment: input.comment ?? null, deadline },
136 select: REVIEW_SELECT
137 });
138 await this.prisma.page.update({ where: { id: pageId }, data: { reviewState: "in_review" } });
139 await this.audit.audit(req, req?.auth, {
140 action: "review.submitted",
141 resourceType: "page",
142 resourceId: pageId,
143 workspaceId,
144 pageId,
145 metadata: { reviewId: review.id, reviewerIds, from: reviewState, to: "in_review" }
146 });
147
148 for (const rid of reviewerIds) {
149 if (rid === userId) continue;
150 try {
151 await this.notifications.notify({
152 workspaceId,
153 userId: rid,
154 type: "REVIEW_REQUESTED",
155 title: `Review requested: ${pageId}`,
156 body: input.comment ?? undefined,
157 link: { type: "page", id: pageId, pageId },
158 actorId: userId
159 });
160 } catch {
161
162 }
163 }
164 return { state: "in_review", review: this.shape(review) };
165 }
166
167
168 const current = await openReview(this.prisma, pageId);
169 if (!current) {
170 throw new ApiError("VALIDATION_ERROR", 400, `No open review on this page (state ${reviewState}).`, [
171 { field: "action", issue: "no_open_review" }
172 ]);
173 }
174 if (spec.by === "reviewer") {
175
176 if (!current.reviewerIds.includes(userId)) {
177 throw new ApiError("FORBIDDEN", 403, "Only a reviewer of record may decide this review.", [
178 { field: "action", issue: "not_reviewer" }
179 ]);
180 }
181 }
182 const now = new Date();
183 if (input.action === "cancel") {
184
185
186 if (current.requestedBy !== userId) {
187 throw new ApiError("FORBIDDEN", 403, "Only the requester may cancel this review.", [
188 { field: "action", issue: "not_requester" }
189 ]);
190 }
191 review = await this.prisma.review.update({
192 where: { id: current.id },
193 data: { state: "cancelled", closedAt: now, comment: input.comment ?? current.comment },
194 select: REVIEW_SELECT
195 });
196 await this.prisma.page.update({ where: { id: pageId }, data: { reviewState: "published" } });
197 await this.audit.audit(req, req?.auth, {
198 action: "review.cancelled",
199 resourceType: "page",
200 resourceId: pageId,
201 workspaceId,
202 pageId,
203 metadata: { reviewId: review.id, from: reviewState, to: "published" }
204 });
205 return { state: "published", review: this.shape(review) };
206 }
207 const decision = input.action === "approve" ? "approved" : "changes_requested";
208 const nextState = input.action === "approve" ? "published" : "changes_requested";
209 review = await this.prisma.review.update({
210 where: { id: current.id },
211 data: {
212 state: nextState,
213 decision,
214 decidedBy: userId,
215 decidedAt: now,
216 closedAt: now,
217 comment: input.comment ?? current.comment
218 },
219 select: REVIEW_SELECT
220 });
221 await this.prisma.page.update({ where: { id: pageId }, data: { reviewState: nextState } });
222 await this.audit.audit(req, req?.auth, {
223 action: input.action === "approve" ? "review.approved" : "review.changes_requested",
224 resourceType: "page",
225 resourceId: pageId,
226 workspaceId,
227 pageId,
228 metadata: { reviewId: review.id, from: reviewState, to: nextState, comment: input.comment ?? null }
229 });
230 await this.events.publish(EVENTS.PAGE_UPDATED, { workspaceId, pageId, fields: ["reviewState"] });
231
232 if (current.requestedBy !== userId) {
233 try {
234 await this.notifications.notify({
235 workspaceId,
236 userId: current.requestedBy,
237 type: "APPROVAL_DECISION",
238 title: input.action === "approve" ? "Your review was approved" : "Changes requested on your review",
239 body: input.comment ?? undefined,
240 link: { type: "page", id: pageId, pageId },
241 actorId: userId
242 });
243 } catch {
244
245 }
246 }
247 return { state: nextState, review: this.shape(review) };
248 }
249
250 private shape(review: Prisma.ReviewGetPayload<{ select: typeof REVIEW_SELECT }>) {
251 return {
252 id: review.id,
253 state: review.state,
254 reviewerIds: review.reviewerIds,
255 requestedBy: review.requestedBy,
256 comment: review.comment,
257 deadline: review.deadline,
258 decision: review.decision,
259 decidedAt: review.decidedAt,
260 decidedBy: review.decidedBy,
261 openedAt: review.openedAt,
262 closedAt: review.closedAt
263 };
264 }
265}
266