Add settings page for managing forecasting API key and URL via UI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-20 14:37:36 +00:00
parent cae411eae7
commit 1c411e402e
19809 changed files with 1962608 additions and 97 deletions

View file

@ -11,6 +11,7 @@
"@fastify/cors": "^9.0.1",
"fastify": "^4.28.1",
"jose": "^5.9.6",
"mssql": "^11.0.1",
"pg": "^8.13.1"
}
}

View file

@ -60,5 +60,24 @@ export async function initDb() {
occ_pct DECIMAL(5,2)
);
CREATE INDEX IF NOT EXISTS idx_forecast_snapshots_session ON forecast_snapshots(session_id);
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT '',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
`)
// Seed default setting keys (value stays empty until set via UI)
await pool.query(`
INSERT INTO app_settings (key, value) VALUES
('forecasting_url', ''),
('forecasting_api_key', '')
ON CONFLICT (key) DO NOTHING
`)
}
export async function getSetting(key) {
const res = await pool.query('SELECT value FROM app_settings WHERE key = $1', [key])
return res.rows[0]?.value || null
}

View file

@ -5,6 +5,7 @@ import { initDb } from './db.js'
import { reportRoutes } from './routes/reports.js'
import { directorsForecastRoutes } from './routes/directors-forecast.js'
import { weeklyActualRoutes } from './routes/weekly-actual.js'
import { settingsRoutes } from './routes/settings.js'
const app = Fastify({ logger: true, trustProxy: true })
const startedAt = Date.now()
@ -20,6 +21,7 @@ app.get('/health', async () => ({ status: 'healthy', version: process.env.BUILD_
await app.register(reportRoutes)
await app.register(directorsForecastRoutes)
await app.register(weeklyActualRoutes)
await app.register(settingsRoutes)
try {
await initDb()

33
backend/src/lib/resos.js Normal file
View file

@ -0,0 +1,33 @@
const API_BASE = 'https://api.resos.com/v1'
async function getCredentials() {
const url = `${process.env.SETTINGS_URL}/settings/api/internal/integration/resos`
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.SETTINGS_SECRET}` },
signal: AbortSignal.timeout(5000),
})
if (!res.ok) throw new Error(`Settings service returned ${res.status} fetching ResOS credentials`)
const s = await res.json()
return { apiKey: s.api_key || '' }
}
export async function callApi(path, params = {}) {
const creds = await getCredentials()
if (!creds.apiKey) throw new Error('ResOS API key not configured — set it in Settings → Integrations')
const url = new URL(`${API_BASE}/${path}`)
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== null) url.searchParams.set(k, String(v))
}
const auth = Buffer.from(`${creds.apiKey}:`).toString('base64')
const res = await fetch(url.toString(), {
headers: { Authorization: `Basic ${auth}`, Accept: 'application/json' },
signal: AbortSignal.timeout(15000),
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`ResOS API ${res.status}: ${text.slice(0, 200)}`)
}
return await res.json()
}

View file

@ -0,0 +1,36 @@
async function getCredentials() {
const url = `${process.env.SETTINGS_URL}/settings/api/internal/integration/sambapos`
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.SETTINGS_SECRET}` },
signal: AbortSignal.timeout(5000),
})
if (!res.ok) throw new Error(`Settings service returned ${res.status} fetching SambaPOS credentials`)
return await res.json()
}
export async function getConnection() {
const creds = await getCredentials()
if (!creds.sql_host || !creds.sql_database || !creds.sql_username || !creds.sql_password) {
throw new Error('SambaPOS SQL credentials not configured — set them in Settings → Integrations')
}
const { default: sql } = await import('mssql')
const [server, instance] = creds.sql_host.split('\\')
const config = {
server,
database: creds.sql_database,
authentication: {
type: 'default',
options: { userName: creds.sql_username, password: creds.sql_password },
},
options: {
trustServerCertificate: true,
connectTimeout: 10000,
requestTimeout: 30000,
...(instance ? { instanceName: instance } : { port: parseInt(creds.sql_port || '1433') }),
},
}
const pool = await sql.connect(config)
return { sql, pool }
}

View file

@ -4,22 +4,10 @@ export default {
category: 'resos',
categoryLabel: 'ResOS',
subcategory: 'Bookings',
description: 'Daily restaurant covers from ResOS within the selected date range. Requires RESOS_API_KEY environment variable.',
description: 'Daily restaurant covers from ResOS within the selected date range.',
async run({ dateFrom, dateTo }, { env }) {
const apiKey = env.RESOS_API_KEY
if (!apiKey) throw new Error('ResOS API key not configured — set RESOS_API_KEY in the .env file')
const url = `https://api.resos.com/v1/bookings?date_from=${dateFrom}&date_to=${dateTo}&per_page=500`
const res = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json' },
signal: AbortSignal.timeout(15000),
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`ResOS API ${res.status}: ${text.slice(0, 200)}`)
}
const data = await res.json()
async run({ dateFrom, dateTo }, { resos }) {
const data = await resos.callApi('bookings', { date_from: dateFrom, date_to: dateTo, per_page: 500 })
const bookings = data?.data ?? data?.bookings ?? []
// Aggregate by date

View file

@ -4,22 +4,10 @@ export default {
category: 'resos',
categoryLabel: 'ResOS',
subcategory: 'Revenue',
description: 'Daily restaurant revenue from ResOS within the selected date range. Requires RESOS_API_KEY environment variable.',
description: 'Daily restaurant revenue from ResOS within the selected date range.',
async run({ dateFrom, dateTo }, { env }) {
const apiKey = env.RESOS_API_KEY
if (!apiKey) throw new Error('ResOS API key not configured — set RESOS_API_KEY in the .env file')
const url = `https://api.resos.com/v1/bookings?date_from=${dateFrom}&date_to=${dateTo}&per_page=500`
const res = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json' },
signal: AbortSignal.timeout(15000),
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`ResOS API ${res.status}: ${text.slice(0, 200)}`)
}
const data = await res.json()
async run({ dateFrom, dateTo }, { resos }) {
const data = await resos.callApi('bookings', { date_from: dateFrom, date_to: dateTo, per_page: 500 })
const bookings = data?.data ?? data?.bookings ?? []
const byDate = {}

View file

@ -4,54 +4,53 @@ export default {
category: 'samba',
categoryLabel: 'SambaPOS',
subcategory: 'Sales',
description: 'Daily sales totals from SambaPOS within the selected date range. Requires SAMBA_DATABASE_URL environment variable.',
description: 'Daily sales totals from SambaPOS within the selected date range.',
async run({ dateFrom, dateTo }, { env }) {
const connStr = env.SAMBA_DATABASE_URL
if (!connStr) throw new Error('SambaPOS database not configured — set SAMBA_DATABASE_URL in the .env file')
// Dynamic import to avoid pg pool creation if not needed
const { Pool } = (await import('pg')).default
const sambaPool = new Pool({ connectionString: connStr, max: 2 })
async run({ dateFrom, dateTo }, { sambapos }) {
const { sql, pool } = await sambapos.getConnection()
try {
const { rows } = await sambaPool.query(`
const req = pool.request()
req.input('dateFrom', sql.Date, dateFrom)
req.input('dateTo', sql.Date, dateTo)
const { recordset: rows } = await req.query(`
SELECT
CAST(Date AS DATE) AS sale_date,
COUNT(*) AS ticket_count,
SUM(TotalAmount) AS gross_total,
SUM(DiscountAmount) AS discount_total,
CAST(Date AS DATE) AS sale_date,
COUNT(*) AS ticket_count,
SUM(TotalAmount) AS gross_total,
SUM(DiscountAmount) AS discount_total,
SUM(TotalAmount - DiscountAmount) AS net_total
FROM Tickets
WHERE CAST(Date AS DATE) BETWEEN $1 AND $2
WHERE CAST(Date AS DATE) BETWEEN @dateFrom AND @dateTo
AND IsCancelled = 0
GROUP BY CAST(Date AS DATE)
ORDER BY CAST(Date AS DATE)
`, [dateFrom, dateTo])
`)
const totalNet = rows.reduce((s, r) => s + parseFloat(r.net_total ?? 0), 0)
return {
columns: [
{ key: 'sale_date', label: 'Date', type: 'date' },
{ key: 'ticket_count', label: 'Tickets', type: 'number' },
{ key: 'gross_total', label: 'Gross', type: 'currency' },
{ key: 'discount_total',label: 'Discounts', type: 'currency' },
{ key: 'net_total', label: 'Net Total', type: 'currency' },
{ key: 'sale_date', label: 'Date', type: 'date' },
{ key: 'ticket_count', label: 'Tickets', type: 'number' },
{ key: 'gross_total', label: 'Gross', type: 'currency' },
{ key: 'discount_total', label: 'Discounts', type: 'currency' },
{ key: 'net_total', label: 'Net Total', type: 'currency' },
],
rows: rows.map(r => ({
sale_date: r.sale_date instanceof Date ? r.sale_date.toISOString().split('T')[0] : r.sale_date,
sale_date: r.sale_date instanceof Date ? r.sale_date.toISOString().split('T')[0] : String(r.sale_date).slice(0, 10),
ticket_count: r.ticket_count,
gross_total: parseFloat(r.gross_total ?? 0).toFixed(2),
gross_total: parseFloat(r.gross_total ?? 0).toFixed(2),
discount_total: parseFloat(r.discount_total ?? 0).toFixed(2),
net_total: parseFloat(r.net_total ?? 0).toFixed(2),
net_total: parseFloat(r.net_total ?? 0).toFixed(2),
})),
summary: [
{ label: 'Total net', value: totalNet.toFixed(2) },
],
}
} finally {
await sambaPool.end()
await pool.close()
}
},
}

View file

@ -4,43 +4,42 @@ export default {
category: 'samba',
categoryLabel: 'SambaPOS',
subcategory: 'Products',
description: 'Best-selling products from SambaPOS ranked by quantity sold within the selected date range. Requires SAMBA_DATABASE_URL environment variable.',
description: 'Best-selling products from SambaPOS ranked by quantity sold within the selected date range.',
async run({ dateFrom, dateTo }, { env }) {
const connStr = env.SAMBA_DATABASE_URL
if (!connStr) throw new Error('SambaPOS database not configured — set SAMBA_DATABASE_URL in the .env file')
const { Pool } = (await import('pg')).default
const sambaPool = new Pool({ connectionString: connStr, max: 2 })
async run({ dateFrom, dateTo }, { sambapos }) {
const { sql, pool } = await sambapos.getConnection()
try {
const { rows } = await sambaPool.query(`
SELECT
ti.MenuItemName AS product_name,
COUNT(*) AS orders,
SUM(ti.Quantity) AS qty_sold,
SUM(ti.Price * ti.Quantity) AS gross_total
const req = pool.request()
req.input('dateFrom', sql.Date, dateFrom)
req.input('dateTo', sql.Date, dateTo)
const { recordset: rows } = await req.query(`
SELECT TOP 50
ti.MenuItemName AS product_name,
COUNT(*) AS orders,
SUM(ti.Quantity) AS qty_sold,
SUM(ti.Price * ti.Quantity) AS gross_total
FROM TicketItems ti
JOIN Tickets t ON t.Id = ti.TicketId
WHERE CAST(t.Date AS DATE) BETWEEN $1 AND $2
WHERE CAST(t.Date AS DATE) BETWEEN @dateFrom AND @dateTo
AND t.IsCancelled = 0
AND ti.IsVoided = 0
GROUP BY ti.MenuItemName
ORDER BY qty_sold DESC
LIMIT 50
`, [dateFrom, dateTo])
ORDER BY SUM(ti.Quantity) DESC
`)
return {
columns: [
{ key: 'product_name', label: 'Product' },
{ key: 'orders', label: 'Orders', type: 'number' },
{ key: 'qty_sold', label: 'Qty Sold', type: 'number' },
{ key: 'gross_total', label: 'Gross', type: 'currency' },
{ key: 'orders', label: 'Orders', type: 'number' },
{ key: 'qty_sold', label: 'Qty Sold', type: 'number' },
{ key: 'gross_total', label: 'Gross', type: 'currency' },
],
rows: rows.map(r => ({
product_name: r.product_name,
orders: r.orders,
qty_sold: parseFloat(r.qty_sold ?? 0).toFixed(0),
qty_sold: parseFloat(r.qty_sold ?? 0).toFixed(0),
gross_total: parseFloat(r.gross_total ?? 0).toFixed(2),
})),
summary: [
@ -48,7 +47,7 @@ export default {
],
}
} finally {
await sambaPool.end()
await pool.close()
}
},
}

View file

@ -1,12 +1,11 @@
import { requireAuth, hasCap } from '../auth.js'
import { pool } from '../db.js'
const FORECASTING_URL = process.env.FORECASTING_URL || 'http://10.10.10.113:3080'
import { pool, getSetting } from '../db.js'
async function fcFetch(path) {
const apiKey = process.env.FORECASTING_API_KEY
const apiKey = await getSetting('forecasting_api_key') || process.env.FORECASTING_API_KEY
const baseUrl = await getSetting('forecasting_url') || process.env.FORECASTING_URL || 'http://10.10.10.113:3080'
if (!apiKey) throw new Error('FORECASTING_API_KEY not configured')
const res = await fetch(`${FORECASTING_URL}/forecasting/api/public${path}`, {
const res = await fetch(`${baseUrl}/forecasting/api/public${path}`, {
headers: { 'X-API-Key': apiKey },
})
if (!res.ok) throw new Error(`Forecasting API ${res.status}${path}`)

View file

@ -2,6 +2,8 @@ import { requireAuth, hasCap } from '../auth.js'
import { pool } from '../db.js'
import { registry } from '../reports/index.js'
import * as newbook from '../lib/newbook.js'
import * as resos from '../lib/resos.js'
import * as sambapos from '../lib/sambapos.js'
const PUBLIC_META = registry.map(r => ({
id: r.id,
@ -28,7 +30,7 @@ export async function reportRoutes(fastify) {
const report = registry.find(r => r.id === id)
if (!report) return reply.status(404).send({ error: 'Report not found' })
const ctx = { db: pool, newbook, env: process.env }
const ctx = { db: pool, newbook, resos, sambapos, env: process.env }
const start = Date.now()
try {

View file

@ -0,0 +1,30 @@
import { requireAuth } from '../auth.js'
import { pool } from '../db.js'
const ALLOWED_KEYS = new Set(['forecasting_url', 'forecasting_api_key'])
export async function settingsRoutes(fastify) {
fastify.addHook('preHandler', requireAuth)
fastify.get('/api/settings', async (request, reply) => {
if (!request.user?.is_admin) return reply.status(403).send({ error: 'Admin only' })
const res = await pool.query('SELECT key, value, updated_at FROM app_settings ORDER BY key')
return { settings: res.rows }
})
fastify.put('/api/settings', async (request, reply) => {
if (!request.user?.is_admin) return reply.status(403).send({ error: 'Admin only' })
const { settings } = request.body || {}
if (!Array.isArray(settings)) return reply.status(400).send({ error: 'settings must be an array' })
for (const { key, value } of settings) {
if (!ALLOWED_KEYS.has(key)) continue
await pool.query(
`INSERT INTO app_settings (key, value, updated_at) VALUES ($1, $2, NOW())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()`,
[key, value ?? '']
)
}
return { ok: true }
})
}

View file

@ -1,11 +1,11 @@
import { requireAuth } from '../auth.js'
const FORECASTING_URL = process.env.FORECASTING_URL || 'http://10.10.10.113:3080'
import { getSetting } from '../db.js'
async function fcFetch(path) {
const apiKey = process.env.FORECASTING_API_KEY
const apiKey = await getSetting('forecasting_api_key') || process.env.FORECASTING_API_KEY
const baseUrl = await getSetting('forecasting_url') || process.env.FORECASTING_URL || 'http://10.10.10.113:3080'
if (!apiKey) throw new Error('FORECASTING_API_KEY not configured')
const res = await fetch(`${FORECASTING_URL}/forecasting/api/public${path}`, {
const res = await fetch(`${baseUrl}/forecasting/api/public${path}`, {
headers: { 'X-API-Key': apiKey },
})
if (!res.ok) throw new Error(`Forecasting API ${res.status}${path}`)