1272b81cee
Page-level Notifications can't fire when a tab is frozen/closed (and never on mobile), which is why recipients on another tab/app got nothing. Adds a notification-only service worker (sw.js, no caching) + Web Push: - push.js: optional web-push wrapper (no-op unless web-push installed AND VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY set -> app unaffected if unconfigured). - push_subscriptions table + R.pushSubs repo (upsert by endpoint, prune dead). - /api/push/vapid|subscribe|unsubscribe; DM + group message routes also send a Web Push to recipients. - Client registers /sw.js, subscribes when permission granted; hidden-tab popups are left to push to avoid double-notifying (pushActive flag); SW suppresses the OS popup when a tab is visible. Removes the old code that unregistered SWs. Requires (prod, once): npm install + VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY/VAPID_SUBJECT env. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
305 lines
11 KiB
JavaScript
305 lines
11 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 */ }
|
|
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);
|
|
`);
|
|
|
|
module.exports = db;
|