@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
@@ -10,12 +10,28 @@ const {
10
10
  SERVER,
11
11
  TOP_N_RESULTS,
12
12
  TREND_PERIOD,
13
+ VIEW_LOG_SOURCE,
13
14
  } = require('../constants');
15
+
16
+ /**
17
+ * Every analytics read and the duplicate check see only live rows. A view an
18
+ * admin has moved to the trash no longer counts anywhere, and one that is
19
+ * restored counts again, without any stored aggregate needing to be rebuilt.
20
+ */
21
+ const LIVE_ROW = 'deleted_at IS NULL';
14
22
  const PrivacyUtils = require('../utils/privacyUtils');
15
23
  const logger = require('../utils/logger');
16
24
  const { getError, logWarning, ErrorType, WarningType } = require('../utils/errorUtils');
17
25
  const { truncate } = require('../utils/stringUtils');
18
26
  const { isValidAppId } = require('../utils/appIdUtils');
27
+ const {
28
+ NEW_TABLE_ADMIN_COLUMNS,
29
+ NEW_TABLE_ADMIN_INDEXES,
30
+ ensureLogTables,
31
+ migrateAppTable,
32
+ } = require('./adminSchema');
33
+ const LogRepository = require('./LogRepository');
34
+ const AdminRepository = require('./AdminRepository');
19
35
 
20
36
  /**
21
37
  * Columns returned for a session lookup.
@@ -99,6 +115,7 @@ function appTableDDL(appId) {
99
115
  \`event_type\` VARCHAR(${FIELD_MAX_LENGTH.EVENT_TYPE}) DEFAULT '${EVENT_TYPE.PAGEVIEW}',
100
116
  \`event_data\` JSON DEFAULT NULL,
101
117
  \`is_unique\` TINYINT(1) DEFAULT 1,
118
+ ${NEW_TABLE_ADMIN_COLUMNS.join(',\n ')},
102
119
  INDEX \`idx_timestamp\` (\`timestamp\`),
103
120
  INDEX \`idx_visitor_timestamp\` (\`visitor_hash\`, \`timestamp\`),
104
121
  INDEX \`idx_masked_ip\` (\`masked_ip\`),
@@ -112,7 +129,8 @@ function appTableDDL(appId) {
112
129
  INDEX \`idx_device_type\` (\`device_type\`),
113
130
  INDEX \`idx_session_id\` (\`session_id\`),
114
131
  INDEX \`idx_event_type\` (\`event_type\`),
115
- INDEX \`idx_is_unique\` (\`is_unique\`)
132
+ INDEX \`idx_is_unique\` (\`is_unique\`),
133
+ ${NEW_TABLE_ADMIN_INDEXES.join(',\n ')}
116
134
  ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
117
135
  `;
118
136
  }
@@ -161,6 +179,29 @@ class DatabaseManager {
161
179
  this.config = config;
162
180
  this.pool = null;
163
181
  this.mode = config.mode || 'connect';
182
+ this.logs = new LogRepository(this);
183
+ this.admin = new AdminRepository(this);
184
+ }
185
+
186
+ /**
187
+ * Bring the service's own tables and every app table up to the current
188
+ * schema. Idempotent, and run on every start, so an upgrade needs no manual
189
+ * migration step. Runs in `connect` mode too: the admin columns and log
190
+ * tables are the service's bookkeeping, like the app registry.
191
+ *
192
+ * @param {string[]} appIds
193
+ * @throws {Error} ErrorType.MIGRATION_FAILED
194
+ */
195
+ async migrate(appIds = []) {
196
+ this.assertReady();
197
+ await ensureLogTables(this.pool);
198
+
199
+ for (const appId of appIds) {
200
+ const result = await migrateAppTable(this.pool, appId);
201
+ if (result.backfilled > 0) {
202
+ logger.info(`Assigned public IDs to ${result.backfilled} existing row(s) in '${appId}'`);
203
+ }
204
+ }
164
205
  }
165
206
 
166
207
  /** @throws {Error} when a query is attempted before initialize() */
@@ -171,7 +212,14 @@ class DatabaseManager {
171
212
  }
172
213
 
173
214
  /**
174
- * Initialize database connection and optionally create schema
215
+ * Connect, create the schema in create mode, and bring the given apps'
216
+ * tables up to the current schema in either mode.
217
+ *
218
+ * The migration belongs here, not only at server startup: every query this
219
+ * manager runs assumes the current schema (public_id, deleted_at, the log
220
+ * tables), so an application that mounts the routers and calls only
221
+ * initialize() must still get tables those queries can run against.
222
+ * Idempotent: a table already in shape is read and left alone.
175
223
  */
176
224
  async initialize(allowedAppIds = []) {
177
225
  try {
@@ -206,8 +254,6 @@ class DatabaseManager {
206
254
 
207
255
  await this.pool.query('SELECT 1');
208
256
  logger.info(`Database connected (mode: ${this.mode})`);
209
-
210
- return true;
211
257
  } catch (cause) {
212
258
  throw getError(ErrorType.DATABASE_CONNECTION_FAILED, {
213
259
  host: this.config.host,
@@ -216,6 +262,11 @@ class DatabaseManager {
216
262
  cause: cause.message,
217
263
  });
218
264
  }
265
+
266
+ // Outside the try: a failed migration is reported as MIGRATION_FAILED
267
+ // naming the table, not as a connection failure.
268
+ await this.migrate(allowedAppIds);
269
+ return true;
219
270
  }
220
271
 
221
272
  /**
@@ -320,8 +371,10 @@ class DatabaseManager {
320
371
  );
321
372
 
322
373
  // The table is (re)created regardless, so an app registered before its
323
- // table existed still converges to a working state.
374
+ // table existed still converges to a working state. A table that
375
+ // already existed in an older shape is migrated in place.
324
376
  await this.pool.query(appTableDDL(appId));
377
+ await migrateAppTable(this.pool, appId);
325
378
 
326
379
  if (existing.length > 0) {
327
380
  logWarning(WarningType.APP_ALREADY_REGISTERED, { appId });
@@ -389,6 +442,7 @@ class DatabaseManager {
389
442
  uniqueWindowHours = SERVER.DEFAULT_UNIQUE_VISITOR_WINDOW_HOURS,
390
443
  userAgent = '',
391
444
  visitorSecret,
445
+ source = VIEW_LOG_SOURCE.REGISTER_VIEW,
392
446
  } = data;
393
447
 
394
448
  // Privacy boundary. Neither the raw IP nor the raw User-Agent is bound
@@ -407,6 +461,7 @@ class DatabaseManager {
407
461
  const [existing] = await this.pool.query(
408
462
  `SELECT id FROM \`${appId}\`
409
463
  WHERE visitor_hash = ? AND event_type = ? AND timestamp > DATE_SUB(NOW(), INTERVAL ? HOUR)
464
+ AND ${LIVE_ROW}
410
465
  LIMIT 1`,
411
466
  [hashedVisitor, EVENT_TYPE.PAGEVIEW, uniqueWindowHours]
412
467
  );
@@ -416,15 +471,19 @@ class DatabaseManager {
416
471
  }
417
472
  }
418
473
 
474
+ const publicId = crypto.randomUUID();
475
+ const storedEventType = truncate(eventType, FIELD_MAX_LENGTH.EVENT_TYPE);
476
+
419
477
  const [result] = await this.pool.query(
420
478
  `INSERT INTO \`${appId}\` (
421
- masked_ip, visitor_hash, country, timestamp, devicesize,
479
+ public_id, masked_ip, visitor_hash, country, timestamp, devicesize,
422
480
  page_path, page_title,
423
481
  referrer, referrer_domain, source_type,
424
482
  browser, browser_version, os, os_version, device_type,
425
483
  session_id, event_type, event_data, is_unique
426
- ) VALUES (?, ?, ?, NOW(), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
484
+ ) VALUES (?, ?, ?, ?, NOW(), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
427
485
  [
486
+ publicId,
428
487
  truncate(maskedIp, FIELD_MAX_LENGTH.MASKED_IP),
429
488
  hashedVisitor,
430
489
  truncate(country, FIELD_MAX_LENGTH.COUNTRY),
@@ -440,15 +499,26 @@ class DatabaseManager {
440
499
  truncate(osVersion, FIELD_MAX_LENGTH.OS_VERSION),
441
500
  truncate(deviceType, FIELD_MAX_LENGTH.DEVICE_TYPE),
442
501
  truncate(sessionId, FIELD_MAX_LENGTH.SESSION_ID),
443
- truncate(eventType, FIELD_MAX_LENGTH.EVENT_TYPE),
502
+ storedEventType,
444
503
  eventData ? JSON.stringify(eventData) : null,
445
504
  isUnique,
446
505
  ]
447
506
  );
448
507
 
508
+ // The view register log. Written after the row, and never able to fail
509
+ // the request: the view is already stored.
510
+ await this.logs.writeViewLog({
511
+ appId,
512
+ source,
513
+ viewId: publicId,
514
+ eventType: storedEventType,
515
+ isUnique: isUnique === 1,
516
+ });
517
+
449
518
  return {
450
519
  duplicate: isUnique === 0,
451
520
  insertId: result.insertId,
521
+ publicId,
452
522
  isUnique: isUnique === 1,
453
523
  };
454
524
  }
@@ -477,14 +547,15 @@ class DatabaseManager {
477
547
  COUNT(*) as total_views,
478
548
  SUM(CASE WHEN is_unique = 1 THEN 1 ELSE 0 END) as unique_views,
479
549
  COUNT(DISTINCT visitor_hash) as unique_visitors
480
- FROM \`${appId}\``
550
+ FROM \`${appId}\`
551
+ WHERE ${LIVE_ROW}`
481
552
  );
482
553
 
483
554
  const stats = totalStats[0];
484
555
 
485
556
  const [byCountry] = await this.pool.query(
486
557
  `SELECT country, COUNT(*) as count FROM \`${appId}\`
487
- WHERE country IS NOT NULL
558
+ WHERE country IS NOT NULL AND ${LIVE_ROW}
488
559
  GROUP BY country
489
560
  ORDER BY count DESC
490
561
  LIMIT ?`,
@@ -493,13 +564,14 @@ class DatabaseManager {
493
564
 
494
565
  const [byDevice] = await this.pool.query(
495
566
  `SELECT devicesize, COUNT(*) as count FROM \`${appId}\`
567
+ WHERE ${LIVE_ROW}
496
568
  GROUP BY devicesize
497
569
  ORDER BY count DESC`
498
570
  );
499
571
 
500
572
  const [recent] = await this.pool.query(
501
573
  `SELECT COUNT(*) as count FROM \`${appId}\`
502
- WHERE timestamp > DATE_SUB(NOW(), INTERVAL ? HOUR)`,
574
+ WHERE timestamp > DATE_SUB(NOW(), INTERVAL ? HOUR) AND ${LIVE_ROW}`,
503
575
  [SERVER.DEFAULT_UNIQUE_VISITOR_WINDOW_HOURS]
504
576
  );
505
577
 
@@ -522,13 +594,14 @@ class DatabaseManager {
522
594
  const [views] = await this.pool.query(
523
595
  `SELECT masked_ip, country, timestamp, devicesize
524
596
  FROM \`${appId}\`
597
+ WHERE ${LIVE_ROW}
525
598
  ORDER BY timestamp DESC
526
599
  LIMIT ? OFFSET ?`,
527
600
  [limit, offset]
528
601
  );
529
602
 
530
603
  const [total] = await this.pool.query(
531
- `SELECT COUNT(*) as count FROM \`${appId}\``
604
+ `SELECT COUNT(*) as count FROM \`${appId}\` WHERE ${LIVE_ROW}`
532
605
  );
533
606
 
534
607
  return {
@@ -560,7 +633,7 @@ class DatabaseManager {
560
633
  const [trends] = await this.pool.query(
561
634
  `SELECT ${groupBy} as period, COUNT(*) as count
562
635
  FROM \`${appId}\`
563
- WHERE timestamp > DATE_SUB(NOW(), INTERVAL ? DAY)
636
+ WHERE timestamp > DATE_SUB(NOW(), INTERVAL ? DAY) AND ${LIVE_ROW}
564
637
  GROUP BY period
565
638
  ORDER BY period ASC`,
566
639
  [days]
@@ -578,7 +651,7 @@ class DatabaseManager {
578
651
  const [bySource] = await this.pool.query(
579
652
  `SELECT source_type, COUNT(*) as count
580
653
  FROM \`${appId}\`
581
- WHERE source_type IS NOT NULL
654
+ WHERE source_type IS NOT NULL AND ${LIVE_ROW}
582
655
  GROUP BY source_type
583
656
  ORDER BY count DESC`
584
657
  );
@@ -586,7 +659,7 @@ class DatabaseManager {
586
659
  const [byDomain] = await this.pool.query(
587
660
  `SELECT referrer_domain, COUNT(*) as count
588
661
  FROM \`${appId}\`
589
- WHERE referrer_domain IS NOT NULL
662
+ WHERE referrer_domain IS NOT NULL AND ${LIVE_ROW}
590
663
  GROUP BY referrer_domain
591
664
  ORDER BY count DESC
592
665
  LIMIT ?`,
@@ -605,7 +678,7 @@ class DatabaseManager {
605
678
  const [byBrowser] = await this.pool.query(
606
679
  `SELECT browser, COUNT(*) as count
607
680
  FROM \`${appId}\`
608
- WHERE browser IS NOT NULL
681
+ WHERE browser IS NOT NULL AND ${LIVE_ROW}
609
682
  GROUP BY browser
610
683
  ORDER BY count DESC
611
684
  LIMIT ?`,
@@ -615,7 +688,7 @@ class DatabaseManager {
615
688
  const [byOS] = await this.pool.query(
616
689
  `SELECT os, COUNT(*) as count
617
690
  FROM \`${appId}\`
618
- WHERE os IS NOT NULL
691
+ WHERE os IS NOT NULL AND ${LIVE_ROW}
619
692
  GROUP BY os
620
693
  ORDER BY count DESC
621
694
  LIMIT ?`,
@@ -625,7 +698,7 @@ class DatabaseManager {
625
698
  const [byDeviceType] = await this.pool.query(
626
699
  `SELECT device_type, COUNT(*) as count
627
700
  FROM \`${appId}\`
628
- WHERE device_type IS NOT NULL
701
+ WHERE device_type IS NOT NULL AND ${LIVE_ROW}
629
702
  GROUP BY device_type
630
703
  ORDER BY count DESC`
631
704
  );
@@ -642,7 +715,7 @@ class DatabaseManager {
642
715
  const [pages] = await this.pool.query(
643
716
  `SELECT page_path, page_title, COUNT(*) as views
644
717
  FROM \`${appId}\`
645
- WHERE page_path IS NOT NULL
718
+ WHERE page_path IS NOT NULL AND ${LIVE_ROW}
646
719
  GROUP BY page_path, page_title
647
720
  ORDER BY views DESC
648
721
  LIMIT ?`,
@@ -662,7 +735,7 @@ class DatabaseManager {
662
735
  const [events] = await this.pool.query(
663
736
  `SELECT ${SESSION_COLUMNS}
664
737
  FROM \`${appId}\`
665
- WHERE session_id = ?
738
+ WHERE session_id = ? AND ${LIVE_ROW}
666
739
  ORDER BY timestamp ASC`,
667
740
  [sessionId]
668
741
  );
@@ -702,3 +775,4 @@ module.exports = DatabaseManager;
702
775
  module.exports.SESSION_COLUMNS = SESSION_COLUMNS;
703
776
  module.exports.appTableDDL = appTableDDL;
704
777
  module.exports.APP_REGISTRY_DDL = APP_REGISTRY_DDL;
778
+ module.exports.LIVE_ROW = LIVE_ROW;
@@ -0,0 +1,217 @@
1
+ /**
2
+ * The two service logs: the admin operation log and the view register log.
3
+ *
4
+ * Both are append-only from the application's point of view. Nothing here
5
+ * updates or deletes an entry.
6
+ */
7
+
8
+ const crypto = require('crypto');
9
+
10
+ const { ADMIN, ADMIN_LOG_TABLE, VIEW_LOG_TABLE } = require('../constants');
11
+ const { logWarning, WarningType } = require('../utils/errorUtils');
12
+
13
+ /**
14
+ * Columns returned by an admin-log listing. `session_id` identifies which
15
+ * admin session acted without revealing its token.
16
+ */
17
+ const ADMIN_LOG_COLUMNS = [
18
+ 'id', 'created_at', 'action', 'session_id', 'masked_ip',
19
+ 'app_id', 'target_count', 'target_ids', 'fields',
20
+ ].join(', ');
21
+
22
+ const VIEW_LOG_COLUMNS = [
23
+ 'id', 'created_at', 'app_id', 'source', 'view_id', 'event_type', 'is_unique',
24
+ ].join(', ');
25
+
26
+ /** mysql2 returns JSON columns parsed; tolerate a string from other drivers. */
27
+ function parseJson(value) {
28
+ if (value === null || value === undefined) return null;
29
+ if (typeof value !== 'string') return value;
30
+ try {
31
+ return JSON.parse(value);
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ class LogRepository {
38
+ /**
39
+ * @param {{ pool: object, assertReady: () => void }} db a DatabaseManager
40
+ */
41
+ constructor(db) {
42
+ this.db = db;
43
+ }
44
+
45
+ get pool() {
46
+ this.db.assertReady();
47
+ return this.db.pool;
48
+ }
49
+
50
+ /**
51
+ * Record an admin operation. Never throws: an operation that succeeded must
52
+ * not be reported as failed because its log line could not be written
53
+ * (LOGGING.md §4). The failure is surfaced as a warning instead.
54
+ *
55
+ * `targetCount` defaults to the number of IDs; the automatic trash purge
56
+ * passes a count with no IDs, since it erases by age rather than by ID.
57
+ *
58
+ * @param {{ action: string, sessionId?: string|null, maskedIp?: string|null,
59
+ * appId?: string|null, targetIds?: string[], targetCount?: number,
60
+ * fields?: string[] }} entry
61
+ * @returns {Promise<boolean>} whether the entry was written
62
+ */
63
+ async writeAdminLog({
64
+ action,
65
+ sessionId = null,
66
+ maskedIp = null,
67
+ appId = null,
68
+ targetIds = [],
69
+ targetCount = targetIds.length,
70
+ fields = [],
71
+ }) {
72
+ try {
73
+ await this.pool.query(
74
+ `INSERT INTO \`${ADMIN_LOG_TABLE}\`
75
+ (id, created_at, action, session_id, masked_ip, app_id, target_count, target_ids, fields)
76
+ VALUES (?, NOW(3), ?, ?, ?, ?, ?, ?, ?)`,
77
+ [
78
+ crypto.randomUUID(),
79
+ action,
80
+ sessionId,
81
+ maskedIp,
82
+ appId,
83
+ targetCount,
84
+ targetIds.length ? JSON.stringify(targetIds) : null,
85
+ fields.length ? JSON.stringify(fields) : null,
86
+ ]
87
+ );
88
+ return true;
89
+ } catch (cause) {
90
+ logWarning(WarningType.ADMIN_LOG_WRITE_FAILED, { action, cause: cause.message });
91
+ return false;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Record one accepted view or event. Never throws, for the same reason as
97
+ * writeAdminLog: the view itself was stored, so the visitor's request
98
+ * succeeded.
99
+ *
100
+ * @param {{ appId: string, source: string, viewId: string, eventType: string,
101
+ * isUnique: boolean }} entry
102
+ * @returns {Promise<boolean>}
103
+ */
104
+ async writeViewLog({ appId, source, viewId, eventType, isUnique }) {
105
+ try {
106
+ await this.pool.query(
107
+ `INSERT INTO \`${VIEW_LOG_TABLE}\`
108
+ (id, created_at, app_id, source, view_id, event_type, is_unique)
109
+ VALUES (?, NOW(3), ?, ?, ?, ?, ?)`,
110
+ [crypto.randomUUID(), appId, source, viewId, eventType, isUnique ? 1 : 0]
111
+ );
112
+ return true;
113
+ } catch (cause) {
114
+ logWarning(WarningType.VIEW_LOG_WRITE_FAILED, { appId, cause: cause.message });
115
+ return false;
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Remove view-log entries older than `days`, a batch at a time so no single
121
+ * statement holds its locks for long. Ordered by the indexed `created_at`,
122
+ * so each batch reads only what it deletes.
123
+ *
124
+ * @param {number} days
125
+ * @returns {Promise<number>} entries removed
126
+ */
127
+ async pruneViewLog(days) {
128
+ let total = 0;
129
+ for (;;) {
130
+ const [result] = await this.pool.query(
131
+ `DELETE FROM \`${VIEW_LOG_TABLE}\` WHERE created_at < DATE_SUB(NOW(3), INTERVAL ? DAY)
132
+ ORDER BY created_at LIMIT ?`,
133
+ [days, ADMIN.VIEW_LOG_PRUNE_BATCH_SIZE]
134
+ );
135
+ const removed = Number(result?.affectedRows || 0);
136
+ total += removed;
137
+ if (removed < ADMIN.VIEW_LOG_PRUNE_BATCH_SIZE) return total;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * @param {{ page: number, pageSize: number, action?: string, appId?: string }} query
143
+ * @returns {Promise<{ entries: object[], total: number }>}
144
+ */
145
+ async listAdminLog({ page, pageSize, action, appId }) {
146
+ const where = [];
147
+ const params = [];
148
+ if (action) { where.push('action = ?'); params.push(action); }
149
+ if (appId) { where.push('app_id = ?'); params.push(appId); }
150
+ const clause = where.length ? `WHERE ${where.join(' AND ')}` : '';
151
+
152
+ const [rows] = await this.pool.query(
153
+ `SELECT ${ADMIN_LOG_COLUMNS} FROM \`${ADMIN_LOG_TABLE}\` ${clause}
154
+ ORDER BY created_at DESC, id ASC LIMIT ? OFFSET ?`,
155
+ [...params, pageSize, (page - 1) * pageSize]
156
+ );
157
+ const [count] = await this.pool.query(
158
+ `SELECT COUNT(*) AS count FROM \`${ADMIN_LOG_TABLE}\` ${clause}`,
159
+ params
160
+ );
161
+
162
+ return {
163
+ entries: rows.map((row) => ({
164
+ id: row.id,
165
+ createdAt: row.created_at,
166
+ action: row.action,
167
+ sessionId: row.session_id,
168
+ maskedIp: row.masked_ip,
169
+ appId: row.app_id,
170
+ targetCount: row.target_count,
171
+ targetIds: parseJson(row.target_ids) || [],
172
+ fields: parseJson(row.fields) || [],
173
+ })),
174
+ total: Number(count[0]?.count || 0),
175
+ };
176
+ }
177
+
178
+ /**
179
+ * @param {{ page: number, pageSize: number, appId?: string, source?: string }} query
180
+ * @returns {Promise<{ entries: object[], total: number }>}
181
+ */
182
+ async listViewLog({ page, pageSize, appId, source }) {
183
+ const where = [];
184
+ const params = [];
185
+ if (appId) { where.push('app_id = ?'); params.push(appId); }
186
+ if (source) { where.push('source = ?'); params.push(source); }
187
+ const clause = where.length ? `WHERE ${where.join(' AND ')}` : '';
188
+
189
+ const [rows] = await this.pool.query(
190
+ `SELECT ${VIEW_LOG_COLUMNS} FROM \`${VIEW_LOG_TABLE}\` ${clause}
191
+ ORDER BY created_at DESC, id ASC LIMIT ? OFFSET ?`,
192
+ [...params, pageSize, (page - 1) * pageSize]
193
+ );
194
+ const [count] = await this.pool.query(
195
+ `SELECT COUNT(*) AS count FROM \`${VIEW_LOG_TABLE}\` ${clause}`,
196
+ params
197
+ );
198
+
199
+ return {
200
+ entries: rows.map((row) => ({
201
+ id: row.id,
202
+ createdAt: row.created_at,
203
+ appId: row.app_id,
204
+ source: row.source,
205
+ viewId: row.view_id,
206
+ eventType: row.event_type,
207
+ isUnique: row.is_unique === 1 || row.is_unique === true,
208
+ })),
209
+ total: Number(count[0]?.count || 0),
210
+ };
211
+ }
212
+ }
213
+
214
+ module.exports = LogRepository;
215
+ module.exports.ADMIN_LOG_COLUMNS = ADMIN_LOG_COLUMNS;
216
+ module.exports.VIEW_LOG_COLUMNS = VIEW_LOG_COLUMNS;
217
+ module.exports.parseJson = parseJson;