@harshankur/viewcounter 3.0.0 → 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.
- package/.env.example +20 -0
- package/README.md +189 -14
- package/admin/apple-touch-icon.png +0 -0
- package/admin/assets/world-map.json +1 -0
- package/admin/css/admin.css +1875 -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 +89 -0
- package/admin/js/api.js +100 -0
- package/admin/js/charts.js +502 -0
- package/admin/js/clamp.js +41 -0
- package/admin/js/constants.js +150 -0
- package/admin/js/dom.js +83 -0
- package/admin/js/format.js +79 -0
- package/admin/js/i18n.js +80 -0
- package/admin/js/insights.js +192 -0
- package/admin/js/listbox.js +144 -0
- package/admin/js/logs.js +167 -0
- package/admin/js/main.js +235 -0
- package/admin/js/modal.js +171 -0
- package/admin/js/table.js +134 -0
- package/admin/js/theme.js +72 -0
- package/admin/js/toast.js +47 -0
- package/admin/js/viewDialogs.js +208 -0
- package/admin/js/views.js +685 -0
- package/admin/locales/en.json +394 -0
- package/admin/site.webmanifest +20 -0
- package/config/index.js +83 -0
- package/constants.js +215 -2
- package/db/AdminRepository.js +562 -0
- package/db/DatabaseManager.js +94 -20
- package/db/LogRepository.js +217 -0
- package/db/adminSchema.js +244 -0
- package/db/retention.js +97 -0
- package/index.js +39 -6
- package/middleware/adminAuth.js +204 -0
- package/middleware/adminValidation.js +253 -0
- package/package.json +17 -10
- package/routes/admin.js +438 -0
- package/routes/analytics.js +11 -2
- package/utils/cookieUtils.js +47 -0
- 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;
|