const Database = require('better-sqlite3-multiple-ciphers'); const path = require('path'); const fs = require('fs'); const bcrypt = require('bcryptjs'); const DB_PATH = process.env.DB_PATH || '/app/data/jama.db'; const DB_KEY = process.env.DB_KEY || ''; let db; function getDb() { if (!db) { // Ensure the data directory exists before opening the DB const dir = path.dirname(DB_PATH); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); console.log(`[DB] Created data directory: ${dir}`); } db = new Database(DB_PATH); if (DB_KEY) { // Use SQLCipher4 AES-256-CBC — compatible with standard sqlcipher CLI and DB Browser // Must be applied before any other DB access const safeKey = DB_KEY.replace(/'/g, "''"); db.pragma(`cipher='sqlcipher'`); db.pragma(`legacy=4`); db.pragma(`key='${safeKey}'`); console.log('[DB] Encryption key applied (SQLCipher4)'); } else { console.warn('[DB] WARNING: DB_KEY not set — database is unencrypted'); } const journalMode = db.pragma('journal_mode = WAL', { simple: true }); if (journalMode !== 'wal') { console.warn(`[DB] WARNING: journal_mode is '${journalMode}', expected 'wal' — performance may be degraded`); } db.pragma('synchronous = NORMAL'); // safe with WAL, faster than FULL db.pragma('cache_size = -8000'); // 8MB page cache db.pragma('foreign_keys = ON'); console.log(`[DB] Opened database at ${DB_PATH} (journal=${journalMode})`); } return db; } function initDb() { const db = getDb(); db.exec(` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, email TEXT UNIQUE NOT NULL, password TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'member', status TEXT NOT NULL DEFAULT 'active', is_default_admin INTEGER NOT NULL DEFAULT 0, must_change_password INTEGER NOT NULL DEFAULT 1, avatar TEXT, about_me TEXT, display_name TEXT, hide_admin_tag INTEGER NOT NULL DEFAULT 0, allow_dm INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS groups ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, type TEXT NOT NULL DEFAULT 'public', owner_id INTEGER, is_default INTEGER NOT NULL DEFAULT 0, is_readonly INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (owner_id) REFERENCES users(id) ); CREATE TABLE IF NOT EXISTS group_members ( id INTEGER PRIMARY KEY AUTOINCREMENT, group_id INTEGER NOT NULL, user_id INTEGER NOT NULL, joined_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(group_id, user_id), FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, group_id INTEGER NOT NULL, user_id INTEGER NOT NULL, content TEXT, type TEXT NOT NULL DEFAULT 'text', image_url TEXT, reply_to_id INTEGER, is_deleted INTEGER NOT NULL DEFAULT 0, link_preview TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id), FOREIGN KEY (reply_to_id) REFERENCES messages(id) ); CREATE TABLE IF NOT EXISTS reactions ( id INTEGER PRIMARY KEY AUTOINCREMENT, message_id INTEGER NOT NULL, user_id INTEGER NOT NULL, emoji TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(message_id, user_id, emoji), FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS notifications ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, type TEXT NOT NULL, message_id INTEGER, group_id INTEGER, from_user_id INTEGER, is_read INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, user_id INTEGER NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), expires_at TEXT NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS active_sessions ( user_id INTEGER NOT NULL, device TEXT NOT NULL DEFAULT 'desktop', token TEXT NOT NULL, ua TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (user_id, device), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS push_subscriptions ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, endpoint TEXT NOT NULL, p256dh TEXT NOT NULL, auth TEXT NOT NULL, device TEXT NOT NULL DEFAULT 'desktop', created_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(user_id, device), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); -- User groups (admin-managed, separate from chat groups) CREATE TABLE IF NOT EXISTS user_groups ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, dm_group_id INTEGER, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (dm_group_id) REFERENCES groups(id) ON DELETE SET NULL ); -- Members of user groups CREATE TABLE IF NOT EXISTS user_group_members ( user_group_id INTEGER NOT NULL, user_id INTEGER NOT NULL, joined_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (user_group_id, user_id), FOREIGN KEY (user_group_id) REFERENCES user_groups(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); -- Multi-group DMs: admin-created DMs whose members are user groups CREATE TABLE IF NOT EXISTS multi_group_dms ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, dm_group_id INTEGER, -- paired private group in groups table created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (dm_group_id) REFERENCES groups(id) ON DELETE SET NULL ); -- User groups that are members of a multi-group DM CREATE TABLE IF NOT EXISTS multi_group_dm_members ( multi_group_dm_id INTEGER NOT NULL, user_group_id INTEGER NOT NULL, joined_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (multi_group_dm_id, user_group_id), FOREIGN KEY (multi_group_dm_id) REFERENCES multi_group_dms(id) ON DELETE CASCADE, FOREIGN KEY (user_group_id) REFERENCES user_groups(id) ON DELETE CASCADE ); `); // Initialize default settings const insertSetting = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)'); insertSetting.run('app_name', process.env.APP_NAME || 'jama'); insertSetting.run('logo_url', ''); insertSetting.run('pw_reset_active', process.env.ADMPW_RESET === 'true' ? 'true' : 'false'); insertSetting.run('icon_newchat', ''); insertSetting.run('icon_groupinfo', ''); insertSetting.run('pwa_icon_192', ''); insertSetting.run('pwa_icon_512', ''); insertSetting.run('color_title', ''); insertSetting.run('color_title_dark', ''); insertSetting.run('color_avatar_public', ''); insertSetting.run('color_avatar_dm', ''); insertSetting.run('registration_code', ''); insertSetting.run('feature_branding', 'false'); insertSetting.run('feature_group_manager', 'false'); insertSetting.run('feature_schedule_manager', 'false'); insertSetting.run('app_type', 'JAMA-Chat'); insertSetting.run('team_group_managers', ''); insertSetting.run('team_schedule_managers', ''); insertSetting.run('team_tool_managers', ''); // Migration: add hide_admin_tag if upgrading from older version try { db.exec("ALTER TABLE users ADD COLUMN hide_admin_tag INTEGER NOT NULL DEFAULT 0"); console.log('[DB] Migration: added hide_admin_tag column'); } catch (e) { /* column already exists */ } // Migration: add allow_dm if upgrading from older version try { db.exec("ALTER TABLE users ADD COLUMN allow_dm INTEGER NOT NULL DEFAULT 1"); console.log('[DB] Migration: added allow_dm column'); } catch (e) { /* column already exists */ } // Migration: replace single-session active_sessions with per-device version try { const cols = db.prepare("PRAGMA table_info(active_sessions)").all().map(c => c.name); if (!cols.includes('device')) { db.exec("DROP TABLE IF EXISTS active_sessions"); db.exec(` CREATE TABLE IF NOT EXISTS active_sessions ( user_id INTEGER NOT NULL, device TEXT NOT NULL DEFAULT 'desktop', token TEXT NOT NULL, ua TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (user_id, device), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) `); console.log('[DB] Migration: rebuilt active_sessions for per-device sessions'); } } catch (e) { console.error('[DB] active_sessions migration error:', e.message); } // Migration: add is_direct for user-to-user direct messages try { db.exec("ALTER TABLE groups ADD COLUMN is_direct INTEGER NOT NULL DEFAULT 0"); console.log('[DB] Migration: added is_direct column'); } catch (e) { /* column already exists */ } // Migration: store both peer IDs so direct-message names survive member leave try { db.exec("ALTER TABLE groups ADD COLUMN direct_peer1_id INTEGER"); console.log('[DB] Migration: added direct_peer1_id column'); } catch (e) { /* column already exists */ } try { db.exec("ALTER TABLE groups ADD COLUMN direct_peer2_id INTEGER"); console.log('[DB] Migration: added direct_peer2_id column'); } catch (e) { /* column already exists */ } // Migration: last_online timestamp per user try { db.exec("ALTER TABLE users ADD COLUMN last_online TEXT"); console.log('[DB] Migration: added last_online column'); } catch (e) { /* column already exists */ } // Migration: help_dismissed preference per user try { db.exec("ALTER TABLE users ADD COLUMN help_dismissed INTEGER NOT NULL DEFAULT 0"); console.log('[DB] Migration: added help_dismissed column'); } catch (e) { /* column already exists */ } // Migration: user-customised group display names (per-user, per-group) try { db.exec(` CREATE TABLE IF NOT EXISTS user_group_names ( user_id INTEGER NOT NULL, group_id INTEGER NOT NULL, name TEXT NOT NULL, PRIMARY KEY (user_id, group_id) ) `); console.log('[DB] Migration: user_group_names table ready'); } catch (e) { console.error('[DB] user_group_names migration error:', e.message); } // Migration: pinned conversations (per-user, pins a group to top of sidebar) try { db.exec(` CREATE TABLE IF NOT EXISTS pinned_conversations ( user_id INTEGER NOT NULL, group_id INTEGER NOT NULL, pinned_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (user_id, group_id), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE ) `); console.log('[DB] Migration: pinned_conversations table ready'); } catch (e) { console.error('[DB] pinned_conversations migration error:', e.message); } // Migration: is_managed flag on groups (admin-managed DMs via Group Manager) try { db.exec("ALTER TABLE groups ADD COLUMN is_managed INTEGER NOT NULL DEFAULT 0"); console.log('[DB] Migration: added is_managed column to groups'); } catch (e) { /* already exists */ } // Migration: is_multi_group flag — distinguishes multi-group DMs from user-group DMs try { db.exec("ALTER TABLE groups ADD COLUMN is_multi_group INTEGER NOT NULL DEFAULT 0"); console.log('[DB] Migration: added is_multi_group column to groups'); } catch (e) { /* already exists */ } // Back-fill feature_schedule_manager for installs that registered before this setting existed try { const appType = db.prepare("SELECT value FROM settings WHERE key = 'app_type'").get(); if (appType && appType.value === 'JAMA-Team') { db.prepare("INSERT INTO settings (key, value) VALUES ('feature_schedule_manager', 'true') ON CONFLICT(key) DO UPDATE SET value = 'true' WHERE value = 'false'").run(); } } catch(e) {} // Back-fill is_multi_group for any existing multi-group DM groups try { db.exec("UPDATE groups SET is_multi_group = 1 WHERE id IN (SELECT dm_group_id FROM multi_group_dms WHERE dm_group_id IS NOT NULL)"); } catch (e) { /* ignore */ } // Migration: user_groups and user_group_members tables try { db.exec(` CREATE TABLE IF NOT EXISTS user_groups ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, dm_group_id INTEGER, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (dm_group_id) REFERENCES groups(id) ON DELETE SET NULL ) `); db.exec(` CREATE TABLE IF NOT EXISTS user_group_members ( user_group_id INTEGER NOT NULL, user_id INTEGER NOT NULL, joined_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (user_group_id, user_id), FOREIGN KEY (user_group_id) REFERENCES user_groups(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ) `); db.exec(` CREATE TABLE IF NOT EXISTS multi_group_dms ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, dm_group_id INTEGER, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (dm_group_id) REFERENCES groups(id) ON DELETE SET NULL ) `); db.exec(` CREATE TABLE IF NOT EXISTS multi_group_dm_members ( multi_group_dm_id INTEGER NOT NULL, user_group_id INTEGER NOT NULL, joined_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (multi_group_dm_id, user_group_id), FOREIGN KEY (multi_group_dm_id) REFERENCES multi_group_dms(id) ON DELETE CASCADE, FOREIGN KEY (user_group_id) REFERENCES user_groups(id) ON DELETE CASCADE ) `); // Migration: add joined_at to user_group_members if missing try { db.exec("ALTER TABLE user_group_members ADD COLUMN joined_at TEXT NOT NULL DEFAULT (datetime('now'))"); } catch(e) {} console.log('[DB] Migration: user_groups tables ready'); } catch (e) { console.error('[DB] user_groups migration error:', e.message); } // ── Schedule Manager ──────────────────────────────────────────────────────── try { db.exec(` CREATE TABLE IF NOT EXISTS event_types ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, colour TEXT NOT NULL DEFAULT '#6366f1', default_user_group_id INTEGER, default_duration_hrs REAL, is_default INTEGER NOT NULL DEFAULT 0, is_protected INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (default_user_group_id) REFERENCES user_groups(id) ON DELETE SET NULL ); CREATE TABLE IF NOT EXISTS events ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, event_type_id INTEGER, start_at TEXT NOT NULL, end_at TEXT NOT NULL, all_day INTEGER NOT NULL DEFAULT 0, location TEXT, description TEXT, is_public INTEGER NOT NULL DEFAULT 1, track_availability INTEGER NOT NULL DEFAULT 0, created_by INTEGER NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (event_type_id) REFERENCES event_types(id) ON DELETE SET NULL, FOREIGN KEY (created_by) REFERENCES users(id) ); CREATE TABLE IF NOT EXISTS event_user_groups ( event_id INTEGER NOT NULL, user_group_id INTEGER NOT NULL, PRIMARY KEY (event_id, user_group_id), FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE, FOREIGN KEY (user_group_id) REFERENCES user_groups(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS event_availability ( event_id INTEGER NOT NULL, user_id INTEGER NOT NULL, response TEXT NOT NULL CHECK(response IN ('going','maybe','not_going')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (event_id, user_id), FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); `); db.prepare("INSERT OR IGNORE INTO event_types (name, colour, is_default, is_protected) VALUES ('Default', '#9ca3af', 1, 1)").run(); db.prepare("INSERT OR IGNORE INTO event_types (name, colour, is_protected, default_duration_hrs) VALUES ('Event', '#6366f1', 1, NULL)").run(); db.prepare("INSERT OR IGNORE INTO event_types (name, colour, default_duration_hrs) VALUES ('Game', '#22c55e', 3.0)").run(); db.prepare("INSERT OR IGNORE INTO event_types (name, colour, default_duration_hrs) VALUES ('Practice', '#f59e0b', 1.0)").run(); // Migration: add is_protected if missing try { db.exec("ALTER TABLE event_types ADD COLUMN is_protected INTEGER NOT NULL DEFAULT 0"); } catch(e) {} try { db.exec("ALTER TABLE event_types ADD COLUMN default_duration_hrs REAL"); } catch(e) {} // Ensure built-in types are protected db.prepare("UPDATE event_types SET is_protected = 1 WHERE name IN ('Default', 'Event')").run(); console.log('[DB] Schedule Manager tables ready'); } catch (e) { console.error('[DB] Schedule Manager migration error:', e.message); } console.log('[DB] Schema initialized'); return db; } function seedAdmin() { const db = getDb(); // Strip any surrounding quotes from env vars (common docker-compose mistake) const adminEmail = (process.env.ADMIN_EMAIL || 'admin@jama.local').replace(/^["']|["']$/g, '').trim(); const adminName = (process.env.ADMIN_NAME || 'Admin User').replace(/^["']|["']$/g, '').trim(); const adminPass = (process.env.ADMIN_PASS || 'Admin@1234').replace(/^["']|["']$/g, '').trim(); const pwReset = process.env.ADMPW_RESET === 'true'; console.log(`[DB] Checking for default admin (${adminEmail})...`); const existing = db.prepare('SELECT * FROM users WHERE is_default_admin = 1').get(); if (!existing) { try { const hash = bcrypt.hashSync(adminPass, 10); const result = db.prepare(` INSERT INTO users (name, email, password, role, status, is_default_admin, must_change_password) VALUES (?, ?, ?, 'admin', 'active', 1, 1) `).run(adminName, adminEmail, hash); console.log(`[DB] Default admin created: ${adminEmail} (id=${result.lastInsertRowid})`); // Create default public group const groupResult = db.prepare(` INSERT INTO groups (name, type, is_default, owner_id) VALUES (?, 'public', 1, ?) `).run(process.env.DEFCHAT_NAME || 'General Chat', result.lastInsertRowid); // Add admin to default group db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)') .run(groupResult.lastInsertRowid, result.lastInsertRowid); console.log(`[DB] Default group created: ${process.env.DEFCHAT_NAME || 'General Chat'}`); seedSupportGroup(); } catch (err) { console.error('[DB] ERROR creating default admin:', err.message); } return; } console.log(`[DB] Default admin already exists (id=${existing.id})`); // Handle ADMPW_RESET if (pwReset) { const hash = bcrypt.hashSync(adminPass, 10); db.prepare(` UPDATE users SET password = ?, must_change_password = 1, updated_at = datetime('now') WHERE is_default_admin = 1 `).run(hash); db.prepare("UPDATE settings SET value = 'true', updated_at = datetime('now') WHERE key = 'pw_reset_active'").run(); console.log('[DB] Admin password reset via ADMPW_RESET=true'); } else { db.prepare("UPDATE settings SET value = 'false', updated_at = datetime('now') WHERE key = 'pw_reset_active'").run(); } } function addUserToPublicGroups(userId) { const db = getDb(); const publicGroups = db.prepare("SELECT id FROM groups WHERE type = 'public'").all(); const insert = db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)'); for (const g of publicGroups) { insert.run(g.id, userId); } } function seedSupportGroup() { const db = getDb(); // Create the Support group if it doesn't exist const existing = db.prepare("SELECT id FROM groups WHERE name = 'Support' AND type = 'private'").get(); if (existing) return existing.id; const admin = db.prepare('SELECT id FROM users WHERE is_default_admin = 1').get(); if (!admin) return null; const result = db.prepare(` INSERT INTO groups (name, type, owner_id, is_default) VALUES ('Support', 'private', ?, 0) `).run(admin.id); const groupId = result.lastInsertRowid; // Add all current admins to the Support group const admins = db.prepare("SELECT id FROM users WHERE role = 'admin' AND status = 'active'").all(); const insert = db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)'); for (const a of admins) insert.run(groupId, a.id); console.log('[DB] Support group created'); return groupId; } function getOrCreateSupportGroup() { const db = getDb(); const group = db.prepare("SELECT id FROM groups WHERE name = 'Support' AND type = 'private'").get(); if (group) return group.id; return seedSupportGroup(); } module.exports = { getDb, initDb, seedAdmin, seedSupportGroup, getOrCreateSupportGroup, addUserToPublicGroups };