import { pool } from '../db.js' import { encrypt, decrypt } from '../crypto.js' import { requireAdmin } from '../auth.js' import { INTEGRATIONS, MASKED } from '../integrations/schema.js' import { testConnection as newbookTest, fetchSiteList } from '../integrations/newbook.js' import { testConnection as resosTest } from '../integrations/resos.js' function decryptSecrets(row) { if (!row.secrets) return {} try { return JSON.parse(decrypt(row.secrets)) } catch { return {} } } function maskRow(row) { const schema = INTEGRATIONS[row.slug] const existing = decryptSecrets(row) const masked = {} for (const f of schema?.secretFields ?? []) { masked[f] = existing[f] ? MASKED : null } return { slug: row.slug, name: row.name, config: row.config, secrets: masked, enabled: row.enabled, updated_at: row.updated_at } } export async function integrationRoutes(app) { // GET /settings/api/integrations app.get('/integrations', { preHandler: requireAdmin }, async () => { const { rows } = await pool.query('SELECT * FROM integrations ORDER BY slug') return rows.map(maskRow) }) // GET /settings/api/integrations/:slug app.get('/integrations/:slug', { preHandler: requireAdmin }, async (req, reply) => { const { rows: [row] } = await pool.query('SELECT * FROM integrations WHERE slug = $1', [req.params.slug]) if (!row) return reply.status(404).send({ error: 'Not found' }) return maskRow(row) }) // PUT /settings/api/integrations/:slug app.put('/integrations/:slug', { preHandler: requireAdmin }, async (req, reply) => { const { slug } = req.params const schema = INTEGRATIONS[slug] if (!schema) return reply.status(404).send({ error: 'Unknown integration' }) const body = req.body || {} // Build config (plaintext fields) const config = {} for (const f of schema.configFields) { if (body[f] !== undefined) config[f] = body[f] } // Merge secrets — skip masked sentinel values so we don't overwrite with garbage const { rows: [existing] } = await pool.query('SELECT secrets FROM integrations WHERE slug = $1', [slug]) const prev = existing ? decryptSecrets(existing) : {} const next = { ...prev } for (const f of schema.secretFields) { if (body[f] !== undefined && body[f] !== MASKED && body[f] !== '') { next[f] = body[f] } } const secretsEnc = Object.keys(next).length ? encrypt(JSON.stringify(next)) : null const enabled = body.enabled !== undefined ? Boolean(body.enabled) : true await pool.query(` INSERT INTO integrations (slug, name, config, secrets, enabled, updated_at) VALUES ($1, $2, $3, $4, $5, NOW()) ON CONFLICT (slug) DO UPDATE SET config = EXCLUDED.config, secrets = EXCLUDED.secrets, enabled = EXCLUDED.enabled, updated_at = NOW() `, [slug, schema.name, config, secretsEnc, enabled]) const { rows: [updated] } = await pool.query('SELECT * FROM integrations WHERE slug = $1', [slug]) return maskRow(updated) }) // POST /settings/api/integrations/:slug/test app.post('/integrations/:slug/test', { preHandler: requireAdmin }, async (req, reply) => { const { rows: [row] } = await pool.query('SELECT * FROM integrations WHERE slug = $1', [req.params.slug]) if (!row) return reply.status(404).send({ error: 'Not found' }) const creds = { ...row.config, ...decryptSecrets(row) } try { if (req.params.slug === 'newbook') await newbookTest(creds) else if (req.params.slug === 'resos') await resosTest(creds) else return reply.status(400).send({ error: `No test available for ${req.params.slug}` }) return { ok: true } } catch (e) { return { ok: false, error: e.message } } }) // POST /settings/api/integrations/newbook/sync-rooms app.post('/integrations/newbook/sync-rooms', { preHandler: requireAdmin }, async (req, reply) => { const { rows: [row] } = await pool.query(`SELECT * FROM integrations WHERE slug = 'newbook'`) if (!row?.enabled) return reply.status(400).send({ error: 'Newbook integration not enabled' }) const creds = { ...row.config, ...decryptSecrets(row) } if (!creds.api_key) return reply.status(400).send({ error: 'Newbook credentials not configured' }) let raw try { raw = await fetchSiteList(creds) } catch (e) { return reply.status(502).send({ error: e.message }) } // Normalise: site_list returns array with category_id, category_name, site_id, site_name const items = Array.isArray(raw) ? raw : (raw?.data ?? []) const catMap = {} const sites = [] for (const item of items) { const cid = String(item.category_id) if (!catMap[cid]) { catMap[cid] = { id: cid, name: item.category_name, sort_order: Object.keys(catMap).length, colour: null } } if (item.site_id) { sites.push({ id: String(item.site_id), name: item.site_name, category_id: cid }) } } // Preserve existing sort_order and colour customisations const { rows: [prev] } = await pool.query(`SELECT value FROM global_config WHERE key = 'newbook.rooms'`) const prevCats = {} for (const c of prev?.value?.categories ?? []) prevCats[c.id] = c const categories = Object.values(catMap).map(c => ({ ...c, sort_order: prevCats[c.id]?.sort_order ?? c.sort_order, colour: prevCats[c.id]?.colour ?? null, })).sort((a, b) => a.sort_order - b.sort_order) const value = { categories, sites, synced_at: new Date().toISOString() } await pool.query(` INSERT INTO global_config (key, value, updated_at) VALUES ('newbook.rooms', $1, NOW()) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() `, [value]) return value }) // PUT /settings/api/integrations/newbook/rooms — save reorder + colour changes app.put('/integrations/newbook/rooms', { preHandler: requireAdmin }, async (req, reply) => { const { rows: [row] } = await pool.query(`SELECT value FROM global_config WHERE key = 'newbook.rooms'`) if (!row) return reply.status(404).send({ error: 'No room data — sync first' }) const categories = (req.body?.categories ?? row.value.categories).map((c, i) => ({ ...c, sort_order: i })) const updated = { ...row.value, categories } await pool.query(`UPDATE global_config SET value = $1, updated_at = NOW() WHERE key = 'newbook.rooms'`, [updated]) return updated }) }