Files
rosterchirp-dev/backend/src/models/db.js

396 lines
14 KiB
JavaScript

/**
* 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 bcrypt = require('bcryptjs');
// APP_TYPE validation — host mode requires HOST_DOMAIN and HOST_ADMIN_KEY.
// If either is missing, fall back to selfhost and warn rather than silently
// exposing a broken or insecure host control plane.
let APP_TYPE = (process.env.APP_TYPE || 'selfhost').toLowerCase().trim();
if (APP_TYPE === 'host') {
if (!process.env.HOST_DOMAIN || !process.env.HOST_ADMIN_KEY) {
console.warn('[DB] WARNING: APP_TYPE=host requires HOST_DOMAIN and HOST_ADMIN_KEY to be set.');
console.warn('[DB] WARNING: Falling back to APP_TYPE=selfhost for safety.');
APP_TYPE = 'selfhost';
}
}
if (APP_TYPE !== 'host') APP_TYPE = 'selfhost'; // only two valid values
// ── Connection pool ───────────────────────────────────────────────────────────
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, '_')}`;
}
// 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 refreshTenantCache(tenants) {
tenantDomainCache.clear();
for (const t of tenants) {
if (t.custom_domain) {
tenantDomainCache.set(t.custom_domain.toLowerCase(), `tenant_${t.slug}`);
}
}
}
// ── Schema name safety guard ──────────────────────────────────────────────────
function assertSafeSchema(schema) {
if (!/^[a-z_][a-z0-9_]*$/.test(schema)) {
throw new Error(`Unsafe schema name rejected: ${schema}`);
}
}
// ── Core query helpers ────────────────────────────────────────────────────────
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();
}
}
async function queryOne(schema, sql, params = []) {
const rows = await query(schema, sql, params);
return rows[0] || null;
}
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();
}
}
async function exec(schema, sql, params = []) {
await query(schema, sql, params);
}
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();
}
}
// ── Migration runner ──────────────────────────────────────────────────────────
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();
}
}
async function runMigrations(schema) {
await ensureSchema(schema);
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()
)
`);
const applied = await query(schema, 'SELECT version FROM schema_migrations ORDER BY version');
const appliedSet = new Set(applied.map(r => r.version));
const migrationsDir = path.join(__dirname, 'migrations');
const files = fs.readdirSync(migrationsDir)
.filter(f => f.endsWith('.sql'))
.sort();
for (const file of files) {
const m = file.match(/^(\d+)_/);
if (!m) continue;
const version = parseInt(m[1]);
if (appliedSet.has(version)) continue;
const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf8');
console.log(`[DB:${schema}] Applying migration ${version}: ${file}`);
await withTransaction(schema, async (client) => {
await client.query(sql);
await client.query(
'INSERT INTO schema_migrations (version, name) VALUES ($1, $2)',
[version, file]
);
});
console.log(`[DB:${schema}] Migration ${version} done`);
}
}
// ── Seeding ───────────────────────────────────────────────────────────────────
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:${schema}] Checking for default admin (${adminEmail})...`);
const existing = await queryOne(schema,
'SELECT * FROM users WHERE is_default_admin = TRUE'
);
if (!existing) {
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;
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]
);
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]
);
console.log(`[DB:${schema}] Default admin + groups created`);
return;
}
console.log(`[DB:${schema}] Default admin exists (id=${existing.id})`);
if (pwReset) {
const hash = bcrypt.hashSync(adminPass, 10);
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 {
await exec(schema, "UPDATE settings SET value='false', updated_at=NOW() WHERE key='pw_reset_active'");
}
}
// ── 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]
);
}
}
async function getOrCreateSupportGroup(schema) {
const g = await queryOne(schema, "SELECT id FROM groups WHERE name='Support' AND type='private'");
if (g) return g.id;
const admin = await queryOne(schema, 'SELECT id FROM users WHERE is_default_admin = TRUE');
if (!admin) return null;
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;
}
// ── 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 = {
query, queryOne, queryResult, exec, withTransaction,
initDb, runMigrations, ensureSchema,
tenantMiddleware, resolveSchema, refreshTenantCache,
APP_TYPE, pool,
addUserToPublicGroups, getOrCreateSupportGroup,
seedSettings, seedEventTypes, seedAdmin,
};