feat(d28): Settings → Operations (SMS gateway URL, low threshold, ticket SLA)

'All this should be db': the three knobs the new features used to read from code
defaults are now editable DB settings in one easy place — Settings → Operations
(owner). GET/PUT /settings/operations reads/writes sms.balance_api_url (SSRF-guarded
before store), sms.low_balance_threshold, ticket.sla_days. No release needed to
retune any of them; the app already resolves each from the DB with a safe fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 3 days ago
parent 24f52fad0a
commit 343cc6795d

@ -935,6 +935,15 @@ export interface SmsBalances {
export const getSmsBalances = (): Promise<SmsBalances> =>
apiFetch<SmsBalances>('/reports/sms-balances')
// D28: operations settings — SMS gateway URL, low-balance threshold, ticket SLA (owner).
export interface OperationsSettings {
smsBalanceApiUrl: string; smsLowThreshold: number; ticketSlaDays: number
}
export const getOperationsSettings = (): Promise<OperationsSettings> =>
apiFetch<OperationsSettings & { ok: boolean }>('/settings/operations')
export const putOperationsSettings = (body: Partial<OperationsSettings>): Promise<OperationsSettings> =>
apiFetch<OperationsSettings & { ok: boolean }>('/settings/operations', { method: 'PUT', body: JSON.stringify(body) })
// D28: live balance fetch from the gateway (managerial — decrypts credentials).
export interface SmsRefreshSummary {
attempted: number; ok: number; failed: number; changed: number; skipped: number

@ -2,12 +2,13 @@ import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { formatINR } from '@sims/domain'
import {
Button, DataTable, Dialog, EmptyState, ErrorState, FormField, FormGrid,
Button, DataTable, Dialog, EmptyState, ErrorState, FormField, FormGrid, Toolbar,
Notice, PageHeader, Skeleton, ThemeSwitcher, useToast,
} from '@sims/ui'
import {
createSchedule, getAwsRanking, getCompanyProfile, getEmailStatus, getReminderSettings,
getSharingSettings, putCompanyProfile, putReminderSettings, putSharingSettings, role,
getOperationsSettings, putOperationsSettings, type OperationsSettings,
} from '../api'
import { COMPANY_FIELDS, TemplatePanel } from './DocumentTemplate'
import { useData } from './Clients'
@ -15,12 +16,13 @@ import { useData } from './Clients'
const inr = (p: number) => formatINR(p, { symbol: false })
type Dict = Record<string, string>
type SectionKey = 'company' | 'template' | 'reminders' | 'sharing' | 'integrations' | 'appearance'
type SectionKey = 'company' | 'template' | 'reminders' | 'operations' | 'sharing' | 'integrations' | 'appearance'
const SECTIONS: { key: SectionKey; label: string; ownerOnly: boolean }[] = [
{ key: 'company', label: 'Company', ownerOnly: true },
{ key: 'template', label: 'Document template', ownerOnly: true },
{ key: 'reminders', label: 'Reminders', ownerOnly: true },
{ key: 'operations', label: 'Operations', ownerOnly: true },
{ key: 'sharing', label: 'Sharing', ownerOnly: true },
{ key: 'integrations', label: 'Integrations', ownerOnly: true },
{ key: 'appearance', label: 'Appearance', ownerOnly: false },
@ -64,6 +66,7 @@ export function Settings() {
{active === 'company' && <CompanyPanel />}
{active === 'template' && <TemplatePanel />}
{active === 'reminders' && <RemindersPanel />}
{active === 'operations' && <OperationsPanel />}
{active === 'sharing' && <SharingPanel />}
{active === 'integrations' && <IntegrationsPanel />}
{active === 'appearance' && <AppearancePanel />}
@ -73,6 +76,59 @@ export function Settings() {
)
}
/** Operations (D28) the running-the-service knobs: SMS gateway + thresholds + ticket SLA.
* All are DB settings resolved by the app (config over code); editing here is the whole point. */
function OperationsPanel() {
const toast = useToast()
const [s, setS] = useState<OperationsSettings | undefined>()
const [saving, setSaving] = useState(false)
useEffect(() => {
getOperationsSettings().then(setS).catch((e: Error) => toast.err(e.message))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const save = () => {
if (saving || s === undefined) return
setSaving(true)
putOperationsSettings(s)
.then((next) => { setS(next); toast.ok('Operations settings saved.') })
.catch((e: Error) => toast.err(e.message))
.finally(() => setSaving(false))
}
return (
<div>
<h2>Operations</h2>
<p className="wf-page-desc">The knobs behind the SMS balance screen and the ticket desk change them here, no release needed.</p>
{s === undefined ? <Skeleton rows={4} /> : (
<>
<FormGrid>
<FormField label="SMS balance API URL" wide>
<input className="wf" value={s.smsBalanceApiUrl}
onChange={(e) => setS({ ...s, smsBalanceApiUrl: e.target.value })} />
<span style={{ display: 'block', fontSize: 12, opacity: 0.6, marginTop: 4 }}>The provider panel HQ polls for each SMS accounts balance. Public host only.</span>
</FormField>
<FormField label="SMS low-balance threshold">
<input className="wf num" type="number" min={0} value={s.smsLowThreshold}
onChange={(e) => setS({ ...s, smsLowThreshold: Number(e.target.value) })} />
<span style={{ display: 'block', fontSize: 12, opacity: 0.6, marginTop: 4 }}>Below this many SMS a client is flagged low and shows in the upsell list.</span>
</FormField>
<FormField label="Ticket SLA (days)">
<input className="wf num" type="number" min={1} value={s.ticketSlaDays}
onChange={(e) => setS({ ...s, ticketSlaDays: Number(e.target.value) })} />
<span style={{ display: 'block', fontSize: 12, opacity: 0.6, marginTop: 4 }}>A still-open ticket older than this is marked overdue on the desk.</span>
</FormField>
</FormGrid>
<Toolbar>
<Button pill tone="primary" disabled={saving} onClick={save}>{saving ? 'Saving…' : 'Save'}</Button>
</Toolbar>
</>
)}
</div>
)
}
/** Company identity same field list as the letterhead template's Company section
* (`COMPANY_FIELDS`, imported so the two never drift apart). */
function CompanyPanel() {

@ -48,7 +48,7 @@ import {
listInteractionTypes, updateInteraction, type CreateInteractionInput, type InteractionPatch,
} from './repos-interactions'
import {
dismissReminder, getNumberSetting, getReminder, insertSchedule, listQueue, listSchedules, setSetting,
dismissReminder, getNumberSetting, getSetting, getReminder, insertSchedule, listQueue, listSchedules, setSetting,
type QueueOpts, type ReminderStatus,
} from './repos-reminders'
import { sendReminder, reminderContext, type SendReminderDeps } from './send-reminder'
@ -62,7 +62,8 @@ import { clientProfitability, duesAging, gstSummary, moduleRevenue, type DateRan
import { listUsageRateCards, setUsageRateCard } from './repos-rate-card'
import { renewalsDue, generateModuleRenewalQuote } from './repos-renewals'
import { misCockpit } from './repos-mis'
import { smsBalances, refreshSmsBalance, refreshAllSmsBalances } from './repos-sms'
import { smsBalances, refreshSmsBalance, refreshAllSmsBalances, DEFAULT_SMS_BALANCE_API } from './repos-sms'
import { assertSafeGatewayUrl } from './sms-gateway'
import { makeRateLimiter } from './rate-limit'
import {
addProjectMilestone, bulkSetMilestone, ensureProjectMilestones, listProjectMilestones, milestoneBoard,
@ -1663,6 +1664,47 @@ export function apiRouter(
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
// ---------- operations knobs (owner): SMS gateway + thresholds + ticket SLA (D28) ----------
r.get('/settings/operations', requireAuth, requireOwner, async (_req, res) => {
try {
res.json({
ok: true,
smsBalanceApiUrl: (await getSetting(db, 'sms.balance_api_url')) ?? DEFAULT_SMS_BALANCE_API,
smsLowThreshold: await getNumberSetting(db, 'sms.low_balance_threshold', 10000),
ticketSlaDays: await getNumberSetting(db, 'ticket.sla_days', 7),
})
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
r.put('/settings/operations', requireAuth, requireOwner, async (req, res) => {
const b = req.body as { smsBalanceApiUrl?: unknown; smsLowThreshold?: unknown; ticketSlaDays?: unknown }
try {
if (b.smsBalanceApiUrl !== undefined) {
const url = String(b.smsBalanceApiUrl).trim()
if (url !== '') assertSafeGatewayUrl(url) // reject internal/loopback targets before storing
await setSetting(db, staffId(res), 'sms.balance_api_url', url)
}
if (b.smsLowThreshold !== undefined) {
const n = Number(b.smsLowThreshold)
if (!Number.isInteger(n) || n < 0) throw new Error('Low-balance threshold must be a whole number ≥ 0')
await setSetting(db, staffId(res), 'sms.low_balance_threshold', String(n))
}
if (b.ticketSlaDays !== undefined) {
const n = Number(b.ticketSlaDays)
if (!Number.isInteger(n) || n < 1) throw new Error('Ticket SLA must be a whole number of days ≥ 1')
await setSetting(db, staffId(res), 'ticket.sla_days', String(n))
}
res.json({
ok: true,
smsBalanceApiUrl: (await getSetting(db, 'sms.balance_api_url')) ?? DEFAULT_SMS_BALANCE_API,
smsLowThreshold: await getNumberSetting(db, 'sms.low_balance_threshold', 10000),
ticketSlaDays: await getNumberSetting(db, 'ticket.sla_days', 7),
})
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
r.put('/settings/company', requireAuth, requireOwner, async (req, res) => {
const body = req.body as Record<string, unknown>
try {

Loading…
Cancel
Save