// 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 */ } // 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 */ } // 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) */ } // 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 */ } // 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. 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 */ } 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 */ } // 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); `); // 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:' or 'group:'. 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;