v0.9.88 major change sqlite to postgres

This commit is contained in:
2026-03-20 10:46:29 -04:00
parent 7dc4cfcbce
commit ac7cba0f92
31 changed files with 3729 additions and 2645 deletions

View File

@@ -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,
};

View File

@@ -0,0 +1,213 @@
-- Migration 001: Initial schema
-- Converts all SQLite tables to Postgres-native types.
-- TIMESTAMPTZ replaces TEXT for dates.
-- SERIAL replaces AUTOINCREMENT.
-- Constraints use Postgres syntax throughout.
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
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 BOOLEAN NOT NULL DEFAULT FALSE,
must_change_password BOOLEAN NOT NULL DEFAULT TRUE,
avatar TEXT,
about_me TEXT,
display_name TEXT,
hide_admin_tag BOOLEAN NOT NULL DEFAULT FALSE,
allow_dm BOOLEAN NOT NULL DEFAULT TRUE,
last_online TIMESTAMPTZ,
help_dismissed BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS groups (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'public',
owner_id INTEGER REFERENCES users(id),
is_default BOOLEAN NOT NULL DEFAULT FALSE,
is_readonly BOOLEAN NOT NULL DEFAULT FALSE,
is_direct BOOLEAN NOT NULL DEFAULT FALSE,
direct_peer1_id INTEGER,
direct_peer2_id INTEGER,
is_managed BOOLEAN NOT NULL DEFAULT FALSE,
is_multi_group BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS group_members (
id SERIAL PRIMARY KEY,
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(group_id, user_id)
);
CREATE TABLE IF NOT EXISTS messages (
id SERIAL PRIMARY KEY,
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
content TEXT,
type TEXT NOT NULL DEFAULT 'text',
image_url TEXT,
reply_to_id INTEGER REFERENCES messages(id),
is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
link_preview TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS reactions (
id SERIAL PRIMARY KEY,
message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
emoji TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(message_id, user_id, emoji)
);
CREATE TABLE IF NOT EXISTS notifications (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL,
message_id INTEGER,
group_id INTEGER,
from_user_id INTEGER,
is_read BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS active_sessions (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
device TEXT NOT NULL DEFAULT 'desktop',
token TEXT NOT NULL,
ua TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, device)
);
CREATE TABLE IF NOT EXISTS push_subscriptions (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
endpoint TEXT NOT NULL,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
device TEXT NOT NULL DEFAULT 'desktop',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id, device)
);
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)
);
CREATE TABLE IF NOT EXISTS pinned_conversations (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
pinned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, group_id)
);
CREATE TABLE IF NOT EXISTS user_groups (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
dm_group_id INTEGER REFERENCES groups(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS user_group_members (
user_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_group_id, user_id)
);
CREATE TABLE IF NOT EXISTS multi_group_dms (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
dm_group_id INTEGER REFERENCES groups(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS multi_group_dm_members (
multi_group_dm_id INTEGER NOT NULL REFERENCES multi_group_dms(id) ON DELETE CASCADE,
user_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (multi_group_dm_id, user_group_id)
);
-- ── Schedule Manager ──────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS event_types (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
colour TEXT NOT NULL DEFAULT '#6366f1',
default_user_group_id INTEGER REFERENCES user_groups(id) ON DELETE SET NULL,
default_duration_hrs NUMERIC,
is_default BOOLEAN NOT NULL DEFAULT FALSE,
is_protected BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS events (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
event_type_id INTEGER REFERENCES event_types(id) ON DELETE SET NULL,
start_at TIMESTAMPTZ NOT NULL,
end_at TIMESTAMPTZ NOT NULL,
all_day BOOLEAN NOT NULL DEFAULT FALSE,
location TEXT,
description TEXT,
is_public BOOLEAN NOT NULL DEFAULT TRUE,
track_availability BOOLEAN NOT NULL DEFAULT FALSE,
recurrence_rule JSONB,
created_by INTEGER NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS event_user_groups (
event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE,
user_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
PRIMARY KEY (event_id, user_group_id)
);
CREATE TABLE IF NOT EXISTS event_availability (
event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
response TEXT NOT NULL CHECK(response IN ('going','maybe','not_going')),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (event_id, user_id)
);
-- ── Indexes for common query patterns ────────────────────────────────────────
CREATE INDEX IF NOT EXISTS idx_messages_group_id ON messages(group_id);
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_group_members_user ON group_members(user_id);
CREATE INDEX IF NOT EXISTS idx_group_members_group ON group_members(group_id);
CREATE INDEX IF NOT EXISTS idx_events_start_at ON events(start_at);
CREATE INDEX IF NOT EXISTS idx_events_created_by ON events(created_by);
CREATE INDEX IF NOT EXISTS idx_reactions_message ON reactions(message_id);

View File

@@ -0,0 +1,96 @@
-- Migration 002: updated_at auto-trigger + additional indexes
--
-- Adds a reusable Postgres trigger function that automatically sets
-- updated_at = NOW() on any UPDATE, eliminating the need to set it
-- manually in every route. Also adds a few missing indexes.
-- ── Auto-updated_at trigger function ─────────────────────────────────────────
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Apply to all tables that have an updated_at column
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_users_updated_at') THEN
CREATE TRIGGER trg_users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_groups_updated_at') THEN
CREATE TRIGGER trg_groups_updated_at
BEFORE UPDATE ON groups
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_settings_updated_at') THEN
CREATE TRIGGER trg_settings_updated_at
BEFORE UPDATE ON settings
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_user_groups_updated_at') THEN
CREATE TRIGGER trg_user_groups_updated_at
BEFORE UPDATE ON user_groups
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_multi_group_dms_updated_at') THEN
CREATE TRIGGER trg_multi_group_dms_updated_at
BEFORE UPDATE ON multi_group_dms
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_events_updated_at') THEN
CREATE TRIGGER trg_events_updated_at
BEFORE UPDATE ON events
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END $$;
-- ── Additional indexes ────────────────────────────────────────────────────────
-- Notifications: most queries filter by user + read status
CREATE INDEX IF NOT EXISTS idx_notifications_user_unread
ON notifications(user_id, is_read)
WHERE is_read = FALSE;
-- Sessions: lookup by user is common on logout / session cleanup
CREATE INDEX IF NOT EXISTS idx_sessions_user_id
ON sessions(user_id);
-- Active sessions: covered by PK (user_id, device) but explicit for clarity
CREATE INDEX IF NOT EXISTS idx_active_sessions_token
ON active_sessions(token);
-- Push subscriptions: lookup by user is the hot path
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user
ON push_subscriptions(user_id);
-- User group members: reverse lookup (which groups is a user in?)
CREATE INDEX IF NOT EXISTS idx_user_group_members_user
ON user_group_members(user_id);
-- Event availability: reverse lookup (which events has a user responded to?)
CREATE INDEX IF NOT EXISTS idx_event_availability_user
ON event_availability(user_id);
-- Events: filter by created_by (schedule manager views)
CREATE INDEX IF NOT EXISTS idx_events_type
ON events(event_type_id);

View File

@@ -0,0 +1,31 @@
-- Migration 003: Tenant registry (JAMA-HOST mode)
--
-- This table lives in the 'public' schema and is the source of truth for
-- all tenants in host mode. In selfhost mode this table exists but stays
-- empty — it has no effect on anything.
CREATE TABLE IF NOT EXISTS tenants (
id SERIAL PRIMARY KEY,
slug TEXT NOT NULL UNIQUE, -- used as schema name: tenant_{slug}
name TEXT NOT NULL, -- display name
schema_name TEXT NOT NULL UNIQUE, -- actual Postgres schema: tenant_{slug}
custom_domain TEXT, -- optional: team1.example.com
plan TEXT NOT NULL DEFAULT 'chat', -- chat | brand | team
status TEXT NOT NULL DEFAULT 'active', -- active | suspended
admin_email TEXT, -- first admin email for this tenant
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_tenants_slug ON tenants(slug);
CREATE INDEX IF NOT EXISTS idx_tenants_custom_domain ON tenants(custom_domain) WHERE custom_domain IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_tenants_status ON tenants(status);
-- Auto-update updated_at
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_tenants_updated_at') THEN
CREATE TRIGGER trg_tenants_updated_at
BEFORE UPDATE ON tenants
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END $$;

View File

@@ -0,0 +1,6 @@
-- Migration 004: Host plan feature flags placeholder
--
-- Feature flag enforcement for APP_TYPE=host is handled in db.js initDb()
-- which runs on every startup and upserts the correct values.
-- This migration exists as a version marker — no SQL changes needed.
SELECT 1;

View File

@@ -0,0 +1,101 @@
# jama Migration Guide
## How migrations work
jama uses a simple file-based migration system. On every startup, `db.js` reads
all `.sql` files in this directory, sorted by version number, and applies any
that haven't been recorded in the `schema_migrations` table.
Migrations run inside a transaction — if anything fails, the whole migration
rolls back and the version is not recorded, so startup will retry it next time.
---
## Adding a new migration
1. Create a new file in this directory named `NNN_description.sql` where `NNN`
is the next sequential number (zero-padded to 3 digits):
```
001_initial_schema.sql ← already applied
002_add_user_preferences.sql
003_add_tenant_table.sql
```
2. Write standard Postgres SQL. Use `IF NOT EXISTS` / `IF EXISTS` guards where
possible so migrations are safe to replay:
```sql
-- Add a new column
ALTER TABLE users ADD COLUMN IF NOT EXISTS theme TEXT NOT NULL DEFAULT 'system';
-- Add a new table
CREATE TABLE IF NOT EXISTS user_preferences (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
key TEXT NOT NULL,
value TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, key)
);
-- Add an index
CREATE INDEX IF NOT EXISTS idx_user_preferences_user ON user_preferences(user_id);
```
3. Deploy. On next startup jama will automatically detect and apply the new
migration, logging:
```
[DB:public] Applying migration 2: 002_add_user_preferences.sql
[DB:public] Migration 2 done
```
---
## Rules
- **Never edit an applied migration.** Once `001_initial_schema.sql` has been
applied to any database, it must not change. Add a new numbered file instead.
- **Always use `IF NOT EXISTS` / `IF EXISTS`.** This makes migrations safe to
run against schemas that may be partially applied (e.g. after a failed deploy).
- **One logical change per file.** Easier to reason about and roll back mentally.
- **No data mutations in migrations unless unavoidable.** Seed data lives in
`db.js` (`seedSettings`, `seedEventTypes`, `seedAdmin`). Migrations are for
schema structure only.
- **JAMA-HOST:** When a new tenant is provisioned, `runMigrations(schema)` is
called on their fresh schema — they get all migrations from `001` onward
applied at creation time. Existing tenants get new migrations on the next
startup automatically.
---
## Checking migration status
```bash
# Connect to the running Postgres container
docker compose exec db psql -U jama -d jama
# See which migrations have been applied
SELECT * FROM schema_migrations ORDER BY version;
# In host mode, check a specific tenant schema
SET search_path TO tenant_teamname;
SELECT * FROM schema_migrations ORDER BY version;
```
---
## Emergency rollback
Migrations do not include automatic down/rollback scripts. If a migration causes
problems in production:
1. Stop the app container: `docker compose stop jama`
2. Connect to Postgres and manually reverse the change
3. Delete the migration record: `DELETE FROM schema_migrations WHERE version = NNN;`
4. Fix the migration file
5. Restart: `docker compose start jama`