@etohq/review 0.0.0-stage → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/migrations/Migration20261028141707.d.ts +5 -0
- package/dist/migrations/Migration20261028141707.d.ts.map +1 -0
- package/dist/migrations/Migration20261028141707.js +29 -0
- package/dist/migrations/Migration20261028141707.js.map +1 -0
- package/dist/models/index.d.ts +5 -0
- package/dist/models/index.d.ts.map +1 -0
- package/dist/models/index.js +15 -0
- package/dist/models/index.js.map +1 -0
- package/dist/models/review-flag.d.ts +19 -0
- package/dist/models/review-flag.d.ts.map +1 -0
- package/dist/models/review-flag.js +31 -0
- package/dist/models/review-flag.js.map +1 -0
- package/dist/models/review-revision.d.ts +17 -0
- package/dist/models/review-revision.d.ts.map +1 -0
- package/dist/models/review-revision.js +28 -0
- package/dist/models/review-revision.js.map +1 -0
- package/dist/models/review-summary.d.ts +22 -0
- package/dist/models/review-summary.d.ts.map +1 -0
- package/dist/models/review-summary.js +34 -0
- package/dist/models/review-summary.js.map +1 -0
- package/dist/models/review.d.ts +53 -0
- package/dist/models/review.d.ts.map +1 -0
- package/dist/models/review.js +103 -0
- package/dist/models/review.js.map +1 -0
- package/dist/repositories/index.d.ts +3 -0
- package/dist/repositories/index.d.ts.map +1 -0
- package/dist/repositories/index.js +4 -0
- package/dist/repositories/index.js.map +1 -0
- package/dist/services/index.d.ts +2 -0
- package/dist/services/index.d.ts.map +1 -0
- package/dist/services/index.js +9 -0
- package/dist/services/index.js.map +1 -0
- package/dist/services/review-module-service.d.ts +269 -0
- package/dist/services/review-module-service.d.ts.map +1 -0
- package/dist/services/review-module-service.js +784 -0
- package/dist/services/review-module-service.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/dist/types/container.d.ts +9 -0
- package/dist/types/container.d.ts.map +1 -0
- package/dist/types/container.js +3 -0
- package/dist/types/container.js.map +1 -0
- package/package.json +64 -3
- package/README.md +0 -3
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
3
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
5
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
6
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
|
+
};
|
|
8
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
9
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
10
|
+
};
|
|
11
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
12
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
13
|
+
};
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
const utils_1 = require("@etohq/framework/utils");
|
|
16
|
+
const review_1 = require("@etohq/utils/review");
|
|
17
|
+
const _models_1 = require("../models");
|
|
18
|
+
const generateMethodForModels = { Review: _models_1.Review, ReviewRevision: _models_1.ReviewRevision, ReviewFlag: _models_1.ReviewFlag, ReviewSummary: _models_1.ReviewSummary };
|
|
19
|
+
/** A clock skew allowance for `occurred_at` from a phone. */
|
|
20
|
+
const FUTURE_SKEW_MS = 5 * 60 * 1000;
|
|
21
|
+
const LIVE_STATUSES = ["pending", "published", "hidden"];
|
|
22
|
+
/**
|
|
23
|
+
* The review engine.
|
|
24
|
+
*
|
|
25
|
+
* Every invariant in DESIGN.md is enforced here or by an index. Eligibility —
|
|
26
|
+
* whether the reviewer really took part — is the consumer's, attested on the
|
|
27
|
+
* input (`verified`, `eligible_until`) and composed by the consumer's flow ahead
|
|
28
|
+
* of the canonical workflows.
|
|
29
|
+
*/
|
|
30
|
+
class ReviewModuleService extends utils_1.ModulesSdkUtils.EtoService(generateMethodForModels) {
|
|
31
|
+
constructor(dependencies) {
|
|
32
|
+
// @ts-ignore
|
|
33
|
+
super(...arguments);
|
|
34
|
+
this.baseRepository_ = dependencies.baseRepository;
|
|
35
|
+
this.reviewService_ = dependencies.reviewService;
|
|
36
|
+
this.reviewRevisionService_ = dependencies.reviewRevisionService;
|
|
37
|
+
this.reviewFlagService_ = dependencies.reviewFlagService;
|
|
38
|
+
this.reviewSummaryService_ = dependencies.reviewSummaryService;
|
|
39
|
+
}
|
|
40
|
+
// ===== SUBMIT =====
|
|
41
|
+
async submitReview(data, sharedContext = {}) {
|
|
42
|
+
const { review, replayed, revealed_ids, events } = await this.submitReview_(data, sharedContext);
|
|
43
|
+
this.emit_(events, sharedContext);
|
|
44
|
+
return {
|
|
45
|
+
review: await this.baseRepository_.serialize(review),
|
|
46
|
+
replayed,
|
|
47
|
+
revealed_ids,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
async submitReview_(data, sharedContext = {}) {
|
|
51
|
+
requireText(data.space_id, "space_id");
|
|
52
|
+
requireRef(data.subject, "subject");
|
|
53
|
+
requireRef(data.reviewer, "reviewer");
|
|
54
|
+
requireRef(data.context, "context");
|
|
55
|
+
if (sameRef(data.reviewer, data.subject)) {
|
|
56
|
+
throw new utils_1.EtoError(utils_1.EtoError.Types.NOT_ALLOWED, "Nobody may review themselves");
|
|
57
|
+
}
|
|
58
|
+
if (data.subject_owner_id && data.subject_owner_id === data.reviewer.id) {
|
|
59
|
+
throw new utils_1.EtoError(utils_1.EtoError.Types.NOT_ALLOWED, "Nobody may review something they own");
|
|
60
|
+
}
|
|
61
|
+
const problems = (0, review_1.reviewContentProblems)(data);
|
|
62
|
+
const display = (0, review_1.cleanReviewText)(data.reviewer_display);
|
|
63
|
+
if (display && display.length > review_1.REVIEW_LIMITS.display) {
|
|
64
|
+
problems.push(`reviewer name is longer than ${review_1.REVIEW_LIMITS.display} characters`);
|
|
65
|
+
}
|
|
66
|
+
if (problems.length) {
|
|
67
|
+
throw new utils_1.EtoError(utils_1.EtoError.Types.INVALID_DATA, problems.join("; "));
|
|
68
|
+
}
|
|
69
|
+
const now = new Date();
|
|
70
|
+
const occurred = actTime(data.occurred_at, now);
|
|
71
|
+
const eligibleUntil = toDate(data.eligible_until, "eligible_until");
|
|
72
|
+
if (eligibleUntil && occurred > eligibleUntil) {
|
|
73
|
+
throw new utils_1.EtoError(utils_1.EtoError.Types.NOT_ALLOWED, "The time to review this has passed");
|
|
74
|
+
}
|
|
75
|
+
const content = {
|
|
76
|
+
rating: data.rating,
|
|
77
|
+
dimensions: data.dimensions ?? null,
|
|
78
|
+
tags: data.tags?.length ? data.tags : null,
|
|
79
|
+
title: (0, review_1.cleanReviewText)(data.title),
|
|
80
|
+
body: (0, review_1.cleanReviewText)(data.body),
|
|
81
|
+
media: data.media?.length ? data.media : null,
|
|
82
|
+
};
|
|
83
|
+
// The same tap arriving twice (offline replay) returns the original.
|
|
84
|
+
const key = (0, review_1.cleanReviewText)(data.idempotency_key);
|
|
85
|
+
if (key) {
|
|
86
|
+
const [existing] = await this.reviewService_.list({ space_id: data.space_id, idempotency_key: key }, { take: 1 }, sharedContext);
|
|
87
|
+
if (existing) {
|
|
88
|
+
if (sameSubmission(existing, data, content)) {
|
|
89
|
+
return { review: existing, replayed: true, revealed_ids: [], events: [] };
|
|
90
|
+
}
|
|
91
|
+
throw new utils_1.EtoError(utils_1.EtoError.Types.CONFLICT, "This idempotency key was already used for a different review");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const [live] = await this.reviewService_.list({
|
|
95
|
+
space_id: data.space_id,
|
|
96
|
+
reviewer_type: data.reviewer.type,
|
|
97
|
+
reviewer_id: data.reviewer.id,
|
|
98
|
+
subject_type: data.subject.type,
|
|
99
|
+
subject_id: data.subject.id,
|
|
100
|
+
context_type: data.context.type,
|
|
101
|
+
context_id: data.context.id,
|
|
102
|
+
status: LIVE_STATUSES,
|
|
103
|
+
}, { take: 1 }, sharedContext);
|
|
104
|
+
if (live) {
|
|
105
|
+
throw new utils_1.EtoError(utils_1.EtoError.Types.CONFLICT, `You already reviewed this (${live.id}); edit that review instead`);
|
|
106
|
+
}
|
|
107
|
+
// Two-sided reviews: if the counterpart already reviewed back in this
|
|
108
|
+
// context, both reveal now; otherwise a sealed review waits for them or for
|
|
109
|
+
// its seal to lapse.
|
|
110
|
+
const [counterpart] = await this.reviewService_.list({
|
|
111
|
+
space_id: data.space_id,
|
|
112
|
+
context_type: data.context.type,
|
|
113
|
+
context_id: data.context.id,
|
|
114
|
+
reviewer_type: data.subject.type,
|
|
115
|
+
reviewer_id: data.subject.id,
|
|
116
|
+
subject_type: data.reviewer.type,
|
|
117
|
+
subject_id: data.reviewer.id,
|
|
118
|
+
status: LIVE_STATUSES,
|
|
119
|
+
}, { take: 1 }, sharedContext);
|
|
120
|
+
const sealUntil = toDate(data.seal_until, "seal_until");
|
|
121
|
+
const sealed = !!sealUntil && sealUntil > occurred && !counterpart;
|
|
122
|
+
const revealCounterpart = !!counterpart && !counterpart.revealed_at;
|
|
123
|
+
const status = data.moderation === "pre" ? "pending" : "published";
|
|
124
|
+
let review;
|
|
125
|
+
try {
|
|
126
|
+
review = await this.reviewService_.create({
|
|
127
|
+
space_id: data.space_id,
|
|
128
|
+
subject_type: data.subject.type,
|
|
129
|
+
subject_id: data.subject.id,
|
|
130
|
+
subject_owner_id: data.subject_owner_id ?? null,
|
|
131
|
+
reviewer_type: data.reviewer.type,
|
|
132
|
+
reviewer_id: data.reviewer.id,
|
|
133
|
+
reviewer_display: display,
|
|
134
|
+
anonymous: !!data.anonymous,
|
|
135
|
+
context_type: data.context.type,
|
|
136
|
+
context_id: data.context.id,
|
|
137
|
+
scope: (0, review_1.cleanReviewText)(data.scope),
|
|
138
|
+
...content,
|
|
139
|
+
locale: (0, review_1.cleanReviewText)(data.locale),
|
|
140
|
+
verified: !!data.verified,
|
|
141
|
+
status,
|
|
142
|
+
revealed_at: sealed ? null : now,
|
|
143
|
+
reveal_after: sealed ? sealUntil : null,
|
|
144
|
+
eligible_until: eligibleUntil,
|
|
145
|
+
occurred_at: occurred,
|
|
146
|
+
idempotency_key: key,
|
|
147
|
+
metadata: data.metadata ?? null,
|
|
148
|
+
}, sharedContext);
|
|
149
|
+
}
|
|
150
|
+
catch (e) {
|
|
151
|
+
throw asConflict(e);
|
|
152
|
+
}
|
|
153
|
+
await this.reviewRevisionService_.create({ review_id: review.id, revision: 1, ...content }, sharedContext);
|
|
154
|
+
const revealed_ids = [];
|
|
155
|
+
const keys = [...keysOf(review)];
|
|
156
|
+
if (revealCounterpart) {
|
|
157
|
+
await this.reviewService_.update({ selector: { id: counterpart.id }, data: { revealed_at: now } }, sharedContext);
|
|
158
|
+
revealed_ids.push(counterpart.id);
|
|
159
|
+
keys.push(...keysOf(counterpart));
|
|
160
|
+
}
|
|
161
|
+
const events = [
|
|
162
|
+
{ name: review_1.ReviewEvents.SUBMITTED, data: refOf(review) },
|
|
163
|
+
...revealed_ids.map((id) => ({ name: review_1.ReviewEvents.REVEALED, data: { id } })),
|
|
164
|
+
...(await this.recomputeSummaries_(keys, sharedContext)),
|
|
165
|
+
];
|
|
166
|
+
return { review, replayed: false, revealed_ids, events };
|
|
167
|
+
}
|
|
168
|
+
// ===== EDIT / WITHDRAW =====
|
|
169
|
+
async editReview(data, sharedContext = {}) {
|
|
170
|
+
const { review, events } = await this.editReview_(data, sharedContext);
|
|
171
|
+
this.emit_(events, sharedContext);
|
|
172
|
+
return await this.baseRepository_.serialize(review);
|
|
173
|
+
}
|
|
174
|
+
async editReview_(data, sharedContext = {}) {
|
|
175
|
+
const existing = await this.ownReview_(data.review_id, data.reviewer, sharedContext);
|
|
176
|
+
if (existing.status === "withdrawn" || existing.status === "hidden") {
|
|
177
|
+
throw new utils_1.EtoError(utils_1.EtoError.Types.NOT_ALLOWED, `This review is ${existing.status} and cannot be edited`);
|
|
178
|
+
}
|
|
179
|
+
const occurred = actTime(data.occurred_at, new Date());
|
|
180
|
+
if (existing.eligible_until && occurred > new Date(existing.eligible_until)) {
|
|
181
|
+
throw new utils_1.EtoError(utils_1.EtoError.Types.NOT_ALLOWED, "The time to change this review has passed");
|
|
182
|
+
}
|
|
183
|
+
const problems = (0, review_1.reviewContentProblems)(data, { partial: true });
|
|
184
|
+
if (problems.length) {
|
|
185
|
+
throw new utils_1.EtoError(utils_1.EtoError.Types.INVALID_DATA, problems.join("; "));
|
|
186
|
+
}
|
|
187
|
+
const has = (k) => data[k] !== undefined;
|
|
188
|
+
const next = {
|
|
189
|
+
rating: has("rating") ? data.rating : existing.rating,
|
|
190
|
+
dimensions: has("dimensions") ? data.dimensions ?? null : existing.dimensions,
|
|
191
|
+
tags: has("tags") ? (data.tags?.length ? data.tags : null) : existing.tags,
|
|
192
|
+
title: has("title") ? (0, review_1.cleanReviewText)(data.title) : existing.title,
|
|
193
|
+
body: has("body") ? (0, review_1.cleanReviewText)(data.body) : existing.body,
|
|
194
|
+
media: has("media") ? (data.media?.length ? data.media : null) : existing.media,
|
|
195
|
+
};
|
|
196
|
+
const revisions = await this.reviewRevisionService_.list({ review_id: existing.id }, { select: ["revision"], order: { revision: "DESC" }, take: 1 }, sharedContext);
|
|
197
|
+
await this.reviewRevisionService_.create({ review_id: existing.id, revision: (revisions[0]?.revision ?? 0) + 1, ...next }, sharedContext);
|
|
198
|
+
const review = firstOf(await this.reviewService_.update({
|
|
199
|
+
selector: { id: existing.id },
|
|
200
|
+
data: {
|
|
201
|
+
...next,
|
|
202
|
+
edited_at: new Date(),
|
|
203
|
+
...(data.moderation === "pre" ? { status: "pending" } : {}),
|
|
204
|
+
},
|
|
205
|
+
}, sharedContext));
|
|
206
|
+
const events = [
|
|
207
|
+
{ name: review_1.ReviewEvents.EDITED, data: refOf(review) },
|
|
208
|
+
...(await this.recomputeSummaries_(keysOf(review), sharedContext)),
|
|
209
|
+
];
|
|
210
|
+
return { review, events };
|
|
211
|
+
}
|
|
212
|
+
async withdrawReview(data, sharedContext = {}) {
|
|
213
|
+
const { review, events } = await this.withdrawReview_(data, sharedContext);
|
|
214
|
+
this.emit_(events, sharedContext);
|
|
215
|
+
return await this.baseRepository_.serialize(review);
|
|
216
|
+
}
|
|
217
|
+
async withdrawReview_(data, sharedContext = {}) {
|
|
218
|
+
const existing = await this.ownReview_(data.review_id, data.reviewer, sharedContext);
|
|
219
|
+
if (existing.status === "withdrawn") {
|
|
220
|
+
return { review: existing, events: [] };
|
|
221
|
+
}
|
|
222
|
+
const review = firstOf(await this.reviewService_.update({ selector: { id: existing.id }, data: { status: "withdrawn" } }, sharedContext));
|
|
223
|
+
const events = [
|
|
224
|
+
{ name: review_1.ReviewEvents.WITHDRAWN, data: refOf(review) },
|
|
225
|
+
...(await this.recomputeSummaries_(keysOf(review), sharedContext)),
|
|
226
|
+
];
|
|
227
|
+
return { review, events };
|
|
228
|
+
}
|
|
229
|
+
// ===== RESPOND =====
|
|
230
|
+
async respondToReview(data, sharedContext = {}) {
|
|
231
|
+
const review = await this.respondToReview_(data, sharedContext);
|
|
232
|
+
this.emit_([{ name: review_1.ReviewEvents.RESPONDED, data: refOf(review) }], sharedContext);
|
|
233
|
+
return await this.baseRepository_.serialize(review);
|
|
234
|
+
}
|
|
235
|
+
async respondToReview_(data, sharedContext = {}) {
|
|
236
|
+
requireRef(data.responder, "responder");
|
|
237
|
+
const existing = await this.reviewService_.retrieve(data.review_id, {}, sharedContext);
|
|
238
|
+
const isSubject = sameRef(data.responder, { type: existing.subject_type, id: existing.subject_id });
|
|
239
|
+
const isOwner = !!existing.subject_owner_id && existing.subject_owner_id === data.responder.id;
|
|
240
|
+
if (!isSubject && !isOwner) {
|
|
241
|
+
throw new utils_1.EtoError(utils_1.EtoError.Types.NOT_ALLOWED, "Only who was reviewed, or its owner, may respond");
|
|
242
|
+
}
|
|
243
|
+
// A sealed review is not yet seen by its subject; answering it would
|
|
244
|
+
// reveal it by the back door.
|
|
245
|
+
if (existing.status !== "published" || !existing.revealed_at) {
|
|
246
|
+
throw new utils_1.EtoError(utils_1.EtoError.Types.NOT_ALLOWED, "Only a published review can be answered");
|
|
247
|
+
}
|
|
248
|
+
const body = (0, review_1.cleanReviewText)(data.body);
|
|
249
|
+
if (!body) {
|
|
250
|
+
throw new utils_1.EtoError(utils_1.EtoError.Types.INVALID_DATA, "A response needs some words");
|
|
251
|
+
}
|
|
252
|
+
if (body.length > review_1.REVIEW_LIMITS.response) {
|
|
253
|
+
throw new utils_1.EtoError(utils_1.EtoError.Types.INVALID_DATA, `A response is at most ${review_1.REVIEW_LIMITS.response} characters`);
|
|
254
|
+
}
|
|
255
|
+
const review = firstOf(await this.reviewService_.update({
|
|
256
|
+
selector: { id: existing.id },
|
|
257
|
+
data: { response_body: body, responder_id: data.responder.id, responded_at: new Date() },
|
|
258
|
+
}, sharedContext));
|
|
259
|
+
return review;
|
|
260
|
+
}
|
|
261
|
+
// ===== MODERATE / FLAG =====
|
|
262
|
+
async moderateReview(data, sharedContext = {}) {
|
|
263
|
+
const { review, events } = await this.moderateReview_(data, sharedContext);
|
|
264
|
+
this.emit_(events, sharedContext);
|
|
265
|
+
return await this.baseRepository_.serialize(review);
|
|
266
|
+
}
|
|
267
|
+
async moderateReview_(data, sharedContext = {}) {
|
|
268
|
+
requireText(data.moderator_id, "moderator_id");
|
|
269
|
+
if (data.decision !== "publish" && data.decision !== "hide") {
|
|
270
|
+
throw new utils_1.EtoError(utils_1.EtoError.Types.INVALID_DATA, "decision is publish or hide");
|
|
271
|
+
}
|
|
272
|
+
const existing = await this.reviewService_.retrieve(data.review_id, {}, sharedContext);
|
|
273
|
+
if (existing.status === "withdrawn") {
|
|
274
|
+
throw new utils_1.EtoError(utils_1.EtoError.Types.NOT_ALLOWED, "A withdrawn review cannot be moderated");
|
|
275
|
+
}
|
|
276
|
+
const reason = (0, review_1.cleanReviewText)(data.reason);
|
|
277
|
+
if (data.decision === "hide" && !reason) {
|
|
278
|
+
throw new utils_1.EtoError(utils_1.EtoError.Types.INVALID_DATA, "Say why a review is hidden");
|
|
279
|
+
}
|
|
280
|
+
const now = new Date();
|
|
281
|
+
const review = firstOf(await this.reviewService_.update({
|
|
282
|
+
selector: { id: existing.id },
|
|
283
|
+
data: {
|
|
284
|
+
status: data.decision === "publish" ? "published" : "hidden",
|
|
285
|
+
moderated_by: data.moderator_id,
|
|
286
|
+
moderated_at: now,
|
|
287
|
+
moderation_reason: reason,
|
|
288
|
+
open_flag_count: 0,
|
|
289
|
+
},
|
|
290
|
+
}, sharedContext));
|
|
291
|
+
// A moderator's decision answers every open report on it.
|
|
292
|
+
await this.reviewFlagService_.update({
|
|
293
|
+
selector: { review_id: existing.id, status: "open" },
|
|
294
|
+
data: { status: "resolved", resolved_by: data.moderator_id, resolved_at: now },
|
|
295
|
+
}, sharedContext);
|
|
296
|
+
const events = [
|
|
297
|
+
{
|
|
298
|
+
name: data.decision === "publish" ? review_1.ReviewEvents.PUBLISHED : review_1.ReviewEvents.HIDDEN,
|
|
299
|
+
data: refOf(review),
|
|
300
|
+
},
|
|
301
|
+
...(await this.recomputeSummaries_(keysOf(review), sharedContext)),
|
|
302
|
+
];
|
|
303
|
+
return { review, events };
|
|
304
|
+
}
|
|
305
|
+
async flagReview(data, sharedContext = {}) {
|
|
306
|
+
const flag = await this.flagReview_(data, sharedContext);
|
|
307
|
+
this.emit_([{ name: review_1.ReviewEvents.FLAGGED, data: { id: flag.review_id, flag_id: flag.id } }], sharedContext);
|
|
308
|
+
return await this.baseRepository_.serialize(flag);
|
|
309
|
+
}
|
|
310
|
+
async flagReview_(data, sharedContext = {}) {
|
|
311
|
+
requireRef(data.reporter, "reporter");
|
|
312
|
+
const reason = (0, review_1.cleanReviewText)(data.reason);
|
|
313
|
+
if (!reason) {
|
|
314
|
+
throw new utils_1.EtoError(utils_1.EtoError.Types.INVALID_DATA, "Say why this review is being reported");
|
|
315
|
+
}
|
|
316
|
+
const review = await this.reviewService_.retrieve(data.review_id, {}, sharedContext);
|
|
317
|
+
if (review.status === "withdrawn") {
|
|
318
|
+
throw new utils_1.EtoError(utils_1.EtoError.Types.NOT_ALLOWED, "This review was withdrawn");
|
|
319
|
+
}
|
|
320
|
+
// Reporting twice changes nothing: the first report stands.
|
|
321
|
+
const [existing] = await this.reviewFlagService_.list({ review_id: review.id, reporter_type: data.reporter.type, reporter_id: data.reporter.id }, { take: 1 }, sharedContext);
|
|
322
|
+
if (existing) {
|
|
323
|
+
return existing;
|
|
324
|
+
}
|
|
325
|
+
let flag;
|
|
326
|
+
try {
|
|
327
|
+
flag = await this.reviewFlagService_.create({
|
|
328
|
+
review_id: review.id,
|
|
329
|
+
space_id: review.space_id,
|
|
330
|
+
reporter_type: data.reporter.type,
|
|
331
|
+
reporter_id: data.reporter.id,
|
|
332
|
+
reason,
|
|
333
|
+
note: (0, review_1.cleanReviewText)(data.note),
|
|
334
|
+
}, sharedContext);
|
|
335
|
+
}
|
|
336
|
+
catch (e) {
|
|
337
|
+
throw asConflict(e);
|
|
338
|
+
}
|
|
339
|
+
const open = await this.reviewFlagService_.list({ review_id: review.id, status: "open" }, { select: ["id"] }, sharedContext);
|
|
340
|
+
await this.reviewService_.update({ selector: { id: review.id }, data: { open_flag_count: open.length } }, sharedContext);
|
|
341
|
+
return flag;
|
|
342
|
+
}
|
|
343
|
+
// ===== REVEAL / ERASE =====
|
|
344
|
+
async revealDueReviews(data = {}, sharedContext = {}) {
|
|
345
|
+
const { revealed_ids, events } = await this.revealDueReviews_(data, sharedContext);
|
|
346
|
+
this.emit_(events, sharedContext);
|
|
347
|
+
return { revealed_ids };
|
|
348
|
+
}
|
|
349
|
+
async revealDueReviews_(data, sharedContext = {}) {
|
|
350
|
+
const now = toDate(data.now, "now") ?? new Date();
|
|
351
|
+
const due = await this.reviewService_.list({ revealed_at: null, reveal_after: { $lte: now } }, { take: 500 }, sharedContext);
|
|
352
|
+
if (!due.length) {
|
|
353
|
+
return { revealed_ids: [], events: [] };
|
|
354
|
+
}
|
|
355
|
+
await this.reviewService_.update({ selector: { id: due.map((r) => r.id) }, data: { revealed_at: now } }, sharedContext);
|
|
356
|
+
const events = [
|
|
357
|
+
...due.map((r) => ({ name: review_1.ReviewEvents.REVEALED, data: { id: r.id } })),
|
|
358
|
+
...(await this.recomputeSummaries_(due.flatMap(keysOf), sharedContext)),
|
|
359
|
+
];
|
|
360
|
+
return { revealed_ids: due.map((r) => r.id), events };
|
|
361
|
+
}
|
|
362
|
+
async anonymizeReviewer(data, sharedContext = {}) {
|
|
363
|
+
return await this.anonymizeReviewer_(data, sharedContext);
|
|
364
|
+
}
|
|
365
|
+
async anonymizeReviewer_(data, sharedContext = {}) {
|
|
366
|
+
requireRef(data.reviewer, "reviewer");
|
|
367
|
+
const reviews = await this.reviewService_.list({ reviewer_type: data.reviewer.type, reviewer_id: data.reviewer.id }, { select: ["id"] }, sharedContext);
|
|
368
|
+
// Each review gets its own placeholder so the one-per-interaction index
|
|
369
|
+
// still holds; scores stay, so summaries do not move.
|
|
370
|
+
for (const r of reviews) {
|
|
371
|
+
await this.reviewService_.update({
|
|
372
|
+
selector: { id: r.id },
|
|
373
|
+
data: {
|
|
374
|
+
reviewer_id: `anonymized_${r.id}`,
|
|
375
|
+
reviewer_display: null,
|
|
376
|
+
anonymous: true,
|
|
377
|
+
idempotency_key: null,
|
|
378
|
+
metadata: null,
|
|
379
|
+
},
|
|
380
|
+
}, sharedContext);
|
|
381
|
+
}
|
|
382
|
+
return { anonymized: reviews.length };
|
|
383
|
+
}
|
|
384
|
+
// ===== READS =====
|
|
385
|
+
async getReviewSummary(query, sharedContext = {}) {
|
|
386
|
+
const [summary] = await this.getReviewSummaries({ ...query, subject_ids: [query.subject_id] }, sharedContext);
|
|
387
|
+
return summary;
|
|
388
|
+
}
|
|
389
|
+
async getReviewSummaries(query, sharedContext = {}) {
|
|
390
|
+
requireText(query.space_id, "space_id");
|
|
391
|
+
requireText(query.subject_type, "subject_type");
|
|
392
|
+
const scope = (0, review_1.cleanReviewText)(query.scope) ?? "";
|
|
393
|
+
const ids = [...new Set(query.subject_ids)];
|
|
394
|
+
if (!ids.length) {
|
|
395
|
+
return [];
|
|
396
|
+
}
|
|
397
|
+
const rows = await this.reviewSummaryService_.list({ space_id: query.space_id, subject_type: query.subject_type, subject_id: ids, scope }, {}, sharedContext);
|
|
398
|
+
const byId = new Map(rows.map((r) => [r.subject_id, r]));
|
|
399
|
+
return ids.map((subject_id) => {
|
|
400
|
+
const row = byId.get(subject_id);
|
|
401
|
+
return row
|
|
402
|
+
? toSummaryDTO(row)
|
|
403
|
+
: emptySummary({ space_id: query.space_id, subject_type: query.subject_type, subject_id, scope });
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
async listPublicReviews(query, sharedContext = {}) {
|
|
407
|
+
requireText(query.space_id, "space_id");
|
|
408
|
+
const filters = {
|
|
409
|
+
space_id: query.space_id,
|
|
410
|
+
subject_type: query.subject_type,
|
|
411
|
+
subject_id: query.subject_id,
|
|
412
|
+
status: "published",
|
|
413
|
+
revealed_at: { $ne: null },
|
|
414
|
+
};
|
|
415
|
+
const scope = (0, review_1.cleanReviewText)(query.scope);
|
|
416
|
+
if (scope)
|
|
417
|
+
filters.scope = scope;
|
|
418
|
+
if (query.rating)
|
|
419
|
+
filters.rating = query.rating;
|
|
420
|
+
if (query.verified_only)
|
|
421
|
+
filters.verified = true;
|
|
422
|
+
if (query.tag) {
|
|
423
|
+
// jsonb containment; the ORM has no portable operator for it.
|
|
424
|
+
const em = this.baseRepository_.getActiveManager(sharedContext);
|
|
425
|
+
const rows = await em.execute(`select id from review where deleted_at is null and space_id = ? and subject_type = ?
|
|
426
|
+
and subject_id = ? and tags @> ?::jsonb`, [query.space_id, query.subject_type, query.subject_id, JSON.stringify([query.tag])]);
|
|
427
|
+
filters.id = rows.map((r) => r.id);
|
|
428
|
+
}
|
|
429
|
+
const [rows, count] = await this.reviewService_.listAndCount(filters, {
|
|
430
|
+
order: { occurred_at: "DESC" },
|
|
431
|
+
take: Math.min(Math.max(query.limit ?? 20, 1), 100),
|
|
432
|
+
skip: Math.max(query.offset ?? 0, 0),
|
|
433
|
+
}, sharedContext);
|
|
434
|
+
const reviews = await this.baseRepository_.serialize(rows);
|
|
435
|
+
return { reviews: reviews.map(toPublic), count };
|
|
436
|
+
}
|
|
437
|
+
// ===== INTERNALS =====
|
|
438
|
+
async ownReview_(id, reviewer, sharedContext) {
|
|
439
|
+
requireRef(reviewer, "reviewer");
|
|
440
|
+
const review = await this.reviewService_.retrieve(id, {}, sharedContext);
|
|
441
|
+
if (!sameRef(reviewer, { type: review.reviewer_type, id: review.reviewer_id })) {
|
|
442
|
+
throw new utils_1.EtoError(utils_1.EtoError.Types.NOT_ALLOWED, "Only who wrote a review may change it");
|
|
443
|
+
}
|
|
444
|
+
return review;
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Recompute summaries from the rows, in SQL, under a per-subject advisory
|
|
448
|
+
* lock held to the end of the transaction. Two reviews of one subject at once
|
|
449
|
+
* serialise here, so the second recompute sees the first's row.
|
|
450
|
+
*/
|
|
451
|
+
async recomputeSummaries_(keys, sharedContext) {
|
|
452
|
+
const em = this.baseRepository_.getActiveManager(sharedContext);
|
|
453
|
+
if (!em?.execute) {
|
|
454
|
+
throw new utils_1.EtoError(utils_1.EtoError.Types.UNEXPECTED_STATE, "No transaction to recompute summaries in");
|
|
455
|
+
}
|
|
456
|
+
// The aggregates are SQL over the table, so this transaction's own pending
|
|
457
|
+
// changes must reach it first.
|
|
458
|
+
await em.flush?.();
|
|
459
|
+
const unique = new Map(keys.map((k) => [summaryKey(k), k]));
|
|
460
|
+
const subjects = new Map([...unique.values()].map((k) => [`${k.space_id}|${k.subject_type}|${k.subject_id}`, k]));
|
|
461
|
+
// Lock in a stable order so two transactions never wait on each other in a cycle.
|
|
462
|
+
for (const lockKey of [...subjects.keys()].sort()) {
|
|
463
|
+
await em.execute("select pg_advisory_xact_lock(hashtext(?))", [`review:${lockKey}`]);
|
|
464
|
+
}
|
|
465
|
+
const events = [];
|
|
466
|
+
for (const key of unique.values()) {
|
|
467
|
+
const where = `deleted_at is null and status = 'published' and revealed_at is not null
|
|
468
|
+
and space_id = ? and subject_type = ? and subject_id = ?${key.scope ? " and scope = ?" : ""}`;
|
|
469
|
+
const params = [key.space_id, key.subject_type, key.subject_id, ...(key.scope ? [key.scope] : [])];
|
|
470
|
+
const stars = await em.execute(`select rating, count(*)::int as n, sum(case when verified then 1 else 0 end)::int as v,
|
|
471
|
+
max(occurred_at) as last
|
|
472
|
+
from review where ${where} group by rating`, params);
|
|
473
|
+
const dims = await em.execute(`select d.key as key, count(*)::int as n, avg((d.value)::numeric)::float as mean
|
|
474
|
+
from review r cross join lateral jsonb_each_text(coalesce(r.dimensions, '{}'::jsonb)) d
|
|
475
|
+
where ${where.replace(/\b(deleted_at|status|revealed_at|space_id|subject_type|subject_id|scope)\b/g, "r.$1")}
|
|
476
|
+
group by d.key`, params);
|
|
477
|
+
const tags = await em.execute(`select t.value as tag, count(*)::int as n
|
|
478
|
+
from review r cross join lateral jsonb_array_elements_text(coalesce(r.tags, '[]'::jsonb)) t
|
|
479
|
+
where ${where.replace(/\b(deleted_at|status|revealed_at|space_id|subject_type|subject_id|scope)\b/g, "r.$1")}
|
|
480
|
+
group by t.value`, params);
|
|
481
|
+
const distribution = { "1": 0, "2": 0, "3": 0, "4": 0, "5": 0 };
|
|
482
|
+
let count = 0;
|
|
483
|
+
let verified = 0;
|
|
484
|
+
let sum = 0;
|
|
485
|
+
let last = null;
|
|
486
|
+
for (const row of stars) {
|
|
487
|
+
distribution[String(row.rating)] = row.n;
|
|
488
|
+
count += row.n;
|
|
489
|
+
verified += row.v;
|
|
490
|
+
sum += row.rating * row.n;
|
|
491
|
+
const at = row.last ? new Date(row.last) : null;
|
|
492
|
+
if (at && (!last || at > last))
|
|
493
|
+
last = at;
|
|
494
|
+
}
|
|
495
|
+
const dimensions = Object.fromEntries(dims.map((d) => [d.key, { count: d.n, mean: round2(d.mean) }]));
|
|
496
|
+
const tagCounts = Object.fromEntries(tags.map((t) => [t.tag, t.n]));
|
|
497
|
+
const mean = count ? round2(sum / count) : null;
|
|
498
|
+
await em.execute(`insert into review_summary
|
|
499
|
+
(id, space_id, subject_type, subject_id, scope, count, verified_count, rating_sum, mean,
|
|
500
|
+
distribution, dimensions, tags, last_reviewed_at)
|
|
501
|
+
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?::jsonb, ?::jsonb, ?::jsonb, ?)
|
|
502
|
+
on conflict (space_id, subject_type, subject_id, scope) where deleted_at is null
|
|
503
|
+
do update set count = excluded.count, verified_count = excluded.verified_count,
|
|
504
|
+
rating_sum = excluded.rating_sum, mean = excluded.mean,
|
|
505
|
+
distribution = excluded.distribution, dimensions = excluded.dimensions,
|
|
506
|
+
tags = excluded.tags, last_reviewed_at = excluded.last_reviewed_at, updated_at = now()`, [
|
|
507
|
+
(0, utils_1.generateEntityId)(undefined, "rvws"),
|
|
508
|
+
key.space_id,
|
|
509
|
+
key.subject_type,
|
|
510
|
+
key.subject_id,
|
|
511
|
+
key.scope,
|
|
512
|
+
count,
|
|
513
|
+
verified,
|
|
514
|
+
sum,
|
|
515
|
+
mean,
|
|
516
|
+
JSON.stringify(distribution),
|
|
517
|
+
JSON.stringify(dimensions),
|
|
518
|
+
JSON.stringify(tagCounts),
|
|
519
|
+
last,
|
|
520
|
+
]);
|
|
521
|
+
events.push({
|
|
522
|
+
name: review_1.ReviewEvents.SUMMARY_CHANGED,
|
|
523
|
+
data: { ...key, count, mean },
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
return events;
|
|
527
|
+
}
|
|
528
|
+
emit_(events, sharedContext) {
|
|
529
|
+
if (!events.length || !sharedContext.messageAggregator) {
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
const messages = events.map((e) => ({
|
|
533
|
+
source: utils_1.Modules.REVIEW,
|
|
534
|
+
action: e.name.split(".").pop(),
|
|
535
|
+
object: e.name.split(".")[1],
|
|
536
|
+
eventName: e.name,
|
|
537
|
+
context: sharedContext,
|
|
538
|
+
data: e.data,
|
|
539
|
+
}));
|
|
540
|
+
sharedContext.messageAggregator.saveRawMessageData(messages);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
exports.default = ReviewModuleService;
|
|
544
|
+
__decorate([
|
|
545
|
+
(0, utils_1.InjectManager)(),
|
|
546
|
+
(0, utils_1.EmitEvents)(),
|
|
547
|
+
__param(1, (0, utils_1.EtoContext)()),
|
|
548
|
+
__metadata("design:type", Function),
|
|
549
|
+
__metadata("design:paramtypes", [Object, Object]),
|
|
550
|
+
__metadata("design:returntype", Promise)
|
|
551
|
+
], ReviewModuleService.prototype, "submitReview", null);
|
|
552
|
+
__decorate([
|
|
553
|
+
(0, utils_1.InjectTransactionManager)(),
|
|
554
|
+
__param(1, (0, utils_1.EtoContext)()),
|
|
555
|
+
__metadata("design:type", Function),
|
|
556
|
+
__metadata("design:paramtypes", [Object, Object]),
|
|
557
|
+
__metadata("design:returntype", Promise)
|
|
558
|
+
], ReviewModuleService.prototype, "submitReview_", null);
|
|
559
|
+
__decorate([
|
|
560
|
+
(0, utils_1.InjectManager)(),
|
|
561
|
+
(0, utils_1.EmitEvents)(),
|
|
562
|
+
__param(1, (0, utils_1.EtoContext)()),
|
|
563
|
+
__metadata("design:type", Function),
|
|
564
|
+
__metadata("design:paramtypes", [Object, Object]),
|
|
565
|
+
__metadata("design:returntype", Promise)
|
|
566
|
+
], ReviewModuleService.prototype, "editReview", null);
|
|
567
|
+
__decorate([
|
|
568
|
+
(0, utils_1.InjectTransactionManager)(),
|
|
569
|
+
__param(1, (0, utils_1.EtoContext)()),
|
|
570
|
+
__metadata("design:type", Function),
|
|
571
|
+
__metadata("design:paramtypes", [Object, Object]),
|
|
572
|
+
__metadata("design:returntype", Promise)
|
|
573
|
+
], ReviewModuleService.prototype, "editReview_", null);
|
|
574
|
+
__decorate([
|
|
575
|
+
(0, utils_1.InjectManager)(),
|
|
576
|
+
(0, utils_1.EmitEvents)(),
|
|
577
|
+
__param(1, (0, utils_1.EtoContext)()),
|
|
578
|
+
__metadata("design:type", Function),
|
|
579
|
+
__metadata("design:paramtypes", [Object, Object]),
|
|
580
|
+
__metadata("design:returntype", Promise)
|
|
581
|
+
], ReviewModuleService.prototype, "withdrawReview", null);
|
|
582
|
+
__decorate([
|
|
583
|
+
(0, utils_1.InjectTransactionManager)(),
|
|
584
|
+
__param(1, (0, utils_1.EtoContext)()),
|
|
585
|
+
__metadata("design:type", Function),
|
|
586
|
+
__metadata("design:paramtypes", [Object, Object]),
|
|
587
|
+
__metadata("design:returntype", Promise)
|
|
588
|
+
], ReviewModuleService.prototype, "withdrawReview_", null);
|
|
589
|
+
__decorate([
|
|
590
|
+
(0, utils_1.InjectManager)(),
|
|
591
|
+
(0, utils_1.EmitEvents)(),
|
|
592
|
+
__param(1, (0, utils_1.EtoContext)()),
|
|
593
|
+
__metadata("design:type", Function),
|
|
594
|
+
__metadata("design:paramtypes", [Object, Object]),
|
|
595
|
+
__metadata("design:returntype", Promise)
|
|
596
|
+
], ReviewModuleService.prototype, "respondToReview", null);
|
|
597
|
+
__decorate([
|
|
598
|
+
(0, utils_1.InjectTransactionManager)(),
|
|
599
|
+
__param(1, (0, utils_1.EtoContext)()),
|
|
600
|
+
__metadata("design:type", Function),
|
|
601
|
+
__metadata("design:paramtypes", [Object, Object]),
|
|
602
|
+
__metadata("design:returntype", Promise)
|
|
603
|
+
], ReviewModuleService.prototype, "respondToReview_", null);
|
|
604
|
+
__decorate([
|
|
605
|
+
(0, utils_1.InjectManager)(),
|
|
606
|
+
(0, utils_1.EmitEvents)(),
|
|
607
|
+
__param(1, (0, utils_1.EtoContext)()),
|
|
608
|
+
__metadata("design:type", Function),
|
|
609
|
+
__metadata("design:paramtypes", [Object, Object]),
|
|
610
|
+
__metadata("design:returntype", Promise)
|
|
611
|
+
], ReviewModuleService.prototype, "moderateReview", null);
|
|
612
|
+
__decorate([
|
|
613
|
+
(0, utils_1.InjectTransactionManager)(),
|
|
614
|
+
__param(1, (0, utils_1.EtoContext)()),
|
|
615
|
+
__metadata("design:type", Function),
|
|
616
|
+
__metadata("design:paramtypes", [Object, Object]),
|
|
617
|
+
__metadata("design:returntype", Promise)
|
|
618
|
+
], ReviewModuleService.prototype, "moderateReview_", null);
|
|
619
|
+
__decorate([
|
|
620
|
+
(0, utils_1.InjectManager)(),
|
|
621
|
+
(0, utils_1.EmitEvents)(),
|
|
622
|
+
__param(1, (0, utils_1.EtoContext)()),
|
|
623
|
+
__metadata("design:type", Function),
|
|
624
|
+
__metadata("design:paramtypes", [Object, Object]),
|
|
625
|
+
__metadata("design:returntype", Promise)
|
|
626
|
+
], ReviewModuleService.prototype, "flagReview", null);
|
|
627
|
+
__decorate([
|
|
628
|
+
(0, utils_1.InjectTransactionManager)(),
|
|
629
|
+
__param(1, (0, utils_1.EtoContext)()),
|
|
630
|
+
__metadata("design:type", Function),
|
|
631
|
+
__metadata("design:paramtypes", [Object, Object]),
|
|
632
|
+
__metadata("design:returntype", Promise)
|
|
633
|
+
], ReviewModuleService.prototype, "flagReview_", null);
|
|
634
|
+
__decorate([
|
|
635
|
+
(0, utils_1.InjectManager)(),
|
|
636
|
+
(0, utils_1.EmitEvents)(),
|
|
637
|
+
__param(1, (0, utils_1.EtoContext)()),
|
|
638
|
+
__metadata("design:type", Function),
|
|
639
|
+
__metadata("design:paramtypes", [Object, Object]),
|
|
640
|
+
__metadata("design:returntype", Promise)
|
|
641
|
+
], ReviewModuleService.prototype, "revealDueReviews", null);
|
|
642
|
+
__decorate([
|
|
643
|
+
(0, utils_1.InjectTransactionManager)(),
|
|
644
|
+
__param(1, (0, utils_1.EtoContext)()),
|
|
645
|
+
__metadata("design:type", Function),
|
|
646
|
+
__metadata("design:paramtypes", [Object, Object]),
|
|
647
|
+
__metadata("design:returntype", Promise)
|
|
648
|
+
], ReviewModuleService.prototype, "revealDueReviews_", null);
|
|
649
|
+
__decorate([
|
|
650
|
+
(0, utils_1.InjectManager)(),
|
|
651
|
+
__param(1, (0, utils_1.EtoContext)()),
|
|
652
|
+
__metadata("design:type", Function),
|
|
653
|
+
__metadata("design:paramtypes", [Object, Object]),
|
|
654
|
+
__metadata("design:returntype", Promise)
|
|
655
|
+
], ReviewModuleService.prototype, "anonymizeReviewer", null);
|
|
656
|
+
__decorate([
|
|
657
|
+
(0, utils_1.InjectTransactionManager)(),
|
|
658
|
+
__param(1, (0, utils_1.EtoContext)()),
|
|
659
|
+
__metadata("design:type", Function),
|
|
660
|
+
__metadata("design:paramtypes", [Object, Object]),
|
|
661
|
+
__metadata("design:returntype", Promise)
|
|
662
|
+
], ReviewModuleService.prototype, "anonymizeReviewer_", null);
|
|
663
|
+
__decorate([
|
|
664
|
+
(0, utils_1.InjectManager)(),
|
|
665
|
+
__param(1, (0, utils_1.EtoContext)()),
|
|
666
|
+
__metadata("design:type", Function),
|
|
667
|
+
__metadata("design:paramtypes", [Object, Object]),
|
|
668
|
+
__metadata("design:returntype", Promise)
|
|
669
|
+
], ReviewModuleService.prototype, "getReviewSummary", null);
|
|
670
|
+
__decorate([
|
|
671
|
+
(0, utils_1.InjectManager)(),
|
|
672
|
+
__param(1, (0, utils_1.EtoContext)()),
|
|
673
|
+
__metadata("design:type", Function),
|
|
674
|
+
__metadata("design:paramtypes", [Object, Object]),
|
|
675
|
+
__metadata("design:returntype", Promise)
|
|
676
|
+
], ReviewModuleService.prototype, "getReviewSummaries", null);
|
|
677
|
+
__decorate([
|
|
678
|
+
(0, utils_1.InjectManager)(),
|
|
679
|
+
__param(1, (0, utils_1.EtoContext)()),
|
|
680
|
+
__metadata("design:type", Function),
|
|
681
|
+
__metadata("design:paramtypes", [Object, Object]),
|
|
682
|
+
__metadata("design:returntype", Promise)
|
|
683
|
+
], ReviewModuleService.prototype, "listPublicReviews", null);
|
|
684
|
+
// ===== pure helpers =====
|
|
685
|
+
function firstOf(value) {
|
|
686
|
+
return Array.isArray(value) ? value[0] : value;
|
|
687
|
+
}
|
|
688
|
+
function requireText(value, name) {
|
|
689
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
690
|
+
throw new utils_1.EtoError(utils_1.EtoError.Types.INVALID_DATA, `${name} is required`);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
function requireRef(ref, name) {
|
|
694
|
+
if (!ref || typeof ref.type !== "string" || !ref.type.trim() || typeof ref.id !== "string" || !ref.id.trim()) {
|
|
695
|
+
throw new utils_1.EtoError(utils_1.EtoError.Types.INVALID_DATA, `${name} needs a type and an id`);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
function sameRef(a, b) {
|
|
699
|
+
return a.type === b.type && a.id === b.id;
|
|
700
|
+
}
|
|
701
|
+
function toDate(value, name) {
|
|
702
|
+
if (value == null || value === "")
|
|
703
|
+
return null;
|
|
704
|
+
const d = value instanceof Date ? value : new Date(value);
|
|
705
|
+
if (Number.isNaN(d.getTime())) {
|
|
706
|
+
throw new utils_1.EtoError(utils_1.EtoError.Types.INVALID_DATA, `${name} is not a date`);
|
|
707
|
+
}
|
|
708
|
+
return d;
|
|
709
|
+
}
|
|
710
|
+
/** When the person acted. A phone's clock may run a little fast; never far. */
|
|
711
|
+
function actTime(value, now) {
|
|
712
|
+
const at = toDate(value, "occurred_at") ?? now;
|
|
713
|
+
if (at.getTime() > now.getTime() + FUTURE_SKEW_MS) {
|
|
714
|
+
throw new utils_1.EtoError(utils_1.EtoError.Types.INVALID_DATA, "occurred_at is in the future");
|
|
715
|
+
}
|
|
716
|
+
return at;
|
|
717
|
+
}
|
|
718
|
+
function sameSubmission(existing, data, content) {
|
|
719
|
+
const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
|
720
|
+
return (existing.subject_type === data.subject.type &&
|
|
721
|
+
existing.subject_id === data.subject.id &&
|
|
722
|
+
existing.reviewer_type === data.reviewer.type &&
|
|
723
|
+
existing.reviewer_id === data.reviewer.id &&
|
|
724
|
+
existing.context_type === data.context.type &&
|
|
725
|
+
existing.context_id === data.context.id &&
|
|
726
|
+
Object.keys(content).every((k) => same(existing[k], content[k])));
|
|
727
|
+
}
|
|
728
|
+
function asConflict(e) {
|
|
729
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
730
|
+
if (/IDX_review_one_per_interaction|duplicate key|already exists/i.test(message)) {
|
|
731
|
+
return new utils_1.EtoError(utils_1.EtoError.Types.CONFLICT, "You already reviewed this; edit that review instead");
|
|
732
|
+
}
|
|
733
|
+
if (/IDX_review_flag_one_per_reporter/.test(message)) {
|
|
734
|
+
return new utils_1.EtoError(utils_1.EtoError.Types.CONFLICT, "You already reported this review");
|
|
735
|
+
}
|
|
736
|
+
return e;
|
|
737
|
+
}
|
|
738
|
+
function keysOf(review) {
|
|
739
|
+
const base = {
|
|
740
|
+
space_id: review.space_id,
|
|
741
|
+
subject_type: review.subject_type,
|
|
742
|
+
subject_id: review.subject_id,
|
|
743
|
+
};
|
|
744
|
+
return review.scope ? [{ ...base, scope: "" }, { ...base, scope: review.scope }] : [{ ...base, scope: "" }];
|
|
745
|
+
}
|
|
746
|
+
function summaryKey(k) {
|
|
747
|
+
return `${k.space_id}|${k.subject_type}|${k.subject_id}|${k.scope}`;
|
|
748
|
+
}
|
|
749
|
+
function refOf(review) {
|
|
750
|
+
return {
|
|
751
|
+
id: review.id,
|
|
752
|
+
space_id: review.space_id,
|
|
753
|
+
subject_type: review.subject_type,
|
|
754
|
+
subject_id: review.subject_id,
|
|
755
|
+
context_type: review.context_type,
|
|
756
|
+
context_id: review.context_id,
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
function round2(n) {
|
|
760
|
+
return Math.round(n * 100) / 100;
|
|
761
|
+
}
|
|
762
|
+
function toSummaryDTO(row) {
|
|
763
|
+
return {
|
|
764
|
+
space_id: row.space_id,
|
|
765
|
+
subject_type: row.subject_type,
|
|
766
|
+
subject_id: row.subject_id,
|
|
767
|
+
scope: row.scope ?? "",
|
|
768
|
+
count: row.count ?? 0,
|
|
769
|
+
verified_count: row.verified_count ?? 0,
|
|
770
|
+
mean: row.mean ?? null,
|
|
771
|
+
distribution: row.distribution ?? { "1": 0, "2": 0, "3": 0, "4": 0, "5": 0 },
|
|
772
|
+
dimensions: row.dimensions ?? {},
|
|
773
|
+
tags: row.tags ?? {},
|
|
774
|
+
last_reviewed_at: row.last_reviewed_at ?? null,
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
function emptySummary(key) {
|
|
778
|
+
return toSummaryDTO({ ...key });
|
|
779
|
+
}
|
|
780
|
+
function toPublic(review) {
|
|
781
|
+
const { reviewer_id: _id, idempotency_key: _key, moderated_by: _by, moderation_reason: _reason, open_flag_count: _flags, metadata: _meta, ...rest } = review;
|
|
782
|
+
return review.anonymous ? { ...rest, reviewer_display: null } : rest;
|
|
783
|
+
}
|
|
784
|
+
//# sourceMappingURL=review-module-service.js.map
|