#!/usr/bin/env node /** * paas-clicks-writeback — push the trailing-window per-article click totals * (aggregated by bin/paas-traffic-rollup into article_clicks_daily) to each * app's own /api/_hooks/clicks endpoint, which writes them into Strapi via * a route deliberately built to never trigger a Varnish purge. * * Runs hourly via systemd timer (User=deploy, Nice=19, io idle) — same * idle-priority shape as paas-traffic-rollup, and reuses that script's data, * so this adds no new log I/O either. Fully offline from the request path; * the only network call it makes is one localhost HTTP request per app, * bypassing Varnish entirely (talks straight to the app's PM2 port). * * Only runs for an app when BOTH are true (checked up front, not via * catching an error — see doc/README or the tier-a plan doc "Scoping"): * - the app has a route-manifest.json (built by a dotnews-an-tier-a based * app; anything else has no notion of "article" this script understands) * - the app's shared/.env has a non-empty NUXT_CLICK_SYNC_SECRET * Apps missing either are skipped with one log line, not retried. */ 'use strict' const fs = require('node:fs') const path = require('node:path') const http = require('node:http') const PAAS_ROOT = process.env.PAAS_ROOT || '/srv/paas' const APPS_ROOT = process.env.APPS_ROOT || '/srv/apps' const REG_DIR = path.join(PAAS_ROOT, 'apps.d') const DB_PATH = process.env.PAAS_DB || path.join(PAAS_ROOT, 'paas.db') const PANEL_SQLITE = process.env.PANEL_SQLITE || path.join(PAAS_ROOT, 'panel/node_modules/better-sqlite3') const WINDOW_DAYS = Number(process.env.CLICKS_WINDOW_DAYS || 7) function parseConf(file) { const out = {} for (const line of fs.readFileSync(file, 'utf8').split('\n')) { const m = /^([A-Z_]+)=(.*)$/.exec(line.trim()) if (m) out[m[1]] = m[2].replace(/^"|"$/g, '') } return out } function readEnvVar(file, key) { if (!fs.existsSync(file)) return '' for (const line of fs.readFileSync(file, 'utf8').split('\n')) { const m = new RegExp(`^${key}=(.*)$`).exec(line.trim()) if (m) return m[1].replace(/^"|"$/g, '') } return '' } function listApps() { if (!fs.existsSync(REG_DIR)) return [] return fs.readdirSync(REG_DIR) .filter((f) => f.endsWith('.conf')) .map((f) => parseConf(path.join(REG_DIR, f))) .filter((c) => c.APP && c.PORT) } function eligibleApps() { const apps = [] for (const conf of listApps()) { // paas deploy flattens .output/* into the release root — see the same // note in bin/paas-traffic-rollup's loadManifest() const manifestFile = path.join(APPS_ROOT, conf.APP, 'current', 'route-manifest.json') if (!fs.existsSync(manifestFile)) continue // not a dotnews-an-tier-a based app — nothing to sync const envFile = path.join(APPS_ROOT, conf.APP, 'shared', '.env') const secret = readEnvVar(envFile, 'NUXT_CLICK_SYNC_SECRET') if (!secret) { console.log(`[clicks-writeback] ${conf.APP}: has route-manifest but no NUXT_CLICK_SYNC_SECRET — skipping`) continue } apps.push({ app: conf.APP, port: Number(conf.PORT), secret }) } return apps } function openDb() { const Database = require(PANEL_SQLITE) const db = new Database(DB_PATH) db.pragma('busy_timeout = 5000') db.exec(`CREATE TABLE IF NOT EXISTS article_clicks_daily ( app TEXT NOT NULL, slug TEXT NOT NULL, day TEXT NOT NULL, clicks INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (app, slug, day))`) db.exec(`CREATE TABLE IF NOT EXISTS article_clicks_synced ( app TEXT NOT NULL, slug TEXT NOT NULL, last_value INTEGER NOT NULL DEFAULT 0, synced_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (app, slug))`) /* click-stats history (articles + static pages, path-keyed) — written by bin/paas-traffic-rollup; synced to Strapi's click-stat collection */ db.exec(`CREATE TABLE IF NOT EXISTS page_clicks_daily ( app TEXT NOT NULL, path TEXT NOT NULL, day TEXT NOT NULL, clicks INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (app, path, day))`) db.exec(`CREATE TABLE IF NOT EXISTS clicks_monthly ( app TEXT NOT NULL, path TEXT NOT NULL, month TEXT NOT NULL, clicks INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (app, path, month))`) db.exec(`CREATE TABLE IF NOT EXISTS click_stats_synced ( app TEXT NOT NULL, path TEXT NOT NULL, hash TEXT NOT NULL DEFAULT '', synced_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (app, path))`) return db } function windowTotals(db, app) { return db.prepare( `SELECT slug, SUM(clicks) AS clicks FROM article_clicks_daily WHERE app = ? AND day >= date('now', ?) GROUP BY slug`, ).all(app, `-${WINDOW_DAYS} days`) } function lastSynced(db, app) { const rows = db.prepare('SELECT slug, last_value FROM article_clicks_synced WHERE app = ?').all(app) return new Map(rows.map((r) => [r.slug, r.last_value])) } function markSynced(db, app, items) { const stmt = db.prepare(`INSERT INTO article_clicks_synced (app, slug, last_value, synced_at) VALUES (?, ?, ?, datetime('now')) ON CONFLICT(app, slug) DO UPDATE SET last_value = excluded.last_value, synced_at = excluded.synced_at`) const tx = db.transaction((rs) => { for (const r of rs) stmt.run(app, r.slug, r.clicks) }) tx(items) } /* ------------------------------------------------------------ Click-stats history: per path (articles as '/'+slug + static pages) the trailing 7/30-day sums and the last 12 monthly totals — pushed as a second `stats` key in the same request; the app forwards them to Strapi's click-stat collection for the admin Klick Report. ------------------------------------------------------------ */ const STATS_MAX_PER_RUN = 400 // receiver caps at 500; remainder next run function statsTotals(db, app) { const stats = new Map() // path -> { clicks7, clicks30, months } const ensure = (p) => { let s = stats.get(p) if (!s) { s = { clicks7: 0, clicks30: 0, months: {} }; stats.set(p, s) } return s } for (const [table, col, toPath] of [ ['article_clicks_daily', 'slug', (v) => '/' + v], ['page_clicks_daily', 'path', (v) => v], ]) { for (const [days, key] of [[7, 'clicks7'], [30, 'clicks30']]) { const rows = db.prepare( `SELECT ${col} AS k, SUM(clicks) AS clicks FROM ${table} WHERE app = ? AND day >= date('now', ?) GROUP BY ${col}`, ).all(app, `-${days} days`) for (const r of rows) ensure(toPath(r.k))[key] += r.clicks } } /* ORDER BY month keeps the object key order (and thus the change hash) deterministic across runs */ const mrows = db.prepare( `SELECT path, month, clicks FROM clicks_monthly WHERE app = ? AND month >= strftime('%Y-%m', date('now', '-12 months')) ORDER BY month`, ).all(app) for (const r of mrows) ensure(r.path).months[r.month] = r.clicks return stats } const statsHash = (s) => JSON.stringify([s.clicks7, s.clicks30, s.months]) function lastStatsSynced(db, app) { const rows = db.prepare('SELECT path, hash FROM click_stats_synced WHERE app = ?').all(app) return new Map(rows.map((r) => [r.path, r.hash])) } function markStatsSynced(db, app, items) { const stmt = db.prepare(`INSERT INTO click_stats_synced (app, path, hash, synced_at) VALUES (?, ?, ?, datetime('now')) ON CONFLICT(app, path) DO UPDATE SET hash = excluded.hash, synced_at = excluded.synced_at`) const tx = db.transaction((rs) => { for (const r of rs) stmt.run(app, r.path, statsHash(r)) }) tx(items) } function postClicks(port, secret, items, stats) { const body = Buffer.from(JSON.stringify(stats && stats.length ? { items, stats } : { items })) return new Promise((resolve) => { const req = http.request( { host: '127.0.0.1', port, path: '/api/_hooks/clicks', method: 'POST', headers: { Authorization: `Bearer ${secret}`, 'Content-Type': 'application/json', 'Content-Length': body.length, }, timeout: 30_000, }, (res) => { let data = '' res.on('data', (c) => { data += c }) res.on('end', () => resolve({ status: res.statusCode, body: data })) }, ) req.on('timeout', () => { req.destroy(); resolve({ status: 0, body: 'timeout' }) }) req.on('error', (err) => resolve({ status: 0, body: String(err) })) req.write(body) req.end() }) } async function main() { const apps = eligibleApps() if (!apps.length) return const db = openDb() try { for (const { app, port, secret } of apps) { const totals = windowTotals(db, app) const synced = lastSynced(db, app) const changed = totals.filter((r) => synced.get(r.slug) !== r.clicks) /* stats: every path whose 7d/30d/months tuple changed since last sync */ const stats = statsTotals(db, app) const statsSynced = lastStatsSynced(db, app) const statsChanged = [...stats.entries()] .filter(([p, s]) => statsSynced.get(p) !== statsHash(s)) .slice(0, STATS_MAX_PER_RUN) .map(([p, s]) => ({ path: p, clicks7: s.clicks7, clicks30: s.clicks30, months: s.months })) if (!changed.length && !statsChanged.length) continue const items = changed.map((r) => ({ slug: r.slug, clicks: r.clicks })) const res = await postClicks(port, secret, items, statsChanged) if (res.status >= 200 && res.status < 300) { if (changed.length) markSynced(db, app, changed) /* watermark stats only when the app confirmed it processed them — an app that predates the stats feature returns no statsUpdated, so the batch is retried after the app is redeployed */ let resBody = {} try { resBody = JSON.parse(res.body) } catch { /* non-JSON reply */ } const statsOk = statsChanged.length && resBody && 'statsUpdated' in resBody if (statsOk) markStatsSynced(db, app, statsChanged) console.log(`[clicks-writeback] ${app}: synced ${changed.length} slug(s)${statsChanged.length ? `, ${statsOk ? statsChanged.length : 0}/${statsChanged.length} stat path(s)${statsOk ? '' : ' (app not stats-ready yet)'}` : ''}`) } else { console.error(`[clicks-writeback] ${app}: sync failed (status ${res.status}) — will retry next run`) } } } finally { db.close() } } main().catch((err) => { console.error(`[clicks-writeback] ${err.stack || err}`); process.exit(1) })