tacilytics 2.0.2 → 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)`);
@@ -216,16 +255,35 @@ function visitorHash(siteId, ip, ua, day) {
216
255
  if (!salt) return crypto.randomBytes(12).toString('hex'); // unlinkable one-off visitor
217
256
  return crypto.createHash('sha256').update(`${INGEST_SALT}|${salt}|${day}|${siteId}|${ip}|${ua}`).digest('hex').slice(0, 24);
218
257
  }
258
+ // The client writes the FIRST X-Forwarded-For entry itself, so it must never be used for rate limits.
259
+ // req.ip honours 'trust proxy': with TRUST_PROXY=1 (default, one reverse proxy in front) it is the
260
+ // entry that proxy appended. Set TRUST_PROXY=0 when the server is reachable without a proxy.
219
261
  function clientIp(req) {
220
- const xff = (req.headers['x-forwarded-for'] || '').split(',')[0].trim();
221
- return xff || req.socket.remoteAddress || '0.0.0.0';
262
+ return req.ip || req.socket.remoteAddress || '0.0.0.0';
222
263
  }
223
264
 
224
265
  // ── app ─────────────────────────────────────────────────────────────────────
225
266
  const app = express();
226
- app.set('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
+ }
227
283
  // Accept both application/json (dashboard) and text/plain (cross-origin beacons).
228
- 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)));
229
287
  app.disable('x-powered-by');
230
288
 
231
289
  // Lightweight in-memory rate limiter (fixed window per key). No external dep.
@@ -326,35 +384,16 @@ app.options('/api/collect', (_req, res) => { res.set('Access-Control-Allow-Origi
326
384
  app.post('/api/collect', collect);
327
385
  app.get('/api/collect', collect);
328
386
 
329
- // ── auth (password → in-memory session token) ──────────────
330
- const sessions = new Map();
331
- function newToken() { return crypto.randomBytes(24).toString('hex'); }
332
- app.post('/auth/login', (req, res) => {
333
- if (rateLimited('login:' + clientIp(req), 10, 15 * 60000)) return res.status(429).json({ error: 'Too many attempts — wait 15 minutes' });
334
- const pw = String((req.body && req.body.password) || '');
335
- const ok = pw.length === WEB_PASSWORD.length && crypto.timingSafeEqual(Buffer.from(pw), Buffer.from(WEB_PASSWORD));
336
- if (!ok) return res.status(401).json({ error: 'Wrong password' });
337
- const token = newToken();
338
- sessions.set(token, { expires: Date.now() + 7 * 864e5 });
339
- res.json({ token });
387
+ // ── auth: accounts, team, optional 2FA + passkeys (auth.js) ───────────────────
388
+ const { setupAuth } = require('./auth');
389
+ const { auth, requireRole, siteAllowed, audit } = setupAuth({
390
+ app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp,
391
+ allSiteIds: () => db.prepare('SELECT id FROM sites').all().map((r) => r.id),
340
392
  });
341
- function checkApiKey(req) {
342
- if (!API_KEY) return false;
343
- const hdr = String(req.headers['authorization'] || '');
344
- const key = hdr.startsWith('Bearer ') ? hdr.slice(7) : String(req.headers['x-api-key'] || '');
345
- return key.length === API_KEY.length && crypto.timingSafeEqual(Buffer.from(key), Buffer.from(API_KEY));
346
- }
347
- function auth(req, res, next) {
348
- if (checkApiKey(req)) return next(); // agents / CLI
349
- const t = req.headers['x-session-token'];
350
- const s = t && sessions.get(t);
351
- if (s && s.expires > Date.now()) return next();
352
- if (s) sessions.delete(t);
353
- res.status(401).json({ error: 'Not authenticated' });
354
- }
393
+ const adminOnly = requireRole('admin');
355
394
 
356
395
  // share-token auth: read-only stats access scoped to exactly one site.
357
- // Accepted as ?share=<token>; the handler must verify req.shareSite matches the queried site.
396
+ // Accepted as ?share=<token>; the handler must check siteOk(req, site).
358
397
  const getShare = db.prepare('SELECT * FROM shares WHERE token = ?');
359
398
  function authOrShare(req, res, next) {
360
399
  const tok = String(req.query.share || '');
@@ -364,10 +403,13 @@ function authOrShare(req, res, next) {
364
403
  }
365
404
  return auth(req, res, next);
366
405
  }
367
- function shareScopeOk(req, site) { return req.shareSiteId == null || req.shareSiteId === site.id; }
406
+ // share links see exactly their site; viewers only the sites they were given
407
+ const siteOk = (req, site) => siteAllowed(req, site.id);
408
+ const NO_SITE = { error: 'You have no access to this site' };
409
+ function newToken() { return crypto.randomBytes(24).toString('hex'); }
368
410
 
369
411
  // ── sites management ─────────────────────────────────────────────────────────
370
- const sitesListHandler = (_req, res) => res.json(allSites.all());
412
+ const sitesListHandler = (req, res) => res.json(allSites.all().filter((st) => siteAllowed(req, st.id)));
371
413
  const sitesCreateHandler = (req, res) => {
372
414
  const rawDomain = norm(req.body && req.body.domain);
373
415
  let domain = rawDomain;
@@ -378,14 +420,37 @@ const sitesCreateHandler = (req, res) => {
378
420
  }
379
421
  // Allow ASCII domains AND Punycode (xn--...). Unicode input was converted above.
380
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' });
381
- 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);
382
428
  res.json(getSiteByDomain.get(domain));
383
429
  };
384
430
  app.get('/api/sites', auth, sitesListHandler);
385
- app.post('/api/sites', auth, sitesCreateHandler);
386
- app.delete('/api/sites/:id', auth, (req, res) => {
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
+ });
444
+ app.delete('/api/sites/:id', adminOnly, (req, res) => {
387
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);
388
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);
389
454
  db.prepare('DELETE FROM sites WHERE id = ?').run(id);
390
455
  res.json({ ok: true });
391
456
  });
@@ -403,27 +468,33 @@ app.get('/api/v1', (_req, res) => res.json({
403
468
  filters: 'add any of country,source,page,browser,os,device,lang,medium,campaign,goal to /stats to segment',
404
469
  }));
405
470
  app.get('/api/v1/sites', auth, sitesListHandler);
406
- app.post('/api/v1/sites', auth, sitesCreateHandler);
471
+ app.post('/api/v1/sites', adminOnly, sitesCreateHandler);
407
472
 
408
473
  // ── stats ────────────────────────────────────────────────────────────────────
474
+ const TZ = require('./tz');
409
475
  const PERIODS = { today: 1, '7d': 7, '30d': 30, '90d': 90, '12mo': 365 };
410
- 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';
411
481
  const now = Date.now();
412
- // custom range: from/to are YYYY-MM-DD (inclusive)
413
- const from = /^\d{4}-\d{2}-\d{2}$/.test(query.from || '') ? Date.parse(query.from + 'T00:00:00Z') : null;
414
- if (from != null && !Number.isNaN(from)) {
415
- const toRaw = /^\d{4}-\d{2}-\d{2}$/.test(query.to || '') ? Date.parse(query.to + 'T00:00:00Z') : from;
416
- const end = Math.min(now, (Number.isNaN(toRaw) ? from : toRaw) + 864e5); // inclusive end day
417
- const days = Math.max(1, Math.round((end - from) / 864e5));
418
- 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 };
419
493
  }
420
494
  const period = PERIODS[query.period] ? query.period : '7d';
421
495
  const days = PERIODS[period];
422
- // Whole UTC days: "7d" = today + the 6 full days before it. Starting at now - 7×24h would
423
- // cut the oldest day off at the current clock time and undercount it.
424
- const today0 = Date.parse(new Date(now).toISOString().slice(0, 10) + 'T00:00:00Z');
425
- const start = today0 - (days - 1) * 864e5;
426
- 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 };
427
498
  }
428
499
 
429
500
  // Whitelisted segmentation filters — click any breakdown row in the dashboard to drill in.
@@ -449,12 +520,51 @@ function eventPlatformFilter(query) {
449
520
  return f;
450
521
  }
451
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
+
452
562
  function statsHandler(req, res) {
453
563
  const site = getSiteByDomain.get(norm(req.query.site));
454
564
  if (!site) return res.status(404).json({ error: 'Unknown site' });
455
- if (!shareScopeOk(req, site)) return res.status(403).json({ error: 'Share link is limited to another site' });
565
+ if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
456
566
  const sid = site.id;
457
- const { start, end, days, period, hourly } = rangeFor(req.query);
567
+ const { start, end, days, period, hourly, tz, fromDay, toDay } = rangeFor(req.query, site.timezone);
458
568
  const { clauses, params, goal } = buildFilters(req.query);
459
569
  const evf = eventPlatformFilter(req.query); // platform/version also narrow event panels (revenue, search, 404)
460
570
 
@@ -466,6 +576,10 @@ function statsHandler(req, res) {
466
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); }
467
577
 
468
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; }
469
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);
470
584
  const bounce = totals.visitors ? Math.round((bounceRow.bounced / totals.visitors) * 100) : 0;
471
585
  const viewsPerVisitor = totals.visitors ? +(totals.pageviews / totals.visitors).toFixed(1) : 0;
@@ -477,30 +591,37 @@ function statsHandler(req, res) {
477
591
  const prevArgs = args.slice(); prevArgs[1] = start - len; prevArgs[2] = start;
478
592
  if (goal) { prevArgs[prevArgs.length - 3] = start - len; prevArgs[prevArgs.length - 2] = start; }
479
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; } }
480
595
  const pct = (cur, was) => (was ? Math.round(((cur - was) / was) * 100) : null);
481
596
 
482
597
  // time series (per hour for a single day, else per day)
483
598
  let series;
484
599
  if (hourly) {
485
- 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);
486
604
  const map = new Map(rows.map((r) => [r.bucket, r]));
487
- const startH = Math.floor(start / 3600000);
488
- series = Array.from({ length: 24 }, (_, i) => {
489
- const r = map.get(startH + i);
490
- 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 };
491
611
  });
492
612
  } else {
493
- const rows = db.prepare(`SELECT day, COUNT(*) pv, COUNT(DISTINCT vh) v FROM events WHERE ${where} GROUP BY day`).all(...args);
494
- 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); }
495
616
  series = Array.from({ length: days }, (_, i) => {
496
- const d = new Date(start + i * 864e5).toISOString().slice(0, 10);
617
+ const d = TZ.addDays(fromDay, i);
497
618
  const r = map.get(d);
498
619
  return { label: d.slice(5), day: d, visitors: r ? r.v : 0, pageviews: r ? r.pv : 0 };
499
620
  });
500
621
  }
501
622
 
502
623
  // previous year: same window shifted by 364 days (same weekdays). Null until data reaches back that far.
503
- const yoyStart = start - 364 * 864e5, yoyEnd = end - 364 * 864e5;
624
+ const yoyStart = TZ.dayStart(tz, TZ.addDays(fromDay, -364)), yoyEnd = yoyStart + (end - start);
504
625
  const firstTs = db.prepare('SELECT MIN(ts) t FROM events WHERE site_id = ?').get(sid).t;
505
626
  let yoy = null;
506
627
  if (firstTs != null && firstTs <= yoyStart) {
@@ -510,9 +631,13 @@ function statsHandler(req, res) {
510
631
  yoy = { visitors: pct(totals.visitors, y.visitors), pageviews: pct(totals.pageviews, y.pageviews), was: y };
511
632
  }
512
633
 
513
- const topBy = (col, limit = 8) => db.prepare(
634
+ const topByRaw = (col, limit = 8) => db.prepare(
514
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}`,
515
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));
516
641
 
517
642
  // entry / exit pages: first / last pageview of each visit (vh,day), counted as visits.
518
643
  const edge = (dir, limit = 8) => db.prepare(
@@ -525,7 +650,8 @@ function statsHandler(req, res) {
525
650
  const goalRows = db.prepare(
526
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`,
527
652
  ).all(sid, start, end);
528
- 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 }));
529
655
 
530
656
  // properties: only meaningful when drilled into a single goal — top values per property key.
531
657
  let properties = null;
@@ -551,6 +677,7 @@ function statsHandler(req, res) {
551
677
  const srcAll = db.prepare(
552
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`,
553
679
  ).all(...args);
680
+ if (imp.on) { const merged = mergeTop(srcAll, imp.top('source'), 40); srcAll.length = 0; srcAll.push(...merged); }
554
681
  const sourcesClean = srcAll.filter((r) => !isPaymentHost(r.name)).slice(0, 8);
555
682
  const paymentReturns = srcAll.filter((r) => isPaymentHost(r.name));
556
683
 
@@ -630,14 +757,17 @@ function statsHandler(req, res) {
630
757
 
631
758
  res.json({
632
759
  site: { domain: site.domain, name: site.name },
633
- 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 },
634
761
  filters: Object.assign({}, ...Object.keys(FILTER_COLS).filter((k) => req.query[k]).map((k) => ({ [k]: req.query[k] })), goal ? { goal } : {}),
635
762
  totals: { visitors: totals.visitors, pageviews: totals.pageviews, bounce, viewsPerVisitor, duration },
636
763
  trend: { visitors: pct(totals.visitors, prev.visitors), pageviews: pct(totals.pageviews, prev.pageviews) },
637
764
  previous: { visitors: prev.visitors, pageviews: prev.pageviews },
638
765
  yoy,
639
- 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,
640
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),
641
771
  pages: topBy('path', 10),
642
772
  entryPages: edge('ASC', 8),
643
773
  exitPages: edge('DESC', 8),
@@ -669,7 +799,7 @@ app.get('/api/v1/stats', authOrShare, statsHandler);
669
799
  function realtimeHandler(req, res) {
670
800
  const site = getSiteByDomain.get(norm(req.query.site));
671
801
  if (!site) return res.status(404).json({ error: 'Unknown site' });
672
- if (!shareScopeOk(req, site)) return res.status(403).json({ error: 'Share link is limited to another site' });
802
+ if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
673
803
  const sid = site.id, now = Date.now(), since = now - 30 * 60000;
674
804
  const online = db.prepare('SELECT COUNT(DISTINCT vh) v FROM events WHERE site_id = ? AND name IS NULL AND ts >= ?').get(sid, now - 5 * 60000).v;
675
805
  const rows = db.prepare('SELECT CAST((ts/60000) AS INT) b, COUNT(*) pv, COUNT(DISTINCT vh) v FROM events WHERE site_id = ? AND name IS NULL AND ts >= ? GROUP BY b').all(sid, since);
@@ -683,18 +813,24 @@ app.get('/api/realtime', authOrShare, realtimeHandler);
683
813
  app.get('/api/v1/realtime', authOrShare, realtimeHandler);
684
814
 
685
815
  // ── share links: create / list / revoke (owner only) + resolve (public) ──────────
686
- app.get('/api/shares', auth, (req, res) => {
687
- const rows = db.prepare('SELECT s.token, s.created, st.domain FROM shares s JOIN sites st ON st.id = s.site_id ORDER BY s.created DESC').all();
816
+ app.get('/api/shares', adminOnly, (req, res) => {
817
+ const rows = db.prepare('SELECT s.token, s.created, s.site_id, st.domain FROM shares s JOIN sites st ON st.id = s.site_id ORDER BY s.created DESC').all()
818
+ .filter((r) => siteAllowed(req, r.site_id)).map(({ site_id, ...r }) => r);
688
819
  res.json(rows);
689
820
  });
690
- app.post('/api/shares', auth, (req, res) => {
821
+ app.post('/api/shares', adminOnly, (req, res) => {
691
822
  const site = getSiteByDomain.get(norm(req.body && req.body.site));
692
823
  if (!site) return res.status(404).json({ error: 'Unknown site' });
824
+ if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
693
825
  const token = newToken();
694
826
  db.prepare('INSERT INTO shares (token, site_id, created) VALUES (?, ?, ?)').run(token, site.id, Date.now());
827
+ audit(res, 'share.add', site.domain);
695
828
  res.json({ token, domain: site.domain, url: '/share/' + token });
696
829
  });
697
- app.delete('/api/shares/:token', auth, (req, res) => {
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); }
698
834
  db.prepare('DELETE FROM shares WHERE token = ?').run(String(req.params.token));
699
835
  res.json({ ok: true });
700
836
  });
@@ -706,15 +842,94 @@ app.get('/api/share-info/:token', (req, res) => {
706
842
  res.json({ domain: site.domain, name: site.name });
707
843
  });
708
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
+
709
922
  // ── funnels: ordered steps (pages or goals), conversion measured per visitor in-range ──
710
923
  app.get('/api/funnels', auth, (req, res) => {
711
924
  const site = getSiteByDomain.get(norm(req.query.site));
712
925
  if (!site) return res.status(404).json({ error: 'Unknown site' });
926
+ if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
713
927
  res.json(db.prepare('SELECT id, name, steps, created FROM funnels WHERE site_id = ? ORDER BY id').all(site.id).map((f) => ({ ...f, steps: JSON.parse(f.steps) })));
714
928
  });
715
- app.post('/api/funnels', auth, (req, res) => {
929
+ app.post('/api/funnels', adminOnly, (req, res) => {
716
930
  const site = getSiteByDomain.get(norm(req.body && req.body.site));
717
931
  if (!site) return res.status(404).json({ error: 'Unknown site' });
932
+ if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
718
933
  const name = String((req.body && req.body.name) || '').slice(0, 80).trim();
719
934
  const raw = (req.body && req.body.steps) || [];
720
935
  if (!name) return res.status(400).json({ error: 'Funnel needs a name' });
@@ -726,9 +941,13 @@ app.post('/api/funnels', auth, (req, res) => {
726
941
  }));
727
942
  if (steps.some((s) => !s.value)) return res.status(400).json({ error: 'Every step needs a value' });
728
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}`);
729
945
  res.json({ id: info.lastInsertRowid, name, steps });
730
946
  });
731
- app.delete('/api/funnels/:id', auth, (req, res) => {
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);
732
951
  db.prepare('DELETE FROM funnels WHERE id = ?').run(Number(req.params.id));
733
952
  res.json({ ok: true });
734
953
  });
@@ -737,8 +956,8 @@ app.get('/api/funnels/:id/stats', authOrShare, (req, res) => {
737
956
  const f = db.prepare('SELECT * FROM funnels WHERE id = ?').get(Number(req.params.id));
738
957
  if (!f) return res.status(404).json({ error: 'Unknown funnel' });
739
958
  const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(f.site_id);
740
- if (!shareScopeOk(req, site)) return res.status(403).json({ error: 'Share link is limited to another site' });
741
- const { start, end } = rangeFor(req.query);
959
+ if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
960
+ const { start, end, fromDay, toDay } = rangeFor(req.query, site.timezone);
742
961
  const steps = JSON.parse(f.steps);
743
962
  // one pass per visitor: fetch matching events sorted by time, walk the step ladder.
744
963
  const conds = steps.map((s) => (s.type === 'goal' ? 'name = ?' : "(name IS NULL AND path = ?)"));
@@ -760,7 +979,7 @@ app.get('/api/funnels/:id/stats', authOrShare, (req, res) => {
760
979
  pctOfFirst: counts[0] ? +((counts[i] / counts[0]) * 100).toFixed(1) : 0,
761
980
  dropFromPrev: i > 0 && counts[i - 1] ? +((1 - counts[i] / counts[i - 1]) * 100).toFixed(1) : 0,
762
981
  }));
763
- 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 });
764
983
  });
765
984
 
766
985
  // ── static dashboard ─────────────────────────────────────────────────────────
@@ -771,8 +990,8 @@ app.use(express.static(path.join(__dirname, 'public')));
771
990
  app.get('/api/journeys', authOrShare, (req, res) => {
772
991
  const site = getSiteByDomain.get(norm(req.query.site));
773
992
  if (!site) return res.status(404).json({ error: 'Unknown site' });
774
- if (!shareScopeOk(req, site)) return res.status(403).json({ error: 'Share link is limited to another site' });
775
- const { start, end } = rangeFor(req.query);
993
+ if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
994
+ const { start, end } = rangeFor(req.query, site.timezone);
776
995
  const maxDepth = Math.min(5, Math.max(2, parseInt(req.query.depth, 10) || 4));
777
996
  const GAP = 30 * 60000;
778
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);
@@ -813,7 +1032,199 @@ app.get('/api/journeys', authOrShare, (req, res) => {
813
1032
  });
814
1033
 
815
1034
 
816
- 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
+
817
1228
 
818
1229
  // ── data retention (privacy by design: minimise what we keep) ─────────────────
819
1230
  // When DATA_RETENTION_DAYS > 0, raw events older than the window are purged daily.
@@ -829,24 +1240,31 @@ if (RETENTION_DAYS > 0) { purgeOld(); setInterval(purgeOld, 24 * 3600 * 1000).un
829
1240
 
830
1241
  // ── daily digest: one webhook message per day summarising every site (yesterday vs the day before).
831
1242
  // Configure DIGEST_WEBHOOK_URL (Discord-compatible) and optionally DIGEST_HOUR_UTC (default 7).
832
- const DIGEST_WEBHOOK_URL = process.env.DIGEST_WEBHOOK_URL || '';
833
1243
  const DIGEST_HOUR_UTC = Math.min(23, Math.max(0, parseInt(process.env.DIGEST_HOUR_UTC, 10) || 7));
834
1244
  let lastDigestDay = null;
835
- async function sendDigest() {
836
- const yStart = new Date(new Date(Date.now() - 864e5).toISOString().slice(0, 10)).getTime();
837
- 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)
838
1247
  const lines = [];
1248
+ let label = null;
839
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;
840
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);
841
1255
  if (!cur.pv) continue; // silent sites stay out of the report
842
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);
843
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);
844
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;
845
1259
  const arrow = was.v ? (cur.v > was.v ? '⬆️' : cur.v < was.v ? '⬇️' : '➡️') : '✨';
846
- 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}\`` : ''}`);
847
1261
  }
848
- if (!lines.length) return;
849
- 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;
850
1268
  try {
851
1269
  const r = await fetch(DIGEST_WEBHOOK_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
852
1270
  if (!r.ok) console.error('digest webhook failed:', r.status);
@@ -860,37 +1278,81 @@ if (DIGEST_WEBHOOK_URL) {
860
1278
  }, 5 * 60000).unref();
861
1279
  }
862
1280
 
863
- // ── anomaly watchdog: hourly check of the last hour vs. the same hour on previous days.
864
- // Baseline = median of the same UTC hour over the last 14 days — same-hour compare avoids
865
- // 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.
866
1284
  const WATCH_MIN_VISITORS = Math.max(1, parseInt(process.env.WATCH_MIN_VISITORS, 10) || 10);
867
- const watchAlerted = new Map(); // "siteId|spike" / "siteId|drop" → last alert ts
1285
+ const legacyFired = new Map(); // "siteId|kind" → ts, for the implicit default rules
868
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; }
869
- async function watchdogTick() {
870
- const now = Date.now();
1287
+ function trafficVerdict(siteId, now, tz) {
871
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 = [];
872
1305
  for (const site of allSites.all()) {
873
- 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;
874
- const hist = [];
875
- for (let d = 1; d <= 14; d++) {
876
- const s = hourStart - d * 864e5;
877
- 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
+ }
878
1341
  }
879
- const base = median(hist);
880
- // abstain when the baseline is too thin to mean anything — a default is not a measurement
881
- if (base < WATCH_MIN_VISITORS && cur < WATCH_MIN_VISITORS) continue;
882
- let kind = null, emoji = '', text = '';
883
- if (base >= 1 && cur >= Math.max(3 * base, base + WATCH_MIN_VISITORS)) { kind = 'spike'; emoji = '🚀'; text = `traffic spike: **${cur}** visitors last hour (usually ~${base})`; }
884
- else if (base >= WATCH_MIN_VISITORS && cur <= base * 0.2) { kind = 'drop'; emoji = '📉'; text = `traffic drop: **${cur}** visitors last hour (usually ~${base}) — tracker offline?`; }
885
- if (!kind) continue;
886
- const key = site.id + '|' + kind;
887
- if (now - (watchAlerted.get(key) || 0) < 6 * 3600e3) continue; // cooldown
888
- watchAlerted.set(key, now);
889
- try {
890
- await fetch(DIGEST_WEBHOOK_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'Tacilytics', content: `${emoji} **${site.domain}** ${text}` }) });
891
- } catch (e) { console.error('watchdog webhook error:', e.message); }
892
1342
  }
1343
+ return fired;
1344
+ }
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())));
893
1350
  }
894
- if (DIGEST_WEBHOOK_URL) setInterval(watchdogTick, 3600e3).unref();
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')));
895
1357
 
896
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` : ''}`));