db: drizzle schema matching spec 02, initial migrations, settings service

Migration 0000 enables citext; 0001 creates all 39 tables. DB tests use a
throwaway database per file (TEST_DATABASE_URL, derived from .env in dev,
a postgres service in CI). Decision 0002: pg driver and @fastify/cookie.

Task: M1-T1
This commit is contained in:
iamdoubz committed 2026-10-17 15:04:18 -05:00
1 parent 63a03b4185
commit 15b8a20751
29 files changed
+6649 -25

No files matched your search

+4 -1
View File
@@ -17,5 +17,8 @@
},
"files": [
"dist"
]
],
"dependencies": {
"zod": "^4.6.7"
}
}
+18
View File
@@ -0,0 +1,18 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// "Where a call goes next" (spec 02 §1). Stored as jsonb in many tables.
import { z } from 'zod';
export const DestinationSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('user'), id: z.uuid() }), // extensions.id; rings, then that user's no-answer handling
z.object({ type: z.literal('voicemail'), id: z.uuid() }), // extensions.id; straight to voicemail
z.object({ type: z.literal('ring_group'), id: z.uuid() }),
z.object({ type: z.literal('queue'), id: z.uuid() }),
z.object({ type: z.literal('ivr'), id: z.uuid() }),
z.object({ type: z.literal('time_condition'), id: z.uuid() }),
z.object({ type: z.literal('conference'), id: z.uuid() }),
z.object({ type: z.literal('announcement'), id: z.uuid() }),
z.object({ type: z.literal('external'), number: z.string().regex(/^[0-9*#+]+$/) }), // normalized external number
z.object({ type: z.literal('hangup') }),
]);
export type Destination = z.infer<typeof DestinationSchema>;
+2
View File
@@ -1,3 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export const APP_NAME = 'SelfPhoneNG';
export * from './phone.ts';
export * from './destination.ts';
export * from './settings.ts';
+84
View File
@@ -0,0 +1,84 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// One zod schema (with its default) per settings key (spec 02 §3).
import { z } from 'zod';
import { DestinationSchema } from './destination.ts';
const range = (from: number, to: number) =>
z
.tuple([z.number().int().min(100), z.number().int().max(999999)])
.refine(([a, b]) => a <= b, 'range start must be <= end')
.default([from, to]);
export const SettingsSchemas = {
'general.companyName': z.string().trim().min(1).max(100).default('My Company'),
// The api replaces this default with env TZ (services/settings.ts).
'general.timezone': z.string().min(1).default('America/Chicago'),
'general.language': z.string().min(2).max(10).default('en'),
'network.publicIp': z.ipv4().nullable().default(null),
'network.localNets': z
.array(z.cidrv4())
.default(['192.168.0.0/16', '10.0.0.0/8', '172.16.0.0/12']),
'network.lanIp': z.ipv4().nullable().default(null),
'network.publicIpWatch': z.boolean().default(false),
'network.stunServer': z
.string()
.regex(/^[A-Za-z0-9.-]+(:\d{1,5})?$/)
.nullable()
.default(null),
'network.turn': z
.object({ url: z.string().min(1), username: z.string(), credentialEnc: z.string() })
.nullable()
.default(null),
'sip.udpPort': z.number().int().min(1).max(65535).default(5060),
'sip.tcpEnabled': z.boolean().default(true),
'sip.tlsEnabled': z.boolean().default(false),
'sip.tlsPort': z.number().int().min(1).max(65535).default(5061),
'numbering.users': range(100, 499),
'numbering.ringGroups': range(500, 549),
'numbering.queues': range(550, 599),
'numbering.ivrs': range(600, 649),
'numbering.conferences': range(800, 849),
'numbering.paging': range(850, 869),
'routing.unmatchedDidDestination': DestinationSchema.default({ type: 'hangup' }),
'routing.defaultCallerIdDidId': z.uuid().nullable().default(null),
smtp: z
.object({
host: z.string().min(1),
port: z.number().int().min(1).max(65535),
secure: z.boolean(),
username: z.string(),
passwordEnc: z.string(),
from: z.string().min(3),
})
.nullable()
.default(null),
'voicemail.maxMessageSeconds': z.number().int().min(10).max(3600).default(300),
'voicemail.maxMessages': z.number().int().min(1).max(9999).default(100),
'voicemail.retentionDays': z.number().int().min(0).default(0),
'recordings.retentionDays': z.number().int().min(0).default(90),
'recordings.announcementSoundId': z.uuid().nullable().default(null),
'emergency.defaultCallerIdDidId': z.uuid().nullable().default(null),
'emergency.notifyEmails': z.array(z.email()).default([]),
'emergency.notifyExtensionIds': z.array(z.uuid()).default([]),
'backup.enabled': z.boolean().default(true),
'backup.time': z
.string()
.regex(/^([01]\d|2[0-3]):[0-5]\d$/)
.default('03:30'),
'backup.keep': z.number().int().min(1).max(365).default(7),
'backup.includeRecordings': z.boolean().default(false),
'webhooks.allowPrivate': z.boolean().default(false),
'setup.completed': z.boolean().default(false),
// The api generates and stores a random value on first read (services/settings.ts).
'sms.webhookToken': z.string().regex(/^[A-Za-z0-9]{32}$/),
'sip.restrictPhonesToLocalNets': z.boolean().default(false),
'sip.extraAllowedNets': z.array(z.cidrv4()).default([]),
'provisioning.lanOnly': z.boolean().default(false),
'provisioning.enabled': z.boolean().default(true),
'system.restartRequired': z.boolean().default(false),
// Not in 02 §3's table but used by the generator (03 §3).
'system.transportHash': z.string().nullable().default(null),
};
export type SettingKey = keyof typeof SettingsSchemas;
export type SettingValue<K extends SettingKey> = z.output<(typeof SettingsSchemas)[K]>;