tacilytics 2.1.0 → 2.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/server.js CHANGED
@@ -34,6 +34,8 @@ const INSIDE_NODE_MODULES = __dirname.split(path.sep).includes('node_modules');
34
34
  const DB_PATH = process.env.DB_PATH || path.join(INSIDE_NODE_MODULES ? process.cwd() : __dirname, 'data', 'analytics.db');
35
35
  fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
36
36
  if (!process.env.WEB_PASSWORD) console.warn('⚠ WEB_PASSWORD is not set — the dashboard uses the default passphrase "tacilytics". Set your own in .env.');
37
+ // Webhook for the daily digest and the default alerts (Discord/Slack-compatible).
38
+ const DIGEST_WEBHOOK_URL = /^(off|none|false|0)$/i.test(process.env.DIGEST_WEBHOOK_URL || '') ? '' : (process.env.DIGEST_WEBHOOK_URL || ''); // 'off' disables it (an empty value would be refilled from .env)
37
39
  const RETENTION_DAYS = parseInt(process.env.DATA_RETENTION_DAYS, 10) || 0; // 0 = keep forever; >0 purges older raw events (data minimisation)
38
40
 
39
41
  // ── DB ──────────────────────────────────────────────────────────────────────
@@ -101,11 +103,48 @@ db.exec(`
101
103
  steps TEXT NOT NULL, -- JSON array of {type:'page'|'goal', value}
102
104
  created INTEGER NOT NULL
103
105
  );
106
+ -- imported history (Plausible / Google Analytics): daily aggregates, never visitor rows
107
+ CREATE TABLE IF NOT EXISTS imported (
108
+ import_id INTEGER NOT NULL,
109
+ site_id INTEGER NOT NULL,
110
+ day TEXT NOT NULL,
111
+ dim TEXT NOT NULL, -- '' totals · page · source · country · browser · os · device · event
112
+ name TEXT NOT NULL,
113
+ visitors INTEGER NOT NULL DEFAULT 0,
114
+ pageviews INTEGER NOT NULL DEFAULT 0,
115
+ visits INTEGER NOT NULL DEFAULT 0,
116
+ bounces INTEGER NOT NULL DEFAULT 0,
117
+ duration INTEGER NOT NULL DEFAULT 0
118
+ );
119
+ CREATE INDEX IF NOT EXISTS idx_imported_site_dim_day ON imported(site_id, dim, day);
120
+ CREATE TABLE IF NOT EXISTS imports (
121
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
122
+ site_id INTEGER NOT NULL,
123
+ source TEXT NOT NULL, -- 'plausible' | 'ga'
124
+ first_day TEXT, last_day TEXT,
125
+ rows INTEGER NOT NULL DEFAULT 0,
126
+ pageviews INTEGER NOT NULL DEFAULT 0,
127
+ files TEXT,
128
+ created_by TEXT,
129
+ created INTEGER NOT NULL
130
+ );
131
+ -- chart annotations ("Newsletter sent", "Release 2.0") — one calendar day each
132
+ CREATE TABLE IF NOT EXISTS annotations (
133
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
134
+ site_id INTEGER NOT NULL,
135
+ day TEXT NOT NULL, -- YYYY-MM-DD (UTC, like events.day)
136
+ text TEXT NOT NULL,
137
+ created_by TEXT,
138
+ created INTEGER NOT NULL
139
+ );
140
+ CREATE INDEX IF NOT EXISTS idx_annotations_site_day ON annotations(site_id, day);
104
141
  `);
105
142
 
106
143
  const norm = (d) => String(d || '').toLowerCase().replace(/^www\./, '').replace(/\/.*$/, '').trim();
144
+ // per-site time zone (Fabian): only query boundaries move, stored days stay UTC (Tyto). Old installs → 'UTC'.
145
+ if (!db.prepare('PRAGMA table_info(sites)').all().some((c) => c.name === 'timezone')) db.exec("ALTER TABLE sites ADD COLUMN timezone TEXT NOT NULL DEFAULT 'UTC'");
107
146
  const getSiteByDomain = db.prepare('SELECT * FROM sites WHERE domain = ?');
108
- const insSite = db.prepare('INSERT OR IGNORE INTO sites (domain, name, created) VALUES (?, ?, ?)');
147
+ const insSite = db.prepare("INSERT OR IGNORE INTO sites (domain, name, created, timezone) VALUES (?, ?, ?, COALESCE(?, 'UTC'))");
109
148
  const allSites = db.prepare('SELECT * FROM sites ORDER BY domain');
110
149
  const insEvent = db.prepare(`INSERT INTO events (site_id, ts, day, path, source, referrer, country, tz, browser, os, device, lang, name, utm_medium, utm_campaign, dur, props, vh, platform, app_version)
111
150
  VALUES (@site_id, @ts, @day, @path, @source, @referrer, @country, @tz, @browser, @os, @device, @lang, @name, @utm_medium, @utm_campaign, @dur, @props, @vh, @platform, @app_version)`);
@@ -225,9 +264,26 @@ function clientIp(req) {
225
264
 
226
265
  // ── app ─────────────────────────────────────────────────────────────────────
227
266
  const app = express();
228
- app.set('trust proxy', Number.isFinite(+process.env.TRUST_PROXY) && process.env.TRUST_PROXY !== '' && process.env.TRUST_PROXY != null ? +process.env.TRUST_PROXY : 1);
267
+ const TRUST_PROXY = Number.isFinite(+process.env.TRUST_PROXY) && process.env.TRUST_PROXY !== '' && process.env.TRUST_PROXY != null ? +process.env.TRUST_PROXY : 1;
268
+ app.set('trust proxy', TRUST_PROXY);
269
+ // A real HTTP proxy always appends X-Forwarded-For. A request without it means something else
270
+ // (socat, ssh -L, a TCP forwarder) is in front: then the last XFF entry is client-written and the
271
+ // limits can be bypassed. Normal visitors never send XFF themselves, so they trigger this; an
272
+ // attacker who forges XFF does not, but the warning is already in the log by then. Warn once.
273
+ let warnedNoXff = false;
274
+ if (TRUST_PROXY > 0) {
275
+ app.use((req, _res, next) => {
276
+ if (!warnedNoXff && !req.headers['x-forwarded-for']) {
277
+ warnedNoXff = true;
278
+ console.warn(`⚠️ TRUST_PROXY=${TRUST_PROXY}, but a request arrived without X-Forwarded-For (from ${req.socket.remoteAddress}, ${req.method} ${req.path}, ${new Date().toISOString()}). Harmless for local health checks/CLI or an ssh -L to the dashboard. Only if ALL traffic comes through a TCP forwarder (socat, ssh -L), set TRUST_PROXY=0.`);
279
+ }
280
+ next();
281
+ });
282
+ }
229
283
  // Accept both application/json (dashboard) and text/plain (cross-origin beacons).
230
- app.use(express.json({ limit: '32kb', type: ['application/json', 'text/plain'] }));
284
+ // /api/imports takes raw files (handled there); everything else is JSON (text/plain = cross-origin beacons)
285
+ const jsonBody = express.json({ limit: '32kb', type: ['application/json', 'text/plain'] });
286
+ app.use((req, res, next) => (req.path === '/api/imports' ? next() : jsonBody(req, res, next)));
231
287
  app.disable('x-powered-by');
232
288
 
233
289
  // Lightweight in-memory rate limiter (fixed window per key). No external dep.
@@ -330,7 +386,7 @@ app.get('/api/collect', collect);
330
386
 
331
387
  // ── auth: accounts, team, optional 2FA + passkeys (auth.js) ───────────────────
332
388
  const { setupAuth } = require('./auth');
333
- const { auth, requireRole, siteAllowed } = setupAuth({
389
+ const { auth, requireRole, siteAllowed, audit } = setupAuth({
334
390
  app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp,
335
391
  allSiteIds: () => db.prepare('SELECT id FROM sites').all().map((r) => r.id),
336
392
  });
@@ -364,14 +420,37 @@ const sitesCreateHandler = (req, res) => {
364
420
  }
365
421
  // Allow ASCII domains AND Punycode (xn--...). Unicode input was converted above.
366
422
  if (!domain || !/^[a-z0-9.-]+\.[a-z0-9-]{2,}$/.test(domain)) return res.status(400).json({ error: 'Enter a valid domain, e.g. example.com' });
367
- insSite.run(domain, (req.body && req.body.name) || displayName, Date.now());
423
+ const rawTz = req.body && req.body.timezone;
424
+ const timezone = rawTz == null || rawTz === '' ? null : TZ.validTz(String(rawTz));
425
+ if (rawTz != null && rawTz !== '' && !timezone) return res.status(400).json({ error: 'Unknown time zone' });
426
+ insSite.run(domain, (req.body && req.body.name) || displayName, Date.now(), timezone);
427
+ audit(res, 'site.add', domain);
368
428
  res.json(getSiteByDomain.get(domain));
369
429
  };
370
430
  app.get('/api/sites', auth, sitesListHandler);
371
431
  app.post('/api/sites', adminOnly, sitesCreateHandler);
432
+ // change a site's time zone (IANA names only — anything Intl rejects is a 400, never stored)
433
+ app.patch('/api/sites/:id', adminOnly, (req, res) => {
434
+ const id = Number(req.params.id);
435
+ if (!siteAllowed(req, id)) return res.status(403).json(NO_SITE);
436
+ const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(id);
437
+ if (!site) return res.status(404).json({ error: 'Unknown site' });
438
+ const timezone = TZ.validTz(String((req.body && req.body.timezone) || ''));
439
+ if (!timezone) return res.status(400).json({ error: 'Unknown time zone' });
440
+ db.prepare('UPDATE sites SET timezone = ? WHERE id = ?').run(timezone, id);
441
+ if (timezone !== site.timezone) audit(res, 'site.timezone', site.domain + ': ' + site.timezone + ' → ' + timezone);
442
+ res.json(db.prepare('SELECT * FROM sites WHERE id = ?').get(id));
443
+ });
372
444
  app.delete('/api/sites/:id', adminOnly, (req, res) => {
373
445
  const id = Number(req.params.id);
446
+ if (!siteAllowed(req, id)) return res.status(403).json(NO_SITE);
447
+ const gone = db.prepare('SELECT domain FROM sites WHERE id = ?').get(id);
448
+ audit(res, 'site.remove', gone ? gone.domain : '#' + id);
374
449
  db.prepare('DELETE FROM events WHERE site_id = ?').run(id);
450
+ db.prepare('DELETE FROM annotations WHERE site_id = ?').run(id);
451
+ db.prepare('DELETE FROM alerts WHERE site_id = ?').run(id);
452
+ db.prepare('DELETE FROM imported WHERE site_id = ?').run(id);
453
+ db.prepare('DELETE FROM imports WHERE site_id = ?').run(id);
375
454
  db.prepare('DELETE FROM sites WHERE id = ?').run(id);
376
455
  res.json({ ok: true });
377
456
  });
@@ -392,24 +471,30 @@ app.get('/api/v1/sites', auth, sitesListHandler);
392
471
  app.post('/api/v1/sites', adminOnly, sitesCreateHandler);
393
472
 
394
473
  // ── stats ────────────────────────────────────────────────────────────────────
474
+ const TZ = require('./tz');
395
475
  const PERIODS = { today: 1, '7d': 7, '30d': 30, '90d': 90, '12mo': 365 };
396
- function rangeFor(query) {
476
+ // Whole LOCAL days of the site's time zone: "7d" = today + the 6 full days before it, each day with
477
+ // its own boundaries (23/25 h on DST days). Starting at now - 7×24h would cut the oldest day off at the
478
+ // current clock time and undercount it.
479
+ function rangeFor(query, tz) {
480
+ tz = tz || 'UTC';
397
481
  const now = Date.now();
398
- // custom range: from/to are YYYY-MM-DD (inclusive)
399
- const from = /^\d{4}-\d{2}-\d{2}$/.test(query.from || '') ? Date.parse(query.from + 'T00:00:00Z') : null;
400
- if (from != null && !Number.isNaN(from)) {
401
- const toRaw = /^\d{4}-\d{2}-\d{2}$/.test(query.to || '') ? Date.parse(query.to + 'T00:00:00Z') : from;
402
- const end = Math.min(now, (Number.isNaN(toRaw) ? from : toRaw) + 864e5); // inclusive end day
403
- const days = Math.max(1, Math.round((end - from) / 864e5));
404
- return { start: from, end, days, period: 'custom', hourly: days <= 1 };
482
+ const today = TZ.localDay(tz, now);
483
+ // custom range: from/to are local YYYY-MM-DD (inclusive)
484
+ const isDay = (d) => /^\d{4}-\d{2}-\d{2}$/.test(d || '') && !Number.isNaN(Date.parse(d + 'T00:00:00Z'));
485
+ if (isDay(query.from)) {
486
+ let fromDay = query.from, toDay = isDay(query.to) ? query.to : query.from;
487
+ if (toDay < fromDay) toDay = fromDay;
488
+ const start = TZ.dayStart(tz, fromDay);
489
+ const end = Math.max(start + 1, Math.min(now, TZ.dayStart(tz, TZ.addDays(toDay, 1))));
490
+ const days = Math.max(1, Math.round((Date.parse(toDay + 'T00:00:00Z') - Date.parse(fromDay + 'T00:00:00Z')) / 864e5) + 1);
491
+ const lastDay = TZ.localDay(tz, end - 1) < toDay ? TZ.localDay(tz, end - 1) : toDay;
492
+ return { start, end, days: Math.min(days, Math.round((Date.parse(lastDay + 'T00:00:00Z') - Date.parse(fromDay + 'T00:00:00Z')) / 864e5) + 1), period: 'custom', hourly: days <= 1, tz, fromDay, toDay: lastDay };
405
493
  }
406
494
  const period = PERIODS[query.period] ? query.period : '7d';
407
495
  const days = PERIODS[period];
408
- // Whole UTC days: "7d" = today + the 6 full days before it. Starting at now - 7×24h would
409
- // cut the oldest day off at the current clock time and undercount it.
410
- const today0 = Date.parse(new Date(now).toISOString().slice(0, 10) + 'T00:00:00Z');
411
- const start = today0 - (days - 1) * 864e5;
412
- return { start, end: now, days, period, hourly: period === 'today' };
496
+ const fromDay = TZ.addDays(today, -(days - 1));
497
+ return { start: TZ.dayStart(tz, fromDay), end: now, days, period, hourly: period === 'today', tz, fromDay, toDay: today };
413
498
  }
414
499
 
415
500
  // Whitelisted segmentation filters — click any breakdown row in the dashboard to drill in.
@@ -435,12 +520,51 @@ function eventPlatformFilter(query) {
435
520
  return f;
436
521
  }
437
522
 
523
+ // name shown as "created by" (same wording as the activity log)
524
+ function whoDid(req) {
525
+ const p = req.principal;
526
+ if (!p) return null;
527
+ if (p.kind === 'user') return p.user.username;
528
+ if (p.kind === 'userkey') return p.user.username + ' (API key)';
529
+ if (p.kind === 'apikey') return 'API key';
530
+ if (p.kind === 'legacy') return 'passphrase';
531
+ return null;
532
+ }
533
+
534
+ // ── imported history helpers ──
535
+ const firstNativeTs = db.prepare('SELECT MIN(ts) t FROM events WHERE site_id = ?');
536
+ // first LOCAL day with own data in the site's time zone (import boundary — shown in the import dialog too)
537
+ function firstOwnDay(site) { const t = firstNativeTs.get(site.id).t; return t == null ? null : TZ.localDay(site.timezone || 'UTC', t); }
538
+ function importedFor(siteId, start, end, filtered, tz) {
539
+ const exists = !!db.prepare('SELECT 1 FROM imports WHERE site_id = ? LIMIT 1').get(siteId);
540
+ const off = { exists, on: false, filtered: exists && filtered, tot: { pageviews: 0, visitors: 0 }, days: [], top: () => [], until: null };
541
+ if (!exists || filtered) return off;
542
+ // imported days count only before the first LOCAL day with own data (events.day is the UTC day)
543
+ const firstTs = firstNativeTs.get(siteId).t;
544
+ const first = firstTs == null ? null : TZ.localDay(tz, firstTs);
545
+ // imported rows are calendar days of the source; they are matched against the site's local days
546
+ const from = TZ.localDay(tz, start);
547
+ const lastDay = TZ.localDay(tz, end - 1);
548
+ const to = first && first <= lastDay ? TZ.addDays(first, -1) : lastDay;
549
+ if (to < from) return { ...off, until: first };
550
+ const days = db.prepare("SELECT day, SUM(visitors) visitors, SUM(pageviews) pageviews FROM imported WHERE site_id = ? AND dim = '' AND day >= ? AND day <= ? GROUP BY day").all(siteId, from, to);
551
+ const tot = days.reduce((a, r) => ({ pageviews: a.pageviews + r.pageviews, visitors: a.visitors + r.visitors }), { pageviews: 0, visitors: 0 });
552
+ const top = (dim) => db.prepare('SELECT name, SUM(pageviews) pageviews, SUM(visitors) visitors FROM imported WHERE site_id = ? AND dim = ? AND day >= ? AND day <= ? GROUP BY name ORDER BY visitors DESC, pageviews DESC LIMIT 200').all(siteId, dim, from, to);
553
+ return { exists, on: days.length > 0, filtered: false, tot, days, top, until: first };
554
+ }
555
+ function mergeTop(native, imported, limit) {
556
+ const m = new Map();
557
+ for (const r of native) m.set(r.name, { name: r.name, pageviews: r.pageviews, visitors: r.visitors });
558
+ for (const r of imported) { const e = m.get(r.name) || { name: r.name, pageviews: 0, visitors: 0 }; e.pageviews += r.pageviews; e.visitors += r.visitors; m.set(r.name, e); }
559
+ return [...m.values()].sort((a, b) => b.visitors - a.visitors || b.pageviews - a.pageviews).slice(0, limit);
560
+ }
561
+
438
562
  function statsHandler(req, res) {
439
563
  const site = getSiteByDomain.get(norm(req.query.site));
440
564
  if (!site) return res.status(404).json({ error: 'Unknown site' });
441
565
  if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
442
566
  const sid = site.id;
443
- const { start, end, days, period, hourly } = rangeFor(req.query);
567
+ const { start, end, days, period, hourly, tz, fromDay, toDay } = rangeFor(req.query, site.timezone);
444
568
  const { clauses, params, goal } = buildFilters(req.query);
445
569
  const evf = eventPlatformFilter(req.query); // platform/version also narrow event panels (revenue, search, 404)
446
570
 
@@ -452,6 +576,10 @@ function statsHandler(req, res) {
452
576
  if (goal) { where += ' AND vh IN (SELECT vh FROM events WHERE site_id = ? AND ts >= ? AND ts < ? AND name = ?)'; args.push(sid, start, end, goal); }
453
577
 
454
578
  const totals = db.prepare(`SELECT COUNT(*) AS pageviews, COUNT(DISTINCT vh) AS visitors FROM events WHERE ${where}`).get(...args);
579
+ // imported history counts only for days before the site's first own event (no double counting)
580
+ // and only without segment filters (imported data is pre-aggregated per single dimension).
581
+ const imp = importedFor(sid, start, end, clauses.length > 0 || !!goal || !!evf, tz);
582
+ if (imp.on) { totals.pageviews += imp.tot.pageviews; totals.visitors += imp.tot.visitors; }
455
583
  const bounceRow = db.prepare(`SELECT COUNT(*) AS bounced FROM (SELECT vh, COUNT(*) c FROM events WHERE ${where} GROUP BY vh HAVING c = 1)`).get(...args);
456
584
  const bounce = totals.visitors ? Math.round((bounceRow.bounced / totals.visitors) * 100) : 0;
457
585
  const viewsPerVisitor = totals.visitors ? +(totals.pageviews / totals.visitors).toFixed(1) : 0;
@@ -463,30 +591,37 @@ function statsHandler(req, res) {
463
591
  const prevArgs = args.slice(); prevArgs[1] = start - len; prevArgs[2] = start;
464
592
  if (goal) { prevArgs[prevArgs.length - 3] = start - len; prevArgs[prevArgs.length - 2] = start; }
465
593
  const prev = db.prepare(`SELECT COUNT(*) pageviews, COUNT(DISTINCT vh) visitors FROM events WHERE ${where}`).get(...prevArgs);
594
+ if (imp.on) { const pi = importedFor(sid, start - len, start, false, tz); if (pi.on) { prev.pageviews += pi.tot.pageviews; prev.visitors += pi.tot.visitors; } }
466
595
  const pct = (cur, was) => (was ? Math.round(((cur - was) / was) * 100) : null);
467
596
 
468
597
  // time series (per hour for a single day, else per day)
469
598
  let series;
470
599
  if (hourly) {
471
- const rows = db.prepare(`SELECT CAST((ts/3600000) AS INT) AS bucket, COUNT(*) pv, COUNT(DISTINCT vh) v FROM events WHERE ${where} GROUP BY bucket`).all(...args);
600
+ // one bucket per real hour of the local day (23 or 25 on DST days), labelled with the local
601
+ // hour. Buckets start at local midnight, so +5:45 zones are not cut at UTC hours.
602
+ const dayEnd = TZ.dayStart(tz, TZ.addDays(fromDay, 1));
603
+ const rows = db.prepare(`SELECT CAST(((ts - ${Math.trunc(start)}) / 3600000) AS INT) AS bucket, COUNT(*) pv, COUNT(DISTINCT vh) v FROM events WHERE ${where} GROUP BY bucket`).all(...args);
472
604
  const map = new Map(rows.map((r) => [r.bucket, r]));
473
- const startH = Math.floor(start / 3600000);
474
- series = Array.from({ length: 24 }, (_, i) => {
475
- const r = map.get(startH + i);
476
- return { label: String(i).padStart(2, '0'), visitors: r ? r.v : 0, pageviews: r ? r.pv : 0 };
605
+ const n = Math.round((dayEnd - start) / 3600000);
606
+ series = Array.from({ length: n }, (_, i) => {
607
+ const r = map.get(i);
608
+ const t = start + i * 3600000;
609
+ const lab = new Date(t + TZ.offsetMin(tz, t) * 60000).toISOString().slice(11, 13);
610
+ return { label: lab, visitors: r ? r.v : 0, pageviews: r ? r.pv : 0 };
477
611
  });
478
612
  } else {
479
- const rows = db.prepare(`SELECT day, COUNT(*) pv, COUNT(DISTINCT vh) v FROM events WHERE ${where} GROUP BY day`).all(...args);
480
- const map = new Map(rows.map((r) => [r.day, r]));
613
+ const rows = db.prepare(`SELECT ${TZ.daySql(tz, start, end)} AS lday, COUNT(*) pv, COUNT(DISTINCT vh) v FROM events WHERE ${where} GROUP BY lday`).all(...args);
614
+ const map = new Map(rows.map((r) => [r.lday, r]));
615
+ if (imp.on) for (const r of imp.days) { const e = map.get(r.day) || { lday: r.day, pv: 0, v: 0 }; e.pv += r.pageviews; e.v += r.visitors; map.set(r.day, e); }
481
616
  series = Array.from({ length: days }, (_, i) => {
482
- const d = new Date(start + i * 864e5).toISOString().slice(0, 10);
617
+ const d = TZ.addDays(fromDay, i);
483
618
  const r = map.get(d);
484
619
  return { label: d.slice(5), day: d, visitors: r ? r.v : 0, pageviews: r ? r.pv : 0 };
485
620
  });
486
621
  }
487
622
 
488
623
  // previous year: same window shifted by 364 days (same weekdays). Null until data reaches back that far.
489
- const yoyStart = start - 364 * 864e5, yoyEnd = end - 364 * 864e5;
624
+ const yoyStart = TZ.dayStart(tz, TZ.addDays(fromDay, -364)), yoyEnd = yoyStart + (end - start);
490
625
  const firstTs = db.prepare('SELECT MIN(ts) t FROM events WHERE site_id = ?').get(sid).t;
491
626
  let yoy = null;
492
627
  if (firstTs != null && firstTs <= yoyStart) {
@@ -496,9 +631,13 @@ function statsHandler(req, res) {
496
631
  yoy = { visitors: pct(totals.visitors, y.visitors), pageviews: pct(totals.pageviews, y.pageviews), was: y };
497
632
  }
498
633
 
499
- const topBy = (col, limit = 8) => db.prepare(
634
+ const topByRaw = (col, limit = 8) => db.prepare(
500
635
  `SELECT ${col} AS name, COUNT(*) AS pageviews, COUNT(DISTINCT vh) AS visitors FROM events WHERE ${where} AND ${col} IS NOT NULL AND ${col} != '' GROUP BY ${col} ORDER BY visitors DESC, pageviews DESC LIMIT ${limit}`,
501
636
  ).all(...args);
637
+ const IMP_DIM = { path: 'page', country: 'country', browser: 'browser', os: 'os', device: 'device' };
638
+ // native + imported for the same dimension; visitors are summed per day-bucket (an approximation,
639
+ // like every imported breakdown — imported data has no visitor identity to deduplicate)
640
+ const topBy = (col, limit = 8) => (imp.on && IMP_DIM[col] ? mergeTop(topByRaw(col, 200), imp.top(IMP_DIM[col]), limit) : topByRaw(col, limit));
502
641
 
503
642
  // entry / exit pages: first / last pageview of each visit (vh,day), counted as visits.
504
643
  const edge = (dir, limit = 8) => db.prepare(
@@ -511,7 +650,8 @@ function statsHandler(req, res) {
511
650
  const goalRows = db.prepare(
512
651
  `SELECT name, COUNT(*) AS pageviews, COUNT(DISTINCT vh) AS visitors FROM events WHERE site_id = ? AND ts >= ? AND ts < ?${evf} AND name IS NOT NULL GROUP BY name ORDER BY visitors DESC, pageviews DESC LIMIT 12`,
513
652
  ).all(sid, start, end);
514
- const goals = goalRows.map((g) => ({ ...g, cr: totals.visitors ? +((g.visitors / totals.visitors) * 100).toFixed(1) : 0 }));
653
+ const goalRowsAll = imp.on ? mergeTop(goalRows, imp.top('event'), 12) : goalRows;
654
+ const goals = goalRowsAll.map((g) => ({ ...g, cr: totals.visitors ? +((g.visitors / totals.visitors) * 100).toFixed(1) : 0 }));
515
655
 
516
656
  // properties: only meaningful when drilled into a single goal — top values per property key.
517
657
  let properties = null;
@@ -537,6 +677,7 @@ function statsHandler(req, res) {
537
677
  const srcAll = db.prepare(
538
678
  `SELECT source AS name, COUNT(*) AS pageviews, COUNT(DISTINCT vh) AS visitors FROM events WHERE ${where} AND source IS NOT NULL AND source != '' GROUP BY source ORDER BY visitors DESC, pageviews DESC LIMIT 40`,
539
679
  ).all(...args);
680
+ if (imp.on) { const merged = mergeTop(srcAll, imp.top('source'), 40); srcAll.length = 0; srcAll.push(...merged); }
540
681
  const sourcesClean = srcAll.filter((r) => !isPaymentHost(r.name)).slice(0, 8);
541
682
  const paymentReturns = srcAll.filter((r) => isPaymentHost(r.name));
542
683
 
@@ -616,14 +757,17 @@ function statsHandler(req, res) {
616
757
 
617
758
  res.json({
618
759
  site: { domain: site.domain, name: site.name },
619
- period, range: { from: new Date(start).toISOString().slice(0, 10), to: new Date(end - 1).toISOString().slice(0, 10) },
760
+ period, timezone: tz, range: { from: fromDay, to: toDay },
620
761
  filters: Object.assign({}, ...Object.keys(FILTER_COLS).filter((k) => req.query[k]).map((k) => ({ [k]: req.query[k] })), goal ? { goal } : {}),
621
762
  totals: { visitors: totals.visitors, pageviews: totals.pageviews, bounce, viewsPerVisitor, duration },
622
763
  trend: { visitors: pct(totals.visitors, prev.visitors), pageviews: pct(totals.pageviews, prev.pageviews) },
623
764
  previous: { visitors: prev.visitors, pageviews: prev.pageviews },
624
765
  yoy,
625
- dataSince: firstTs ? new Date(firstTs).toISOString().slice(0, 10) : null,
766
+ dataSince: firstTs ? TZ.localDay(tz, firstTs) : null,
767
+ imported: imp.exists ? { included: imp.on, excludedByFilter: imp.filtered, until: imp.until } : null,
626
768
  series,
769
+ annotations: db.prepare('SELECT id, day, text, created_by FROM annotations WHERE site_id = ? AND day >= ? AND day <= ? ORDER BY day, id')
770
+ .all(sid, fromDay, toDay),
627
771
  pages: topBy('path', 10),
628
772
  entryPages: edge('ASC', 8),
629
773
  exitPages: edge('DESC', 8),
@@ -680,9 +824,13 @@ app.post('/api/shares', adminOnly, (req, res) => {
680
824
  if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
681
825
  const token = newToken();
682
826
  db.prepare('INSERT INTO shares (token, site_id, created) VALUES (?, ?, ?)').run(token, site.id, Date.now());
827
+ audit(res, 'share.add', site.domain);
683
828
  res.json({ token, domain: site.domain, url: '/share/' + token });
684
829
  });
685
830
  app.delete('/api/shares/:token', adminOnly, (req, res) => {
831
+ const sh = getShare.get(String(req.params.token));
832
+ if (sh && !siteAllowed(req, sh.site_id)) return res.status(403).json(NO_SITE);
833
+ if (sh) { const st = db.prepare('SELECT domain FROM sites WHERE id = ?').get(sh.site_id); audit(res, 'share.revoke', st ? st.domain : null); }
686
834
  db.prepare('DELETE FROM shares WHERE token = ?').run(String(req.params.token));
687
835
  res.json({ ok: true });
688
836
  });
@@ -694,6 +842,83 @@ app.get('/api/share-info/:token', (req, res) => {
694
842
  res.json({ domain: site.domain, name: site.name });
695
843
  });
696
844
 
845
+ // ── import history: Plausible export ZIP / CSVs, Google Analytics 4 CSVs (owner/admin) ──
846
+ const { parseImport } = require('./importers');
847
+ app.post('/api/imports', adminOnly, express.raw({ type: ['application/zip', 'application/octet-stream', 'text/csv', 'text/plain'], limit: '60mb' }), async (req, res) => {
848
+ const site = getSiteByDomain.get(norm(req.query.site));
849
+ if (!site) return res.status(404).json({ error: 'Unknown site' });
850
+ if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
851
+ const source = String(req.query.source || '');
852
+ if (!['plausible', 'ga'].includes(source)) return res.status(400).json({ error: 'source must be plausible or ga' });
853
+ if (!Buffer.isBuffer(req.body) || !req.body.length) return res.status(400).json({ error: 'Upload a .zip or .csv file' });
854
+ const isZip = req.body.readUInt32LE(0) === 0x04034b50;
855
+ let parsed;
856
+ try { parsed = await parseImport(source, isZip ? { zip: req.body } : { files: [{ name: String(req.query.name || 'upload.csv').slice(0, 120), text: req.body.toString('utf8') }] }); }
857
+ catch (e) { return res.status(400).json({ error: e.message }); }
858
+ const rows = parsed.rows.filter((r) => r.visitors || r.pageviews);
859
+ if (!rows.length) return res.status(400).json({ error: 'The file contains no daily numbers' });
860
+ if (rows.length > 2e6) return res.status(400).json({ error: 'Too many rows' });
861
+ const daysTot = rows.filter((r) => r.dim === '').map((r) => r.day).sort();
862
+ const first = firstOwnDay(site);
863
+ const by = whoDid(req);
864
+ const id = db.transaction(() => {
865
+ const info = db.prepare('INSERT INTO imports (site_id, source, first_day, last_day, rows, pageviews, files, created_by, created) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
866
+ .run(site.id, source, daysTot[0] || null, daysTot[daysTot.length - 1] || null, rows.length, rows.filter((r) => r.dim === '').reduce((a, r) => a + r.pageviews, 0), parsed.files.join(', ').slice(0, 500), by, Date.now());
867
+ const ins = db.prepare('INSERT INTO imported (import_id, site_id, day, dim, name, visitors, pageviews, visits, bounces, duration) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
868
+ for (const r of rows) ins.run(info.lastInsertRowid, site.id, r.day, r.dim, r.name || '', r.visitors, r.pageviews, r.visits || 0, r.bounces || 0, r.duration || 0);
869
+ return info.lastInsertRowid;
870
+ })();
871
+ const imp = db.prepare('SELECT * FROM imports WHERE id = ?').get(id);
872
+ audit(res, 'import.add', `${site.domain}: ${source} ${imp.first_day || '?'} – ${imp.last_day || '?'} (${imp.pageviews} views)`);
873
+ res.json({ ...imp, countsUntil: first, note: first && imp.last_day && imp.last_day >= first ? `Days from ${first} on already have own data — imported numbers count only before that day.` : null });
874
+ });
875
+ app.get('/api/imports', auth, (req, res) => {
876
+ const site = getSiteByDomain.get(norm(req.query.site));
877
+ if (!site) return res.status(404).json({ error: 'Unknown site' });
878
+ if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
879
+ res.json({ countsUntil: firstOwnDay(site), imports: db.prepare('SELECT id, source, first_day, last_day, rows, pageviews, files, created_by, created FROM imports WHERE site_id = ? ORDER BY id DESC').all(site.id) });
880
+ });
881
+ app.delete('/api/imports/:id', adminOnly, (req, res) => {
882
+ const imp = db.prepare('SELECT * FROM imports WHERE id = ?').get(Number(req.params.id));
883
+ if (!imp) return res.json({ ok: true });
884
+ if (!siteAllowed(req, imp.site_id)) return res.status(403).json(NO_SITE);
885
+ db.transaction(() => { db.prepare('DELETE FROM imported WHERE import_id = ?').run(imp.id); db.prepare('DELETE FROM imports WHERE id = ?').run(imp.id); })();
886
+ audit(res, 'import.remove', `${imp.source} ${imp.first_day || ''} – ${imp.last_day || ''}`);
887
+ res.json({ ok: true });
888
+ });
889
+
890
+ // ── chart annotations: list for a site (any reader of the site), add/remove (admin) ──
891
+ app.get('/api/annotations', authOrShare, (req, res) => {
892
+ const site = getSiteByDomain.get(norm(req.query.site));
893
+ if (!site) return res.status(404).json({ error: 'Unknown site' });
894
+ if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
895
+ res.json(db.prepare('SELECT id, day, text, created_by, created FROM annotations WHERE site_id = ? ORDER BY day DESC, id DESC LIMIT 500').all(site.id));
896
+ });
897
+ app.post('/api/annotations', adminOnly, (req, res) => {
898
+ const b = req.body || {};
899
+ const site = getSiteByDomain.get(norm(b.site));
900
+ if (!site) return res.status(404).json({ error: 'Unknown site' });
901
+ if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
902
+ const day = String(b.day || '');
903
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(day) || isNaN(Date.parse(day + 'T00:00:00Z')) || new Date(day + 'T00:00:00Z').toISOString().slice(0, 10) !== day) {
904
+ return res.status(400).json({ error: 'day must be a date like 2026-10-15' });
905
+ }
906
+ const text = String(b.text || '').replace(/\s+/g, ' ').trim().slice(0, 140);
907
+ if (!text) return res.status(400).json({ error: 'Write a short note' });
908
+ const by = whoDid(req);
909
+ const info = db.prepare('INSERT INTO annotations (site_id, day, text, created_by, created) VALUES (?, ?, ?, ?, ?)').run(site.id, day, text, by, Date.now());
910
+ audit(res, 'note.add', `${site.domain} ${day}: ${text}`);
911
+ res.json({ id: info.lastInsertRowid, day, text, created_by: by });
912
+ });
913
+ app.delete('/api/annotations/:id', adminOnly, (req, res) => {
914
+ const a = db.prepare('SELECT * FROM annotations WHERE id = ?').get(Number(req.params.id));
915
+ if (!a) return res.json({ ok: true });
916
+ if (!siteAllowed(req, a.site_id)) return res.status(403).json(NO_SITE);
917
+ db.prepare('DELETE FROM annotations WHERE id = ?').run(a.id);
918
+ audit(res, 'note.remove', `${a.day}: ${a.text}`);
919
+ res.json({ ok: true });
920
+ });
921
+
697
922
  // ── funnels: ordered steps (pages or goals), conversion measured per visitor in-range ──
698
923
  app.get('/api/funnels', auth, (req, res) => {
699
924
  const site = getSiteByDomain.get(norm(req.query.site));
@@ -716,9 +941,13 @@ app.post('/api/funnels', adminOnly, (req, res) => {
716
941
  }));
717
942
  if (steps.some((s) => !s.value)) return res.status(400).json({ error: 'Every step needs a value' });
718
943
  const info = db.prepare('INSERT INTO funnels (site_id, name, steps, created) VALUES (?, ?, ?, ?)').run(site.id, name, JSON.stringify(steps), Date.now());
944
+ audit(res, 'funnel.add', `${site.domain}: ${name}`);
719
945
  res.json({ id: info.lastInsertRowid, name, steps });
720
946
  });
721
947
  app.delete('/api/funnels/:id', adminOnly, (req, res) => {
948
+ const f = db.prepare('SELECT site_id, name FROM funnels WHERE id = ?').get(Number(req.params.id));
949
+ if (f && !siteAllowed(req, f.site_id)) return res.status(403).json(NO_SITE);
950
+ if (f) audit(res, 'funnel.remove', f.name);
722
951
  db.prepare('DELETE FROM funnels WHERE id = ?').run(Number(req.params.id));
723
952
  res.json({ ok: true });
724
953
  });
@@ -728,7 +957,7 @@ app.get('/api/funnels/:id/stats', authOrShare, (req, res) => {
728
957
  if (!f) return res.status(404).json({ error: 'Unknown funnel' });
729
958
  const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(f.site_id);
730
959
  if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
731
- const { start, end } = rangeFor(req.query);
960
+ const { start, end, fromDay, toDay } = rangeFor(req.query, site.timezone);
732
961
  const steps = JSON.parse(f.steps);
733
962
  // one pass per visitor: fetch matching events sorted by time, walk the step ladder.
734
963
  const conds = steps.map((s) => (s.type === 'goal' ? 'name = ?' : "(name IS NULL AND path = ?)"));
@@ -750,7 +979,7 @@ app.get('/api/funnels/:id/stats', authOrShare, (req, res) => {
750
979
  pctOfFirst: counts[0] ? +((counts[i] / counts[0]) * 100).toFixed(1) : 0,
751
980
  dropFromPrev: i > 0 && counts[i - 1] ? +((1 - counts[i] / counts[i - 1]) * 100).toFixed(1) : 0,
752
981
  }));
753
- res.json({ id: f.id, name: f.name, range: { from: new Date(start).toISOString().slice(0, 10), to: new Date(end - 1).toISOString().slice(0, 10) }, steps: out });
982
+ res.json({ id: f.id, name: f.name, range: { from: fromDay, to: toDay }, steps: out });
754
983
  });
755
984
 
756
985
  // ── static dashboard ─────────────────────────────────────────────────────────
@@ -762,7 +991,7 @@ app.get('/api/journeys', authOrShare, (req, res) => {
762
991
  const site = getSiteByDomain.get(norm(req.query.site));
763
992
  if (!site) return res.status(404).json({ error: 'Unknown site' });
764
993
  if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
765
- const { start, end } = rangeFor(req.query);
994
+ const { start, end } = rangeFor(req.query, site.timezone);
766
995
  const maxDepth = Math.min(5, Math.max(2, parseInt(req.query.depth, 10) || 4));
767
996
  const GAP = 30 * 60000;
768
997
  const rows = db.prepare('SELECT vh, ts, path FROM events WHERE site_id = ? AND ts >= ? AND ts < ? AND name IS NULL ORDER BY vh, ts, id').all(site.id, start, end);
@@ -803,7 +1032,199 @@ app.get('/api/journeys', authOrShare, (req, res) => {
803
1032
  });
804
1033
 
805
1034
 
806
- app.get('*', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'index.html')));
1035
+ // ── alerts: per-site rules (traffic spike / drop / goal reached), delivered to a webhook ──────────
1036
+ // Rules live in the DB and are managed from the dashboard (admin+). Delivery: the rule's own webhook,
1037
+ // else DIGEST_WEBHOOK_URL. Discord / Slack / generic JSON. Each rule has a cooldown so a long spike
1038
+ // sends one message, not twelve.
1039
+ db.exec(`
1040
+ CREATE TABLE IF NOT EXISTS alerts (
1041
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1042
+ site_id INTEGER NOT NULL,
1043
+ kind TEXT NOT NULL, -- 'spike' | 'drop' | 'goal'
1044
+ goal TEXT, -- event name for kind = goal
1045
+ threshold INTEGER, -- goal: events within the window; spike/drop: unused (baseline-based)
1046
+ window_hours INTEGER NOT NULL DEFAULT 24,
1047
+ webhook TEXT, -- NULL = DIGEST_WEBHOOK_URL
1048
+ enabled INTEGER NOT NULL DEFAULT 1,
1049
+ last_fired INTEGER,
1050
+ created INTEGER NOT NULL
1051
+ );
1052
+ `);
1053
+ const ALERT_KINDS = ['spike', 'drop', 'goal'];
1054
+ // SSRF guard (webhook.js): https only, public addresses only — checked when saved AND at
1055
+ // connect time (the dialled IP itself), so DNS rebinding between check and send does not help.
1056
+ const { webhookProblem, resolvesPublic: webhookResolvesPublic, postJson: postWebhook } = require('./webhook');
1057
+ // tests only: a local http receiver (never set this in production)
1058
+ const TEST_WEBHOOK_BASE = process.env.TACILYTICS_TEST_HOOKS === '1' ? (process.env.TACILYTICS_TEST_WEBHOOK_BASE || '') : '';
1059
+ const isTestWebhook = (u) => !!TEST_WEBHOOK_BASE && String(u).startsWith(TEST_WEBHOOK_BASE);
1060
+ // The operator's DIGEST_WEBHOOK_URL (.env) is trusted as before (may be internal, e.g. ntfy on localhost).
1061
+ // Webhooks typed into the dashboard are checked, because a teammate must not reach internal services.
1062
+ // tests only: a fake resolver (TACILYTICS_TEST_DNS='{"name":["1.2.3.4","127.0.0.1"]}' → answers in turn)
1063
+ let webhookLookup = require('dns').lookup;
1064
+ if (process.env.TACILYTICS_TEST_HOOKS === '1' && process.env.TACILYTICS_TEST_DNS) {
1065
+ const table = JSON.parse(process.env.TACILYTICS_TEST_DNS), seen = {};
1066
+ const real = webhookLookup;
1067
+ webhookLookup = (host, opts, cb) => {
1068
+ if (typeof opts === 'function') { cb = opts; opts = {}; }
1069
+ if (!table[host]) return real(host, opts, cb);
1070
+ const list = table[host], i = Math.min(seen[host] = (seen[host] || 0) + 1, list.length) - 1;
1071
+ const address = list[i], family = address.includes(':') ? 6 : 4;
1072
+ return opts.all ? cb(null, [{ address, family }]) : cb(null, address, family);
1073
+ };
1074
+ }
1075
+ async function deliver(url, text) {
1076
+ const trusted = url && (url === DIGEST_WEBHOOK_URL || isTestWebhook(url));
1077
+ if (!url || (!trusted && (webhookProblem(url) || !(await webhookResolvesPublic(url, webhookLookup))))) { console.error('alert: webhook refused'); return false; }
1078
+ const isSlack = /hooks\.slack\.com/.test(url);
1079
+ const body = isSlack ? { text } : { username: 'Tacilytics', content: text, text };
1080
+ try {
1081
+ const r = trusted
1082
+ ? await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(10000) })
1083
+ : await postWebhook(url, body, { lookup: webhookLookup }); // no redirects, IP checked on connect
1084
+ if (!r.ok) console.error('alert webhook failed:', r.status);
1085
+ return r.ok;
1086
+ } catch (e) { console.error(e.code === 'EWEBHOOKPRIVATE' ? 'alert: webhook refused (resolved to a private address)' : 'alert webhook error: ' + e.message); return false; }
1087
+ }
1088
+ function publicAlert(a, domain) {
1089
+ return { id: a.id, site: domain, kind: a.kind, goal: a.goal, threshold: a.threshold, window_hours: a.window_hours,
1090
+ webhook: a.webhook ? new URL(a.webhook).hostname : null, enabled: !!a.enabled, last_fired: a.last_fired };
1091
+ }
1092
+ app.get('/api/alerts', auth, (req, res) => {
1093
+ const site = getSiteByDomain.get(norm(req.query.site));
1094
+ if (!site) return res.status(404).json({ error: 'Unknown site' });
1095
+ if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
1096
+ res.json({ fallbackWebhook: !!DIGEST_WEBHOOK_URL, alerts: db.prepare('SELECT * FROM alerts WHERE site_id = ? ORDER BY id').all(site.id).map((a) => publicAlert(a, site.domain)) });
1097
+ });
1098
+ app.post('/api/alerts', adminOnly, async (req, res) => {
1099
+ const b = req.body || {};
1100
+ const site = getSiteByDomain.get(norm(b.site));
1101
+ if (!site) return res.status(404).json({ error: 'Unknown site' });
1102
+ if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
1103
+ if (!ALERT_KINDS.includes(b.kind)) return res.status(400).json({ error: 'kind must be spike, drop or goal' });
1104
+ let goal = null, threshold = null, windowHours = 1;
1105
+ if (b.kind === 'goal') {
1106
+ goal = String(b.goal || '').trim().slice(0, 80);
1107
+ threshold = Math.floor(Number(b.threshold));
1108
+ windowHours = Math.floor(Number(b.window_hours) || 24);
1109
+ if (!goal) return res.status(400).json({ error: 'Name the goal (event name), e.g. Purchase' });
1110
+ if (!(threshold >= 1 && threshold <= 1e6)) return res.status(400).json({ error: 'Threshold must be at least 1' });
1111
+ if (![1, 24, 168].includes(windowHours)) return res.status(400).json({ error: 'Window must be 1, 24 or 168 hours' });
1112
+ }
1113
+ let webhook = null;
1114
+ if (b.webhook && isTestWebhook(b.webhook)) {
1115
+ webhook = String(b.webhook);
1116
+ } else if (b.webhook) {
1117
+ const prob = webhookProblem(b.webhook);
1118
+ if (prob) return res.status(400).json({ error: prob });
1119
+ if (!(await webhookResolvesPublic(b.webhook, webhookLookup))) return res.status(400).json({ error: 'Webhook must point to a public address' });
1120
+ webhook = String(b.webhook);
1121
+ } else if (!DIGEST_WEBHOOK_URL) return res.status(400).json({ error: 'Add a webhook URL (no default DIGEST_WEBHOOK_URL is set)' });
1122
+ if (db.prepare('SELECT COUNT(*) n FROM alerts WHERE site_id = ?').get(site.id).n >= 20) return res.status(400).json({ error: 'At most 20 alerts per site' });
1123
+ const info = db.prepare('INSERT INTO alerts (site_id, kind, goal, threshold, window_hours, webhook, created) VALUES (?, ?, ?, ?, ?, ?, ?)')
1124
+ .run(site.id, b.kind, goal, threshold, windowHours, webhook, Date.now());
1125
+ audit(res, 'alert.add', `${site.domain}: ${b.kind}${goal ? ' ' + goal + ' ≥ ' + threshold + '/' + windowHours + 'h' : ''}`);
1126
+ res.json(publicAlert(db.prepare('SELECT * FROM alerts WHERE id = ?').get(info.lastInsertRowid), site.domain));
1127
+ });
1128
+ app.patch('/api/alerts/:id', adminOnly, (req, res) => {
1129
+ const a = db.prepare('SELECT * FROM alerts WHERE id = ?').get(Number(req.params.id));
1130
+ if (!a) return res.status(404).json({ error: 'Unknown alert' });
1131
+ if (!siteAllowed(req, a.site_id)) return res.status(403).json(NO_SITE);
1132
+ db.prepare('UPDATE alerts SET enabled = ? WHERE id = ?').run((req.body || {}).enabled ? 1 : 0, a.id);
1133
+ audit(res, (req.body || {}).enabled ? 'alert.on' : 'alert.off', `${a.kind}${a.goal ? ' ' + a.goal : ''}`);
1134
+ res.json({ ok: true });
1135
+ });
1136
+ app.delete('/api/alerts/:id', adminOnly, (req, res) => {
1137
+ const a = db.prepare('SELECT * FROM alerts WHERE id = ?').get(Number(req.params.id));
1138
+ if (!a) return res.json({ ok: true });
1139
+ if (!siteAllowed(req, a.site_id)) return res.status(403).json(NO_SITE);
1140
+ db.prepare('DELETE FROM alerts WHERE id = ?').run(a.id);
1141
+ audit(res, 'alert.remove', `${a.kind}${a.goal ? ' ' + a.goal : ''}`);
1142
+ res.json({ ok: true });
1143
+ });
1144
+ app.post('/api/alerts/:id/test', adminOnly, async (req, res) => {
1145
+ const a = db.prepare('SELECT * FROM alerts WHERE id = ?').get(Number(req.params.id));
1146
+ if (!a) return res.status(404).json({ error: 'Unknown alert' });
1147
+ if (!siteAllowed(req, a.site_id)) return res.status(403).json(NO_SITE);
1148
+ if (rateLimited('alert-test:' + a.id, 5, 60 * 60000)) return res.status(429).json({ error: 'Max. 5 test messages per hour' });
1149
+ const site = db.prepare('SELECT domain FROM sites WHERE id = ?').get(a.site_id);
1150
+ const ok = await deliver(a.webhook || DIGEST_WEBHOOK_URL, `🔔 **${site.domain}** test message from Tacilytics — this alert works.`);
1151
+ res.status(ok ? 200 : 502).json(ok ? { ok: true } : { error: 'The webhook did not accept the message' });
1152
+ });
1153
+
1154
+ // ── weekly report by e-mail ──────────────────────────────────────────────────────────────
1155
+ // Every account can opt in (Settings → Security) with an address; it gets one mail per week
1156
+ // (Monday, REPORT_HOUR_UTC) covering the sites it can see: last 7 days vs. the 7 before.
1157
+ // Needs SMTP_URL (smtp://user:pass@host:587 or smtps://…:465) and REPORT_FROM in .env.
1158
+ const SMTP_URL = process.env.SMTP_URL || '';
1159
+ const REPORT_FROM = process.env.REPORT_FROM || '';
1160
+ const REPORT_HOUR_UTC = Math.min(23, Math.max(0, parseInt(process.env.REPORT_HOUR_UTC, 10) || 6));
1161
+ const PUBLIC_URL = (process.env.TACILYTICS_ORIGIN || '').replace(/\/$/, '');
1162
+ for (const col of ['report_email TEXT', 'report_weekly INTEGER NOT NULL DEFAULT 0', 'report_last INTEGER', 'report_lang TEXT']) {
1163
+ const name = col.split(' ')[0];
1164
+ if (!db.prepare('PRAGMA table_info(users)').all().some((c) => c.name === name)) db.exec(`ALTER TABLE users ADD COLUMN ${col}`);
1165
+ }
1166
+ let mailer = null;
1167
+ function getMailer() {
1168
+ if (!SMTP_URL || !REPORT_FROM) return null;
1169
+ if (!mailer) mailer = require('nodemailer').createTransport(SMTP_URL);
1170
+ return mailer;
1171
+ }
1172
+ const validEmail = (e) => /^[^\s@<>",;]{1,64}@[^\s@<>",;]{1,190}\.[A-Za-z]{2,}$/.test(e);
1173
+ const { createReport, REPORT_LANGS } = require('./report');
1174
+ const reporter = createReport(db);
1175
+ function buildReport(user, now) {
1176
+ const sites = allSites.all().filter((st) => !user.sites || JSON.parse(user.sites).includes(st.id));
1177
+ return reporter.build(user, sites, now, { publicUrl: PUBLIC_URL, lang: REPORT_LANGS.includes(user.report_lang) ? user.report_lang : 'en' });
1178
+ }
1179
+ async function sendReport(user, now = Date.now()) {
1180
+ const m = getMailer();
1181
+ if (!m) throw new Error('E-mail is not configured on this server (SMTP_URL, REPORT_FROM)');
1182
+ const r = buildReport(user, now);
1183
+ if (!r) return { skipped: 'no traffic' };
1184
+ await m.sendMail({ from: REPORT_FROM, to: user.report_email, subject: r.subject, text: r.text, html: r.html });
1185
+ return { sent: true };
1186
+ }
1187
+ async function reportTick(now = Date.now()) {
1188
+ const d = new Date(now);
1189
+ if (d.getUTCDay() !== 1 || d.getUTCHours() !== REPORT_HOUR_UTC || !getMailer()) return [];
1190
+ const due = db.prepare('SELECT * FROM users WHERE report_weekly = 1 AND report_email IS NOT NULL AND (report_last IS NULL OR report_last < ?)').all(now - 6 * 864e5);
1191
+ const out = [];
1192
+ for (const u of due) {
1193
+ db.prepare('UPDATE users SET report_last = ? WHERE id = ?').run(now, u.id); // first: a crash must not mean two mails
1194
+ try { out.push({ user: u.username, ...(await sendReport(u, now)) }); } catch (e) { console.error('weekly report:', e.message); out.push({ user: u.username, error: e.message }); }
1195
+ }
1196
+ return out;
1197
+ }
1198
+ if (process.env.TACILYTICS_ALERT_TICK !== 'off') setInterval(() => { reportTick().catch((e) => console.error('reports:', e.message)); }, 10 * 60000).unref();
1199
+
1200
+ app.get('/api/me/report', auth, (req, res) => {
1201
+ if (!req.principal.user || req.principal.kind !== 'user') return res.status(400).json({ error: 'Sign in with an account' });
1202
+ const u = db.prepare('SELECT report_email, report_weekly, report_last, report_lang FROM users WHERE id = ?').get(req.principal.user.id);
1203
+ res.json({ configured: !!getMailer(), email: u.report_email, weekly: !!u.report_weekly, last: u.report_last, lang: u.report_lang || null });
1204
+ });
1205
+ app.post('/api/me/report', auth, (req, res) => {
1206
+ if (!req.principal.user || req.principal.kind !== 'user') return res.status(400).json({ error: 'Sign in with an account' });
1207
+ const b = req.body || {};
1208
+ const email = String(b.email || '').trim();
1209
+ if (b.weekly && !validEmail(email)) return res.status(400).json({ error: 'Enter a valid e-mail address' });
1210
+ if (email && !validEmail(email)) return res.status(400).json({ error: 'Enter a valid e-mail address' });
1211
+ if (b.lang != null && !REPORT_LANGS.includes(b.lang)) return res.status(400).json({ error: 'Unknown language' });
1212
+ db.prepare('UPDATE users SET report_email = ?, report_weekly = ?, report_lang = COALESCE(?, report_lang) WHERE id = ?').run(email || null, b.weekly ? 1 : 0, b.lang || null, req.principal.user.id);
1213
+ audit(res, b.weekly ? 'me.report.on' : 'me.report.off');
1214
+ res.json({ ok: true });
1215
+ });
1216
+ app.post('/api/me/report/send', auth, async (req, res) => {
1217
+ if (!req.principal.user || req.principal.kind !== 'user') return res.status(400).json({ error: 'Sign in with an account' });
1218
+ if (rateLimited('report-now:' + req.principal.user.id, 3, 60 * 60000)) return res.status(429).json({ error: 'Max. 3 test reports per hour' });
1219
+ const u = db.prepare('SELECT * FROM users WHERE id = ?').get(req.principal.user.id);
1220
+ if (!u.report_email) return res.status(400).json({ error: 'Save an e-mail address first' });
1221
+ try { const r = await sendReport(u); res.json(r.skipped ? { error: 'No traffic in the last two weeks — nothing to report' } : { ok: true }); }
1222
+ catch (e) { res.status(502).json({ error: e.message.startsWith('E-mail is not') ? e.message : 'Sending failed: ' + e.message.slice(0, 120) }); }
1223
+ });
1224
+ if (process.env.TACILYTICS_TEST_HOOKS === '1') {
1225
+ app.post('/api/_test/report-tick', requireRole('owner'), async (req, res) => res.json(await reportTick(Number((req.body || {}).now) || Date.now())));
1226
+ }
1227
+
807
1228
 
808
1229
  // ── data retention (privacy by design: minimise what we keep) ─────────────────
809
1230
  // When DATA_RETENTION_DAYS > 0, raw events older than the window are purged daily.
@@ -819,24 +1240,31 @@ if (RETENTION_DAYS > 0) { purgeOld(); setInterval(purgeOld, 24 * 3600 * 1000).un
819
1240
 
820
1241
  // ── daily digest: one webhook message per day summarising every site (yesterday vs the day before).
821
1242
  // Configure DIGEST_WEBHOOK_URL (Discord-compatible) and optionally DIGEST_HOUR_UTC (default 7).
822
- const DIGEST_WEBHOOK_URL = process.env.DIGEST_WEBHOOK_URL || '';
823
1243
  const DIGEST_HOUR_UTC = Math.min(23, Math.max(0, parseInt(process.env.DIGEST_HOUR_UTC, 10) || 7));
824
1244
  let lastDigestDay = null;
825
- async function sendDigest() {
826
- const yStart = new Date(new Date(Date.now() - 864e5).toISOString().slice(0, 10)).getTime();
827
- const yEnd = yStart + 864e5, pStart = yStart - 864e5;
1245
+ function digestBody(now = Date.now()) {
1246
+ // "yesterday" per site, in the site's time zone (23/25 h on DST days)
828
1247
  const lines = [];
1248
+ let label = null;
829
1249
  for (const site of allSites.all()) {
1250
+ const tz = site.timezone || 'UTC';
1251
+ const yDay = TZ.addDays(TZ.localDay(tz, now), -1);
1252
+ const yStart = TZ.dayStart(tz, yDay), yEnd = TZ.dayStart(tz, TZ.addDays(yDay, 1)), pStart = TZ.dayStart(tz, TZ.addDays(yDay, -1));
1253
+ if (!label) label = yDay;
830
1254
  const cur = db.prepare('SELECT COUNT(*) pv, COUNT(DISTINCT vh) v FROM events WHERE site_id = ? AND ts >= ? AND ts < ? AND name IS NULL').get(site.id, yStart, yEnd);
831
1255
  if (!cur.pv) continue; // silent sites stay out of the report
832
1256
  const was = db.prepare('SELECT COUNT(*) pv, COUNT(DISTINCT vh) v FROM events WHERE site_id = ? AND ts >= ? AND ts < ? AND name IS NULL').get(site.id, pStart, yStart);
833
1257
  const top = db.prepare("SELECT path, COUNT(*) c FROM events WHERE site_id = ? AND ts >= ? AND ts < ? AND name IS NULL GROUP BY path ORDER BY c DESC LIMIT 1").get(site.id, yStart, yEnd);
834
1258
  const goals = db.prepare('SELECT COUNT(*) c FROM events WHERE site_id = ? AND ts >= ? AND ts < ? AND name IS NOT NULL').get(site.id, yStart, yEnd).c;
835
1259
  const arrow = was.v ? (cur.v > was.v ? '⬆️' : cur.v < was.v ? '⬇️' : '➡️') : '✨';
836
- lines.push(`**${site.domain}** ${arrow} ${cur.v} visitors · ${cur.pv} views${goals ? ` · ${goals} events` : ''}${top ? ` · top \`${top.path}\`` : ''}`);
1260
+ lines.push(`**${site.domain}**${yDay !== label ? ` (${yDay})` : ''} ${arrow} ${cur.v} visitors · ${cur.pv} views${goals ? ` · ${goals} events` : ''}${top ? ` · top \`${top.path}\`` : ''}`);
837
1261
  }
838
- if (!lines.length) return;
839
- const body = { username: 'Tacilytics', content: `📊 **Daily digest — ${new Date(yStart).toISOString().slice(0, 10)}**\n` + lines.join('\n') };
1262
+ if (!lines.length) return null;
1263
+ return { username: 'Tacilytics', content: `📊 **Daily digest — ${label}**\n` + lines.join('\n') };
1264
+ }
1265
+ async function sendDigest(now = Date.now()) {
1266
+ const body = digestBody(now);
1267
+ if (!body) return;
840
1268
  try {
841
1269
  const r = await fetch(DIGEST_WEBHOOK_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
842
1270
  if (!r.ok) console.error('digest webhook failed:', r.status);
@@ -850,37 +1278,81 @@ if (DIGEST_WEBHOOK_URL) {
850
1278
  }, 5 * 60000).unref();
851
1279
  }
852
1280
 
853
- // ── anomaly watchdog: hourly check of the last hour vs. the same hour on previous days.
854
- // Baseline = median of the same UTC hour over the last 14 days — same-hour compare avoids
855
- // the classic "every night is an incident" trap of naive averages. Cooldown 6h per site+kind.
1281
+ // ── alert engine: every 10 min. Spike/drop compare the last hour with the median of the same LOCAL
1282
+ // hour (site time zone — after a DST switch that is 23/25 h away, not 24) over 14 days (avoids "every night is an incident"); goals count events in a rolling window.
1283
+ // Sites without own rules keep the old behaviour when DIGEST_WEBHOOK_URL is set: spike + drop.
856
1284
  const WATCH_MIN_VISITORS = Math.max(1, parseInt(process.env.WATCH_MIN_VISITORS, 10) || 10);
857
- const watchAlerted = new Map(); // "siteId|spike" / "siteId|drop" → last alert ts
1285
+ const legacyFired = new Map(); // "siteId|kind" → ts, for the implicit default rules
858
1286
  function median(a) { const s = [...a].sort((x, y) => x - y); return s.length ? (s[s.length >> 1] + s[(s.length - 1) >> 1]) / 2 : 0; }
859
- async function watchdogTick() {
860
- const now = Date.now();
1287
+ function trafficVerdict(siteId, now, tz) {
861
1288
  const hourStart = now - 3600e3;
1289
+ const cur = db.prepare('SELECT COUNT(DISTINCT vh) v FROM events WHERE site_id = ? AND ts >= ? AND ts < ? AND name IS NULL').get(siteId, hourStart, now).v;
1290
+ const hist = [];
1291
+ for (let d = 1; d <= 14; d++) {
1292
+ const s0 = TZ.sameLocalTime(tz || 'UTC', hourStart, d);
1293
+ hist.push(db.prepare('SELECT COUNT(DISTINCT vh) v FROM events WHERE site_id = ? AND ts >= ? AND ts < ? AND name IS NULL').get(siteId, s0, s0 + 3600e3).v);
1294
+ }
1295
+ const base = median(hist);
1296
+ // abstain when the baseline is too thin to mean anything — a default is not a measurement
1297
+ if (base < WATCH_MIN_VISITORS && cur < WATCH_MIN_VISITORS) return { cur, base, kind: null };
1298
+ if (base >= 1 && cur >= Math.max(3 * base, base + WATCH_MIN_VISITORS)) return { cur, base, kind: 'spike' };
1299
+ if (base >= WATCH_MIN_VISITORS && cur <= base * 0.2) return { cur, base, kind: 'drop' };
1300
+ return { cur, base, kind: null };
1301
+ }
1302
+ const COOLDOWN = { spike: 6 * 3600e3, drop: 6 * 3600e3 };
1303
+ async function alertTick(now = Date.now()) {
1304
+ const fired = [];
862
1305
  for (const site of allSites.all()) {
863
- const cur = db.prepare('SELECT COUNT(DISTINCT vh) v FROM events WHERE site_id = ? AND ts >= ? AND name IS NULL').get(site.id, hourStart).v;
864
- const hist = [];
865
- for (let d = 1; d <= 14; d++) {
866
- const s = hourStart - d * 864e5;
867
- hist.push(db.prepare('SELECT COUNT(DISTINCT vh) v FROM events WHERE site_id = ? AND ts >= ? AND ts < ? AND name IS NULL').get(site.id, s, s + 3600e3).v);
1306
+ const rules = db.prepare('SELECT * FROM alerts WHERE site_id = ? AND enabled = 1').all(site.id);
1307
+ const hasOwn = db.prepare('SELECT COUNT(*) n FROM alerts WHERE site_id = ?').get(site.id).n > 0;
1308
+ let verdict = null;
1309
+ const traffic = () => verdict || (verdict = trafficVerdict(site.id, now, site.timezone));
1310
+ const msgFor = (kind, v) => kind === 'spike'
1311
+ ? `🚀 **${site.domain}** traffic spike: **${v.cur}** visitors in the last hour (usually ~${v.base})`
1312
+ : `📉 **${site.domain}** traffic drop: **${v.cur}** visitors in the last hour (usually ~${v.base}) — tracker offline?`;
1313
+ for (const r of rules) {
1314
+ let text = null;
1315
+ if (r.kind === 'goal') {
1316
+ // fire once per window: after firing, wait until the window has passed
1317
+ if (r.last_fired && now - r.last_fired < r.window_hours * 3600e3) continue;
1318
+ const n = db.prepare('SELECT COUNT(*) c FROM events WHERE site_id = ? AND name = ? AND ts >= ? AND ts < ?').get(site.id, r.goal, now - r.window_hours * 3600e3, now).c;
1319
+ if (n >= r.threshold) text = `🎯 **${site.domain}** goal reached: **${n}× ${r.goal}** in the last ${r.window_hours === 1 ? 'hour' : r.window_hours === 24 ? '24 hours' : '7 days'} (threshold ${r.threshold})`;
1320
+ } else {
1321
+ if (r.last_fired && now - r.last_fired < COOLDOWN[r.kind]) continue;
1322
+ const v = traffic();
1323
+ if (v.kind === r.kind) text = msgFor(r.kind, v);
1324
+ }
1325
+ if (!text) continue;
1326
+ db.prepare('UPDATE alerts SET last_fired = ? WHERE id = ?').run(now, r.id); // before sending: a slow webhook must not double-fire
1327
+ fired.push({ site: site.domain, kind: r.kind, text });
1328
+ await deliver(r.webhook || DIGEST_WEBHOOK_URL, text);
1329
+ }
1330
+ if (!hasOwn && DIGEST_WEBHOOK_URL) {
1331
+ const v = traffic();
1332
+ if (v.kind) {
1333
+ const key = site.id + '|' + v.kind;
1334
+ if (now - (legacyFired.get(key) || 0) >= COOLDOWN[v.kind]) {
1335
+ legacyFired.set(key, now);
1336
+ const text = msgFor(v.kind, v);
1337
+ fired.push({ site: site.domain, kind: v.kind, text, implicit: true });
1338
+ await deliver(DIGEST_WEBHOOK_URL, text);
1339
+ }
1340
+ }
868
1341
  }
869
- const base = median(hist);
870
- // abstain when the baseline is too thin to mean anything — a default is not a measurement
871
- if (base < WATCH_MIN_VISITORS && cur < WATCH_MIN_VISITORS) continue;
872
- let kind = null, emoji = '', text = '';
873
- if (base >= 1 && cur >= Math.max(3 * base, base + WATCH_MIN_VISITORS)) { kind = 'spike'; emoji = '🚀'; text = `traffic spike: **${cur}** visitors last hour (usually ~${base})`; }
874
- else if (base >= WATCH_MIN_VISITORS && cur <= base * 0.2) { kind = 'drop'; emoji = '📉'; text = `traffic drop: **${cur}** visitors last hour (usually ~${base}) — tracker offline?`; }
875
- if (!kind) continue;
876
- const key = site.id + '|' + kind;
877
- if (now - (watchAlerted.get(key) || 0) < 6 * 3600e3) continue; // cooldown
878
- watchAlerted.set(key, now);
879
- try {
880
- await fetch(DIGEST_WEBHOOK_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'Tacilytics', content: `${emoji} **${site.domain}** ${text}` }) });
881
- } catch (e) { console.error('watchdog webhook error:', e.message); }
882
1342
  }
1343
+ return fired;
883
1344
  }
884
- if (DIGEST_WEBHOOK_URL) setInterval(watchdogTick, 3600e3).unref();
1345
+ if (process.env.TACILYTICS_ALERT_TICK !== 'off') setInterval(() => { alertTick().catch((e) => console.error('alerts:', e.message)); }, 10 * 60000).unref();
1346
+ // test hook: run one evaluation on demand (only with the server API_KEY, only when enabled explicitly)
1347
+ if (process.env.TACILYTICS_TEST_HOOKS === '1') {
1348
+ app.post('/api/_test/digest', requireRole('owner'), (req, res) => res.json(digestBody(Number((req.body || {}).now) || Date.now())));
1349
+ app.post('/api/_test/alert-tick', requireRole('owner'), async (req, res) => res.json(await alertTick(Number((req.body || {}).now) || Date.now())));
1350
+ }
1351
+
1352
+ // ── fallbacks: LAST, after every route (a route registered below these would never be reached) ──
1353
+ // unknown API paths are a 404 in JSON — never the SPA page. A client that asks an older/newer server for a
1354
+ // route it doesn't have must get an error it can show, not HTML it would try to read as data (Kiro, 2.2.0).
1355
+ app.all(/^\/(api|auth)(\/|$)/, (_req, res) => res.status(404).json({ error: 'Not found' }));
1356
+ app.get('*', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'index.html')));
885
1357
 
886
1358
  app.listen(PORT, '127.0.0.1', () => console.log(`Tacilytics on http://127.0.0.1:${PORT}${RETENTION_DAYS ? ` · retention ${RETENTION_DAYS}d` : ''}${DIGEST_WEBHOOK_URL ? ` · digest ${DIGEST_HOUR_UTC}:00 UTC` : ''}`));