settings/src/routes/integrations.js
jtricerolph 5053b37adf Add internal GET endpoint for global_config keys
Allows other services (e.g. room-planner) to read shared config
stored in settings_db without cross-database queries. Auth via
SETTINGS_SECRET bearer token, same pattern as /internal/integration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-04 13:13:20 +00:00

196 lines
8.9 KiB
JavaScript

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]
// Always return all schema-defined config fields, even if not yet saved
const config = {}
for (const f of schema?.configFields ?? []) {
config[f] = row.config?.[f] ?? ''
}
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, 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 if (req.params.slug === 'workforce') {
const baseUrl = creds.base_url || 'https://my.workforce.com'
const res = await fetch(`${baseUrl}/api/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ grant_type: 'password', username: creds.email, password: creds.password, scope: 'platform' }).toString(),
signal: AbortSignal.timeout(8000),
})
if (!res.ok) throw new Error(`Workforce auth failed (${res.status})`)
const data = await res.json()
if (!data.access_token) throw new Error('No access token returned')
}
else if (req.params.slug === 'smtp') {
const nodemailer = await import('nodemailer')
const transporter = nodemailer.default.createTransport({
host: creds.host, port: parseInt(creds.port || '587'),
auth: { user: creds.user, pass: creds.pass },
})
await transporter.verify()
}
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
})
// GET /settings/api/internal/global-config/:key — service-to-service read of global_config
// Authenticated by SETTINGS_SECRET bearer token; returns the value JSON for the given key.
app.get('/internal/global-config/:key', async (req, reply) => {
const token = (req.headers.authorization || '').replace(/^Bearer\s+/i, '')
if (!token || token !== process.env.SETTINGS_SECRET) {
return reply.status(401).send({ error: 'Unauthorized' })
}
const { rows: [row] } = await pool.query('SELECT value FROM global_config WHERE key = $1', [req.params.key])
if (!row) return reply.status(404).send({ error: 'Not found' })
return { value: row.value }
})
// GET /settings/api/internal/integration/:slug — service-to-service, no user session required
// Authenticated by SETTINGS_SECRET bearer token; returns unmasked credentials.
app.get('/internal/integration/:slug', async (req, reply) => {
const token = (req.headers.authorization || '').replace(/^Bearer\s+/i, '')
if (!token || token !== process.env.SETTINGS_SECRET) {
return reply.status(401).send({ error: 'Unauthorized' })
}
const { rows: [row] } = await pool.query('SELECT * FROM integrations WHERE slug = $1', [req.params.slug])
if (!row) return reply.status(404).send({ error: 'Integration not found' })
if (!row.enabled) return reply.status(403).send({ error: 'Integration is disabled' })
return { ...row.config, ...decryptSecrets(row) }
})
// 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
})
}