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:
parent
cae411eae7
commit
1c411e402e
19809 changed files with 1962608 additions and 97 deletions
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
33
backend/src/lib/resos.js
Normal 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()
|
||||
}
|
||||
36
backend/src/lib/sambapos.js
Normal file
36
backend/src/lib/sambapos.js
Normal 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 }
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
30
backend/src/routes/settings.js
Normal file
30
backend/src/routes/settings.js
Normal 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 }
|
||||
})
|
||||
}
|
||||
|
|
@ -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}`)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue