@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.
Files changed (64) hide show
  1. package/.env.example +50 -6
  2. package/README.md +444 -104
  3. package/admin/apple-touch-icon.png +0 -0
  4. package/admin/assets/world-map.json +1 -0
  5. package/admin/css/admin.css +2568 -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 +95 -0
  11. package/admin/js/api.js +146 -0
  12. package/admin/js/appTabs.js +100 -0
  13. package/admin/js/charts.js +842 -0
  14. package/admin/js/clamp.js +41 -0
  15. package/admin/js/constants.js +239 -0
  16. package/admin/js/dataTable.js +478 -0
  17. package/admin/js/dom.js +83 -0
  18. package/admin/js/format.js +130 -0
  19. package/admin/js/i18n.js +80 -0
  20. package/admin/js/icons.js +168 -0
  21. package/admin/js/listbox.js +145 -0
  22. package/admin/js/logs.js +318 -0
  23. package/admin/js/main.js +399 -0
  24. package/admin/js/modal.js +171 -0
  25. package/admin/js/overview.js +905 -0
  26. package/admin/js/passwordPrompt.js +75 -0
  27. package/admin/js/table.js +94 -0
  28. package/admin/js/theme.js +72 -0
  29. package/admin/js/toast.js +47 -0
  30. package/admin/js/viewDialogs.js +224 -0
  31. package/admin/js/views.js +751 -0
  32. package/admin/locales/en.json +683 -0
  33. package/admin/site.webmanifest +20 -0
  34. package/config/index.js +122 -4
  35. package/constants.js +334 -3
  36. package/db/AdminRepository.js +488 -0
  37. package/db/DatabaseManager.js +148 -26
  38. package/db/LogRepository.js +354 -0
  39. package/db/adminSchema.js +329 -0
  40. package/db/adminSessionStore.js +104 -0
  41. package/db/analysis.js +479 -0
  42. package/db/rejectionCounter.js +117 -0
  43. package/db/retention.js +97 -0
  44. package/index.js +91 -24
  45. package/middleware/adminAuth.js +244 -0
  46. package/middleware/adminValidation.js +319 -0
  47. package/middleware/auth.js +2 -2
  48. package/middleware/security.js +26 -2
  49. package/middleware/validation.js +50 -2
  50. package/package.json +20 -10
  51. package/routes/admin.js +546 -0
  52. package/routes/analytics.js +207 -19
  53. package/tracker/tracker.js +191 -0
  54. package/utils/appIdUtils.js +1 -1
  55. package/utils/cookieUtils.js +47 -0
  56. package/utils/durationUtils.js +33 -0
  57. package/utils/errorUtils.js +39 -1
  58. package/utils/geoCity.js +87 -0
  59. package/utils/ipUtils.js +1 -1
  60. package/utils/privacyUtils.js +2 -2
  61. package/utils/referrerParser.js +23 -5
  62. package/utils/secretStore.js +1 -1
  63. package/utils/userAgentParser.js +52 -3
  64. 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;