@harshankur/viewcounter 3.0.1 → 3.2.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/.env.example +50 -6
- package/README.md +444 -104
- package/admin/apple-touch-icon.png +0 -0
- package/admin/assets/world-map.json +1 -0
- package/admin/css/admin.css +2568 -0
- package/admin/favicon.ico +0 -0
- package/admin/favicon.svg +9 -0
- package/admin/icon-192.png +0 -0
- package/admin/icon-512.png +0 -0
- package/admin/index.html +95 -0
- package/admin/js/api.js +146 -0
- package/admin/js/appTabs.js +100 -0
- package/admin/js/charts.js +842 -0
- package/admin/js/clamp.js +41 -0
- package/admin/js/constants.js +239 -0
- package/admin/js/dataTable.js +478 -0
- package/admin/js/dom.js +83 -0
- package/admin/js/format.js +130 -0
- package/admin/js/i18n.js +80 -0
- package/admin/js/icons.js +168 -0
- package/admin/js/listbox.js +145 -0
- package/admin/js/logs.js +318 -0
- package/admin/js/main.js +399 -0
- package/admin/js/modal.js +171 -0
- package/admin/js/overview.js +905 -0
- package/admin/js/passwordPrompt.js +75 -0
- package/admin/js/table.js +94 -0
- package/admin/js/theme.js +72 -0
- package/admin/js/toast.js +47 -0
- package/admin/js/viewDialogs.js +224 -0
- package/admin/js/views.js +751 -0
- package/admin/locales/en.json +683 -0
- package/admin/site.webmanifest +20 -0
- package/config/index.js +122 -4
- package/constants.js +334 -3
- package/db/AdminRepository.js +488 -0
- package/db/DatabaseManager.js +148 -26
- package/db/LogRepository.js +354 -0
- package/db/adminSchema.js +329 -0
- package/db/adminSessionStore.js +104 -0
- package/db/analysis.js +479 -0
- package/db/rejectionCounter.js +117 -0
- package/db/retention.js +97 -0
- package/index.js +91 -24
- package/middleware/adminAuth.js +244 -0
- package/middleware/adminValidation.js +319 -0
- package/middleware/auth.js +2 -2
- package/middleware/security.js +26 -2
- package/middleware/validation.js +50 -2
- package/package.json +20 -10
- package/routes/admin.js +546 -0
- package/routes/analytics.js +207 -19
- package/tracker/tracker.js +191 -0
- package/utils/appIdUtils.js +1 -1
- package/utils/cookieUtils.js +47 -0
- package/utils/durationUtils.js +33 -0
- package/utils/errorUtils.js +39 -1
- package/utils/geoCity.js +87 -0
- package/utils/ipUtils.js +1 -1
- package/utils/privacyUtils.js +2 -2
- package/utils/referrerParser.js +23 -5
- package/utils/secretStore.js +1 -1
- package/utils/userAgentParser.js +52 -3
- package/utils/visitorContext.js +70 -0
|
@@ -0,0 +1,488 @@
|
|
|
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
|
+
EDITABLE_FIELDS,
|
|
14
|
+
MODIFIED_FILTER,
|
|
15
|
+
SORT_ORDER,
|
|
16
|
+
SOURCE_TYPE,
|
|
17
|
+
VIEW_STATUS,
|
|
18
|
+
} = require('../constants');
|
|
19
|
+
const analysis = require('./analysis');
|
|
20
|
+
const { getError, ErrorType } = require('../utils/errorUtils');
|
|
21
|
+
const { isValidAppId } = require('../utils/appIdUtils');
|
|
22
|
+
const { parseJson } = require('./LogRepository');
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Columns returned to the admin UI. Excludes the internal auto-increment `id`
|
|
26
|
+
* (enumerable) and `visitor_hash` (the pseudonymous visitor identifier, which
|
|
27
|
+
* an admin has no need to see).
|
|
28
|
+
*/
|
|
29
|
+
const ADMIN_VIEW_COLUMNS = [
|
|
30
|
+
'public_id', 'timestamp', 'masked_ip', 'country', 'devicesize',
|
|
31
|
+
'page_path', 'page_title', 'referrer', 'referrer_domain', 'source_type',
|
|
32
|
+
'browser', 'browser_version', 'os', 'os_version', 'device_type',
|
|
33
|
+
'session_id', 'event_type', 'event_data', 'is_unique',
|
|
34
|
+
'hostname', 'language', 'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',
|
|
35
|
+
'region', 'city', 'engaged_ms', 'scroll_depth',
|
|
36
|
+
'note', 'admin_modified_at', 'deleted_at',
|
|
37
|
+
].join(', ');
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Columns an edit may write. The editable content fields plus the two
|
|
41
|
+
* columns re-derived from `referrer`, which an admin cannot set directly.
|
|
42
|
+
*/
|
|
43
|
+
const WRITABLE_COLUMNS = new Set([
|
|
44
|
+
...Object.values(EDITABLE_FIELDS),
|
|
45
|
+
'referrer_domain',
|
|
46
|
+
'source_type',
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
/** The row-state condition each operation requires of its targets. */
|
|
50
|
+
const STATE = {
|
|
51
|
+
ACTIVE: 'deleted_at IS NULL',
|
|
52
|
+
DELETED: 'deleted_at IS NOT NULL',
|
|
53
|
+
ANY: '1 = 1',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Row condition for each listing status. */
|
|
57
|
+
const STATUS_CONDITION = {
|
|
58
|
+
[VIEW_STATUS.ACTIVE]: STATE.ACTIVE,
|
|
59
|
+
[VIEW_STATUS.DELETED]: STATE.DELETED,
|
|
60
|
+
[VIEW_STATUS.ALL]: STATE.ANY,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const MODIFIED_CONDITION = {
|
|
64
|
+
[MODIFIED_FILTER.ANY]: null,
|
|
65
|
+
[MODIFIED_FILTER.MODIFIED]: 'admin_modified_at IS NOT NULL',
|
|
66
|
+
[MODIFIED_FILTER.UNMODIFIED]: 'admin_modified_at IS NULL',
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Escape LIKE's wildcards so a search for "50%" matches that text rather than
|
|
71
|
+
* everything starting with "50".
|
|
72
|
+
* @param {string} text
|
|
73
|
+
* @returns {string}
|
|
74
|
+
*/
|
|
75
|
+
function escapeLike(text) {
|
|
76
|
+
return String(text).replace(/[\\%_]/g, (char) => `\\${char}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The WHERE clause shared by the listing and the analysis, so the table and
|
|
81
|
+
* the charts above it always describe the same rows.
|
|
82
|
+
*
|
|
83
|
+
* With `window` 'previous', the range moves back by its own length, for
|
|
84
|
+
* comparing with the period before: last 7 days against the 7 days before.
|
|
85
|
+
*
|
|
86
|
+
* `where` narrows to breakdown values, such as one country or one page, as
|
|
87
|
+
* the analysis groups them; null matches the rows with no value ("Unknown").
|
|
88
|
+
*
|
|
89
|
+
* @param {{ status?: string, modified?: string, search?: string, range?: string,
|
|
90
|
+
* eventType?: string, where?: Record<string, string|null> }} query
|
|
91
|
+
* @param {'previous'} [window]
|
|
92
|
+
* @returns {{ clause: string, params: unknown[] }}
|
|
93
|
+
*/
|
|
94
|
+
function buildFilter({ status, modified, search, range, eventType, where = {} } = {}, window) {
|
|
95
|
+
const conditions = [STATUS_CONDITION[status] || STATE.ACTIVE];
|
|
96
|
+
const params = [];
|
|
97
|
+
|
|
98
|
+
const modifiedCondition = MODIFIED_CONDITION[modified];
|
|
99
|
+
if (modifiedCondition) conditions.push(modifiedCondition);
|
|
100
|
+
|
|
101
|
+
if (eventType) {
|
|
102
|
+
conditions.push('event_type = ?');
|
|
103
|
+
params.push(eventType);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const days = ADMIN_RANGE_DAYS[range];
|
|
107
|
+
if (days && window === 'previous') {
|
|
108
|
+
conditions.push('timestamp >= DATE_SUB(NOW(), INTERVAL ? DAY) AND timestamp < DATE_SUB(NOW(), INTERVAL ? DAY)');
|
|
109
|
+
params.push(days * 2, days);
|
|
110
|
+
} else if (days) {
|
|
111
|
+
conditions.push('timestamp >= DATE_SUB(NOW(), INTERVAL ? DAY)');
|
|
112
|
+
params.push(days);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const [dim, value] of Object.entries(where)) {
|
|
116
|
+
// The expression is a fixed literal, looked up by a validated name;
|
|
117
|
+
// <=> is equality that also matches NULL to NULL.
|
|
118
|
+
if (!Object.hasOwn(analysis.FILTER_COLUMNS, dim)) continue;
|
|
119
|
+
conditions.push(`(${analysis.FILTER_COLUMNS[dim]}) <=> ?`);
|
|
120
|
+
params.push(value);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (search) {
|
|
124
|
+
const pattern = `%${escapeLike(search)}%`;
|
|
125
|
+
conditions.push(`(public_id = ? OR page_path LIKE ? OR page_title LIKE ? OR referrer_domain LIKE ?
|
|
126
|
+
OR hostname LIKE ? OR utm_campaign LIKE ? OR note LIKE ? OR event_type LIKE ? OR session_id LIKE ?)`);
|
|
127
|
+
params.push(search, pattern, pattern, pattern, pattern, pattern, pattern, pattern, pattern);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { clause: conditions.join(' AND '), params };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Shape one row for the API: camelCase, public ID, parsed JSON. */
|
|
134
|
+
function toApiRow(row, appId = row.app_id) {
|
|
135
|
+
return {
|
|
136
|
+
id: row.public_id,
|
|
137
|
+
appId,
|
|
138
|
+
timestamp: row.timestamp,
|
|
139
|
+
maskedIp: row.masked_ip,
|
|
140
|
+
country: row.country,
|
|
141
|
+
deviceSize: row.devicesize,
|
|
142
|
+
pagePath: row.page_path,
|
|
143
|
+
pageTitle: row.page_title,
|
|
144
|
+
referrer: row.referrer,
|
|
145
|
+
referrerDomain: row.referrer_domain,
|
|
146
|
+
sourceType: row.source_type,
|
|
147
|
+
browser: row.browser,
|
|
148
|
+
browserVersion: row.browser_version,
|
|
149
|
+
os: row.os,
|
|
150
|
+
osVersion: row.os_version,
|
|
151
|
+
deviceType: row.device_type,
|
|
152
|
+
sessionId: row.session_id,
|
|
153
|
+
eventType: row.event_type,
|
|
154
|
+
eventData: parseJson(row.event_data),
|
|
155
|
+
isUnique: row.is_unique === 1 || row.is_unique === true,
|
|
156
|
+
hostname: row.hostname ?? null,
|
|
157
|
+
language: row.language ?? null,
|
|
158
|
+
utmSource: row.utm_source ?? null,
|
|
159
|
+
utmMedium: row.utm_medium ?? null,
|
|
160
|
+
utmCampaign: row.utm_campaign ?? null,
|
|
161
|
+
utmTerm: row.utm_term ?? null,
|
|
162
|
+
utmContent: row.utm_content ?? null,
|
|
163
|
+
region: row.region ?? null,
|
|
164
|
+
city: row.city ?? null,
|
|
165
|
+
engagedMs: row.engaged_ms ?? null,
|
|
166
|
+
scrollDepth: row.scroll_depth ?? null,
|
|
167
|
+
note: row.note,
|
|
168
|
+
adminModifiedAt: row.admin_modified_at,
|
|
169
|
+
deletedAt: row.deleted_at,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
class AdminRepository {
|
|
174
|
+
/**
|
|
175
|
+
* @param {{ pool: object, assertReady: () => void }} db a DatabaseManager
|
|
176
|
+
*/
|
|
177
|
+
constructor(db) {
|
|
178
|
+
this.db = db;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
get pool() {
|
|
182
|
+
this.db.assertReady();
|
|
183
|
+
return this.db.pool;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Backstop for the route-level allowlist check. */
|
|
187
|
+
table(appId) {
|
|
188
|
+
if (!isValidAppId(appId)) {
|
|
189
|
+
throw getError(ErrorType.INVALID_APP_ID, { appId });
|
|
190
|
+
}
|
|
191
|
+
return `\`${appId}\``;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Row counts per app, for the app picker.
|
|
196
|
+
* @param {string[]} appIds
|
|
197
|
+
* @returns {Promise<Array<{appId: string, active: number, deleted: number, modified: number, available: boolean}>>}
|
|
198
|
+
*/
|
|
199
|
+
async summarizeApps(appIds) {
|
|
200
|
+
const summaries = [];
|
|
201
|
+
for (const appId of appIds) {
|
|
202
|
+
try {
|
|
203
|
+
const [rows] = await this.pool.query(
|
|
204
|
+
`SELECT
|
|
205
|
+
SUM(CASE WHEN deleted_at IS NULL THEN 1 ELSE 0 END) AS active,
|
|
206
|
+
SUM(CASE WHEN deleted_at IS NOT NULL THEN 1 ELSE 0 END) AS deleted,
|
|
207
|
+
SUM(CASE WHEN deleted_at IS NULL AND admin_modified_at IS NOT NULL THEN 1 ELSE 0 END) AS modified
|
|
208
|
+
FROM ${this.table(appId)}`
|
|
209
|
+
);
|
|
210
|
+
summaries.push({
|
|
211
|
+
appId,
|
|
212
|
+
active: Number(rows[0]?.active || 0),
|
|
213
|
+
deleted: Number(rows[0]?.deleted || 0),
|
|
214
|
+
modified: Number(rows[0]?.modified || 0),
|
|
215
|
+
available: true,
|
|
216
|
+
});
|
|
217
|
+
} catch {
|
|
218
|
+
// A configured app whose table is missing still appears, so the
|
|
219
|
+
// operator can see it is broken rather than silently absent.
|
|
220
|
+
summaries.push({ appId, active: 0, deleted: 0, modified: 0, available: false });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return summaries;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Which of these apps have a table. Listing and analysing all apps at
|
|
228
|
+
* once skips an app whose table is missing rather than failing outright.
|
|
229
|
+
* @param {string[]} appIds
|
|
230
|
+
* @returns {Promise<string[]>} in the order given
|
|
231
|
+
*/
|
|
232
|
+
async existingTables(appIds) {
|
|
233
|
+
if (appIds.length === 0) return [];
|
|
234
|
+
const [rows] = await this.pool.query(
|
|
235
|
+
`SELECT TABLE_NAME AS name FROM information_schema.TABLES
|
|
236
|
+
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (?)`,
|
|
237
|
+
[appIds]
|
|
238
|
+
);
|
|
239
|
+
const present = new Set(rows.map((row) => row.name));
|
|
240
|
+
return appIds.filter((appId) => present.has(appId));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* One page of views from one app or several.
|
|
245
|
+
*
|
|
246
|
+
* Several apps are read with UNION ALL. Each branch is sorted and cut to
|
|
247
|
+
* the rows the requested page could possibly need, so a deep page on a
|
|
248
|
+
* large table never unions whole tables.
|
|
249
|
+
*
|
|
250
|
+
* @param {string|string[]} apps one app ID or several
|
|
251
|
+
* @param {{ status: string, modified: string, search?: string, range?: string,
|
|
252
|
+
* sort: string, order: string, page: number, pageSize: number }} query already validated
|
|
253
|
+
* @returns {Promise<{ views: object[], total: number }>}
|
|
254
|
+
*/
|
|
255
|
+
async listViews(apps, query) {
|
|
256
|
+
const appIds = Array.isArray(apps) ? apps : [apps];
|
|
257
|
+
if (appIds.length === 0) return { views: [], total: 0 };
|
|
258
|
+
|
|
259
|
+
const { page, pageSize, sort, order } = query;
|
|
260
|
+
const { clause, params } = buildFilter(query);
|
|
261
|
+
const column = ADMIN_SORT_COLUMNS[sort] || ADMIN_SORT_COLUMNS.timestamp;
|
|
262
|
+
const direction = order === SORT_ORDER.ASC ? 'ASC' : 'DESC';
|
|
263
|
+
const offset = (page - 1) * pageSize;
|
|
264
|
+
|
|
265
|
+
let rows;
|
|
266
|
+
if (appIds.length === 1) {
|
|
267
|
+
[rows] = await this.pool.query(
|
|
268
|
+
`SELECT ? AS app_id, ${ADMIN_VIEW_COLUMNS} FROM ${this.table(appIds[0])}
|
|
269
|
+
WHERE ${clause}
|
|
270
|
+
ORDER BY ${column} ${direction}, id DESC
|
|
271
|
+
LIMIT ? OFFSET ?`,
|
|
272
|
+
[appIds[0], ...params, pageSize, offset]
|
|
273
|
+
);
|
|
274
|
+
} else {
|
|
275
|
+
const branches = appIds.map((appId) =>
|
|
276
|
+
`(SELECT ? AS app_id, ${ADMIN_VIEW_COLUMNS} FROM ${this.table(appId)}
|
|
277
|
+
WHERE ${clause}
|
|
278
|
+
ORDER BY ${column} ${direction}, public_id ${direction}
|
|
279
|
+
LIMIT ?)`);
|
|
280
|
+
[rows] = await this.pool.query(
|
|
281
|
+
`${branches.join(' UNION ALL ')}
|
|
282
|
+
ORDER BY ${column} ${direction}, public_id ${direction}
|
|
283
|
+
LIMIT ? OFFSET ?`,
|
|
284
|
+
[...appIds.flatMap((appId) => [appId, ...params, offset + pageSize]), pageSize, offset]
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const counts = appIds.map((appId) => `(SELECT COUNT(*) FROM ${this.table(appId)} WHERE ${clause})`);
|
|
289
|
+
const [count] = await this.pool.query(
|
|
290
|
+
`SELECT ${counts.join(' + ')} AS count`,
|
|
291
|
+
appIds.flatMap(() => params)
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
return { views: rows.map((row) => toApiRow(row)), total: Number(count[0]?.count || 0) };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Aggregates over the same rows a listing with this query would show: see
|
|
299
|
+
* db/analysis.js. A bounded range is compared with the period before it.
|
|
300
|
+
*
|
|
301
|
+
* @param {string[]} appIds
|
|
302
|
+
* @param {{ status: string, modified: string, search?: string, range?: string, eventType?: string }} query
|
|
303
|
+
* @returns {Promise<object>}
|
|
304
|
+
*/
|
|
305
|
+
async analyze(appIds, query) {
|
|
306
|
+
if (appIds.length === 0) {
|
|
307
|
+
return { totals: null, previous: null, window: null, ...analysis.emptySections(), eventTypes: [] };
|
|
308
|
+
}
|
|
309
|
+
const result = await analysis.runAnalysis(this.pool, (appId) => this.table(appId), appIds,
|
|
310
|
+
(window) => buildFilter(query, window),
|
|
311
|
+
{ hasPrevious: Boolean(ADMIN_RANGE_DAYS[query.range]), spanDays: ADMIN_RANGE_DAYS[query.range] });
|
|
312
|
+
// After the analysis: when the filters match nothing is exactly when
|
|
313
|
+
// the admin needs every type on offer to pick another.
|
|
314
|
+
return { ...result, eventTypes: await this.eventTypes(appIds, query.status) };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Who is on the sites right now, and views per minute over the last half
|
|
319
|
+
* hour. Live views of these apps only.
|
|
320
|
+
* @param {string[]} appIds
|
|
321
|
+
*/
|
|
322
|
+
async realtime(appIds) {
|
|
323
|
+
if (appIds.length === 0) return { visitors: 0, nowMinute: null, minutes: [], pages: [] };
|
|
324
|
+
return analysis.runRealtime(this.pool, (appId) => this.table(appId), appIds);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Every event type the apps hold in this status, whatever the other
|
|
329
|
+
* filters say. The event-type filter offers all of them, so narrowing the
|
|
330
|
+
* range or choosing one type never hides the rest, and a rare type is
|
|
331
|
+
* never cut off the way the top-N breakdown cuts it.
|
|
332
|
+
*
|
|
333
|
+
* @param {string[]} appIds
|
|
334
|
+
* @param {string} status
|
|
335
|
+
* @returns {Promise<string[]>}
|
|
336
|
+
*/
|
|
337
|
+
async eventTypes(appIds, status) {
|
|
338
|
+
const { clause, params } = buildFilter({ status });
|
|
339
|
+
const branches = appIds.map((appId) =>
|
|
340
|
+
`SELECT event_type FROM ${this.table(appId)} WHERE ${clause} AND event_type IS NOT NULL`);
|
|
341
|
+
const [rows] = await this.pool.query(
|
|
342
|
+
`SELECT DISTINCT event_type FROM (${branches.join(' UNION ')}) AS e ORDER BY event_type`,
|
|
343
|
+
appIds.flatMap(() => params)
|
|
344
|
+
);
|
|
345
|
+
return rows.map((row) => row.event_type);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Which of `ids` exist in `appId` and are in the required state.
|
|
350
|
+
* @returns {Promise<string[]>}
|
|
351
|
+
*/
|
|
352
|
+
async matchIds(appId, ids, state) {
|
|
353
|
+
if (ids.length === 0) return [];
|
|
354
|
+
const [rows] = await this.pool.query(
|
|
355
|
+
`SELECT public_id FROM ${this.table(appId)} WHERE public_id IN (?) AND ${state}`,
|
|
356
|
+
[ids]
|
|
357
|
+
);
|
|
358
|
+
return rows.map((row) => row.public_id);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Edit content fields and mark the rows as admin-modified. Trashed rows are
|
|
363
|
+
* not editable; restore them first.
|
|
364
|
+
*
|
|
365
|
+
* @param {string} appId
|
|
366
|
+
* @param {string[]} ids
|
|
367
|
+
* @param {Record<string, unknown>} columnValues column -> new value
|
|
368
|
+
* @returns {Promise<string[]>} IDs actually changed
|
|
369
|
+
*/
|
|
370
|
+
async updateContent(appId, ids, columnValues) {
|
|
371
|
+
const columns = Object.keys(columnValues);
|
|
372
|
+
for (const column of columns) {
|
|
373
|
+
if (!WRITABLE_COLUMNS.has(column)) {
|
|
374
|
+
throw getError(ErrorType.FIELD_NOT_WRITABLE, { column });
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const matched = await this.matchIds(appId, ids, STATE.ACTIVE);
|
|
379
|
+
if (matched.length === 0 || columns.length === 0) return [];
|
|
380
|
+
|
|
381
|
+
const assignments = [];
|
|
382
|
+
const params = [];
|
|
383
|
+
for (const column of columns) {
|
|
384
|
+
if (column === 'source_type' && columnValues.referrer_domain) {
|
|
385
|
+
// A referrer on the row's own site is internal, as on the write
|
|
386
|
+
// path; each row is judged by its own site, in the same statement.
|
|
387
|
+
const site = String(columnValues.referrer_domain).toLowerCase().replace(/^www\./, '');
|
|
388
|
+
assignments.push('`source_type` = CASE WHEN LOWER(hostname) IN (?, ?) THEN ? ELSE ? END');
|
|
389
|
+
params.push(site, `www.${site}`, SOURCE_TYPE.INTERNAL, columnValues.source_type);
|
|
390
|
+
} else {
|
|
391
|
+
assignments.push(`\`${column}\` = ?`);
|
|
392
|
+
params.push(columnValues[column]);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
await this.pool.query(
|
|
396
|
+
`UPDATE ${this.table(appId)} SET ${assignments.join(', ')}, admin_modified_at = NOW()
|
|
397
|
+
WHERE public_id IN (?) AND ${STATE.ACTIVE}`,
|
|
398
|
+
[...params, matched]
|
|
399
|
+
);
|
|
400
|
+
return matched;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Set or clear the note. A note is an annotation, not a change to what was
|
|
405
|
+
* observed, so it does not mark the row admin-modified.
|
|
406
|
+
*
|
|
407
|
+
* @param {string} appId
|
|
408
|
+
* @param {string[]} ids
|
|
409
|
+
* @param {string|null} note null clears it
|
|
410
|
+
* @returns {Promise<string[]>}
|
|
411
|
+
*/
|
|
412
|
+
async setNote(appId, ids, note) {
|
|
413
|
+
const matched = await this.matchIds(appId, ids, STATE.ANY);
|
|
414
|
+
if (matched.length === 0) return [];
|
|
415
|
+
|
|
416
|
+
await this.pool.query(
|
|
417
|
+
`UPDATE ${this.table(appId)} SET note = ? WHERE public_id IN (?)`,
|
|
418
|
+
[note, matched]
|
|
419
|
+
);
|
|
420
|
+
return matched;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** Move active rows to the trash. @returns {Promise<string[]>} */
|
|
424
|
+
async softDelete(appId, ids) {
|
|
425
|
+
const matched = await this.matchIds(appId, ids, STATE.ACTIVE);
|
|
426
|
+
if (matched.length === 0) return [];
|
|
427
|
+
|
|
428
|
+
await this.pool.query(
|
|
429
|
+
`UPDATE ${this.table(appId)} SET deleted_at = NOW() WHERE public_id IN (?) AND ${STATE.ACTIVE}`,
|
|
430
|
+
[matched]
|
|
431
|
+
);
|
|
432
|
+
return matched;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Bring trashed rows back. @returns {Promise<string[]>} */
|
|
436
|
+
async restore(appId, ids) {
|
|
437
|
+
const matched = await this.matchIds(appId, ids, STATE.DELETED);
|
|
438
|
+
if (matched.length === 0) return [];
|
|
439
|
+
|
|
440
|
+
await this.pool.query(
|
|
441
|
+
`UPDATE ${this.table(appId)} SET deleted_at = NULL WHERE public_id IN (?) AND ${STATE.DELETED}`,
|
|
442
|
+
[matched]
|
|
443
|
+
);
|
|
444
|
+
return matched;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Permanently erase rows. Only rows already in the trash can be erased, so
|
|
449
|
+
* erasure is always a deliberate second step after a soft delete.
|
|
450
|
+
* @returns {Promise<string[]>}
|
|
451
|
+
*/
|
|
452
|
+
async purge(appId, ids) {
|
|
453
|
+
const matched = await this.matchIds(appId, ids, STATE.DELETED);
|
|
454
|
+
if (matched.length === 0) return [];
|
|
455
|
+
|
|
456
|
+
await this.pool.query(
|
|
457
|
+
`DELETE FROM ${this.table(appId)} WHERE public_id IN (?) AND ${STATE.DELETED}`,
|
|
458
|
+
[matched]
|
|
459
|
+
);
|
|
460
|
+
return matched;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Erase trashed rows older than the retention period.
|
|
465
|
+
* @param {string} appId
|
|
466
|
+
* @param {number} days
|
|
467
|
+
* @returns {Promise<number>} rows erased
|
|
468
|
+
*/
|
|
469
|
+
async purgeExpired(appId, days) {
|
|
470
|
+
const [result] = await this.pool.query(
|
|
471
|
+
`DELETE FROM ${this.table(appId)}
|
|
472
|
+
WHERE ${STATE.DELETED} AND deleted_at < DATE_SUB(NOW(), INTERVAL ? DAY)`,
|
|
473
|
+
[days]
|
|
474
|
+
);
|
|
475
|
+
return Number(result?.affectedRows || 0);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
module.exports = AdminRepository;
|
|
480
|
+
module.exports.ADMIN_VIEW_COLUMNS = ADMIN_VIEW_COLUMNS;
|
|
481
|
+
module.exports.WRITABLE_COLUMNS = WRITABLE_COLUMNS;
|
|
482
|
+
module.exports.escapeLike = escapeLike;
|
|
483
|
+
module.exports.toApiRow = toApiRow;
|
|
484
|
+
module.exports.buildFilter = buildFilter;
|
|
485
|
+
module.exports.chooseBucket = analysis.chooseBucket;
|
|
486
|
+
module.exports.BREAKDOWN_COLUMNS = analysis.BREAKDOWN_COLUMNS;
|
|
487
|
+
module.exports.FILTER_COLUMNS = analysis.FILTER_COLUMNS;
|
|
488
|
+
module.exports.BUCKET_EXPRESSION = analysis.BUCKET_EXPRESSION;
|