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

+147
View File
@@ -0,0 +1,147 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M1-T1: the migration matches the DDL in docs/spec/02-data-model.md for five key tables.
import { sql } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { Db } from '../src/db/client.ts';
import { createTestDb, hasTestDb } from './db.ts';
// column → "data_type|nullable|default-ish", straight from the spec DDL (+ created_at/updated_at).
const ts = {
created_at: 'timestamp with time zone|NO|now()',
updated_at: 'timestamp with time zone|NO|now()',
};
const EXPECTED: Record<string, Record<string, string>> = {
users: {
id: 'uuid|NO|gen_random_uuid()',
email: 'USER-DEFINED:citext|NO|',
display_name: 'text|NO|',
password_hash: 'text|NO|',
role: "USER-DEFINED:user_role|NO|'user'::user_role",
is_active: 'boolean|NO|true',
totp_secret_enc: 'text|YES|',
must_change_password: 'boolean|NO|false',
last_login_at: 'timestamp with time zone|YES|',
...ts,
},
extensions: {
id: 'uuid|NO|gen_random_uuid()',
number: 'text|NO|',
user_id: 'uuid|YES|',
name: 'text|NO|',
sip_password_enc: 'text|NO|',
webrtc_enabled: 'boolean|NO|true',
max_contacts: 'integer|NO|5',
ring_seconds: 'integer|NO|25',
voicemail_enabled: 'boolean|NO|true',
voicemail_pin_enc: 'text|NO|',
voicemail_email: 'boolean|NO|true',
voicemail_attach_audio: 'boolean|NO|true',
voicemail_delete_after_email: 'boolean|NO|false',
outbound_class: "USER-DEFINED:outbound_class|NO|'national'::outbound_class",
outbound_caller_id_did_id: 'uuid|YES|',
emergency_caller_id_did_id: 'uuid|YES|',
emergency_location: 'text|YES|',
record_mode: "USER-DEFINED:record_mode|NO|'never'::record_mode",
pickup_group: 'text|YES|',
language: "text|NO|'en'::text",
moh_class: "text|NO|'default'::text",
blf_numbers: "ARRAY:_text|NO|'{}'::text[]",
...ts,
},
trunks: {
id: 'uuid|NO|gen_random_uuid()',
name: 'text|NO|',
provider: "USER-DEFINED:trunk_provider|NO|'voipms'::trunk_provider",
enabled: 'boolean|NO|true',
server_host: 'text|NO|',
server_port: 'integer|NO|5060',
transport: "USER-DEFINED:sip_transport|NO|'udp'::sip_transport",
auth_mode: "USER-DEFINED:trunk_auth_mode|NO|'registration'::trunk_auth_mode",
username: 'text|YES|',
password_enc: 'text|YES|',
from_user: 'text|YES|',
from_domain: 'text|YES|',
codecs: "ARRAY:_text|NO|'{ulaw}'::text[]",
srtp: 'boolean|NO|false',
max_channels: 'integer|YES|',
voipms_api_username: 'text|YES|',
voipms_api_password_enc: 'text|YES|',
...ts,
},
calls: {
id: 'uuid|NO|gen_random_uuid()',
linkedid: 'text|NO|',
direction: 'USER-DEFINED:call_direction|NO|',
status: 'USER-DEFINED:call_status|NO|',
started_at: 'timestamp with time zone|NO|',
answered_at: 'timestamp with time zone|YES|',
ended_at: 'timestamp with time zone|NO|',
from_number: 'text|YES|',
from_name: 'text|YES|',
to_number: 'text|YES|',
did_id: 'uuid|YES|',
answered_by_extension_id: 'uuid|YES|',
talk_seconds: 'integer|NO|0',
recording_id: 'uuid|YES|',
...ts,
},
audit_log: {
id: "bigint|NO|nextval('audit_log_id_seq'::regclass)",
created_at: 'timestamp with time zone|NO|now()',
actor_user_id: 'uuid|YES|',
actor_ip: 'inet|YES|',
action: 'text|NO|',
entity_type: 'text|YES|',
entity_id: 'text|YES|',
summary: 'text|NO|',
diff: 'jsonb|YES|',
},
};
describe.skipIf(!hasTestDb)('schema matches spec 02', () => {
let db: Db;
let drop: () => Promise<void>;
beforeAll(async () => ({ db, drop } = await createTestDb()));
afterAll(() => drop?.());
it('creates all 39 tables', async () => {
const r = await db.execute(
sql`select count(*)::int as n from information_schema.tables where table_schema = 'public'`,
);
expect(r.rows[0]).toEqual({ n: 39 });
});
it.each(Object.keys(EXPECTED))('%s columns', async (table) => {
const r = await db.execute<{
column_name: string;
data_type: string;
udt_name: string;
is_nullable: string;
column_default: string | null;
}>(sql`select column_name, data_type, udt_name, is_nullable, column_default
from information_schema.columns where table_schema = 'public' and table_name = ${table}`);
const actual = Object.fromEntries(
r.rows.map((c) => {
const type = ['USER-DEFINED', 'ARRAY'].includes(c.data_type)
? `${c.data_type}:${c.udt_name}`
: c.data_type;
return [c.column_name, `${type}|${c.is_nullable}|${c.column_default ?? ''}`];
}),
);
expect(actual).toEqual(EXPECTED[table]);
});
it('enforces the spec check constraints', async () => {
await expect(
db.execute(
sql`insert into holidays (name, start_date, end_date) values ('x', '2026-01-01', '2026-01-01')`,
),
).resolves.toBeDefined();
await expect(
db.execute(
sql`insert into time_conditions (name, timezone, open_ranges, override_digit, open_destination, closed_destination)
values ('t', 'UTC', '[]', 12, '{}', '{}')`,
),
).rejects.toThrow();
});
});