324 lines
13 KiB
JavaScript
324 lines
13 KiB
JavaScript
/**
|
|
* routes/host.js — RosterChirp-Host control plane
|
|
*
|
|
* All routes require the HOST_ADMIN_KEY header.
|
|
* These routes operate on the 'public' schema (tenant registry).
|
|
* They provision/deprovision per-tenant schemas.
|
|
*
|
|
* APP_TYPE must be 'host' for these routes to be registered.
|
|
*/
|
|
|
|
const express = require('express');
|
|
const router = express.Router();
|
|
const {
|
|
query, queryOne, queryResult, exec,
|
|
runMigrations, ensureSchema,
|
|
seedSettings, seedEventTypes, seedAdmin, seedUserGroups,
|
|
refreshTenantCache,
|
|
} = require('../models/db');
|
|
|
|
const HOST_ADMIN_KEY = process.env.HOST_ADMIN_KEY || '';
|
|
|
|
// ── Host admin key guard ──────────────────────────────────────────────────────
|
|
|
|
function hostAdminMiddleware(req, res, next) {
|
|
if (!HOST_ADMIN_KEY) {
|
|
return res.status(503).json({ error: 'HOST_ADMIN_KEY is not configured' });
|
|
}
|
|
const key = req.headers['x-host-admin-key'] || req.headers['authorization']?.replace('Bearer ', '');
|
|
if (!key || key !== HOST_ADMIN_KEY) {
|
|
return res.status(401).json({ error: 'Invalid host admin key' });
|
|
}
|
|
next();
|
|
}
|
|
|
|
// All routes in this file require the host admin key
|
|
router.use(hostAdminMiddleware);
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
function slugToSchema(slug) {
|
|
return `tenant_${slug.toLowerCase().replace(/[^a-z0-9]/g, '_')}`;
|
|
}
|
|
|
|
function isValidSlug(slug) {
|
|
return /^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$/.test(slug);
|
|
}
|
|
|
|
async function reloadTenantCache() {
|
|
const tenants = await query('public', "SELECT * FROM tenants WHERE status = 'active'");
|
|
refreshTenantCache(tenants);
|
|
return tenants;
|
|
}
|
|
|
|
// ── GET /api/host/tenants — list all tenants ──────────────────────────────────
|
|
|
|
router.get('/tenants', async (req, res) => {
|
|
try {
|
|
const tenants = await query('public',
|
|
'SELECT * FROM tenants ORDER BY created_at DESC'
|
|
);
|
|
res.json({ tenants });
|
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
});
|
|
|
|
// ── GET /api/host/tenants/:slug — get single tenant ───────────────────────────
|
|
|
|
router.get('/tenants/:slug', async (req, res) => {
|
|
try {
|
|
const tenant = await queryOne('public',
|
|
'SELECT * FROM tenants WHERE slug = $1', [req.params.slug]
|
|
);
|
|
if (!tenant) return res.status(404).json({ error: 'Tenant not found' });
|
|
res.json({ tenant });
|
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
});
|
|
|
|
// ── POST /api/host/tenants — provision a new tenant ───────────────────────────
|
|
//
|
|
// Body: { slug, name, plan, adminEmail, adminName, adminPass, customDomain? }
|
|
//
|
|
// This:
|
|
// 1. Validates the slug (becomes subdomain + schema name)
|
|
// 2. Creates the Postgres schema
|
|
// 3. Runs all migrations in the new schema
|
|
// 4. Seeds settings, event types, and the first admin user
|
|
// 5. Records the tenant in the registry
|
|
// 6. Reloads the tenant domain cache
|
|
|
|
router.post('/tenants', async (req, res) => {
|
|
const { slug, name, plan, adminEmail, adminName, adminPass, customDomain } = req.body;
|
|
|
|
if (!slug || !name) return res.status(400).json({ error: 'slug and name are required' });
|
|
if (!isValidSlug(slug)) {
|
|
return res.status(400).json({
|
|
error: 'slug must be 3-32 lowercase alphanumeric characters or hyphens, starting and ending with alphanumeric'
|
|
});
|
|
}
|
|
|
|
const schemaName = slugToSchema(slug);
|
|
|
|
try {
|
|
// Check slug not already taken
|
|
const existing = await queryOne('public',
|
|
'SELECT id FROM tenants WHERE slug = $1', [slug]
|
|
);
|
|
if (existing) return res.status(400).json({ error: `Tenant '${slug}' already exists` });
|
|
|
|
if (customDomain) {
|
|
const domainTaken = await queryOne('public',
|
|
'SELECT id FROM tenants WHERE custom_domain = $1', [customDomain.toLowerCase()]
|
|
);
|
|
if (domainTaken) return res.status(400).json({ error: `Custom domain '${customDomain}' is already in use` });
|
|
}
|
|
|
|
console.log(`[Host] Provisioning tenant: ${slug} (schema: ${schemaName})`);
|
|
|
|
// 1. Create schema + run migrations
|
|
await runMigrations(schemaName);
|
|
|
|
// 2. Seed settings (uses env defaults unless overridden by body)
|
|
await seedSettings(schemaName);
|
|
|
|
// 3. Seed event types
|
|
await seedEventTypes(schemaName);
|
|
|
|
// 3b. Seed default user groups (Coaches, Players, Parents)
|
|
await seedUserGroups(schemaName);
|
|
|
|
// 4. Seed admin user — temporarily override env vars for this tenant
|
|
const origEmail = process.env.ADMIN_EMAIL;
|
|
const origName = process.env.ADMIN_NAME;
|
|
const origPass = process.env.ADMIN_PASS;
|
|
if (adminEmail) process.env.ADMIN_EMAIL = adminEmail;
|
|
if (adminName) process.env.ADMIN_NAME = adminName;
|
|
if (adminPass) process.env.ADMIN_PASS = adminPass;
|
|
|
|
await seedAdmin(schemaName);
|
|
|
|
process.env.ADMIN_EMAIL = origEmail;
|
|
process.env.ADMIN_NAME = origName;
|
|
process.env.ADMIN_PASS = origPass;
|
|
|
|
// 5. Set app_type based on plan
|
|
const planAppType = { chat: 'RosterChirp-Chat', brand: 'RosterChirp-Brand', team: 'RosterChirp-Team' }[plan] || 'RosterChirp-Chat';
|
|
await exec(schemaName, "UPDATE settings SET value=$1 WHERE key='app_type'", [planAppType]);
|
|
if (plan === 'brand' || plan === 'team') {
|
|
await exec(schemaName, "UPDATE settings SET value='true' WHERE key='feature_branding'");
|
|
}
|
|
if (plan === 'team') {
|
|
await exec(schemaName, "UPDATE settings SET value='true' WHERE key='feature_group_manager'");
|
|
await exec(schemaName, "UPDATE settings SET value='true' WHERE key='feature_schedule_manager'");
|
|
}
|
|
|
|
// 6. Register in tenants table
|
|
const tr = await queryResult('public', `
|
|
INSERT INTO tenants (slug, name, schema_name, custom_domain, plan, admin_email)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
RETURNING *
|
|
`, [slug, name, schemaName, customDomain?.toLowerCase() || null, plan || 'chat', adminEmail || null]);
|
|
|
|
// 7. Reload domain cache
|
|
await reloadTenantCache();
|
|
|
|
const baseDomain = process.env.HOST_DOMAIN || 'rosterchirp.com';
|
|
const tenant = tr.rows[0];
|
|
tenant.url = `https://${slug}.${baseDomain}`;
|
|
|
|
console.log(`[Host] Tenant provisioned: ${slug} → ${schemaName}`);
|
|
res.status(201).json({ tenant });
|
|
|
|
} catch (e) {
|
|
console.error(`[Host] Provisioning failed for ${slug}:`, e.message);
|
|
// Attempt cleanup of partially-created schema
|
|
try {
|
|
await exec('public', `DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`);
|
|
console.log(`[Host] Cleaned up schema ${schemaName} after failed provision`);
|
|
} catch (cleanupErr) {
|
|
console.error(`[Host] Cleanup failed:`, cleanupErr.message);
|
|
}
|
|
res.status(500).json({ error: e.message });
|
|
}
|
|
});
|
|
|
|
// ── PATCH /api/host/tenants/:slug — update tenant ─────────────────────────────
|
|
//
|
|
// Supports updating: name, plan, customDomain, status
|
|
|
|
router.patch('/tenants/:slug', async (req, res) => {
|
|
const { name, plan, customDomain, status } = req.body;
|
|
try {
|
|
const tenant = await queryOne('public',
|
|
'SELECT * FROM tenants WHERE slug = $1', [req.params.slug]
|
|
);
|
|
if (!tenant) return res.status(404).json({ error: 'Tenant not found' });
|
|
|
|
if (customDomain && customDomain !== tenant.custom_domain) {
|
|
const taken = await queryOne('public',
|
|
'SELECT id FROM tenants WHERE custom_domain=$1 AND slug!=$2',
|
|
[customDomain.toLowerCase(), req.params.slug]
|
|
);
|
|
if (taken) return res.status(400).json({ error: 'Custom domain already in use' });
|
|
}
|
|
|
|
if (status && !['active','suspended'].includes(status))
|
|
return res.status(400).json({ error: 'status must be active or suspended' });
|
|
|
|
await exec('public', `
|
|
UPDATE tenants SET
|
|
name = COALESCE($1, name),
|
|
plan = COALESCE($2, plan),
|
|
custom_domain = $3,
|
|
status = COALESCE($4, status),
|
|
updated_at = NOW()
|
|
WHERE slug = $5
|
|
`, [name || null, plan || null, customDomain?.toLowerCase() ?? tenant.custom_domain, status || null, req.params.slug]);
|
|
|
|
// If plan changed, update feature flags in tenant schema
|
|
if (plan && plan !== tenant.plan) {
|
|
const s = tenant.schema_name;
|
|
await exec(s, "UPDATE settings SET value=CASE WHEN $1 IN ('brand','team') THEN 'true' ELSE 'false' END WHERE key='feature_branding'", [plan]);
|
|
await exec(s, "UPDATE settings SET value=CASE WHEN $1 = 'team' THEN 'true' ELSE 'false' END WHERE key='feature_group_manager'", [plan]);
|
|
await exec(s, "UPDATE settings SET value=CASE WHEN $1 = 'team' THEN 'true' ELSE 'false' END WHERE key='feature_schedule_manager'", [plan]);
|
|
const planAppType = { chat: 'RosterChirp-Chat', brand: 'RosterChirp-Brand', team: 'RosterChirp-Team' }[plan] || 'RosterChirp-Chat';
|
|
await exec(s, "UPDATE settings SET value=$1 WHERE key='app_type'", [planAppType]);
|
|
}
|
|
|
|
await reloadTenantCache();
|
|
const updated = await queryOne('public', 'SELECT * FROM tenants WHERE slug=$1', [req.params.slug]);
|
|
res.json({ tenant: updated });
|
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
});
|
|
|
|
// ── DELETE /api/host/tenants/:slug — deprovision tenant ───────────────────────
|
|
//
|
|
// Permanently drops the tenant's Postgres schema and all data.
|
|
// Requires confirmation: body must include { confirm: "DELETE {slug}" }
|
|
|
|
router.delete('/tenants/:slug', async (req, res) => {
|
|
const { confirm } = req.body;
|
|
if (confirm !== `DELETE ${req.params.slug}`) {
|
|
return res.status(400).json({
|
|
error: `Confirmation required. Send { "confirm": "DELETE ${req.params.slug}" } in the request body.`
|
|
});
|
|
}
|
|
|
|
try {
|
|
const tenant = await queryOne('public',
|
|
'SELECT * FROM tenants WHERE slug=$1', [req.params.slug]
|
|
);
|
|
if (!tenant) return res.status(404).json({ error: 'Tenant not found' });
|
|
|
|
console.log(`[Host] Deprovisioning tenant: ${req.params.slug} (schema: ${tenant.schema_name})`);
|
|
|
|
// Drop the entire schema — CASCADE removes all tables, indexes, triggers
|
|
await exec('public', `DROP SCHEMA IF EXISTS "${tenant.schema_name}" CASCADE`);
|
|
|
|
// Remove from registry
|
|
await exec('public', 'DELETE FROM tenants WHERE slug=$1', [req.params.slug]);
|
|
|
|
await reloadTenantCache();
|
|
|
|
console.log(`[Host] Tenant deprovisioned: ${req.params.slug}`);
|
|
res.json({ success: true, message: `Tenant '${req.params.slug}' and all its data have been permanently deleted.` });
|
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
});
|
|
|
|
// ── POST /api/host/tenants/:slug/migrate — run pending migrations ─────────────
|
|
//
|
|
// Useful after deploying a new migration file to apply it to all tenants.
|
|
|
|
router.post('/tenants/:slug/migrate', async (req, res) => {
|
|
try {
|
|
const tenant = await queryOne('public', 'SELECT * FROM tenants WHERE slug=$1', [req.params.slug]);
|
|
if (!tenant) return res.status(404).json({ error: 'Tenant not found' });
|
|
await runMigrations(tenant.schema_name);
|
|
await seedSettings(tenant.schema_name);
|
|
await seedEventTypes(tenant.schema_name);
|
|
await seedUserGroups(tenant.schema_name);
|
|
const applied = await query(tenant.schema_name, 'SELECT * FROM schema_migrations ORDER BY version');
|
|
res.json({ success: true, migrations: applied });
|
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
});
|
|
|
|
// ── POST /api/host/migrate-all — run pending migrations on every tenant ───────
|
|
|
|
router.post('/migrate-all', async (req, res) => {
|
|
try {
|
|
const tenants = await query('public', "SELECT * FROM tenants WHERE status='active'");
|
|
const results = [];
|
|
for (const t of tenants) {
|
|
try {
|
|
await runMigrations(t.schema_name);
|
|
// Also re-run seeding so new defaults (e.g. user groups, event types)
|
|
// are applied to existing tenants that were provisioned before they existed.
|
|
await seedSettings(t.schema_name);
|
|
await seedEventTypes(t.schema_name);
|
|
await seedUserGroups(t.schema_name);
|
|
results.push({ slug: t.slug, status: 'ok' });
|
|
} catch (e) {
|
|
results.push({ slug: t.slug, status: 'error', error: e.message });
|
|
}
|
|
}
|
|
res.json({ results });
|
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
});
|
|
|
|
// ── GET /api/host/status — host health check ──────────────────────────────────
|
|
|
|
router.get('/status', async (req, res) => {
|
|
try {
|
|
const tenantCount = await queryOne('public', 'SELECT COUNT(*) AS count FROM tenants');
|
|
const active = await queryOne('public', "SELECT COUNT(*) AS count FROM tenants WHERE status='active'");
|
|
const baseDomain = process.env.HOST_DOMAIN || 'rosterchirp.com';
|
|
res.json({
|
|
ok: true,
|
|
appType: process.env.APP_TYPE || 'selfhost',
|
|
baseDomain,
|
|
tenants: { total: parseInt(tenantCount.count), active: parseInt(active.count) },
|
|
});
|
|
} catch (e) { res.status(500).json({ error: e.message }); }
|
|
});
|
|
|
|
module.exports = router;
|