// 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/). 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/ 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 */ } 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 */ } // Meeting recordings & transcripts. Video bytes live in recordings/m_.webm, transcript text // in transcripts/m_.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); `); module.exports = db;