fad8a52da4
#1 Last seen still read "Offline": last_seen is a NEW column, so every existing user was NULL until they happened to reconnect. Backfill it from the newest message each user sent (best evidence we already have), and also stamp it on message send — not just on connect/disconnect — so it stays fresh if a socket never closes cleanly. #3 Past-meetings filter is a report filter now: presets (Today, Yesterday, Last 7/30 days, This month, Year to date, All time) plus a Custom range that uses the SAME branded calendar popup as the scheduler. The raw <input type=date> looked foreign and behaved differently per browser. #4 Hover action row overflowed off-panel on SHORT received messages: it's wider than the bubble and was anchored to the bubble's right edge, so it ran off the left. Received bubbles now anchor it from the left (growing into the empty space); own messages keep the right anchor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
407 lines
16 KiB
JavaScript
407 lines
16 KiB
JavaScript
// 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 */ }
|
|
// 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 */ }
|
|
// 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 */ }
|
|
// 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_<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;
|