@harshankur/viewcounter 3.0.1 → 3.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.
Files changed (44) hide show
  1. package/.env.example +20 -0
  2. package/README.md +174 -4
  3. package/admin/apple-touch-icon.png +0 -0
  4. package/admin/assets/world-map.json +1 -0
  5. package/admin/css/admin.css +1875 -0
  6. package/admin/favicon.ico +0 -0
  7. package/admin/favicon.svg +9 -0
  8. package/admin/icon-192.png +0 -0
  9. package/admin/icon-512.png +0 -0
  10. package/admin/index.html +89 -0
  11. package/admin/js/api.js +100 -0
  12. package/admin/js/charts.js +502 -0
  13. package/admin/js/clamp.js +41 -0
  14. package/admin/js/constants.js +150 -0
  15. package/admin/js/dom.js +83 -0
  16. package/admin/js/format.js +79 -0
  17. package/admin/js/i18n.js +80 -0
  18. package/admin/js/insights.js +192 -0
  19. package/admin/js/listbox.js +144 -0
  20. package/admin/js/logs.js +167 -0
  21. package/admin/js/main.js +235 -0
  22. package/admin/js/modal.js +171 -0
  23. package/admin/js/table.js +134 -0
  24. package/admin/js/theme.js +72 -0
  25. package/admin/js/toast.js +47 -0
  26. package/admin/js/viewDialogs.js +208 -0
  27. package/admin/js/views.js +685 -0
  28. package/admin/locales/en.json +394 -0
  29. package/admin/site.webmanifest +20 -0
  30. package/config/index.js +83 -0
  31. package/constants.js +215 -2
  32. package/db/AdminRepository.js +562 -0
  33. package/db/DatabaseManager.js +94 -20
  34. package/db/LogRepository.js +217 -0
  35. package/db/adminSchema.js +244 -0
  36. package/db/retention.js +97 -0
  37. package/index.js +39 -6
  38. package/middleware/adminAuth.js +204 -0
  39. package/middleware/adminValidation.js +253 -0
  40. package/package.json +16 -9
  41. package/routes/admin.js +438 -0
  42. package/routes/analytics.js +11 -2
  43. package/utils/cookieUtils.js +47 -0
  44. package/utils/errorUtils.js +35 -0
@@ -0,0 +1,562 @@
1
+ /**
2
+ * Data access for the admin UI: listing, editing, annotating, soft-deleting,
3
+ * restoring, and permanently erasing views.
4
+ *
5
+ * Rows are addressed by `public_id` only. Every identifier interpolated into a
6
+ * statement is either an app ID that has passed `isValidAppId` or a column name
7
+ * taken from a fixed allowlist in constants.js, never caller text.
8
+ */
9
+
10
+ const {
11
+ ADMIN_RANGE_DAYS,
12
+ ADMIN_SORT_COLUMNS,
13
+ ANALYSIS_TOP_N,
14
+ EDITABLE_FIELDS,
15
+ MODIFIED_FILTER,
16
+ SORT_ORDER,
17
+ TREND_BUCKET,
18
+ TREND_BUCKET_MAX_DAYS,
19
+ VIEW_STATUS,
20
+ } = require('../constants');
21
+ const { getError, ErrorType } = require('../utils/errorUtils');
22
+ const { isValidAppId } = require('../utils/appIdUtils');
23
+ const { parseJson } = require('./LogRepository');
24
+
25
+ /**
26
+ * Columns returned to the admin UI. Excludes the internal auto-increment `id`
27
+ * (enumerable) and `visitor_hash` (the pseudonymous visitor identifier, which
28
+ * an admin has no need to see).
29
+ */
30
+ const ADMIN_VIEW_COLUMNS = [
31
+ 'public_id', 'timestamp', 'masked_ip', 'country', 'devicesize',
32
+ 'page_path', 'page_title', 'referrer', 'referrer_domain', 'source_type',
33
+ 'browser', 'browser_version', 'os', 'os_version', 'device_type',
34
+ 'session_id', 'event_type', 'event_data', 'is_unique',
35
+ 'note', 'admin_modified_at', 'deleted_at',
36
+ ].join(', ');
37
+
38
+ /**
39
+ * Columns an edit may write. The editable content fields plus the two
40
+ * columns re-derived from `referrer`, which an admin cannot set directly.
41
+ */
42
+ const WRITABLE_COLUMNS = new Set([
43
+ ...Object.values(EDITABLE_FIELDS),
44
+ 'referrer_domain',
45
+ 'source_type',
46
+ ]);
47
+
48
+ /** The row-state condition each operation requires of its targets. */
49
+ const STATE = {
50
+ ACTIVE: 'deleted_at IS NULL',
51
+ DELETED: 'deleted_at IS NOT NULL',
52
+ ANY: '1 = 1',
53
+ };
54
+
55
+ /** Row condition for each listing status. */
56
+ const STATUS_CONDITION = {
57
+ [VIEW_STATUS.ACTIVE]: STATE.ACTIVE,
58
+ [VIEW_STATUS.DELETED]: STATE.DELETED,
59
+ [VIEW_STATUS.ALL]: STATE.ANY,
60
+ };
61
+
62
+ const MODIFIED_CONDITION = {
63
+ [MODIFIED_FILTER.ANY]: null,
64
+ [MODIFIED_FILTER.MODIFIED]: 'admin_modified_at IS NOT NULL',
65
+ [MODIFIED_FILTER.UNMODIFIED]: 'admin_modified_at IS NULL',
66
+ };
67
+
68
+ /**
69
+ * Escape LIKE's wildcards so a search for "50%" matches that text rather than
70
+ * everything starting with "50".
71
+ * @param {string} text
72
+ * @returns {string}
73
+ */
74
+ function escapeLike(text) {
75
+ return String(text).replace(/[\\%_]/g, (char) => `\\${char}`);
76
+ }
77
+
78
+ /**
79
+ * Columns the analysis reads. `visitor_hash` is here only so the database can
80
+ * count distinct visitors; it is never selected into a result set.
81
+ */
82
+ const ANALYSIS_COLUMNS = [
83
+ 'country', 'event_type', 'source_type', 'devicesize', 'browser', 'os',
84
+ 'visitor_hash', 'is_unique', 'timestamp', 'admin_modified_at',
85
+ ].join(', ');
86
+
87
+ /**
88
+ * Time-series bucket expressions, keyed by TREND_BUCKET. Fixed literals: the
89
+ * bucket is chosen in code from the data's span, never from caller input.
90
+ * Every bucket is labelled by the date it starts on (YYYY-MM-DD).
91
+ */
92
+ const BUCKET_EXPRESSION = {
93
+ [TREND_BUCKET.DAY]: "DATE_FORMAT(timestamp, '%Y-%m-%d')",
94
+ [TREND_BUCKET.WEEK]: "DATE_FORMAT(DATE_SUB(DATE(timestamp), INTERVAL WEEKDAY(timestamp) DAY), '%Y-%m-%d')",
95
+ [TREND_BUCKET.MONTH]: "DATE_FORMAT(timestamp, '%Y-%m-01')",
96
+ };
97
+
98
+ /** Breakdown dimensions of the analysis: API name -> column. Fixed literals. */
99
+ const BREAKDOWN_COLUMNS = {
100
+ source: 'source_type',
101
+ deviceSize: 'devicesize',
102
+ browser: 'browser',
103
+ os: 'os',
104
+ eventType: 'event_type',
105
+ app: 'app_id',
106
+ };
107
+
108
+ const DAY_MS = 24 * 60 * 60 * 1000;
109
+
110
+ /** The coarsest bucket that keeps a chart of this span readable. */
111
+ function chooseBucket(firstAt, lastAt) {
112
+ if (!firstAt || !lastAt) return TREND_BUCKET.DAY;
113
+ const days = (new Date(lastAt).getTime() - new Date(firstAt).getTime()) / DAY_MS;
114
+ if (days <= TREND_BUCKET_MAX_DAYS[TREND_BUCKET.DAY]) return TREND_BUCKET.DAY;
115
+ if (days <= TREND_BUCKET_MAX_DAYS[TREND_BUCKET.WEEK]) return TREND_BUCKET.WEEK;
116
+ return TREND_BUCKET.MONTH;
117
+ }
118
+
119
+ /**
120
+ * The WHERE clause shared by the listing and the analysis, so the table and
121
+ * the charts above it always describe the same rows.
122
+ *
123
+ * @param {{ status?: string, modified?: string, search?: string, range?: string,
124
+ * eventType?: string }} query
125
+ * @returns {{ clause: string, params: unknown[] }}
126
+ */
127
+ function buildFilter({ status, modified, search, range, eventType } = {}) {
128
+ const where = [STATUS_CONDITION[status] || STATE.ACTIVE];
129
+ const params = [];
130
+
131
+ const modifiedCondition = MODIFIED_CONDITION[modified];
132
+ if (modifiedCondition) where.push(modifiedCondition);
133
+
134
+ if (eventType) {
135
+ where.push('event_type = ?');
136
+ params.push(eventType);
137
+ }
138
+
139
+ const days = ADMIN_RANGE_DAYS[range];
140
+ if (days) {
141
+ where.push('timestamp >= DATE_SUB(NOW(), INTERVAL ? DAY)');
142
+ params.push(days);
143
+ }
144
+
145
+ if (search) {
146
+ const pattern = `%${escapeLike(search)}%`;
147
+ where.push(`(public_id = ? OR page_path LIKE ? OR page_title LIKE ? OR referrer_domain LIKE ?
148
+ OR note LIKE ? OR event_type LIKE ? OR session_id LIKE ?)`);
149
+ params.push(search, pattern, pattern, pattern, pattern, pattern, pattern);
150
+ }
151
+
152
+ return { clause: where.join(' AND '), params };
153
+ }
154
+
155
+ /** Shape one row for the API: camelCase, public ID, parsed JSON. */
156
+ function toApiRow(row, appId = row.app_id) {
157
+ return {
158
+ id: row.public_id,
159
+ appId,
160
+ timestamp: row.timestamp,
161
+ maskedIp: row.masked_ip,
162
+ country: row.country,
163
+ deviceSize: row.devicesize,
164
+ pagePath: row.page_path,
165
+ pageTitle: row.page_title,
166
+ referrer: row.referrer,
167
+ referrerDomain: row.referrer_domain,
168
+ sourceType: row.source_type,
169
+ browser: row.browser,
170
+ browserVersion: row.browser_version,
171
+ os: row.os,
172
+ osVersion: row.os_version,
173
+ deviceType: row.device_type,
174
+ sessionId: row.session_id,
175
+ eventType: row.event_type,
176
+ eventData: parseJson(row.event_data),
177
+ isUnique: row.is_unique === 1 || row.is_unique === true,
178
+ note: row.note,
179
+ adminModifiedAt: row.admin_modified_at,
180
+ deletedAt: row.deleted_at,
181
+ };
182
+ }
183
+
184
+ class AdminRepository {
185
+ /**
186
+ * @param {{ pool: object, assertReady: () => void }} db a DatabaseManager
187
+ */
188
+ constructor(db) {
189
+ this.db = db;
190
+ }
191
+
192
+ get pool() {
193
+ this.db.assertReady();
194
+ return this.db.pool;
195
+ }
196
+
197
+ /** Backstop for the route-level allowlist check. */
198
+ table(appId) {
199
+ if (!isValidAppId(appId)) {
200
+ throw getError(ErrorType.INVALID_APP_ID, { appId });
201
+ }
202
+ return `\`${appId}\``;
203
+ }
204
+
205
+ /**
206
+ * Row counts per app, for the app picker.
207
+ * @param {string[]} appIds
208
+ * @returns {Promise<Array<{appId: string, active: number, deleted: number, modified: number, available: boolean}>>}
209
+ */
210
+ async summarizeApps(appIds) {
211
+ const summaries = [];
212
+ for (const appId of appIds) {
213
+ try {
214
+ const [rows] = await this.pool.query(
215
+ `SELECT
216
+ SUM(CASE WHEN deleted_at IS NULL THEN 1 ELSE 0 END) AS active,
217
+ SUM(CASE WHEN deleted_at IS NOT NULL THEN 1 ELSE 0 END) AS deleted,
218
+ SUM(CASE WHEN deleted_at IS NULL AND admin_modified_at IS NOT NULL THEN 1 ELSE 0 END) AS modified
219
+ FROM ${this.table(appId)}`
220
+ );
221
+ summaries.push({
222
+ appId,
223
+ active: Number(rows[0]?.active || 0),
224
+ deleted: Number(rows[0]?.deleted || 0),
225
+ modified: Number(rows[0]?.modified || 0),
226
+ available: true,
227
+ });
228
+ } catch {
229
+ // A configured app whose table is missing still appears, so the
230
+ // operator can see it is broken rather than silently absent.
231
+ summaries.push({ appId, active: 0, deleted: 0, modified: 0, available: false });
232
+ }
233
+ }
234
+ return summaries;
235
+ }
236
+
237
+ /**
238
+ * Which of these apps have a table. Listing and analysing all apps at
239
+ * once skips an app whose table is missing rather than failing outright.
240
+ * @param {string[]} appIds
241
+ * @returns {Promise<string[]>} in the order given
242
+ */
243
+ async existingTables(appIds) {
244
+ if (appIds.length === 0) return [];
245
+ const [rows] = await this.pool.query(
246
+ `SELECT TABLE_NAME AS name FROM information_schema.TABLES
247
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (?)`,
248
+ [appIds]
249
+ );
250
+ const present = new Set(rows.map((row) => row.name));
251
+ return appIds.filter((appId) => present.has(appId));
252
+ }
253
+
254
+ /**
255
+ * One page of views from one app or several.
256
+ *
257
+ * Several apps are read with UNION ALL. Each branch is sorted and cut to
258
+ * the rows the requested page could possibly need, so a deep page on a
259
+ * large table never unions whole tables.
260
+ *
261
+ * @param {string|string[]} apps one app ID or several
262
+ * @param {{ status: string, modified: string, search?: string, range?: string,
263
+ * sort: string, order: string, page: number, pageSize: number }} query already validated
264
+ * @returns {Promise<{ views: object[], total: number }>}
265
+ */
266
+ async listViews(apps, query) {
267
+ const appIds = Array.isArray(apps) ? apps : [apps];
268
+ if (appIds.length === 0) return { views: [], total: 0 };
269
+
270
+ const { page, pageSize, sort, order } = query;
271
+ const { clause, params } = buildFilter(query);
272
+ const column = ADMIN_SORT_COLUMNS[sort] || ADMIN_SORT_COLUMNS.timestamp;
273
+ const direction = order === SORT_ORDER.ASC ? 'ASC' : 'DESC';
274
+ const offset = (page - 1) * pageSize;
275
+
276
+ let rows;
277
+ if (appIds.length === 1) {
278
+ [rows] = await this.pool.query(
279
+ `SELECT ? AS app_id, ${ADMIN_VIEW_COLUMNS} FROM ${this.table(appIds[0])}
280
+ WHERE ${clause}
281
+ ORDER BY ${column} ${direction}, id DESC
282
+ LIMIT ? OFFSET ?`,
283
+ [appIds[0], ...params, pageSize, offset]
284
+ );
285
+ } else {
286
+ const branches = appIds.map((appId) =>
287
+ `(SELECT ? AS app_id, ${ADMIN_VIEW_COLUMNS} FROM ${this.table(appId)}
288
+ WHERE ${clause}
289
+ ORDER BY ${column} ${direction}, public_id ${direction}
290
+ LIMIT ?)`);
291
+ [rows] = await this.pool.query(
292
+ `${branches.join(' UNION ALL ')}
293
+ ORDER BY ${column} ${direction}, public_id ${direction}
294
+ LIMIT ? OFFSET ?`,
295
+ [...appIds.flatMap((appId) => [appId, ...params, offset + pageSize]), pageSize, offset]
296
+ );
297
+ }
298
+
299
+ const counts = appIds.map((appId) => `(SELECT COUNT(*) FROM ${this.table(appId)} WHERE ${clause})`);
300
+ const [count] = await this.pool.query(
301
+ `SELECT ${counts.join(' + ')} AS count`,
302
+ appIds.flatMap(() => params)
303
+ );
304
+
305
+ return { views: rows.map((row) => toApiRow(row)), total: Number(count[0]?.count || 0) };
306
+ }
307
+
308
+ /**
309
+ * Aggregates over the same rows a listing with this query would show.
310
+ *
311
+ * @param {string[]} appIds
312
+ * @param {{ status: string, modified: string, search?: string, range?: string }} query
313
+ * @returns {Promise<object>} totals, trend, breakdowns, and per-country counts
314
+ */
315
+ async analyze(appIds, query) {
316
+ const empty = {
317
+ totals: { views: 0, uniqueViews: 0, visitors: 0, countries: 0, modified: 0, firstAt: null, lastAt: null },
318
+ bucket: TREND_BUCKET.DAY,
319
+ trend: [],
320
+ breakdowns: Object.fromEntries(Object.keys(BREAKDOWN_COLUMNS).map((dim) => [dim, []])),
321
+ countries: [],
322
+ eventTypes: [],
323
+ };
324
+ if (appIds.length === 0) return empty;
325
+
326
+ const { clause, params } = buildFilter(query);
327
+ const branches = appIds.map((appId) =>
328
+ `SELECT ? AS app_id, ${ANALYSIS_COLUMNS} FROM ${this.table(appId)} WHERE ${clause}`);
329
+ const cte = `WITH v AS (${branches.join(' UNION ALL ')})`;
330
+ const cteParams = appIds.flatMap((appId) => [appId, ...params]);
331
+
332
+ const [totalRows] = await this.pool.query(
333
+ `${cte} SELECT
334
+ COUNT(*) AS views,
335
+ COALESCE(SUM(is_unique), 0) AS unique_views,
336
+ COUNT(DISTINCT visitor_hash) AS visitors,
337
+ COUNT(DISTINCT country) AS countries,
338
+ COALESCE(SUM(admin_modified_at IS NOT NULL), 0) AS modified,
339
+ MIN(timestamp) AS first_at,
340
+ MAX(timestamp) AS last_at
341
+ FROM v`,
342
+ cteParams
343
+ );
344
+ const totalsRow = totalRows[0] || {};
345
+ const totals = {
346
+ views: Number(totalsRow.views || 0),
347
+ uniqueViews: Number(totalsRow.unique_views || 0),
348
+ visitors: Number(totalsRow.visitors || 0),
349
+ countries: Number(totalsRow.countries || 0),
350
+ modified: Number(totalsRow.modified || 0),
351
+ firstAt: totalsRow.first_at ?? null,
352
+ lastAt: totalsRow.last_at ?? null,
353
+ };
354
+ // Before the early return: when the filters match nothing is exactly
355
+ // when the admin needs every type on offer to pick another.
356
+ const eventTypes = await this.eventTypes(appIds, query.status);
357
+ if (totals.views === 0) return { ...empty, totals, eventTypes };
358
+
359
+ const bucket = chooseBucket(totals.firstAt, totals.lastAt);
360
+ const [trendRows] = await this.pool.query(
361
+ `${cte} SELECT ${BUCKET_EXPRESSION[bucket]} AS period, COUNT(*) AS views,
362
+ COALESCE(SUM(is_unique), 0) AS unique_views
363
+ FROM v GROUP BY period ORDER BY period`,
364
+ cteParams
365
+ );
366
+
367
+ const groups = Object.entries(BREAKDOWN_COLUMNS).map(([dim, column]) =>
368
+ `SELECT '${dim}' AS dim, ${column} AS value, COUNT(*) AS views FROM v GROUP BY ${column}`);
369
+ const [breakdownRows] = await this.pool.query(
370
+ `${cte} SELECT dim, value, views FROM (
371
+ SELECT dim, value, views,
372
+ ROW_NUMBER() OVER (PARTITION BY dim ORDER BY views DESC, value) AS rank_in_dim
373
+ FROM (${groups.join(' UNION ALL ')}) AS g
374
+ ) AS ranked
375
+ WHERE rank_in_dim <= ?
376
+ ORDER BY dim, views DESC, value`,
377
+ [...cteParams, ANALYSIS_TOP_N]
378
+ );
379
+ const breakdowns = Object.fromEntries(Object.keys(BREAKDOWN_COLUMNS).map((dim) => [dim, []]));
380
+ for (const row of breakdownRows) {
381
+ breakdowns[row.dim]?.push({ value: row.value ?? null, views: Number(row.views) });
382
+ }
383
+
384
+ // Per-country counts, split by the leading event types so the map can
385
+ // show where each type comes from; the rest are grouped as null.
386
+ const types = breakdowns.eventType.map((entry) => entry.value).filter((value) => value !== null);
387
+ const [countryRows] = await this.pool.query(
388
+ `${cte} SELECT country,
389
+ CASE WHEN event_type IN (?) THEN event_type ELSE NULL END AS event_type,
390
+ COUNT(*) AS views
391
+ FROM v WHERE country IS NOT NULL
392
+ GROUP BY country, CASE WHEN event_type IN (?) THEN event_type ELSE NULL END
393
+ ORDER BY country`,
394
+ [...cteParams, types.length ? types : [''], types.length ? types : ['']]
395
+ );
396
+
397
+ return {
398
+ totals,
399
+ bucket,
400
+ trend: trendRows.map((row) => ({
401
+ period: String(row.period),
402
+ views: Number(row.views),
403
+ uniqueViews: Number(row.unique_views),
404
+ })),
405
+ breakdowns,
406
+ countries: countryRows.map((row) => ({
407
+ country: row.country,
408
+ eventType: row.event_type ?? null,
409
+ views: Number(row.views),
410
+ })),
411
+ eventTypes,
412
+ };
413
+ }
414
+
415
+ /**
416
+ * Every event type the apps hold in this status, whatever the other
417
+ * filters say. The event-type filter offers all of them, so narrowing the
418
+ * range or choosing one type never hides the rest, and a rare type is
419
+ * never cut off the way the top-N breakdown cuts it.
420
+ *
421
+ * @param {string[]} appIds
422
+ * @param {string} status
423
+ * @returns {Promise<string[]>}
424
+ */
425
+ async eventTypes(appIds, status) {
426
+ const { clause, params } = buildFilter({ status });
427
+ const branches = appIds.map((appId) =>
428
+ `SELECT event_type FROM ${this.table(appId)} WHERE ${clause} AND event_type IS NOT NULL`);
429
+ const [rows] = await this.pool.query(
430
+ `SELECT DISTINCT event_type FROM (${branches.join(' UNION ')}) AS e ORDER BY event_type`,
431
+ appIds.flatMap(() => params)
432
+ );
433
+ return rows.map((row) => row.event_type);
434
+ }
435
+
436
+ /**
437
+ * Which of `ids` exist in `appId` and are in the required state.
438
+ * @returns {Promise<string[]>}
439
+ */
440
+ async matchIds(appId, ids, state) {
441
+ if (ids.length === 0) return [];
442
+ const [rows] = await this.pool.query(
443
+ `SELECT public_id FROM ${this.table(appId)} WHERE public_id IN (?) AND ${state}`,
444
+ [ids]
445
+ );
446
+ return rows.map((row) => row.public_id);
447
+ }
448
+
449
+ /**
450
+ * Edit content fields and mark the rows as admin-modified. Trashed rows are
451
+ * not editable; restore them first.
452
+ *
453
+ * @param {string} appId
454
+ * @param {string[]} ids
455
+ * @param {Record<string, unknown>} columnValues column -> new value
456
+ * @returns {Promise<string[]>} IDs actually changed
457
+ */
458
+ async updateContent(appId, ids, columnValues) {
459
+ const columns = Object.keys(columnValues);
460
+ for (const column of columns) {
461
+ if (!WRITABLE_COLUMNS.has(column)) {
462
+ throw getError(ErrorType.FIELD_NOT_WRITABLE, { column });
463
+ }
464
+ }
465
+
466
+ const matched = await this.matchIds(appId, ids, STATE.ACTIVE);
467
+ if (matched.length === 0 || columns.length === 0) return [];
468
+
469
+ const assignments = columns.map((column) => `\`${column}\` = ?`).join(', ');
470
+ await this.pool.query(
471
+ `UPDATE ${this.table(appId)} SET ${assignments}, admin_modified_at = NOW()
472
+ WHERE public_id IN (?) AND ${STATE.ACTIVE}`,
473
+ [...columns.map((column) => columnValues[column]), matched]
474
+ );
475
+ return matched;
476
+ }
477
+
478
+ /**
479
+ * Set or clear the note. A note is an annotation, not a change to what was
480
+ * observed, so it does not mark the row admin-modified.
481
+ *
482
+ * @param {string} appId
483
+ * @param {string[]} ids
484
+ * @param {string|null} note null clears it
485
+ * @returns {Promise<string[]>}
486
+ */
487
+ async setNote(appId, ids, note) {
488
+ const matched = await this.matchIds(appId, ids, STATE.ANY);
489
+ if (matched.length === 0) return [];
490
+
491
+ await this.pool.query(
492
+ `UPDATE ${this.table(appId)} SET note = ? WHERE public_id IN (?)`,
493
+ [note, matched]
494
+ );
495
+ return matched;
496
+ }
497
+
498
+ /** Move active rows to the trash. @returns {Promise<string[]>} */
499
+ async softDelete(appId, ids) {
500
+ const matched = await this.matchIds(appId, ids, STATE.ACTIVE);
501
+ if (matched.length === 0) return [];
502
+
503
+ await this.pool.query(
504
+ `UPDATE ${this.table(appId)} SET deleted_at = NOW() WHERE public_id IN (?) AND ${STATE.ACTIVE}`,
505
+ [matched]
506
+ );
507
+ return matched;
508
+ }
509
+
510
+ /** Bring trashed rows back. @returns {Promise<string[]>} */
511
+ async restore(appId, ids) {
512
+ const matched = await this.matchIds(appId, ids, STATE.DELETED);
513
+ if (matched.length === 0) return [];
514
+
515
+ await this.pool.query(
516
+ `UPDATE ${this.table(appId)} SET deleted_at = NULL WHERE public_id IN (?) AND ${STATE.DELETED}`,
517
+ [matched]
518
+ );
519
+ return matched;
520
+ }
521
+
522
+ /**
523
+ * Permanently erase rows. Only rows already in the trash can be erased, so
524
+ * erasure is always a deliberate second step after a soft delete.
525
+ * @returns {Promise<string[]>}
526
+ */
527
+ async purge(appId, ids) {
528
+ const matched = await this.matchIds(appId, ids, STATE.DELETED);
529
+ if (matched.length === 0) return [];
530
+
531
+ await this.pool.query(
532
+ `DELETE FROM ${this.table(appId)} WHERE public_id IN (?) AND ${STATE.DELETED}`,
533
+ [matched]
534
+ );
535
+ return matched;
536
+ }
537
+
538
+ /**
539
+ * Erase trashed rows older than the retention period.
540
+ * @param {string} appId
541
+ * @param {number} days
542
+ * @returns {Promise<number>} rows erased
543
+ */
544
+ async purgeExpired(appId, days) {
545
+ const [result] = await this.pool.query(
546
+ `DELETE FROM ${this.table(appId)}
547
+ WHERE ${STATE.DELETED} AND deleted_at < DATE_SUB(NOW(), INTERVAL ? DAY)`,
548
+ [days]
549
+ );
550
+ return Number(result?.affectedRows || 0);
551
+ }
552
+ }
553
+
554
+ module.exports = AdminRepository;
555
+ module.exports.ADMIN_VIEW_COLUMNS = ADMIN_VIEW_COLUMNS;
556
+ module.exports.WRITABLE_COLUMNS = WRITABLE_COLUMNS;
557
+ module.exports.escapeLike = escapeLike;
558
+ module.exports.toApiRow = toApiRow;
559
+ module.exports.buildFilter = buildFilter;
560
+ module.exports.chooseBucket = chooseBucket;
561
+ module.exports.BREAKDOWN_COLUMNS = BREAKDOWN_COLUMNS;
562
+ module.exports.BUCKET_EXPRESSION = BUCKET_EXPRESSION;