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,
|
||||
};
|
||||
|
||||
213
backend/src/models/migrations/001_initial_schema.sql
Normal file
213
backend/src/models/migrations/001_initial_schema.sql
Normal 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);
|
||||
96
backend/src/models/migrations/002_triggers_and_indexes.sql
Normal file
96
backend/src/models/migrations/002_triggers_and_indexes.sql
Normal 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);
|
||||
31
backend/src/models/migrations/003_tenants.sql
Normal file
31
backend/src/models/migrations/003_tenants.sql
Normal 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 $$;
|
||||
6
backend/src/models/migrations/004_host_plan.sql
Normal file
6
backend/src/models/migrations/004_host_plan.sql
Normal 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;
|
||||
101
backend/src/models/migrations/MIGRATIONS.md
Normal file
101
backend/src/models/migrations/MIGRATIONS.md
Normal 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`
|
||||
Reference in New Issue
Block a user