Retire the SQLite backend — Postgres is the only engine
The dual backend (SQLite via db.js + Postgres via schema.pg.sql) was a maintenance foot-gun: a schema change could land on the SQLite path only and silently 500 every read on prod (it just did, with #18/#13). Production has run on Postgres for weeks, so SQLite is retired: ONE schema source of truth (db/schema.pg.sql), no drift possible. - dbx.js: default DB_BACKEND=pg; an unknown backend now fails loudly at require time instead of silently selecting a stale engine. - Deleted server/db.js, server/db/sqlite.js, server/db/migrate-sqlite-to-pg.js, server/scripts/migrate-bizgaze-only.js (all SQLite-only, none in the runtime path — the running server loads db/pg.js). - Tests (e2e, db-smoke) target Postgres now and fail-fast (skip) unless DATABASE_URL points at a disposable test DB — never SQLite, never prod. - Removed the dead DB_PATH env + fixed misleading SQLite comments in the Dockerfile / docker-compose (kept the /data volume: it holds uploads/recordings/transcripts/downloads, not just the old data.db). - CLAUDE.md: stack + repo-layout + run-locally updated for Postgres-only. Runtime is unaffected (prod already sets DB_BACKEND=pg and pg is a prod dep); this only removes the unused SQLite path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
-414
@@ -1,414 +0,0 @@
|
||||
// SQLite data layer + schema.
|
||||
// Uses Node's built-in node:sqlite (no native compilation needed).
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const path = require('path');
|
||||
|
||||
const db = new DatabaseSync(process.env.DB_PATH || path.join(__dirname, 'data.db'));
|
||||
// WAL is preferred but unsupported on some mounted/network filesystems; fall back quietly.
|
||||
try { db.exec('PRAGMA journal_mode = WAL'); } catch { /* default rollback journal is fine */ }
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS teams (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
team_id TEXT NOT NULL REFERENCES teams(id),
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
pw_hash TEXT NOT NULL,
|
||||
pw_salt TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'technician',
|
||||
mfa_secret TEXT,
|
||||
mfa_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions_auth (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
mfa_passed INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS machines (
|
||||
id TEXT PRIMARY KEY,
|
||||
team_id TEXT NOT NULL REFERENCES teams(id),
|
||||
name TEXT NOT NULL,
|
||||
enroll_token TEXT NOT NULL UNIQUE,
|
||||
unattended INTEGER NOT NULL DEFAULT 0,
|
||||
last_seen INTEGER,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
team_id TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
user_email TEXT,
|
||||
machine_id TEXT,
|
||||
machine_name TEXT,
|
||||
action TEXT NOT NULL,
|
||||
detail TEXT,
|
||||
at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Migration: optional display name for agents (shown to customers on consent)
|
||||
try { db.exec('ALTER TABLE users ADD COLUMN name TEXT'); } catch (e) { /* already exists */ }
|
||||
|
||||
// Migration: agent active flag (deactivate without deleting)
|
||||
try { db.exec('ALTER TABLE users ADD COLUMN active INTEGER NOT NULL DEFAULT 1'); } catch (e) { /* exists */ }
|
||||
|
||||
// Session report: one row per support session with duration
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS sessions_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
team_id TEXT NOT NULL,
|
||||
agent_email TEXT,
|
||||
agent_name TEXT,
|
||||
ticket TEXT,
|
||||
started_at INTEGER NOT NULL,
|
||||
ended_at INTEGER
|
||||
);
|
||||
`);
|
||||
|
||||
// Migration: stored recording filename for a session (null if not recorded)
|
||||
try { db.exec('ALTER TABLE sessions_log ADD COLUMN recording TEXT'); } catch (e) { /* exists */ }
|
||||
try { db.exec('ALTER TABLE sessions_log ADD COLUMN transcript TEXT'); } catch (e) { /* exists */ }
|
||||
|
||||
// Refresh tokens for native (desktop/mobile) clients: long-lived, rotated on use,
|
||||
// stored as a SHA-256 hash so a DB leak doesn't expose usable tokens.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
revoked INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`);
|
||||
|
||||
// API keys for third-party / system integrations (machine-to-machine, no human login).
|
||||
// Scoped per tenant; the key is stored as a SHA-256 hash (plaintext shown once at creation).
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
team_id TEXT NOT NULL,
|
||||
name TEXT,
|
||||
key_hash TEXT NOT NULL UNIQUE,
|
||||
scopes TEXT NOT NULL DEFAULT '',
|
||||
created_by TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_used_at INTEGER,
|
||||
revoked INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`);
|
||||
|
||||
// Outbound webhook subscriptions: per-tenant endpoints that receive signed event
|
||||
// callbacks (session.started / session.ended). Each has its own signing secret.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS webhooks (
|
||||
id TEXT PRIMARY KEY,
|
||||
team_id TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
secret TEXT NOT NULL,
|
||||
events TEXT NOT NULL DEFAULT '',
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
created_by TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_status INTEGER,
|
||||
last_error TEXT,
|
||||
last_at INTEGER
|
||||
);
|
||||
`);
|
||||
|
||||
// Persistent 1:1 chat between users in the same team.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
team_id TEXT NOT NULL,
|
||||
sender_id TEXT NOT NULL,
|
||||
recipient_id TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
read_at INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_pair ON messages(team_id, sender_id, recipient_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_unread ON messages(team_id, recipient_id, sender_id, read_at);
|
||||
`);
|
||||
// Migration: a message can quote/reply to another message.
|
||||
try { db.exec('ALTER TABLE messages ADD COLUMN reply_to TEXT'); } catch (e) { /* exists */ }
|
||||
|
||||
// Emoji reactions on messages (one row per user+message+emoji; toggling adds/removes).
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS message_reactions (
|
||||
message_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
emoji TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (message_id, user_id, emoji)
|
||||
);
|
||||
`);
|
||||
|
||||
// File attachments for chat messages (file bytes stored on disk at uploads/<id>).
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
id TEXT PRIMARY KEY,
|
||||
team_id TEXT NOT NULL,
|
||||
uploader_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
mime TEXT,
|
||||
size INTEGER,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
try { db.exec('ALTER TABLE messages ADD COLUMN attachment_id TEXT'); } catch (e) { /* exists */ }
|
||||
|
||||
// Group conversations + membership. (1:1 DMs keep using sender_id/recipient_id directly;
|
||||
// group messages set conversation_id instead, with recipient_id left blank.)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id TEXT PRIMARY KEY,
|
||||
team_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'group',
|
||||
name TEXT,
|
||||
created_by TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS conversation_members (
|
||||
conversation_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
last_read_at INTEGER NOT NULL DEFAULT 0,
|
||||
joined_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (conversation_id, user_id)
|
||||
);
|
||||
`);
|
||||
try { db.exec('ALTER TABLE messages ADD COLUMN conversation_id TEXT'); } catch (e) { /* exists */ }
|
||||
try { db.exec('CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(conversation_id, created_at)'); } catch (e) {}
|
||||
// Group admins: 1 = this member is an admin (multiple admins allowed). Creator seeded as admin.
|
||||
try { db.exec('ALTER TABLE conversation_members ADD COLUMN admin INTEGER NOT NULL DEFAULT 0'); } catch (e) { /* exists */ }
|
||||
try { db.exec('UPDATE conversation_members SET admin=1 WHERE user_id IN (SELECT created_by FROM conversations WHERE conversations.id=conversation_members.conversation_id) AND admin=0'); } catch (e) {}
|
||||
|
||||
// Avatars: a user's profile picture (BizGaze photo URL) and a group's uploaded image
|
||||
// (an attachment id, served via /files/<id> with group-membership auth).
|
||||
try { db.exec('ALTER TABLE users ADD COLUMN avatar_url TEXT'); } catch (e) { /* exists */ }
|
||||
try { db.exec('ALTER TABLE conversations ADD COLUMN avatar_id TEXT'); } catch (e) { /* exists */ }
|
||||
|
||||
// @mentions on a (group) message: JSON array of mentioned user ids, and/or the literal
|
||||
// "everyone" for @everyone/@all. Used to highlight and notify mentioned members.
|
||||
try { db.exec('ALTER TABLE messages ADD COLUMN mentions TEXT'); } catch (e) { /* exists */ }
|
||||
|
||||
// Delivered receipt for DMs (double tick): set when the recipient's client acknowledges.
|
||||
try { db.exec('ALTER TABLE messages ADD COLUMN delivered_at INTEGER'); } catch (e) { /* exists */ }
|
||||
// Group setting: when 1, only the creator can add/remove members.
|
||||
try { db.exec('ALTER TABLE conversations ADD COLUMN admin_only INTEGER NOT NULL DEFAULT 0'); } catch (e) { /* exists */ }
|
||||
|
||||
// Polls live within a group conversation, attached to a message (the poll's question is
|
||||
// the message body). options is a JSON array of option strings; votes are one row each.
|
||||
try { db.exec('ALTER TABLE messages ADD COLUMN poll_id TEXT'); } catch (e) { /* exists */ }
|
||||
// Activity/event lines (e.g. 'call-start','call-end') render as centered system messages.
|
||||
try { db.exec('ALTER TABLE messages ADD COLUMN msg_type TEXT'); } catch (e) { /* exists */ }
|
||||
// Deleted ("delete for everyone"): the row stays so threads/ordering hold, but body+attachment
|
||||
// are cleared and clients render a "This message was deleted" placeholder.
|
||||
try { db.exec('ALTER TABLE messages ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0'); } catch (e) { /* exists */ }
|
||||
// #18 "Delete for me": a per-user hide. The message row is untouched (others still see it); this
|
||||
// table records that THIS user removed it from their own view (threads + sidebar filter it out).
|
||||
try { db.exec('CREATE TABLE IF NOT EXISTS message_hidden (message_id TEXT NOT NULL, user_id TEXT NOT NULL, hidden_at INTEGER, PRIMARY KEY (message_id, user_id))'); } catch (e) { /* exists */ }
|
||||
// A message can be edited by its sender; edited_at marks it (shows an "edited" label).
|
||||
try { db.exec('ALTER TABLE messages ADD COLUMN edited_at INTEGER'); } catch (e) { /* exists */ }
|
||||
try { db.exec('ALTER TABLE messages ADD COLUMN fwd_from TEXT'); } catch (e) { /* exists — original sender name when a message was forwarded (#5) */ }
|
||||
// #13 Pin a message: pinned_at (when it was pinned, NULL = not pinned) + pinned_by (who pinned it). A
|
||||
// conversation's pinned strip lists its messages with a non-NULL pinned_at.
|
||||
try { db.exec('ALTER TABLE messages ADD COLUMN pinned_at INTEGER'); } catch (e) { /* exists */ }
|
||||
try { db.exec('ALTER TABLE messages ADD COLUMN pinned_by TEXT'); } catch (e) { /* exists */ }
|
||||
// User-set presence status: 'active' | 'away' | 'onleave'. ('incall' is derived live, not stored.)
|
||||
try { db.exec("ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active'"); } catch (e) { /* exists */ }
|
||||
// BizGaze person-id (s.userId): the SAME value whether the person signs in with their email or
|
||||
// their mobile number, so this — not the typed login identifier — is the stable identity key.
|
||||
// Provisioning matches on it to keep one Biz Connect account per person (#2 account merge).
|
||||
try { db.exec('ALTER TABLE users ADD COLUMN bizgaze_user_id TEXT'); } catch (e) { /* exists */ }
|
||||
// #2: when this user was last connected (stamped on connect + on their last socket closing), so contacts
|
||||
// can show "last seen 10 minutes ago" instead of a bare "Offline".
|
||||
try { db.exec('ALTER TABLE users ADD COLUMN last_seen INTEGER'); } catch (e) { /* exists */ }
|
||||
// Backfill: the column is new, so every existing user was NULL and therefore still read as a bare
|
||||
// "Offline" until they happened to reconnect. Seed it from the last message they sent — the best
|
||||
// evidence we already have of when they were last around. Only fills rows that are still NULL.
|
||||
try {
|
||||
db.exec(`UPDATE users SET last_seen = (
|
||||
SELECT MAX(created_at) FROM messages WHERE messages.sender_id = users.id
|
||||
) WHERE last_seen IS NULL AND EXISTS (SELECT 1 FROM messages WHERE messages.sender_id = users.id)`);
|
||||
} catch (e) { /* messages table may not exist yet on a fresh db */ }
|
||||
try { db.exec('CREATE INDEX IF NOT EXISTS idx_users_bizgaze_user_id ON users(bizgaze_user_id)'); } catch (e) { /* exists */ }
|
||||
// When two accounts merge (#2), the merged-away row is deleted. This records old_id -> survivor so
|
||||
// any lingering reference to the old id (a cached contact, an in-flight DM) resolves to the survivor
|
||||
// instead of hitting a deleted user (which made messages to merged contacts silently vanish).
|
||||
// #7: a log of finished CALLS (ad-hoc / group / 1:1). Scheduled meetings already have their own row, so
|
||||
// they're not duplicated here. `peak` is the most people who were in the room at once — that's what lets
|
||||
// "Past meetings" show a call that grew past 2 people while hiding plain 1:1s.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS call_history (
|
||||
id TEXT PRIMARY KEY,
|
||||
team_id TEXT NOT NULL,
|
||||
room TEXT NOT NULL,
|
||||
group_id TEXT,
|
||||
kind TEXT,
|
||||
title TEXT,
|
||||
peak INTEGER NOT NULL DEFAULT 0,
|
||||
participants TEXT,
|
||||
uids TEXT,
|
||||
started_at INTEGER NOT NULL,
|
||||
ended_at INTEGER NOT NULL
|
||||
)`);
|
||||
try { db.exec('CREATE INDEX IF NOT EXISTS idx_call_history_team ON call_history(team_id, ended_at)'); } catch (e) {}
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_aliases (
|
||||
old_id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
team_id TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
)`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS polls (
|
||||
id TEXT PRIMARY KEY,
|
||||
team_id TEXT NOT NULL,
|
||||
conversation_id TEXT NOT NULL,
|
||||
message_id TEXT,
|
||||
question TEXT NOT NULL,
|
||||
options TEXT NOT NULL,
|
||||
multi INTEGER NOT NULL DEFAULT 0,
|
||||
closed INTEGER NOT NULL DEFAULT 0,
|
||||
created_by TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS poll_votes (
|
||||
poll_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
option_idx INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (poll_id, user_id, option_idx)
|
||||
);
|
||||
`);
|
||||
|
||||
// Scheduled meetings/calls. Each carries a stable room_code so a scheduled call can be
|
||||
// joined later (the live mesh room is created on first join). group_id is optional — a
|
||||
// scheduled meeting may target a specific group conversation or be standalone.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS scheduled_meetings (
|
||||
id TEXT PRIMARY KEY,
|
||||
team_id TEXT NOT NULL,
|
||||
group_id TEXT,
|
||||
room_code TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
scheduled_at INTEGER NOT NULL,
|
||||
created_by TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
ended_at INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sched_team ON scheduled_meetings(team_id, scheduled_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_sched_code ON scheduled_meetings(room_code);
|
||||
`);
|
||||
// Invited participants (JSON array of user ids) + a one-shot "10-min reminder sent" flag.
|
||||
try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN participants TEXT'); } catch (e) { /* exists */ }
|
||||
try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN reminded INTEGER NOT NULL DEFAULT 0'); } catch (e) { /* exists */ }
|
||||
// Cancelled meetings are kept (shown as "Cancelled"), not deleted.
|
||||
try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN cancelled INTEGER NOT NULL DEFAULT 0'); } catch (e) { /* exists */ }
|
||||
try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN duration_mins INTEGER'); } catch (e) { /* exists */ }
|
||||
// Weekly recurrence: JSON array of weekdays (0=Sun..6=Sat), or null for a one-off.
|
||||
try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN recurrence TEXT'); } catch (e) { /* exists */ }
|
||||
// External (non-Connect) invitee emails on a scheduled meeting (#4) — JSON array. They get an emailed
|
||||
// guest join link instead of an in-app invite. (Must come AFTER the CREATE above — on a fresh DB these
|
||||
// ALTERs previously ran before the table existed and were silently lost.)
|
||||
try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN guest_emails TEXT'); } catch (e) { /* exists */ }
|
||||
// Lobby (#4): 1 = guests joining by link must be admitted by the host; 0 = they join directly. NULL is
|
||||
// treated as "require approval" (safe default) by the signaling layer.
|
||||
try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN lobby INTEGER'); } catch (e) { /* exists */ }
|
||||
|
||||
// Meeting recordings & transcripts. Video bytes live in recordings/m_<id>.webm, transcript text
|
||||
// in transcripts/m_<id>.txt. Tied to a room (and group/scheduled meeting when applicable) so they
|
||||
// surface under "Past meetings". kind = 'video' | 'transcript'.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS recordings (
|
||||
id TEXT PRIMARY KEY,
|
||||
team_id TEXT NOT NULL,
|
||||
room TEXT,
|
||||
group_id TEXT,
|
||||
meeting_id TEXT,
|
||||
title TEXT,
|
||||
kind TEXT NOT NULL,
|
||||
file TEXT,
|
||||
mime TEXT,
|
||||
size INTEGER,
|
||||
duration_ms INTEGER,
|
||||
created_by TEXT,
|
||||
created_by_name TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rec_team ON recordings(team_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_rec_room ON recordings(room);
|
||||
`);
|
||||
|
||||
// Web Push subscriptions (one per browser/device per user) for background/closed-tab
|
||||
// notifications. endpoint is unique; p256dh+auth are the encryption keys from the browser.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
endpoint TEXT NOT NULL UNIQUE,
|
||||
p256dh TEXT NOT NULL,
|
||||
auth TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_push_user ON push_subscriptions(user_id);
|
||||
`);
|
||||
|
||||
// Native device push tokens (FCM for Android, APNs for iOS) registered by the mobile app.
|
||||
// Distinct from push_subscriptions (Web Push): a native token is just an opaque string + platform.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS device_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
tenant_id TEXT,
|
||||
platform TEXT NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_seen INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_devtok_user ON device_tokens(user_id);
|
||||
`);
|
||||
|
||||
// App installs (desktop/mobile clients): one row per install, associated with the user once
|
||||
// they sign in. Lets admins see who installed the app, which version, and when it was last used.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS app_installs (
|
||||
id TEXT PRIMARY KEY,
|
||||
install_id TEXT NOT NULL UNIQUE,
|
||||
user_id TEXT,
|
||||
user_email TEXT,
|
||||
tenant_id TEXT,
|
||||
platform TEXT,
|
||||
app_version TEXT,
|
||||
os TEXT,
|
||||
first_seen INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_installs_tenant ON app_installs(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_installs_user ON app_installs(user_id);
|
||||
`);
|
||||
|
||||
// Favourite conversations (per user). target = 'dm:<userId>' or 'group:<groupId>'.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS favorites (
|
||||
user_id TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, target)
|
||||
);
|
||||
`);
|
||||
|
||||
module.exports = db;
|
||||
@@ -1,56 +0,0 @@
|
||||
// One-shot data migration: copy every row from the SQLite data.db into Postgres. Run ONCE at cutover,
|
||||
// with the app stopped, BEFORE switching DB_BACKEND to pg.
|
||||
//
|
||||
// DB_PATH=/data/data.db DATABASE_URL=postgres://user:pass@host/db node db/migrate-sqlite-to-pg.js
|
||||
//
|
||||
// It applies the Postgres schema first, TRUNCATEs the target tables (so a re-run re-copies cleanly), then
|
||||
// bulk-inserts in FK-dependency order. audit_log.id is a GENERATED identity, so its id is not copied (PG
|
||||
// assigns fresh ones — nothing references audit_log.id). Timestamps/flags are plain integers on both sides.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const SQLITE = process.env.DB_PATH || path.join(__dirname, '..', 'data.db');
|
||||
if (!process.env.DATABASE_URL) { console.error('DATABASE_URL is required'); process.exit(1); }
|
||||
|
||||
const src = new DatabaseSync(SQLITE);
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 4 });
|
||||
|
||||
// Parents before children (users→teams, sessions_auth→users, machines→teams); the rest have no FKs.
|
||||
const ORDER = [
|
||||
'teams', 'users', 'machines', 'sessions_auth', 'audit_log', 'sessions_log', 'refresh_tokens',
|
||||
'api_keys', 'webhooks', 'messages', 'message_reactions', 'attachments', 'conversations',
|
||||
'conversation_members', 'call_history', 'user_aliases', 'polls', 'poll_votes', 'scheduled_meetings',
|
||||
'recordings', 'push_subscriptions', 'device_tokens', 'app_installs', 'favorites',
|
||||
];
|
||||
|
||||
async function main() {
|
||||
await pool.query(fs.readFileSync(path.join(__dirname, 'schema.pg.sql'), 'utf8')); // ensure schema exists
|
||||
await pool.query('TRUNCATE ' + ORDER.map((t) => '"' + t + '"').join(', ') + ' RESTART IDENTITY CASCADE');
|
||||
|
||||
const totals = {};
|
||||
for (const table of ORDER) {
|
||||
let rows = [];
|
||||
try { rows = src.prepare('SELECT * FROM ' + table).all(); } catch (e) { totals[table] = 'skip(' + e.message + ')'; continue; }
|
||||
if (!rows.length) { totals[table] = 0; continue; }
|
||||
let cols = Object.keys(rows[0]);
|
||||
if (table === 'audit_log') cols = cols.filter((c) => c !== 'id'); // GENERATED — let PG assign
|
||||
const colList = cols.map((c) => '"' + c + '"').join(',');
|
||||
const CHUNK = 400; // keep param count well under Postgres' 65535 limit even for wide tables
|
||||
for (let i = 0; i < rows.length; i += CHUNK) {
|
||||
const batch = rows.slice(i, i + CHUNK);
|
||||
const values = []; const params = [];
|
||||
batch.forEach((r, ri) => {
|
||||
values.push('(' + cols.map((c, ci) => '$' + (ri * cols.length + ci + 1)).join(',') + ')');
|
||||
cols.forEach((c) => params.push(r[c] === undefined ? null : r[c]));
|
||||
});
|
||||
await pool.query('INSERT INTO "' + table + '" (' + colList + ') VALUES ' + values.join(','), params);
|
||||
}
|
||||
totals[table] = rows.length;
|
||||
}
|
||||
console.log('MIGRATED rows:', JSON.stringify(totals, null, 0));
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error('MIGRATION FAILED:', e && e.message); process.exit(1); });
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
// PostgreSQL backend for the async DB adapter. Same interface as db/sqlite.js — prepare(sql).{get,all,run},
|
||||
// exec(sql), tx(fn), init() — so repos and app code are engine-agnostic. Selected by DB_BACKEND=pg;
|
||||
// connection string from DATABASE_URL.
|
||||
// PostgreSQL backend for the async DB adapter — the ONLY backend (SQLite retired 2026-08-12). Implements
|
||||
// prepare(sql).{get,all,run}, exec(sql), tx(fn), init() so repos/app code stay engine-agnostic (the facade
|
||||
// in dbx.js keeps the door open for future backends). Connection string from DATABASE_URL.
|
||||
const { Pool, types } = require('pg');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
// SQLite backend for the async DB adapter (dev + tests; also the current prod engine until pg cutover).
|
||||
//
|
||||
// Wraps the synchronous node:sqlite instance (schema applied at load in ../db.js) in the async interface
|
||||
// the repos call. Results are returned via resolved Promises, so the SAME repo code runs unchanged on this
|
||||
// synchronous engine and on asynchronous Postgres — the app never sees the difference.
|
||||
const raw = require('../db'); // DatabaseSync instance with the full schema already applied
|
||||
|
||||
// node:sqlite re-prepares cheaply, but caching by SQL text avoids re-parsing on hot paths.
|
||||
const cache = new Map();
|
||||
function stmt(sql) {
|
||||
let s = cache.get(sql);
|
||||
if (!s) { s = raw.prepare(sql); cache.set(sql, s); }
|
||||
return s;
|
||||
}
|
||||
const num = (v) => (typeof v === 'bigint' ? Number(v) : v);
|
||||
const runResult = (r) => ({ changes: num(r.changes), lastInsertRowid: num(r.lastInsertRowid) });
|
||||
|
||||
function prepare(sql) {
|
||||
return {
|
||||
get: (...p) => Promise.resolve(stmt(sql).get(...p)),
|
||||
all: (...p) => Promise.resolve(stmt(sql).all(...p)),
|
||||
run: (...p) => Promise.resolve(runResult(stmt(sql).run(...p))),
|
||||
};
|
||||
}
|
||||
|
||||
function exec(sql) { raw.exec(sql); return Promise.resolve(); }
|
||||
|
||||
// Transaction primitive. SQLite is single-connection, so BEGIN/COMMIT/ROLLBACK on `raw` is safe; the
|
||||
// callback gets a runner with the same async run/get shape. (The pg backend implements this on ONE pooled
|
||||
// client — the reason repos must use tx() rather than bare exec('BEGIN') for multi-statement atomicity.)
|
||||
async function tx(fn) {
|
||||
raw.exec('BEGIN');
|
||||
try {
|
||||
const t = {
|
||||
run: (sql, ...p) => Promise.resolve(runResult(stmt(sql).run(...p))),
|
||||
get: (sql, ...p) => Promise.resolve(stmt(sql).get(...p)),
|
||||
all: (sql, ...p) => Promise.resolve(stmt(sql).all(...p)),
|
||||
};
|
||||
const out = await fn(t);
|
||||
raw.exec('COMMIT');
|
||||
return out;
|
||||
} catch (e) {
|
||||
try { raw.exec('ROLLBACK'); } catch (_) {}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
function init() { return Promise.resolve(); } // schema already applied synchronously in ../db.js
|
||||
|
||||
module.exports = { prepare, exec, tx, init, name: 'sqlite', _raw: raw };
|
||||
+9
-5
@@ -1,6 +1,10 @@
|
||||
// Async DB adapter facade. The backend is chosen by DB_BACKEND (default 'sqlite'); 'pg' is added at
|
||||
// cutover. Every backend implements the same async interface — prepare(sql).{get,all,run}, exec(sql),
|
||||
// tx(fn), init() — so repos and app code are engine-agnostic. Swapping engines is one backend file, no
|
||||
// repo changes. (This is the same "never hardwire the engine" principle we'll apply to the pub/sub layer.)
|
||||
const name = process.env.DB_BACKEND || 'sqlite';
|
||||
// Async DB adapter facade. Production runs PostgreSQL. SQLite was RETIRED on 2026-08-12 so there is exactly
|
||||
// ONE schema source of truth (db/schema.pg.sql) — no more dual-maintenance drift between a SQLite migration
|
||||
// list and the PG schema (that gap once made a column land on SQLite only and 500'd every read on prod).
|
||||
//
|
||||
// DB_BACKEND is kept for future swappable backends but defaults to 'pg', and only 'pg' ships today. An
|
||||
// unknown value fails LOUDLY here at require time (module-not-found) rather than silently selecting a stale
|
||||
// or non-existent engine. Every backend implements the same async interface: prepare(sql).{get,all,run},
|
||||
// exec(sql), tx(fn), init().
|
||||
const name = process.env.DB_BACKEND || 'pg';
|
||||
module.exports = require('./db/' + name);
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// One-time PRODUCTION migration for "BizGaze-only logins".
|
||||
//
|
||||
// Deletes the in-app (pre-BizGaze) local accounts. Combined with the BizGaze-only login
|
||||
// change, every user then signs in through BizGaze and is provisioned into the same
|
||||
// tenant — which restores the admin's "see all sessions" report.
|
||||
//
|
||||
// A "pre-BizGaze" account = a user with NO 'sso_user_created' audit entry for its email
|
||||
// (i.e. created locally via register/console, not provisioned by a BizGaze login).
|
||||
//
|
||||
// SAFE BY DEFAULT: dry-run unless you pass --apply. BACK UP THE DB FIRST.
|
||||
// Dry run : node scripts/migrate-bizgaze-only.js
|
||||
// Apply : node scripts/migrate-bizgaze-only.js --apply
|
||||
// Honors DB_PATH (same env var the server uses).
|
||||
|
||||
const db = require('../db');
|
||||
const APPLY = process.argv.includes('--apply');
|
||||
|
||||
const tableExists = (name) => !!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(name);
|
||||
|
||||
const ssoEmails = new Set(
|
||||
db.prepare("SELECT DISTINCT lower(user_email) AS e FROM audit_log WHERE action='sso_user_created' AND user_email IS NOT NULL")
|
||||
.all().map((r) => r.e),
|
||||
);
|
||||
const users = db.prepare('SELECT id,email,name,role,team_id,active FROM users').all();
|
||||
const keep = users.filter((u) => ssoEmails.has(String(u.email).toLowerCase()));
|
||||
const remove = users.filter((u) => !ssoEmails.has(String(u.email).toLowerCase()));
|
||||
|
||||
console.log('=== Teams ===');
|
||||
for (const t of db.prepare('SELECT id,name FROM teams').all()) {
|
||||
const uc = db.prepare('SELECT COUNT(*) AS c FROM users WHERE team_id=?').get(t.id).c;
|
||||
console.log(` ${t.id} ${t.name} (${uc} users)`);
|
||||
}
|
||||
console.log('\n=== Users ===');
|
||||
console.log(` total: ${users.length} | BizGaze-provisioned (keep): ${keep.length} | local pre-BizGaze (delete): ${remove.length}`);
|
||||
console.log('\n KEEP (already BizGaze-provisioned):');
|
||||
keep.forEach((u) => console.log(` ${u.email} [${u.role}] team ${u.team_id}`));
|
||||
console.log('\n DELETE (local / pre-BizGaze):');
|
||||
remove.forEach((u) => console.log(` ${u.email} [${u.role}] team ${u.team_id}`));
|
||||
|
||||
if (!remove.length) { console.log('\nNothing to delete. Done.'); process.exit(0); }
|
||||
|
||||
if (!APPLY) {
|
||||
console.log('\nDRY RUN — no changes made. Re-run with --apply to delete the local accounts above.');
|
||||
console.log('After deletion, those users sign in via BizGaze and are recreated automatically.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const delAuth = db.prepare('DELETE FROM sessions_auth WHERE user_id=?');
|
||||
const delRefresh = tableExists('refresh_tokens') ? db.prepare('DELETE FROM refresh_tokens WHERE user_id=?') : null;
|
||||
const delUser = db.prepare('DELETE FROM users WHERE id=?');
|
||||
let deleted = 0;
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
for (const u of remove) {
|
||||
delAuth.run(u.id); // clear active sessions (FK) — also logs them out
|
||||
if (delRefresh) delRefresh.run(u.id);
|
||||
delUser.run(u.id);
|
||||
deleted++;
|
||||
}
|
||||
db.exec('COMMIT');
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK');
|
||||
console.error('FAILED — rolled back, no changes applied:', e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`\nDONE. Deleted ${deleted} local account(s). They are recreated via BizGaze on next sign-in.`);
|
||||
@@ -8,12 +8,14 @@
|
||||
//
|
||||
// Run: node test/db-smoke.js (uses a throwaway temp DB; DB_BACKEND env selects sqlite|pg)
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const DB = path.join(os.tmpdir(), 'bzc-smoke.db');
|
||||
process.env.DB_PATH = DB;
|
||||
for (const f of [DB, DB + '-wal', DB + '-shm']) { try { fs.unlinkSync(f); } catch {} }
|
||||
// SQLite was retired 2026-08-12 — this suite runs against Postgres now. Point DATABASE_URL at a DISPOSABLE
|
||||
// test database (NEVER production — the suite creates + mutates rows), e.g.:
|
||||
// DATABASE_URL=postgres://bizgaze:PW@localhost:5432/bizgaze_test node test/db-smoke.js
|
||||
process.env.DB_BACKEND = process.env.DB_BACKEND || 'pg';
|
||||
if (!process.env.DATABASE_URL) {
|
||||
console.log('SKIP db-smoke: set DATABASE_URL to a disposable Postgres test DB (SQLite retired 2026-08-12).');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const PORT = 8097;
|
||||
process.env.PORT = PORT;
|
||||
@@ -40,7 +42,7 @@ async function get(p, cookie) {
|
||||
|
||||
(async () => {
|
||||
await wait(300);
|
||||
console.log('DB smoke tests (backend=' + (process.env.DB_BACKEND || 'sqlite') + '):');
|
||||
console.log('DB smoke tests (backend=' + process.env.DB_BACKEND + '):');
|
||||
|
||||
// Auth
|
||||
const reg = await post('/api/register', { email: 'admin@smoke.test', password: 'supersecret', teamName: 'Smoke Co' });
|
||||
|
||||
+8
-6
@@ -5,12 +5,14 @@
|
||||
// (Login currently marks the session MFA-passed directly, so there is no separate
|
||||
// TOTP step in the product flow; the MFA endpoints still exist but aren't exercised here.)
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const DB = path.join(os.tmpdir(), 'ra-e2e.db');
|
||||
process.env.DB_PATH = DB;
|
||||
for (const f of [DB, DB + '-wal', DB + '-shm']) { try { fs.unlinkSync(f); } catch {} }
|
||||
// SQLite was retired 2026-08-12 — the backend is Postgres now. Point DATABASE_URL at a DISPOSABLE test DB
|
||||
// (never production — this creates + mutates rows), e.g.:
|
||||
// DATABASE_URL=postgres://bizgaze:PW@localhost:5432/bizgaze_test node test/e2e.js
|
||||
process.env.DB_BACKEND = process.env.DB_BACKEND || 'pg';
|
||||
if (!process.env.DATABASE_URL) {
|
||||
console.log('SKIP e2e: set DATABASE_URL to a disposable Postgres test DB (SQLite retired 2026-08-12).');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const PORT = 8099;
|
||||
process.env.PORT = PORT;
|
||||
|
||||
Reference in New Issue
Block a user