extensions: numbering service, CRUD API, credentials, Users page tabs
One namespace for internal numbers (range, reserved codes, parking, every object table) with a next-free lookup. Creating an extension generates its SIP password and voicemail PIN and a runtime-state row; every change and password regeneration triggers a config apply. Credentials are revealed only on request and each reveal is audited. Users get an Ext. column and a drawer with General (number + next free), Phone and Credentials tabs. scripts/e2e/m2-t5.mjs checks the flow in a browser and that the generated PJSIP file follows; the files it produced load cleanly in Asterisk. Task: M2-T5
This commit is contained in:
1 parent
a4da81cf8f
commit
1cfc13ead3
12 files changed
+1167
-141
No files matched your search
@@ -0,0 +1,240 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// /api/extensions and /api/numbering (04 §7.2, §7.3). Admin only.
|
||||
import { asc, eq } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
ExtensionCreateSchema,
|
||||
ExtensionUpdateSchema,
|
||||
NUMBER_KINDS,
|
||||
type ExtensionDto,
|
||||
type ListResponse,
|
||||
type SipCredentials,
|
||||
} from '@selfphoneng/shared';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { extensions, userRuntimeState } from '../db/schema.ts';
|
||||
import { badRequest, conflict, notFound } from '../errors.ts';
|
||||
import { requireAdmin } from '../auth/guards.ts';
|
||||
import {
|
||||
decrypt,
|
||||
encrypt,
|
||||
generatePin,
|
||||
generateSipPassword,
|
||||
isTrivialPin,
|
||||
} from '../crypto/secrets.ts';
|
||||
import { audit } from '../services/audit.ts';
|
||||
import { assertNumberAvailable, nextFreeNumber } from '../services/numbering.ts';
|
||||
import { getSetting } from '../services/settings.ts';
|
||||
|
||||
type ExtRow = typeof extensions.$inferSelect;
|
||||
|
||||
export const toExtensionDto = (e: ExtRow): ExtensionDto => ({
|
||||
id: e.id,
|
||||
number: e.number,
|
||||
userId: e.userId,
|
||||
name: e.name,
|
||||
webrtcEnabled: e.webrtcEnabled,
|
||||
maxContacts: e.maxContacts,
|
||||
ringSeconds: e.ringSeconds,
|
||||
voicemailEnabled: e.voicemailEnabled,
|
||||
voicemailEmail: e.voicemailEmail,
|
||||
voicemailAttachAudio: e.voicemailAttachAudio,
|
||||
voicemailDeleteAfterEmail: e.voicemailDeleteAfterEmail,
|
||||
outboundClass: e.outboundClass,
|
||||
outboundCallerIdDidId: e.outboundCallerIdDidId,
|
||||
emergencyCallerIdDidId: e.emergencyCallerIdDidId,
|
||||
emergencyLocation: e.emergencyLocation,
|
||||
recordMode: e.recordMode,
|
||||
pickupGroup: e.pickupGroup,
|
||||
language: e.language,
|
||||
mohClass: e.mohClass,
|
||||
blfNumbers: e.blfNumbers,
|
||||
createdAt: e.createdAt.toISOString(),
|
||||
});
|
||||
|
||||
const IdParams = z.object({ id: z.uuid() });
|
||||
|
||||
const isUniqueViolation = (e: unknown, constraint?: string) => {
|
||||
const err =
|
||||
(e as { cause?: { code?: string; constraint?: string } }).cause ??
|
||||
(e as { code?: string; constraint?: string });
|
||||
return err?.code === '23505' && (!constraint || err.constraint?.includes(constraint));
|
||||
};
|
||||
|
||||
function checkPin(pin: string, number: string) {
|
||||
if (isTrivialPin(pin, number))
|
||||
throw badRequest(
|
||||
'That PIN is too easy to guess (repeated, sequential or the extension number)',
|
||||
);
|
||||
}
|
||||
|
||||
/** extensions.user_id is unique: one extension per user. */
|
||||
async function assertUserFree(db: Db, userId: string | null | undefined, excludeId?: string) {
|
||||
if (!userId) return;
|
||||
const [e] = await db
|
||||
.select({ id: extensions.id, number: extensions.number })
|
||||
.from(extensions)
|
||||
.where(eq(extensions.userId, userId));
|
||||
if (e && e.id !== excludeId) throw conflict(`That user already has extension ${e.number}`);
|
||||
}
|
||||
|
||||
async function getExt(db: Db, id: string): Promise<ExtRow> {
|
||||
const [e] = await db.select().from(extensions).where(eq(extensions.id, id));
|
||||
if (!e) throw notFound('Extension not found');
|
||||
return e;
|
||||
}
|
||||
|
||||
export async function extensionRoutes(app: FastifyInstance) {
|
||||
const { db } = app;
|
||||
app.addHook('preHandler', requireAdmin);
|
||||
|
||||
app.get('/api/numbering/next', async (req) => {
|
||||
const { kind } = z.object({ kind: z.enum(NUMBER_KINDS) }).parse(req.query);
|
||||
return { number: await nextFreeNumber(db, kind) };
|
||||
});
|
||||
|
||||
// ponytail: one page with every extension (v1 targets ≤100); cursor paging when needed.
|
||||
app.get('/api/extensions', async (): Promise<ListResponse<ExtensionDto>> => {
|
||||
const rows = await db.select().from(extensions).orderBy(asc(extensions.number));
|
||||
return { items: rows.map(toExtensionDto), nextCursor: null };
|
||||
});
|
||||
|
||||
app.get('/api/extensions/:id', async (req) => {
|
||||
const { id } = IdParams.parse(req.params);
|
||||
return toExtensionDto(await getExt(db, id));
|
||||
});
|
||||
|
||||
app.post('/api/extensions', async (req, reply) => {
|
||||
const { voicemailPin, ...body } = ExtensionCreateSchema.parse(req.body);
|
||||
await assertNumberAvailable(db, body.number, 'user');
|
||||
await assertUserFree(db, body.userId);
|
||||
if (voicemailPin) checkPin(voicemailPin, body.number);
|
||||
let created: ExtRow;
|
||||
try {
|
||||
created = await db.transaction(async (tx) => {
|
||||
const [e] = await tx
|
||||
.insert(extensions)
|
||||
.values({
|
||||
...body,
|
||||
sipPasswordEnc: encrypt(generateSipPassword()),
|
||||
voicemailPinEnc: encrypt(voicemailPin ?? generatePin()),
|
||||
})
|
||||
.returning();
|
||||
await tx.insert(userRuntimeState).values({ extensionId: e!.id });
|
||||
return e!;
|
||||
});
|
||||
} catch (e) {
|
||||
if (isUniqueViolation(e, 'user_id')) throw conflict('That user already has an extension');
|
||||
if (isUniqueViolation(e)) throw conflict(`${body.number} is already taken`);
|
||||
throw e;
|
||||
}
|
||||
await audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
action: 'create',
|
||||
entityType: 'extension',
|
||||
entityId: created.id,
|
||||
summary: `Created extension ${created.number} (${created.name})`,
|
||||
diff: { after: body },
|
||||
});
|
||||
app.generator.requestApply('api-change');
|
||||
return reply.code(201).send(toExtensionDto(created));
|
||||
});
|
||||
|
||||
app.patch('/api/extensions/:id', async (req) => {
|
||||
const { id } = IdParams.parse(req.params);
|
||||
const { voicemailPin, ...body } = ExtensionUpdateSchema.parse(req.body);
|
||||
const before = await getExt(db, id);
|
||||
if (body.number && body.number !== before.number)
|
||||
await assertNumberAvailable(db, body.number, 'user', id);
|
||||
await assertUserFree(db, body.userId, id);
|
||||
if (voicemailPin) checkPin(voicemailPin, body.number ?? before.number);
|
||||
let after: ExtRow;
|
||||
try {
|
||||
[after] = (await db
|
||||
.update(extensions)
|
||||
.set({ ...body, ...(voicemailPin ? { voicemailPinEnc: encrypt(voicemailPin) } : {}) })
|
||||
.where(eq(extensions.id, id))
|
||||
.returning()) as [ExtRow];
|
||||
} catch (e) {
|
||||
if (isUniqueViolation(e, 'user_id')) throw conflict('That user already has an extension');
|
||||
throw e;
|
||||
}
|
||||
const changed = (Object.keys(body) as (keyof typeof body)[]).filter(
|
||||
(k) => JSON.stringify(before[k]) !== JSON.stringify(after[k]),
|
||||
);
|
||||
await audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
action: 'update',
|
||||
entityType: 'extension',
|
||||
entityId: id,
|
||||
summary: `Updated extension ${after.number}${voicemailPin ? ' (voicemail PIN changed)' : ''}${changed.length ? ` (${changed.join(', ')})` : ''}`,
|
||||
diff: {
|
||||
before: Object.fromEntries(changed.map((k) => [k, before[k]])),
|
||||
after: Object.fromEntries(changed.map((k) => [k, after[k]])),
|
||||
},
|
||||
});
|
||||
app.generator.requestApply('api-change');
|
||||
return toExtensionDto(after);
|
||||
});
|
||||
|
||||
app.delete('/api/extensions/:id', async (req) => {
|
||||
const { id } = IdParams.parse(req.params);
|
||||
const e = await getExt(db, id);
|
||||
// TODO(M3-T2): refuse with 409 when a destination points here (findReferences, 02 §1).
|
||||
await db.delete(extensions).where(eq(extensions.id, id)); // members, runtime state, devices cascade
|
||||
await audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
action: 'delete',
|
||||
entityType: 'extension',
|
||||
entityId: id,
|
||||
summary: `Deleted extension ${e.number} (${e.name})`,
|
||||
diff: { before: toExtensionDto(e) },
|
||||
});
|
||||
app.generator.requestApply('api-change');
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post('/api/extensions/:id/regenerate-sip-password', async (req) => {
|
||||
const { id } = IdParams.parse(req.params);
|
||||
const e = await getExt(db, id);
|
||||
await db
|
||||
.update(extensions)
|
||||
.set({ sipPasswordEnc: encrypt(generateSipPassword()) })
|
||||
.where(eq(extensions.id, id));
|
||||
await audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
action: 'regenerate_sip_password',
|
||||
entityType: 'extension',
|
||||
entityId: id,
|
||||
summary: `New SIP password for extension ${e.number}; its phones must be updated`,
|
||||
});
|
||||
app.generator.requestApply('api-change');
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// Reveal (05 §1): only on an explicit click, and every reveal is audited.
|
||||
app.get('/api/extensions/:id/sip-credentials', async (req): Promise<SipCredentials> => {
|
||||
const { id } = IdParams.parse(req.params);
|
||||
const e = await getExt(db, id);
|
||||
await audit(db, {
|
||||
actorUserId: req.user!.id,
|
||||
actorIp: req.ip,
|
||||
action: 'reveal_sip_credentials',
|
||||
entityType: 'extension',
|
||||
entityId: id,
|
||||
summary: `Viewed the SIP password of extension ${e.number}`,
|
||||
});
|
||||
return {
|
||||
server: app.config.PUBLIC_HOSTNAME,
|
||||
port: await getSetting(db, 'sip.udpPort'),
|
||||
transport: 'udp',
|
||||
username: e.number,
|
||||
password: decrypt(e.sipPasswordEnc),
|
||||
webUsername: e.webrtcEnabled ? `${e.number}-web` : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user