v0.9.88 major change sqlite to postgres
This commit is contained in:
@@ -1,557 +1,384 @@
|
||||
const Database = require('better-sqlite3-multiple-ciphers');
|
||||
/**
|
||||
* db.js — Postgres database layer for jama
|
||||
*
|
||||
* APP_TYPE environment variable controls tenancy:
|
||||
* selfhost (default) → single schema 'public', one Postgres database
|
||||
* host → one schema per tenant, derived from HTTP Host header
|
||||
*
|
||||
* All routes call: query(req.schema, sql, $params)
|
||||
* req.schema is set by tenantMiddleware before any route handler runs.
|
||||
*/
|
||||
|
||||
const { Pool } = require('pg');
|
||||
const fs = require('fs');
|
||||
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 || '';
|
||||
const APP_TYPE = process.env.APP_TYPE || 'selfhost';
|
||||
|
||||
let db;
|
||||
// ── Connection pool ───────────────────────────────────────────────────────────
|
||||
|
||||
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})`);
|
||||
const pool = new Pool({
|
||||
host: process.env.DB_HOST || 'db',
|
||||
port: parseInt(process.env.DB_PORT || '5432'),
|
||||
database: process.env.DB_NAME || 'jama',
|
||||
user: process.env.DB_USER || 'jama',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 5000,
|
||||
});
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error('[DB] Unexpected pool error:', err.message);
|
||||
});
|
||||
|
||||
// ── Schema resolution ─────────────────────────────────────────────────────────
|
||||
|
||||
const tenantDomainCache = new Map();
|
||||
|
||||
function resolveSchema(req) {
|
||||
if (APP_TYPE === 'selfhost') return 'public';
|
||||
|
||||
const host = (req.headers.host || '').toLowerCase().split(':')[0];
|
||||
const baseDomain = (process.env.HOST_DOMAIN || 'jamachat.com').toLowerCase();
|
||||
|
||||
// Internal requests (Docker health checks, localhost) → public schema
|
||||
if (host === 'localhost' || host === '127.0.0.1' || host === '::1') return 'public';
|
||||
|
||||
// Subdomain: team1.jamachat.com → tenant_team1
|
||||
if (host.endsWith(`.${baseDomain}`)) {
|
||||
const slug = host.slice(0, -(baseDomain.length + 1));
|
||||
if (!slug || slug === 'www') throw new Error(`Invalid tenant slug: ${slug}`);
|
||||
return `tenant_${slug.replace(/[^a-z0-9]/g, '_')}`;
|
||||
}
|
||||
return db;
|
||||
|
||||
// Custom domain lookup (populated from host admin DB)
|
||||
if (tenantDomainCache.has(host)) return tenantDomainCache.get(host);
|
||||
|
||||
// Base domain → public schema (host admin panel)
|
||||
if (host === baseDomain || host === `www.${baseDomain}`) return 'public';
|
||||
|
||||
throw new Error(`Unknown tenant for host: ${host}`);
|
||||
}
|
||||
|
||||
function initDb() {
|
||||
const db = getDb();
|
||||
function refreshTenantCache(tenants) {
|
||||
tenantDomainCache.clear();
|
||||
for (const t of tenants) {
|
||||
if (t.custom_domain) {
|
||||
tenantDomainCache.set(t.custom_domain.toLowerCase(), `tenant_${t.slug}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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'))
|
||||
);
|
||||
// ── Schema name safety guard ──────────────────────────────────────────────────
|
||||
|
||||
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)
|
||||
);
|
||||
function assertSafeSchema(schema) {
|
||||
if (!/^[a-z_][a-z0-9_]*$/.test(schema)) {
|
||||
throw new Error(`Unsafe schema name rejected: ${schema}`);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
// ── Core query helpers ────────────────────────────────────────────────────────
|
||||
|
||||
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)
|
||||
);
|
||||
async function query(schema, sql, params = []) {
|
||||
assertSafeSchema(schema);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query(`SET search_path TO "${schema}", public`);
|
||||
const result = await client.query(sql, params);
|
||||
return result.rows;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
async function queryOne(schema, sql, params = []) {
|
||||
const rows = await query(schema, sql, params);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
async function queryResult(schema, sql, params = []) {
|
||||
assertSafeSchema(schema);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query(`SET search_path TO "${schema}", public`);
|
||||
return await client.query(sql, params);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
async function exec(schema, sql, params = []) {
|
||||
await query(schema, sql, params);
|
||||
}
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
async function withTransaction(schema, callback) {
|
||||
assertSafeSchema(schema);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query(`SET search_path TO "${schema}", public`);
|
||||
await client.query('BEGIN');
|
||||
const result = await callback(client);
|
||||
await client.query('COMMIT');
|
||||
return result;
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
// ── Migration runner ──────────────────────────────────────────────────────────
|
||||
|
||||
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
|
||||
);
|
||||
async function ensureSchema(schema) {
|
||||
assertSafeSchema(schema);
|
||||
// Use a direct client outside of search_path for schema creation
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query(`CREATE SCHEMA IF NOT EXISTS "${schema}"`);
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
-- 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
|
||||
);
|
||||
async function runMigrations(schema) {
|
||||
await ensureSchema(schema);
|
||||
|
||||
-- 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
|
||||
);
|
||||
await exec(schema, `
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
|
||||
// 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', '');
|
||||
const applied = await query(schema, 'SELECT version FROM schema_migrations ORDER BY version');
|
||||
const appliedSet = new Set(applied.map(r => r.version));
|
||||
|
||||
// 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 */ }
|
||||
const migrationsDir = path.join(__dirname, 'migrations');
|
||||
const files = fs.readdirSync(migrationsDir)
|
||||
.filter(f => f.endsWith('.sql'))
|
||||
.sort();
|
||||
|
||||
// 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 */ }
|
||||
for (const file of files) {
|
||||
const m = file.match(/^(\d+)_/);
|
||||
if (!m) continue;
|
||||
const version = parseInt(m[1]);
|
||||
if (appliedSet.has(version)) continue;
|
||||
|
||||
// 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); }
|
||||
const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf8');
|
||||
console.log(`[DB:${schema}] Applying migration ${version}: ${file}`);
|
||||
|
||||
// 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
|
||||
await withTransaction(schema, async (client) => {
|
||||
await client.query(sql);
|
||||
await client.query(
|
||||
'INSERT INTO schema_migrations (version, name) VALUES ($1, $2)',
|
||||
[version, file]
|
||||
);
|
||||
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
|
||||
);
|
||||
`);
|
||||
// Migration: add columns if missing (must run before inserts)
|
||||
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) {}
|
||||
try { db.exec("ALTER TABLE events ADD COLUMN recurrence_rule TEXT"); } catch(e) {}
|
||||
// Delete the legacy "Default" type — "Event" is the canonical default
|
||||
db.prepare("DELETE FROM event_types WHERE name = 'Default'").run();
|
||||
// Seed built-in event types — "Event" is the primary default (1hr, protected, cannot edit/delete)
|
||||
db.prepare("INSERT OR IGNORE INTO event_types (name, colour, is_default, is_protected, default_duration_hrs) VALUES ('Event', '#6366f1', 1, 1, 1.0)").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();
|
||||
// Remove duplicates — keep the one with is_default=1
|
||||
const evtTypes = db.prepare("SELECT id, is_default FROM event_types WHERE name = 'Event' ORDER BY is_default DESC").all();
|
||||
if (evtTypes.length > 1) {
|
||||
for (let i=1; i<evtTypes.length; i++) db.prepare('DELETE FROM event_types WHERE id = ?').run(evtTypes[i].id);
|
||||
}
|
||||
// Ensure built-in types are correct
|
||||
db.prepare("UPDATE event_types SET is_protected = 1, is_default = 1, default_duration_hrs = 1.0 WHERE name = 'Event'").run();
|
||||
db.prepare("UPDATE event_types SET default_duration_hrs = 3.0 WHERE name = 'Game'").run();
|
||||
db.prepare("UPDATE event_types SET default_duration_hrs = 1.0 WHERE name = 'Practice'").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;
|
||||
console.log(`[DB:${schema}] Migration ${version} done`);
|
||||
}
|
||||
}
|
||||
|
||||
function seedAdmin() {
|
||||
const db = getDb();
|
||||
// ── Seeding ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// 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();
|
||||
async function seedSettings(schema) {
|
||||
const defaults = [
|
||||
['app_name', process.env.APP_NAME || 'jama'],
|
||||
['logo_url', ''],
|
||||
['pw_reset_active', process.env.ADMPW_RESET === 'true' ? 'true' : 'false'],
|
||||
['icon_newchat', ''],
|
||||
['icon_groupinfo', ''],
|
||||
['pwa_icon_192', ''],
|
||||
['pwa_icon_512', ''],
|
||||
['color_title', ''],
|
||||
['color_title_dark', ''],
|
||||
['color_avatar_public', ''],
|
||||
['color_avatar_dm', ''],
|
||||
['registration_code', ''],
|
||||
['feature_branding', 'false'],
|
||||
['feature_group_manager', 'false'],
|
||||
['feature_schedule_manager', 'false'],
|
||||
['app_type', 'JAMA-Chat'],
|
||||
['team_group_managers', ''],
|
||||
['team_schedule_managers', ''],
|
||||
['team_tool_managers', ''],
|
||||
];
|
||||
for (const [key, value] of defaults) {
|
||||
await exec(schema,
|
||||
'INSERT INTO settings (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING',
|
||||
[key, value]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function seedEventTypes(schema) {
|
||||
await exec(schema, `
|
||||
INSERT INTO event_types (name, colour, is_default, is_protected, default_duration_hrs)
|
||||
VALUES ('Event', '#6366f1', TRUE, TRUE, 1.0)
|
||||
ON CONFLICT (name) DO UPDATE SET is_default=TRUE, is_protected=TRUE, default_duration_hrs=1.0
|
||||
`);
|
||||
await exec(schema,
|
||||
"INSERT INTO event_types (name, colour, default_duration_hrs) VALUES ('Game', '#22c55e', 3.0) ON CONFLICT (name) DO NOTHING"
|
||||
);
|
||||
await exec(schema,
|
||||
"INSERT INTO event_types (name, colour, default_duration_hrs) VALUES ('Practice', '#f59e0b', 1.0) ON CONFLICT (name) DO NOTHING"
|
||||
);
|
||||
}
|
||||
|
||||
async function seedAdmin(schema) {
|
||||
const strip = s => (s || '').replace(/^['"]+|['"]+$/g, '').trim();
|
||||
const adminEmail = strip(process.env.ADMIN_EMAIL) || 'admin@jama.local';
|
||||
const adminName = strip(process.env.ADMIN_NAME) || 'Admin User';
|
||||
const adminPass = strip(process.env.ADMIN_PASS) || 'Admin@1234';
|
||||
const pwReset = process.env.ADMPW_RESET === 'true';
|
||||
|
||||
console.log(`[DB] Checking for default admin (${adminEmail})...`);
|
||||
console.log(`[DB:${schema}] Checking for default admin (${adminEmail})...`);
|
||||
|
||||
const existing = db.prepare('SELECT * FROM users WHERE is_default_admin = 1').get();
|
||||
const existing = await queryOne(schema,
|
||||
'SELECT * FROM users WHERE is_default_admin = TRUE'
|
||||
);
|
||||
|
||||
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);
|
||||
const hash = bcrypt.hashSync(adminPass, 10);
|
||||
const ur = await queryResult(schema, `
|
||||
INSERT INTO users (name, email, password, role, status, is_default_admin, must_change_password)
|
||||
VALUES ($1, $2, $3, 'admin', 'active', TRUE, TRUE) RETURNING id
|
||||
`, [adminName, adminEmail, hash]);
|
||||
const adminId = ur.rows[0].id;
|
||||
|
||||
console.log(`[DB] Default admin created: ${adminEmail} (id=${result.lastInsertRowid})`);
|
||||
const chatName = strip(process.env.DEFCHAT_NAME) || 'General Chat';
|
||||
const gr = await queryResult(schema,
|
||||
"INSERT INTO groups (name, type, is_default, owner_id) VALUES ($1, 'public', TRUE, $2) RETURNING id",
|
||||
[chatName, adminId]
|
||||
);
|
||||
await exec(schema,
|
||||
'INSERT INTO group_members (group_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
|
||||
[gr.rows[0].id, adminId]
|
||||
);
|
||||
|
||||
// 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);
|
||||
const sr = await queryResult(schema,
|
||||
"INSERT INTO groups (name, type, owner_id, is_default) VALUES ('Support', 'private', $1, FALSE) RETURNING id",
|
||||
[adminId]
|
||||
);
|
||||
await exec(schema,
|
||||
'INSERT INTO group_members (group_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
|
||||
[sr.rows[0].id, adminId]
|
||||
);
|
||||
|
||||
// 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);
|
||||
}
|
||||
console.log(`[DB:${schema}] Default admin + groups created`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[DB] Default admin already exists (id=${existing.id})`);
|
||||
|
||||
// Handle ADMPW_RESET
|
||||
console.log(`[DB:${schema}] Default admin exists (id=${existing.id})`);
|
||||
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');
|
||||
await exec(schema,
|
||||
"UPDATE users SET password=$1, must_change_password=TRUE, updated_at=NOW() WHERE is_default_admin=TRUE",
|
||||
[hash]
|
||||
);
|
||||
await exec(schema, "UPDATE settings SET value='true', updated_at=NOW() WHERE key='pw_reset_active'");
|
||||
console.log(`[DB:${schema}] Admin password reset`);
|
||||
} else {
|
||||
db.prepare("UPDATE settings SET value = 'false', updated_at = datetime('now') WHERE key = 'pw_reset_active'").run();
|
||||
await exec(schema, "UPDATE settings SET value='false', updated_at=NOW() WHERE key='pw_reset_active'");
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
// ── Main init (called on server startup) ─────────────────────────────────────
|
||||
|
||||
async function initDb() {
|
||||
// Wait for Postgres to be ready (up to 30s)
|
||||
for (let i = 0; i < 30; i++) {
|
||||
try {
|
||||
await pool.query('SELECT 1');
|
||||
console.log('[DB] Connected to Postgres');
|
||||
break;
|
||||
} catch (e) {
|
||||
console.log(`[DB] Waiting for Postgres... (${i + 1}/30)`);
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
await runMigrations('public');
|
||||
await seedSettings('public');
|
||||
await seedEventTypes('public');
|
||||
await seedAdmin('public');
|
||||
|
||||
// Host mode: the public schema is the host's own workspace — always full JAMA-Team plan.
|
||||
// ON CONFLICT DO UPDATE ensures existing installs get corrected on restart too.
|
||||
if (APP_TYPE === 'host') {
|
||||
const hostPlan = [
|
||||
['app_type', 'JAMA-Team'],
|
||||
['feature_branding', 'true'],
|
||||
['feature_group_manager', 'true'],
|
||||
['feature_schedule_manager', 'true'],
|
||||
];
|
||||
for (const [key, value] of hostPlan) {
|
||||
await exec('public',
|
||||
'INSERT INTO settings (key,value) VALUES ($1,$2) ON CONFLICT (key) DO UPDATE SET value=$2, updated_at=NOW()',
|
||||
[key, value]
|
||||
);
|
||||
}
|
||||
console.log('[DB] Host mode: public schema upgraded to JAMA-Team plan');
|
||||
}
|
||||
|
||||
console.log('[DB] Initialisation complete');
|
||||
}
|
||||
|
||||
// ── Helper functions used by routes ──────────────────────────────────────────
|
||||
|
||||
async function addUserToPublicGroups(schema, userId) {
|
||||
const groups = await query(schema, "SELECT id FROM groups WHERE type = 'public'");
|
||||
for (const g of groups) {
|
||||
await exec(schema,
|
||||
'INSERT INTO group_members (group_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
|
||||
[g.id, userId]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function seedSupportGroup() {
|
||||
const db = getDb();
|
||||
async function getOrCreateSupportGroup(schema) {
|
||||
const g = await queryOne(schema, "SELECT id FROM groups WHERE name='Support' AND type='private'");
|
||||
if (g) return g.id;
|
||||
|
||||
// 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();
|
||||
const admin = await queryOne(schema, 'SELECT id FROM users WHERE is_default_admin = TRUE');
|
||||
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');
|
||||
const r = await queryResult(schema,
|
||||
"INSERT INTO groups (name, type, owner_id, is_default) VALUES ('Support','private',$1,FALSE) RETURNING id",
|
||||
[admin.id]
|
||||
);
|
||||
const groupId = r.rows[0].id;
|
||||
const admins = await query(schema, "SELECT id FROM users WHERE role='admin' AND status='active'");
|
||||
for (const a of admins) {
|
||||
await exec(schema,
|
||||
'INSERT INTO group_members (group_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
|
||||
[groupId, a.id]
|
||||
);
|
||||
}
|
||||
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();
|
||||
// ── Tenant middleware ─────────────────────────────────────────────────────────
|
||||
|
||||
function tenantMiddleware(req, res, next) {
|
||||
try {
|
||||
req.schema = resolveSchema(req);
|
||||
next();
|
||||
} catch (err) {
|
||||
console.error('[Tenant]', err.message);
|
||||
res.status(404).json({ error: 'Unknown tenant' });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getDb, initDb, seedAdmin, seedSupportGroup, getOrCreateSupportGroup, addUserToPublicGroups };
|
||||
module.exports = {
|
||||
query, queryOne, queryResult, exec, withTransaction,
|
||||
initDb, runMigrations, ensureSchema,
|
||||
tenantMiddleware, resolveSchema, refreshTenantCache,
|
||||
APP_TYPE, pool,
|
||||
addUserToPublicGroups, getOrCreateSupportGroup,
|
||||
seedSettings, seedEventTypes, seedAdmin,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user