Files
selfphoneng/apps/api/src/live/hub.ts
T
iamdoubz 57e0e3dac2 featurecodes: DND, forwarding, pickup, echo, say-ext; /me settings
Feature codes per 03 §6.13: *78/*79 DND, *72<number>/*73 forward always,
**<ext> directed pickup, *8 group pickup (also as a dialplan line, since
pickupexten only catches a phone's own INVITE), *43 echo test, *65 say
my extension; queue login and intercom stay for M8. SpToggle DND/CFU
update user_runtime_state; a *72 target the user's calling permission
doesn't allow is removed from AstDB, audited (forward_rejected) and sent
to the user as a toast (new private live message). Forward targets are
checked the same way everywhere (services/forwards.ts: an existing
internal number, or an outside number some outbound rule allows them).

GET/PATCH /api/me: DND, cfu/cfb/cfna, voicemail email options and PIN;
PostgreSQL first, then AstDB; AstDB resync on connect now includes DND
and forwards. Web: /me/settings (forwarding, DND, voicemail PIN and
email options), a status card on Home, toasts.

e2e m6-t7 with real phones covers every code, the refused forward toast,
and the settings page reaching AstDB; it found the settings forms losing
"Saved." on remount.

Task: M6-T7
2026-10-18 11:19:21 -05:00

108 lines
3.4 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
// GET /api/live WebSocket (04 §5.2): a snapshot on connect, then batched deltas.
import type { FastifyInstance } from 'fastify';
import type { WebSocket } from '@fastify/websocket';
import type { ExtensionLive, LiveMessage } from '@selfphoneng/shared';
import { COOKIE, getSessionUser } from '../auth/sessions.ts';
import type { LiveState } from './state.ts';
const BATCH_MS = 250;
const PING_MS = 30_000;
type Client = {
ws: WebSocket;
admin: boolean;
userId: string;
/** `${type}:${key}` → latest message, so many updates of one thing collapse into one. */
pending: Map<string, LiveMessage>;
alive: boolean;
};
declare module 'fastify' {
interface FastifyInstance {
live: LiveState;
}
}
export async function liveRoutes(app: FastifyInstance) {
const clients = new Set<Client>();
const keyOf = (m: LiveMessage) =>
m.type === 'extension.updated' || m.type === 'extension.removed'
? `extension:${m.data.number}` // a removal replaces a pending update and vice versa
: m.type === 'trunk.updated' || m.type === 'trunk.removed'
? `trunk:${m.data.trunkId}`
: m.type === 'emergency.call'
? `emergency:${m.data.at}:${m.data.ext}` // never merge two 911 calls
: m.type === 'voicemail.new'
? `voicemail:${m.data.id}`
: m.type === 'toast'
? `toast:${m.data.message}`
: m.type;
const maySee = (c: Client, m: LiveMessage) =>
m.type === 'voicemail.new' || m.type === 'toast'
? m.data.userId === c.userId // private, even from admins
: c.admin ||
(m.type === 'emergency.call'
? m.data.audienceUserIds.includes(c.userId)
: !m.type.startsWith('trunk.'));
app.live.on('message', (m) => {
for (const c of clients) if (maySee(c, m)) c.pending.set(keyOf(m), m);
});
const viewFor = (c: Client, m: LiveMessage): LiveMessage =>
m.type === 'extension.updated'
? {
type: m.type,
data: app.live.view(m.data as ExtensionLive & { contacts: [] }, c.admin),
}
: m;
const flush = setInterval(() => {
for (const c of clients) {
if (!c.pending.size) continue;
for (const m of c.pending.values()) c.ws.send(JSON.stringify(viewFor(c, m)));
c.pending.clear();
}
}, BATCH_MS);
const ping = setInterval(() => {
for (const c of clients) {
if (!c.alive) {
c.ws.terminate(); // missed the last ping
continue;
}
c.alive = false;
c.ws.ping();
}
}, PING_MS);
app.addHook('onClose', async () => {
clearInterval(flush);
clearInterval(ping);
for (const c of clients) c.ws.close(1001, 'server shutting down');
});
app.get('/api/live', { websocket: true }, async (ws, req) => {
const token = req.cookies[COOKIE];
const found = token ? await getSessionUser(app.db, token) : null;
// Browsers can't read an HTTP 401 on an upgrade, so open and close with 4401 (04 §5.2).
if (!found || found.user.mustChangePassword) return ws.close(4401, 'unauthorized');
const c: Client = {
ws,
admin: found.user.role === 'admin',
userId: found.user.id,
pending: new Map(),
alive: true,
};
clients.add(c);
ws.on('pong', () => (c.alive = true));
ws.on('close', () => clients.delete(c));
ws.send(
JSON.stringify({ type: 'snapshot', data: app.live.snapshot(c.admin) } satisfies LiveMessage),
);
});
}