events: add bin/events.js CLI for reading the event log

This commit is contained in:
Arlind Ukshini 2026-04-27 10:38:27 +02:00
parent 0cc3dc808e
commit 6b5e2f1297

116
bin/events.js Normal file
View file

@ -0,0 +1,116 @@
#!/usr/bin/env node
// ─────────────────────────────────────────────────────────────
// bin/events.js — read the engagement-event log.
//
// Records every landmark event (login, timeline_view) with the
// user's email, device fields parsed from the UA, and the session
// ID at time-of-event.
//
// Usage:
// node bin/events.js list [--type <event>] [--limit <n>]
// node bin/events.js summary # per-user counts
// node bin/events.js for <email> # full history for one user
// node bin/events.js stats # totals + device breakdown
// ─────────────────────────────────────────────────────────────
import { q } from '../src/db.js';
const args = process.argv.slice(2);
const cmd = args[0];
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
function help() {
console.log('Usage:');
console.log(' events list [--type <event>] [--limit <n>]');
console.log(' events summary # per-user counts');
console.log(' events for <email> # event history for a user');
console.log(' events stats # totals + device breakdown');
process.exit(1);
}
function iso(t) { return new Date(t).toISOString(); }
function shortSid(s) { return s ? `[${s.slice(0, 8)}…]` : '[—]'; }
function parseFlag(name) {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : null;
}
function parseMeta(s) {
if (!s) return null;
try { return JSON.parse(s); } catch { return s; }
}
function metaCompact(m) {
if (!m) return '';
if (typeof m !== 'object') return String(m);
return Object.entries(m).map(([k, v]) => `${k}=${v}`).join(' ');
}
switch (cmd) {
case 'list': {
const type = parseFlag('--type');
const limit = Number(parseFlag('--limit') || 200);
const rows = type
? q.listEventsByType.all(type, limit)
: q.listEvents.all(limit);
if (rows.length === 0) { console.log('(no events yet)'); break; }
for (const r of rows) {
const dev = [r.device_type, r.os, r.browser].filter(Boolean).join('/') || '?';
const meta = metaCompact(parseMeta(r.meta));
console.log(
` ${iso(r.occurred_at)} ${r.event_type.padEnd(14)} ${r.email.padEnd(28)} ${dev.padEnd(24)} ${shortSid(r.session_id)} ${meta}`
);
}
console.log(`\n${rows.length} event${rows.length === 1 ? '' : 's'} shown.`);
break;
}
case 'summary': {
const rows = q.summariseEvents.all();
if (rows.length === 0) { console.log('(no events yet)'); break; }
console.log(' LOGINS TIMELINE LAST SEEN EMAIL');
for (const r of rows) {
const lg = String(r.logins).padStart(6);
const tv = String(r.timeline_views).padStart(8);
console.log(` ${lg} ${tv} ${iso(r.last_seen)} ${r.email}`);
}
console.log(`\n${rows.length} unique user${rows.length === 1 ? '' : 's'}.`);
break;
}
case 'for': {
const arg = args[1];
if (!arg || !EMAIL_RE.test(arg)) help();
const email = arg.trim().toLowerCase();
const rows = q.listEventsForEmail.all(email);
if (rows.length === 0) { console.log(`(no events for ${email})`); break; }
console.log(`Events for ${email}:`);
for (const r of rows) {
const dev = [r.device_type, r.os, r.browser].filter(Boolean).join('/') || '?';
const meta = metaCompact(parseMeta(r.meta));
console.log(` ${iso(r.occurred_at)} ${r.event_type.padEnd(14)} ${dev.padEnd(24)} ${shortSid(r.session_id)} ${meta}`);
}
console.log(`\n${rows.length} event${rows.length === 1 ? '' : 's'}.`);
break;
}
case 'stats': {
const byType = q.countEventsByType.all();
if (byType.length === 0) { console.log('(no events yet)'); break; }
console.log(' EVENT TYPE TOTAL UNIQUE USERS');
for (const r of byType) {
console.log(` ${r.event_type.padEnd(14)} ${String(r.total).padStart(5)} ${String(r.unique_users).padStart(12)}`);
}
const dev = q.deviceBreakdown.all();
if (dev.length > 0) {
console.log('\n DEVICE TYPE COUNT');
for (const r of dev) {
console.log(` ${r.device_type.padEnd(14)} ${String(r.n).padStart(5)}`);
}
}
break;
}
default:
help();
}