Files
selfphoneng/apps/api/src/app.ts
T
iamdoubz 15624f7b77 ivrs: digital receptionist with keypad grid, SoundPicker, golden case
/api/ivrs CRUD: greeting and wrong-key sounds must be prompts, every
option destination must exist (an option may point at its own menu),
keys unique; delete drops the menu's own options before checking
references, inside a transaction so a refused delete keeps them. The
generator renders sp-ivr-{id} per 03 §6.9 (Background greeting,
WaitExten, keys sorted, i/t retry then give up, include sp-local-numbers
for direct dial), adds the number to sp-local-numbers, and destApp sends
ivr destinations there. Golden case ivr (incl. # and self-reference).

Web: Digital receptionist page with a 3x4 keypad grid (pick a key, then
its destination or "unused"), SoundPicker (pick, play, upload new right
in the form), DestinationPicker gains "Digital receptionist" and a shared
target source; useDescribeDestination labels unsaved keys.

e2e m6-t3: menu built in the UI, the greeting plays, keys 1/2 ring the
right phones, a wrong key plays pbx-invalid and repeats the greeting.

Task: M6-T3
2026-10-18 10:39:13 -05:00

116 lines
3.9 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
import Fastify from 'fastify';
import fastifyCookie from '@fastify/cookie';
import fastifyStatic from '@fastify/static';
import fastifyWebsocket from '@fastify/websocket';
import { sql } from 'drizzle-orm';
import type { Config } from './config.ts';
import type { Db } from './db/client.ts';
import type { Generator } from './generator/index.ts';
import type { AmiClient } from './ami/client.ts';
import { settingsRoutes } from './routes/settings.ts';
import { errorHandler, forbidden, notFound, sendError } from './errors.ts';
import { authRoutes } from './routes/auth.ts';
import { userRoutes } from './routes/users.ts';
import { systemRoutes } from './routes/system.ts';
import { extensionRoutes } from './routes/extensions.ts';
import { trunkRoutes } from './routes/trunks.ts';
import { didRoutes } from './routes/dids.ts';
import { outboundRouteRoutes } from './routes/outbound.ts';
import { liveRoutes } from './live/hub.ts';
import { meRoutes } from './routes/me.ts';
import { soundRoutes } from './routes/sounds.ts';
import { ringGroupRoutes } from './routes/ringgroups.ts';
import { ivrRoutes } from './routes/ivrs.ts';
declare module 'fastify' {
interface FastifyInstance {
db: Db;
config: Config;
/** Decorated by the caller (index.ts, tests) before ready(): it needs app.log. */
generator: Generator;
/** Decorated by the caller too (tests pass a fake). */
ami: Pick<AmiClient, 'action' | 'isConnected'>;
}
}
// Paths the SPA must never swallow (04 §1 step 5).
const NON_SPA = /^\/(api|prov|hooks)(\/|$)/;
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
export function buildApp(config: Config, db: Db, publicDir = resolve('public')) {
const app = Fastify({
trustProxy: config.TRUST_PROXY ? '127.0.0.1' : false,
logger: {
level: config.LOG_LEVEL,
redact: [
'*.password',
'*.secret',
'*.token',
'*.pin',
'*.Secret',
'req.headers.authorization',
'req.headers.cookie',
],
},
});
app.decorate('db', db);
app.decorate('config', config);
app.decorateRequest('user', null);
app.decorateRequest('sessionId', null);
app.register(fastifyCookie);
app.register(fastifyWebsocket);
app.setErrorHandler(errorHandler);
// CSRF (04 §2.2): browsers can't add this header cross-site without CORS, and CORS is off.
// ponytail: required on every unsafe /api request, not only cookie-authenticated ones; the
// Bearer-token exemption arrives with API tokens in M11.
app.addHook('onRequest', async (req, reply) => {
if (
req.url.startsWith('/api/') &&
!SAFE_METHODS.has(req.method) &&
req.headers['x-requested-with'] !== 'selfphoneng'
)
return sendError(reply, forbidden('Missing X-Requested-With header', 'csrf'));
});
app.get('/healthz', async (_req, reply) => {
try {
await db.execute(sql`select 1`);
return { ok: true };
} catch {
return reply.code(503).send({ ok: false, reasons: ['db'] });
}
});
app.register(authRoutes);
app.register(userRoutes);
app.register(systemRoutes);
app.register(extensionRoutes);
app.register(trunkRoutes);
app.register(didRoutes);
app.register(outboundRouteRoutes);
app.register(settingsRoutes);
app.register(liveRoutes);
app.register(meRoutes);
app.register(soundRoutes);
app.register(ringGroupRoutes);
app.register(ivrRoutes);
// Always registered: reply.sendFile also streams voicemail audio (with Range) from the spool.
app.register(fastifyStatic, {
root: publicDir,
serve: existsSync(publicDir),
wildcard: false,
});
app.setNotFoundHandler((req, reply) => {
if (existsSync(publicDir) && req.method === 'GET' && !NON_SPA.test(req.url))
return reply.sendFile('index.html');
return sendError(reply, notFound());
});
return app;
}