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' const AUTH_URL = process.env.AUTH_URL || 'http://10.10.10.101:3001' function bustAuthCache() { const secret = process.env.SETTINGS_SECRET || '' fetch(`${AUTH_URL}/api/auth/internal/cache-bust`, { method: 'POST', headers: { Authorization: `Bearer ${secret}` }, signal: AbortSignal.timeout(3000), }).catch(() => {}) } 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]) if (slug === 'workforce' || slug === 'smtp') bustAuthCache() 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') { if (!creds.bearer_token) throw new Error('Workforce bearer token not configured') const baseUrl = creds.base_url || 'https://my.workforce.com' const res = await fetch(`${baseUrl}/api/v2/users/me`, { headers: { Authorization: `Bearer ${creds.bearer_token}` }, signal: AbortSignal.timeout(8000), }) if (!res.ok) throw new Error(`Workforce bearer token invalid (${res.status})`) } 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 if (req.params.slug === 'nextcloud') { if (!creds.base_url || !creds.username || !creds.password) throw new Error('Nextcloud base URL, username and password are required') const url = `${creds.base_url.replace(/\/$/, '')}/remote.php/dav/files/${encodeURIComponent(creds.username)}/` const res = await fetch(url, { method: 'PROPFIND', headers: { Authorization: `Basic ${Buffer.from(`${creds.username}:${creds.password}`).toString('base64')}`, Depth: '0' }, signal: AbortSignal.timeout(8000), }) if (res.status === 401) throw new Error('Invalid Nextcloud credentials') if (!res.ok) throw new Error(`Nextcloud returned ${res.status}`) } else if (req.params.slug === 'azure') { if (!creds.endpoint || !creds.api_key) throw new Error('Azure endpoint and API key are required') const base = creds.endpoint.replace(/\/$/, '') const res = await fetch(`${base}/documentintelligence/info?api-version=2024-11-30`, { headers: { 'Ocp-Apim-Subscription-Key': creds.api_key }, signal: AbortSignal.timeout(8000), }) if (res.status === 401 || res.status === 403) throw new Error('Invalid Azure API key') if (!res.ok) throw new Error(`Azure returned ${res.status}`) } else if (req.params.slug === 'sambapos') { const errors = [] // SQL test try { if (!creds.sql_host || !creds.sql_database || !creds.sql_username || !creds.sql_password) throw new Error('SQL host, database, username and password are required') const { default: pg } = await import('pg') const sqlPool = new pg.Pool({ host: creds.sql_host, port: parseInt(creds.sql_port || '5432'), database: creds.sql_database, user: creds.sql_username, password: creds.sql_password, connectionTimeoutMillis: 8000, max: 1, }) await sqlPool.query('SELECT 1') await sqlPool.end() } catch (e) { errors.push(`SQL: ${e.message}`) } // GraphQL test — obtain token via password grant then run a minimal query try { if (!creds.graphql_endpoint || !creds.graphql_username || !creds.graphql_password || !creds.graphql_client_id) throw new Error('GraphQL endpoint, username, password and client ID are required') const base = creds.graphql_endpoint.replace(/\/$/, '') const tokenRes = await fetch(`${base}/connect/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'password', username: creds.graphql_username, password: creds.graphql_password, client_id: creds.graphql_client_id, scope: 'api', }), signal: AbortSignal.timeout(8000), }) if (!tokenRes.ok) throw new Error(`Token request failed (${tokenRes.status})`) const { access_token } = await tokenRes.json() if (!access_token) throw new Error('No access token returned') const gqlRes = await fetch(`${base}/api`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${access_token}` }, body: JSON.stringify({ query: '{ __typename }' }), signal: AbortSignal.timeout(8000), }) if (!gqlRes.ok) throw new Error(`GraphQL request failed (${gqlRes.status})`) } catch (e) { errors.push(`GraphQL: ${e.message}`) } if (errors.length) throw new Error(errors.join(' | ')) } 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 // GET /settings/api/integrations/workforce/locations — derive locations from teams list app.get('/integrations/workforce/locations', { preHandler: requireAdmin }, async (req, reply) => { const { rows: [row] } = await pool.query(`SELECT * FROM integrations WHERE slug = 'workforce'`) if (!row?.enabled) return reply.status(400).send({ error: 'Workforce integration not enabled' }) const creds = { ...row.config, ...decryptSecrets(row) } if (!creds.bearer_token) return reply.status(400).send({ error: 'Workforce bearer token not configured' }) const baseUrl = creds.base_url || 'https://my.workforce.com' // /api/v2/locations list may be billing-locked — get unique IDs from teams then fetch each individually const teams = [] let page = 1 while (true) { const res = await fetch(`${baseUrl}/api/v2/departments?page=${page}&page_size=100`, { headers: { Authorization: `Bearer ${creds.bearer_token}` }, signal: AbortSignal.timeout(8000), }) if (!res.ok) { const body = await res.text().catch(() => '') const msg = (() => { try { return JSON.parse(body).error } catch { return body.slice(0, 200) } })() return reply.status(502).send({ error: msg || `Workforce API error (${res.status})` }) } const data = await res.json() const items = Array.isArray(data) ? data : (data.departments ?? []) teams.push(...items) if (items.length < 100) break page++ } const locationIds = [...new Set(teams.map(t => t.location_id).filter(Boolean).map(String))] // Fetch each location individually in parallel const locations = await Promise.all(locationIds.map(async id => { try { const res = await fetch(`${baseUrl}/api/v2/locations/${id}`, { headers: { Authorization: `Bearer ${creds.bearer_token}` }, signal: AbortSignal.timeout(8000), }) if (!res.ok) return { id, name: `Location ${id}`, short_name: null } const loc = await res.json() return { id, name: loc.name ?? `Location ${id}`, short_name: loc.short_name ?? null } } catch { return { id, name: `Location ${id}`, short_name: null } } })) return locations.sort((a, b) => a.name.localeCompare(b.name)) }) 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) } }) // GET /settings/api/integrations/nextcloud/browse?path=/ — list directories via WebDAV PROPFIND app.get('/integrations/nextcloud/browse', { preHandler: requireAdmin }, async (req, reply) => { const { rows: [row] } = await pool.query('SELECT * FROM integrations WHERE slug = $1', ['nextcloud']) if (!row?.enabled) return reply.status(400).send({ error: 'Nextcloud integration is not enabled' }) const creds = { ...row.config, ...decryptSecrets(row) } if (!creds.base_url || !creds.username || !creds.password) { return reply.status(400).send({ error: 'Nextcloud credentials not fully configured' }) } const rawPath = (req.query.path || '/').replace(/\/+/g, '/') if (rawPath.includes('..') || !rawPath.startsWith('/')) { return reply.status(400).send({ error: 'Invalid path' }) } const browsePath = rawPath === '/' ? '/' : rawPath.replace(/\/$/, '') const davBase = `${creds.base_url.replace(/\/$/, '')}/remote.php/dav/files/${encodeURIComponent(creds.username)}` const davUrl = browsePath === '/' ? `${davBase}/` : `${davBase}${browsePath}/` const auth = Buffer.from(`${creds.username}:${creds.password}`).toString('base64') let xmlRes try { xmlRes = await fetch(davUrl, { method: 'PROPFIND', headers: { Authorization: `Basic ${auth}`, Depth: '1', 'Content-Type': 'application/xml', }, body: '', signal: AbortSignal.timeout(10_000), }) } catch (e) { return reply.status(502).send({ error: `Could not reach Nextcloud: ${e.message}` }) } if (xmlRes.status === 401) return reply.status(502).send({ error: 'Nextcloud credentials rejected' }) if (xmlRes.status === 404) return reply.status(404).send({ error: 'Path not found' }) if (xmlRes.status !== 207) return reply.status(502).send({ error: `Nextcloud returned ${xmlRes.status}` }) const xml = await xmlRes.text() const prefix = `/remote.php/dav/files/${creds.username}` const dirs = [] for (const block of xml.split(/<[^:>]*:?response[^>]*>/).slice(1)) { if (!block.includes('collection')) continue const m = block.match(/<[^:>]*:?href[^>]*>([^<]+)<\/[^:>]*:?href>/) if (!m) continue const decoded = decodeURIComponent(m[1].trim()) if (!decoded.startsWith(prefix)) continue const relPath = decoded.slice(prefix.length).replace(/\/$/, '') || '/' if (relPath === browsePath) continue // skip self const name = relPath.split('/').filter(Boolean).pop() || '' if (name) dirs.push({ name, path: relPath }) } dirs.sort((a, b) => a.name.localeCompare(b.name)) return { path: browsePath, dirs } }) // 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 }) }