@blamejs/blamejs-shop 0.0.66 → 0.0.72
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/CHANGELOG.md +12 -0
- package/lib/assembly-instructions.js +777 -0
- package/lib/auto-replenish.js +933 -0
- package/lib/click-and-collect.js +711 -0
- package/lib/clickstream.js +713 -0
- package/lib/customer-activity.js +862 -0
- package/lib/customer-notes.js +712 -0
- package/lib/customer-risk-profile.js +593 -0
- package/lib/customer-surveys.js +1012 -0
- package/lib/damage-photos.js +473 -0
- package/lib/dropship-forwarding.js +645 -0
- package/lib/email-templates.js +817 -0
- package/lib/index.js +36 -0
- package/lib/inventory-allocations.js +559 -0
- package/lib/inventory-writeoffs.js +636 -0
- package/lib/knowledge-base.js +1104 -0
- package/lib/locale-router.js +1077 -0
- package/lib/loyalty-earn-rules.js +786 -0
- package/lib/operator-roles.js +768 -0
- package/lib/order-escalation.js +951 -0
- package/lib/order-ratings.js +495 -0
- package/lib/order-tags.js +944 -0
- package/lib/packing-slips.js +810 -0
- package/lib/pixel-events.js +995 -0
- package/lib/print-queue.js +681 -0
- package/lib/product-qa.js +749 -0
- package/lib/promo-bundles.js +835 -0
- package/lib/push-notifications.js +937 -0
- package/lib/refund-automation.js +853 -0
- package/lib/reorder-reminders.js +798 -0
- package/lib/robots-config.js +753 -0
- package/lib/seller-signup.js +1052 -0
- package/lib/sitemap-generator.js +717 -0
- package/lib/split-shipments.js +7 -1
- package/lib/subscription-gifts.js +710 -0
- package/lib/tax-cert-renewals.js +632 -0
- package/lib/tier-benefits.js +776 -0
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/api-snapshot.json +2 -2
- package/lib/vendor/blamejs/lib/metrics.js +68 -4
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
- package/lib/wishlist-alerts.js +842 -0
- package/lib/wishlist-sharing.js +718 -0
- package/package.json +1 -1
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.damagePhotos
|
|
4
|
+
* @title Damage photos — operator-uploaded image attachments for
|
|
5
|
+
* damage / quality-control / return / complaint events
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* When an operator reports a write-off, accepts a return, fails a
|
|
9
|
+
* bench QC check, receives a damaged shipment, or fields a customer
|
|
10
|
+
* complaint, they routinely have a photograph as the supporting
|
|
11
|
+
* evidence. This primitive is the verb for filing that photo
|
|
12
|
+
* alongside the originating event.
|
|
13
|
+
*
|
|
14
|
+
* The image bytes themselves never touch this primitive. Operators
|
|
15
|
+
* upload to their own R2 / S3 / object store and hand back a signed-
|
|
16
|
+
* URL reference plus the SHA3-512 digest of the original bytes; the
|
|
17
|
+
* row captures the digest, the byte size, the content-type, the
|
|
18
|
+
* signed-URL string, and the actor who uploaded it. The operator's
|
|
19
|
+
* CDN owns the bytes; this table owns the audit trail.
|
|
20
|
+
*
|
|
21
|
+
* Surface:
|
|
22
|
+
* recordPhoto({ subject_kind, subject_id, content_type, sha3_512,
|
|
23
|
+
* byte_size, source_url, caption?, uploaded_by })
|
|
24
|
+
* Validates input (sha3_512 lowercase-hex 128 chars; content_type
|
|
25
|
+
* on the image/* allowlist; source_url https-only via b.safeUrl;
|
|
26
|
+
* byte_size <= 50 MiB) and lands the row. Returns the hydrated
|
|
27
|
+
* row.
|
|
28
|
+
*
|
|
29
|
+
* getPhoto(id)
|
|
30
|
+
* Hydrated row or null on miss. guardUuid validates id shape.
|
|
31
|
+
*
|
|
32
|
+
* photosForSubject({ subject_kind, subject_id })
|
|
33
|
+
* Active (non-archived) photos for the originating event,
|
|
34
|
+
* newest-first. Replaced rows drop out — the operator only sees
|
|
35
|
+
* the current evidence.
|
|
36
|
+
*
|
|
37
|
+
* archivePhoto({ photo_id, reason })
|
|
38
|
+
* Soft-archives the row (stamps archived_at + archive_reason).
|
|
39
|
+
* Refuses on already-archived rows.
|
|
40
|
+
*
|
|
41
|
+
* replacePhoto({ photo_id, new_sha3_512, new_source_url,
|
|
42
|
+
* new_content_type?, new_byte_size?, reason })
|
|
43
|
+
* Records the operator-attested replacement chain: mints a new
|
|
44
|
+
* photo row carrying the new digest + signed-URL, then flags the
|
|
45
|
+
* prior row with replaced_by_id pointing at the new id +
|
|
46
|
+
* replaced_at + replace_reason. Returns
|
|
47
|
+
* { old_photo, new_photo }.
|
|
48
|
+
*
|
|
49
|
+
* metricsForKind({ subject_kind, from, to })
|
|
50
|
+
* Counts the photos uploaded for the subject_kind in the period
|
|
51
|
+
* `[from, to)`. Returns { count, distinct_subjects,
|
|
52
|
+
* total_byte_size, archived_count }.
|
|
53
|
+
*
|
|
54
|
+
* findDuplicatesBySha({ sha3_512 })
|
|
55
|
+
* Returns every row carrying the same SHA3-512 digest, ordered
|
|
56
|
+
* by occurred_at ASC. Archival does NOT remove a row from this
|
|
57
|
+
* view — the fraud signal survives. Empty array when the digest
|
|
58
|
+
* is novel.
|
|
59
|
+
*
|
|
60
|
+
* Composition:
|
|
61
|
+
* - b.uuid.v7 — photo PK (sortable so listings cursor-paginate
|
|
62
|
+
* cleanly off the primary key without a second
|
|
63
|
+
* index)
|
|
64
|
+
* - b.guardUuid — strict UUID shape validation on every id input
|
|
65
|
+
* - b.safeUrl — https-only signed-URL validation at recordPhoto
|
|
66
|
+
* and replacePhoto entry points
|
|
67
|
+
*
|
|
68
|
+
* Strict-monotonic clock: per-(subject_kind, subject_id)
|
|
69
|
+
* `occurred_at` is bumped to `prior + 1` when the wall-clock value
|
|
70
|
+
* would tie or land older than the most recent photo on the same
|
|
71
|
+
* subject. Two photos filed against the same writeoff in the same
|
|
72
|
+
* millisecond don't collide on the timeline; photosForSubject
|
|
73
|
+
* ordering is unambiguous.
|
|
74
|
+
*
|
|
75
|
+
* @primitive damagePhotos
|
|
76
|
+
* @related b.guardUuid, b.uuid, b.safeUrl
|
|
77
|
+
*/
|
|
78
|
+
|
|
79
|
+
var bShop;
|
|
80
|
+
function _b() {
|
|
81
|
+
if (!bShop) bShop = require("./index");
|
|
82
|
+
return bShop.framework;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---- constants ----------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
var SUBJECT_KINDS = Object.freeze([
|
|
88
|
+
"writeoff", "return", "quality_check",
|
|
89
|
+
"damaged_receipt", "customer_complaint",
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
var CONTENT_TYPES = Object.freeze([
|
|
93
|
+
"image/jpeg", "image/png", "image/webp", "image/heic",
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
var SHA3_512_RE = /^[0-9a-f]{128}$/;
|
|
97
|
+
var SUBJECT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
98
|
+
var ACTOR_RE = /^[\S\s]{1,256}$/;
|
|
99
|
+
var PRINTABLE_RE = /^[^\x00-\x08\x0b\x0c\x0e-\x1f\x7f]+$/;
|
|
100
|
+
|
|
101
|
+
var MAX_CAPTION_LEN = 2000;
|
|
102
|
+
var MAX_REASON_LEN = 1000;
|
|
103
|
+
var MAX_SOURCE_URL = 2048;
|
|
104
|
+
var MAX_BYTE_SIZE = 50 * 1024 * 1024; // 50 MiB ceiling per photo
|
|
105
|
+
|
|
106
|
+
// ---- validators ---------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
function _id(s, label) {
|
|
109
|
+
try {
|
|
110
|
+
return _b().guardUuid.sanitize(s, { profile: "strict" });
|
|
111
|
+
} catch (e) {
|
|
112
|
+
throw new TypeError("damage-photos: " + (label || "id") +
|
|
113
|
+
" — " + (e && e.message || "invalid UUID"));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function _subjectKind(s) {
|
|
118
|
+
if (typeof s !== "string" || SUBJECT_KINDS.indexOf(s) === -1) {
|
|
119
|
+
throw new TypeError("damage-photos: subject_kind must be one of " +
|
|
120
|
+
SUBJECT_KINDS.join(", ") + ", got " + JSON.stringify(s));
|
|
121
|
+
}
|
|
122
|
+
return s;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function _subjectId(s) {
|
|
126
|
+
if (typeof s !== "string" || !SUBJECT_ID_RE.test(s)) {
|
|
127
|
+
throw new TypeError("damage-photos: subject_id must match /^[A-Za-z0-9][A-Za-z0-9._:-]*$/ (1..128 chars)");
|
|
128
|
+
}
|
|
129
|
+
return s;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function _contentType(s) {
|
|
133
|
+
if (typeof s !== "string" || CONTENT_TYPES.indexOf(s) === -1) {
|
|
134
|
+
throw new TypeError("damage-photos: content_type must be one of " +
|
|
135
|
+
CONTENT_TYPES.join(", ") + ", got " + JSON.stringify(s));
|
|
136
|
+
}
|
|
137
|
+
return s;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function _sha3_512(s) {
|
|
141
|
+
if (typeof s !== "string") {
|
|
142
|
+
throw new TypeError("damage-photos: sha3_512 must be a string");
|
|
143
|
+
}
|
|
144
|
+
if (!SHA3_512_RE.test(s)) {
|
|
145
|
+
throw new TypeError("damage-photos: sha3_512 must be 128 lowercase hex characters");
|
|
146
|
+
}
|
|
147
|
+
return s;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function _byteSize(n) {
|
|
151
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
152
|
+
throw new TypeError("damage-photos: byte_size must be a positive integer");
|
|
153
|
+
}
|
|
154
|
+
if (n > MAX_BYTE_SIZE) {
|
|
155
|
+
throw new TypeError("damage-photos: byte_size must be <= " + MAX_BYTE_SIZE + " bytes (50 MiB)");
|
|
156
|
+
}
|
|
157
|
+
return n;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function _sourceUrl(s) {
|
|
161
|
+
if (typeof s !== "string" || !s.length) {
|
|
162
|
+
throw new TypeError("damage-photos: source_url must be a non-empty string");
|
|
163
|
+
}
|
|
164
|
+
if (s.length > MAX_SOURCE_URL) {
|
|
165
|
+
throw new TypeError("damage-photos: source_url must be <= " + MAX_SOURCE_URL + " characters");
|
|
166
|
+
}
|
|
167
|
+
if (!PRINTABLE_RE.test(s)) {
|
|
168
|
+
throw new TypeError("damage-photos: source_url must not contain control bytes");
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
_b().safeUrl.parse(s, { allowedProtocols: ["https:"] });
|
|
172
|
+
} catch (e) {
|
|
173
|
+
throw new TypeError("damage-photos: source_url — " +
|
|
174
|
+
(e && e.message || "must be a valid https:// URL"));
|
|
175
|
+
}
|
|
176
|
+
return s;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function _caption(s) {
|
|
180
|
+
if (s == null) return null;
|
|
181
|
+
if (typeof s !== "string") {
|
|
182
|
+
throw new TypeError("damage-photos: caption must be a string or null");
|
|
183
|
+
}
|
|
184
|
+
if (s.length > MAX_CAPTION_LEN) {
|
|
185
|
+
throw new TypeError("damage-photos: caption must be <= " + MAX_CAPTION_LEN + " characters");
|
|
186
|
+
}
|
|
187
|
+
if (s.length && !PRINTABLE_RE.test(s)) {
|
|
188
|
+
throw new TypeError("damage-photos: caption must not contain control bytes other than tab/newline/CR");
|
|
189
|
+
}
|
|
190
|
+
return s;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function _actor(s, label) {
|
|
194
|
+
if (typeof s !== "string" || !ACTOR_RE.test(s) || s.length > 256) {
|
|
195
|
+
throw new TypeError("damage-photos: " + label + " must be a non-empty string <= 256 chars");
|
|
196
|
+
}
|
|
197
|
+
if (!PRINTABLE_RE.test(s)) {
|
|
198
|
+
throw new TypeError("damage-photos: " + label + " must not contain control bytes");
|
|
199
|
+
}
|
|
200
|
+
return s;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function _reason(s, label) {
|
|
204
|
+
if (typeof s !== "string" || !s.length) {
|
|
205
|
+
throw new TypeError("damage-photos: " + label + " must be a non-empty string");
|
|
206
|
+
}
|
|
207
|
+
if (s.length > MAX_REASON_LEN) {
|
|
208
|
+
throw new TypeError("damage-photos: " + label + " must be <= " + MAX_REASON_LEN + " characters");
|
|
209
|
+
}
|
|
210
|
+
if (!PRINTABLE_RE.test(s)) {
|
|
211
|
+
throw new TypeError("damage-photos: " + label + " must not contain control bytes");
|
|
212
|
+
}
|
|
213
|
+
return s;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function _epochMs(ts, label) {
|
|
217
|
+
if (ts == null) return null;
|
|
218
|
+
if (typeof ts !== "number" || !Number.isInteger(ts) || ts < 0) {
|
|
219
|
+
throw new TypeError("damage-photos: " + label + " must be a non-negative integer epoch-ms");
|
|
220
|
+
}
|
|
221
|
+
return ts;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ---- factory ------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
function create(opts) {
|
|
227
|
+
opts = opts || {};
|
|
228
|
+
var query = opts.query;
|
|
229
|
+
if (!query) {
|
|
230
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Strict-monotonic clock scoped to (subject_kind, subject_id). Two
|
|
234
|
+
// photos filed against the same writeoff in the same millisecond
|
|
235
|
+
// get distinct occurred_at values; photos against DIFFERENT subjects
|
|
236
|
+
// are independent — same wall-clock ts is fine across subjects.
|
|
237
|
+
async function _latestTsForSubject(subjectKind, subjectId) {
|
|
238
|
+
var r = await query(
|
|
239
|
+
"SELECT MAX(occurred_at) AS ts FROM damage_photos " +
|
|
240
|
+
"WHERE subject_kind = ?1 AND subject_id = ?2",
|
|
241
|
+
[subjectKind, subjectId],
|
|
242
|
+
);
|
|
243
|
+
if (!r.rows.length || r.rows[0].ts == null) return null;
|
|
244
|
+
return Number(r.rows[0].ts);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function _resolveOccurredAt(requestedTs, latestTs) {
|
|
248
|
+
if (latestTs == null) return requestedTs;
|
|
249
|
+
if (requestedTs > latestTs) return requestedTs;
|
|
250
|
+
return latestTs + 1;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function _getRow(id) {
|
|
254
|
+
var r = await query("SELECT * FROM damage_photos WHERE id = ?1", [id]);
|
|
255
|
+
if (!r.rows.length) return null;
|
|
256
|
+
return r.rows[0];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
|
|
261
|
+
// Record a photo against a subject event. The image bytes live in
|
|
262
|
+
// operator-owned object storage; this row owns the digest +
|
|
263
|
+
// signed-URL + audit metadata. Returns the hydrated row.
|
|
264
|
+
recordPhoto: async function (input) {
|
|
265
|
+
if (!input || typeof input !== "object") {
|
|
266
|
+
throw new TypeError("damage-photos.recordPhoto: input object required");
|
|
267
|
+
}
|
|
268
|
+
var subjectKind = _subjectKind(input.subject_kind);
|
|
269
|
+
var subjectId = _subjectId(input.subject_id);
|
|
270
|
+
var contentType = _contentType(input.content_type);
|
|
271
|
+
var sha3 = _sha3_512(input.sha3_512);
|
|
272
|
+
var byteSize = _byteSize(input.byte_size);
|
|
273
|
+
var sourceUrl = _sourceUrl(input.source_url);
|
|
274
|
+
var caption = _caption(input.caption);
|
|
275
|
+
var uploadedBy = _actor(input.uploaded_by, "uploaded_by");
|
|
276
|
+
var requested = _epochMs(input.occurred_at, "occurred_at");
|
|
277
|
+
if (requested == null) requested = Date.now();
|
|
278
|
+
var latestTs = await _latestTsForSubject(subjectKind, subjectId);
|
|
279
|
+
var occurredAt = _resolveOccurredAt(requested, latestTs);
|
|
280
|
+
|
|
281
|
+
var id = _b().uuid.v7();
|
|
282
|
+
await query(
|
|
283
|
+
"INSERT INTO damage_photos " +
|
|
284
|
+
"(id, subject_kind, subject_id, content_type, sha3_512, byte_size, " +
|
|
285
|
+
" source_url, caption, uploaded_by, occurred_at) " +
|
|
286
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
|
287
|
+
[id, subjectKind, subjectId, contentType, sha3, byteSize,
|
|
288
|
+
sourceUrl, caption, uploadedBy, occurredAt],
|
|
289
|
+
);
|
|
290
|
+
return await _getRow(id);
|
|
291
|
+
},
|
|
292
|
+
|
|
293
|
+
// Hydrated row or null on miss.
|
|
294
|
+
getPhoto: async function (photoId) {
|
|
295
|
+
var id = _id(photoId, "photo_id");
|
|
296
|
+
return await _getRow(id);
|
|
297
|
+
},
|
|
298
|
+
|
|
299
|
+
// Active photos for a subject event. Archived rows AND rows that
|
|
300
|
+
// have been superseded by replacePhoto drop out — the operator
|
|
301
|
+
// sees only the current evidence set, newest-first.
|
|
302
|
+
photosForSubject: async function (input) {
|
|
303
|
+
if (!input || typeof input !== "object") {
|
|
304
|
+
throw new TypeError("damage-photos.photosForSubject: input object required");
|
|
305
|
+
}
|
|
306
|
+
var subjectKind = _subjectKind(input.subject_kind);
|
|
307
|
+
var subjectId = _subjectId(input.subject_id);
|
|
308
|
+
var r = await query(
|
|
309
|
+
"SELECT * FROM damage_photos " +
|
|
310
|
+
"WHERE subject_kind = ?1 AND subject_id = ?2 " +
|
|
311
|
+
" AND archived_at IS NULL " +
|
|
312
|
+
" AND replaced_at IS NULL " +
|
|
313
|
+
"ORDER BY occurred_at DESC, id DESC",
|
|
314
|
+
[subjectKind, subjectId],
|
|
315
|
+
);
|
|
316
|
+
return r.rows;
|
|
317
|
+
},
|
|
318
|
+
|
|
319
|
+
// Soft-archive a photo. The row stays on disk for fraud-signal
|
|
320
|
+
// continuity (findDuplicatesBySha still sees it) but drops out of
|
|
321
|
+
// photosForSubject. Refuses on already-archived rows.
|
|
322
|
+
archivePhoto: async function (input) {
|
|
323
|
+
if (!input || typeof input !== "object") {
|
|
324
|
+
throw new TypeError("damage-photos.archivePhoto: input object required");
|
|
325
|
+
}
|
|
326
|
+
var id = _id(input.photo_id, "photo_id");
|
|
327
|
+
var reason = _reason(input.reason, "reason");
|
|
328
|
+
var row = await _getRow(id);
|
|
329
|
+
if (!row) {
|
|
330
|
+
throw new TypeError("damage-photos.archivePhoto: photo " + id + " not found");
|
|
331
|
+
}
|
|
332
|
+
if (row.archived_at != null) {
|
|
333
|
+
throw new TypeError("damage-photos.archivePhoto: photo " + id + " already archived");
|
|
334
|
+
}
|
|
335
|
+
var ts = Date.now();
|
|
336
|
+
await query(
|
|
337
|
+
"UPDATE damage_photos SET archived_at = ?1, archive_reason = ?2 WHERE id = ?3",
|
|
338
|
+
[ts, reason, id],
|
|
339
|
+
);
|
|
340
|
+
return await _getRow(id);
|
|
341
|
+
},
|
|
342
|
+
|
|
343
|
+
// Replace a photo. Mints a new row carrying the new digest + new
|
|
344
|
+
// signed-URL (and optional new content_type / byte_size — operators
|
|
345
|
+
// re-encode JPEG -> PNG sometimes, or compress a 12 MiB original to
|
|
346
|
+
// a 2 MiB resend), then flags the prior row with replaced_by_id +
|
|
347
|
+
// replaced_at + replace_reason. The replace chain is the audit
|
|
348
|
+
// trail; the new row is the current evidence. Refuses on already-
|
|
349
|
+
// replaced or already-archived rows.
|
|
350
|
+
replacePhoto: async function (input) {
|
|
351
|
+
if (!input || typeof input !== "object") {
|
|
352
|
+
throw new TypeError("damage-photos.replacePhoto: input object required");
|
|
353
|
+
}
|
|
354
|
+
var oldId = _id(input.photo_id, "photo_id");
|
|
355
|
+
var newSha = _sha3_512(input.new_sha3_512);
|
|
356
|
+
var newSourceUrl = _sourceUrl(input.new_source_url);
|
|
357
|
+
var reason = _reason(input.reason, "reason");
|
|
358
|
+
var oldRow = await _getRow(oldId);
|
|
359
|
+
if (!oldRow) {
|
|
360
|
+
throw new TypeError("damage-photos.replacePhoto: photo " + oldId + " not found");
|
|
361
|
+
}
|
|
362
|
+
if (oldRow.archived_at != null) {
|
|
363
|
+
throw new TypeError("damage-photos.replacePhoto: photo " + oldId + " is archived");
|
|
364
|
+
}
|
|
365
|
+
if (oldRow.replaced_at != null) {
|
|
366
|
+
throw new TypeError("damage-photos.replacePhoto: photo " + oldId + " already replaced");
|
|
367
|
+
}
|
|
368
|
+
// Operator may re-encode or recompress on replace — accept the
|
|
369
|
+
// new content_type and byte_size when supplied, otherwise carry
|
|
370
|
+
// the original metadata forward unchanged.
|
|
371
|
+
var newContentType = input.new_content_type == null
|
|
372
|
+
? oldRow.content_type
|
|
373
|
+
: _contentType(input.new_content_type);
|
|
374
|
+
var newByteSize = input.new_byte_size == null
|
|
375
|
+
? Number(oldRow.byte_size)
|
|
376
|
+
: _byteSize(input.new_byte_size);
|
|
377
|
+
|
|
378
|
+
// The new row carries the SAME subject_kind / subject_id as the
|
|
379
|
+
// old row — operators don't re-target evidence onto a different
|
|
380
|
+
// event via replace.
|
|
381
|
+
var latestTs = await _latestTsForSubject(oldRow.subject_kind, oldRow.subject_id);
|
|
382
|
+
var requested = Date.now();
|
|
383
|
+
var occurredAt = _resolveOccurredAt(requested, latestTs);
|
|
384
|
+
var newId = _b().uuid.v7();
|
|
385
|
+
|
|
386
|
+
await query(
|
|
387
|
+
"INSERT INTO damage_photos " +
|
|
388
|
+
"(id, subject_kind, subject_id, content_type, sha3_512, byte_size, " +
|
|
389
|
+
" source_url, caption, uploaded_by, occurred_at) " +
|
|
390
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
|
391
|
+
[newId, oldRow.subject_kind, oldRow.subject_id, newContentType,
|
|
392
|
+
newSha, newByteSize, newSourceUrl, oldRow.caption,
|
|
393
|
+
oldRow.uploaded_by, occurredAt],
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
var nowTs = Date.now();
|
|
397
|
+
await query(
|
|
398
|
+
"UPDATE damage_photos SET replaced_by_id = ?1, replaced_at = ?2, " +
|
|
399
|
+
"replace_reason = ?3 WHERE id = ?4",
|
|
400
|
+
[newId, nowTs, reason, oldId],
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
return {
|
|
404
|
+
old_photo: await _getRow(oldId),
|
|
405
|
+
new_photo: await _getRow(newId),
|
|
406
|
+
};
|
|
407
|
+
},
|
|
408
|
+
|
|
409
|
+
// Period-scoped aggregate for the operator dashboard. Counts every
|
|
410
|
+
// row whose occurred_at falls in `[from, to)` for the given
|
|
411
|
+
// subject_kind. Reports active vs archived split so the dashboard
|
|
412
|
+
// can render "150 quality_check photos this month, 12 archived".
|
|
413
|
+
metricsForKind: async function (input) {
|
|
414
|
+
if (!input || typeof input !== "object") {
|
|
415
|
+
throw new TypeError("damage-photos.metricsForKind: input object required");
|
|
416
|
+
}
|
|
417
|
+
var subjectKind = _subjectKind(input.subject_kind);
|
|
418
|
+
var from = _epochMs(input.from, "from");
|
|
419
|
+
var to = _epochMs(input.to, "to");
|
|
420
|
+
if (from == null || to == null) {
|
|
421
|
+
throw new TypeError("damage-photos.metricsForKind: from and to are required (epoch-ms)");
|
|
422
|
+
}
|
|
423
|
+
if (to <= from) {
|
|
424
|
+
throw new TypeError("damage-photos.metricsForKind: to must be > from");
|
|
425
|
+
}
|
|
426
|
+
var r = await query(
|
|
427
|
+
"SELECT COUNT(*) AS n, " +
|
|
428
|
+
" COUNT(DISTINCT subject_id) AS distinct_n, " +
|
|
429
|
+
" COALESCE(SUM(byte_size), 0) AS total_bytes, " +
|
|
430
|
+
" SUM(CASE WHEN archived_at IS NOT NULL THEN 1 ELSE 0 END) AS archived_n " +
|
|
431
|
+
"FROM damage_photos " +
|
|
432
|
+
"WHERE subject_kind = ?1 AND occurred_at >= ?2 AND occurred_at < ?3",
|
|
433
|
+
[subjectKind, from, to],
|
|
434
|
+
);
|
|
435
|
+
var row = r.rows[0] || {};
|
|
436
|
+
return {
|
|
437
|
+
subject_kind: subjectKind,
|
|
438
|
+
from: from,
|
|
439
|
+
to: to,
|
|
440
|
+
count: Number(row.n || 0),
|
|
441
|
+
distinct_subjects: Number(row.distinct_n || 0),
|
|
442
|
+
total_byte_size: Number(row.total_bytes || 0),
|
|
443
|
+
archived_count: Number(row.archived_n || 0),
|
|
444
|
+
};
|
|
445
|
+
},
|
|
446
|
+
|
|
447
|
+
// Every prior row carrying the same digest — fraud signal for the
|
|
448
|
+
// same image submitted against multiple events. Archival does NOT
|
|
449
|
+
// remove a row from this view (the operator wants to see that
|
|
450
|
+
// "this image was archived from event X but is now showing up on
|
|
451
|
+
// event Y"). Ordered by occurred_at ASC so the timeline reads
|
|
452
|
+
// first-upload-first.
|
|
453
|
+
findDuplicatesBySha: async function (input) {
|
|
454
|
+
if (!input || typeof input !== "object") {
|
|
455
|
+
throw new TypeError("damage-photos.findDuplicatesBySha: input object required");
|
|
456
|
+
}
|
|
457
|
+
var sha = _sha3_512(input.sha3_512);
|
|
458
|
+
var r = await query(
|
|
459
|
+
"SELECT * FROM damage_photos WHERE sha3_512 = ?1 " +
|
|
460
|
+
"ORDER BY occurred_at ASC, id ASC",
|
|
461
|
+
[sha],
|
|
462
|
+
);
|
|
463
|
+
return r.rows;
|
|
464
|
+
},
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
module.exports = {
|
|
469
|
+
create: create,
|
|
470
|
+
SUBJECT_KINDS: SUBJECT_KINDS,
|
|
471
|
+
CONTENT_TYPES: CONTENT_TYPES,
|
|
472
|
+
MAX_BYTE_SIZE: MAX_BYTE_SIZE,
|
|
473
|
+
};
|