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:
1 parent
63a03b4185
commit
15b8a20751
29 files changed
+6649
-25
No files matched your search
@@ -8,6 +8,17 @@ on:
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18-alpine
|
||||
env:
|
||||
POSTGRES_USER: selfphoneng
|
||||
POSTGRES_PASSWORD: ci
|
||||
POSTGRES_DB: selfphoneng
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U selfphoneng" --health-interval 5s --health-retries 20
|
||||
env:
|
||||
TEST_DATABASE_URL: postgres://selfphoneng:ci@postgres:5432/selfphoneng
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4 # version comes from package.json "packageManager"
|
||||
|
||||
@@ -5,3 +5,4 @@ data/
|
||||
dist/
|
||||
AGENTS.md
|
||||
CLAUDE.md
|
||||
apps/api/src/db/migrations/
|
||||
+2
-1
@@ -24,7 +24,8 @@ COPY --from=build /out/package.json ./package.json
|
||||
COPY --from=build /out/node_modules ./node_modules
|
||||
COPY --from=build /src/apps/api/dist ./dist
|
||||
COPY --from=build /src/apps/web/dist ./public
|
||||
# ponytail: migrations (M1) and provisioning templates (M10) get their COPY lines when they exist.
|
||||
COPY --from=build /src/apps/api/src/db/migrations ./dist/db/migrations
|
||||
# ponytail: provisioning templates (M10) get their COPY line when they exist.
|
||||
ENV NODE_ENV=production
|
||||
ENTRYPOINT ["/usr/bin/tini","--"]
|
||||
CMD ["node","dist/index.js"]
|
||||
@@ -0,0 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
export default defineConfig({
|
||||
dialect: 'postgresql',
|
||||
schema: './src/db/schema.ts',
|
||||
out: './src/db/migrations',
|
||||
casing: 'snake_case',
|
||||
});
|
||||
@@ -7,12 +7,19 @@
|
||||
"scripts": {
|
||||
"dev": "node --watch --env-file-if-exists=../../.env --conditions=source src/index.ts",
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"db:generate": "drizzle-kit generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/static": "^10.1.6",
|
||||
"@selfphoneng/shared": "workspace:*",
|
||||
"drizzle-orm": "^0.45.4",
|
||||
"fastify": "^5.12.7",
|
||||
"pg": "^8.23.1",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pg": "^8.23.1",
|
||||
"drizzle-kit": "^0.31.11"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import { migrate } from 'drizzle-orm/node-postgres/migrator';
|
||||
import pg from 'pg';
|
||||
import * as schema from './schema.ts';
|
||||
|
||||
// Next to this file in src/ and in the image (the Dockerfile copies them to dist/db/migrations).
|
||||
const MIGRATIONS_DIR = fileURLToPath(new URL('./migrations', import.meta.url));
|
||||
|
||||
export function createDb(url: string) {
|
||||
const pool = new pg.Pool({ connectionString: url, max: 10 });
|
||||
const db = drizzle({ client: pool, schema, casing: 'snake_case' });
|
||||
return { db, pool };
|
||||
}
|
||||
|
||||
export type Db = ReturnType<typeof createDb>['db'];
|
||||
|
||||
export async function runMigrations(db: Db): Promise<void> {
|
||||
await migrate(db, { migrationsFolder: MIGRATIONS_DIR });
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
CREATE EXTENSION IF NOT EXISTS citext;
|
||||
@@ -0,0 +1,571 @@
|
||||
CREATE TYPE "public"."call_direction" AS ENUM('inbound', 'outbound', 'internal');--> statement-breakpoint
|
||||
CREATE TYPE "public"."call_status" AS ENUM('answered', 'missed', 'voicemail', 'abandoned', 'busy', 'failed');--> statement-breakpoint
|
||||
CREATE TYPE "public"."outbound_class" AS ENUM('internal', 'local', 'national', 'international');--> statement-breakpoint
|
||||
CREATE TYPE "public"."phone_vendor" AS ENUM('yealink', 'grandstream', 'polycom', 'generic');--> statement-breakpoint
|
||||
CREATE TYPE "public"."queue_strategy" AS ENUM('ringall', 'leastrecent', 'fewestcalls', 'random', 'rrmemory', 'linear');--> statement-breakpoint
|
||||
CREATE TYPE "public"."record_mode" AS ENUM('never', 'inbound', 'outbound', 'all');--> statement-breakpoint
|
||||
CREATE TYPE "public"."ring_strategy" AS ENUM('ringall', 'hunt');--> statement-breakpoint
|
||||
CREATE TYPE "public"."sip_transport" AS ENUM('udp', 'tcp', 'tls');--> statement-breakpoint
|
||||
CREATE TYPE "public"."trunk_auth_mode" AS ENUM('registration', 'ip');--> statement-breakpoint
|
||||
CREATE TYPE "public"."trunk_provider" AS ENUM('voipms', 'generic');--> statement-breakpoint
|
||||
CREATE TYPE "public"."user_role" AS ENUM('admin', 'user');--> statement-breakpoint
|
||||
CREATE TABLE "announcements" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"sound_id" uuid NOT NULL,
|
||||
"destination" jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "api_tokens" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"token_prefix" text NOT NULL,
|
||||
"token_hash" text NOT NULL,
|
||||
"created_by" uuid NOT NULL,
|
||||
"last_used_at" timestamp with time zone,
|
||||
"revoked_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "api_tokens_tokenHash_unique" UNIQUE("token_hash")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "audit_log" (
|
||||
"id" bigserial PRIMARY KEY NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"actor_user_id" uuid,
|
||||
"actor_ip" "inet",
|
||||
"action" text NOT NULL,
|
||||
"entity_type" text,
|
||||
"entity_id" text,
|
||||
"summary" text NOT NULL,
|
||||
"diff" jsonb
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "blocked_numbers" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"number" text NOT NULL,
|
||||
"reason" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "blocked_numbers_number_unique" UNIQUE("number")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "call_extensions" (
|
||||
"call_id" uuid NOT NULL,
|
||||
"extension_id" uuid NOT NULL,
|
||||
"role" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "call_extensions_call_id_extension_id_role_pk" PRIMARY KEY("call_id","extension_id","role"),
|
||||
CONSTRAINT "call_extensions_role_check" CHECK ("call_extensions"."role" in ('caller', 'rang', 'answered'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "calls" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"linkedid" text NOT NULL,
|
||||
"direction" "call_direction" NOT NULL,
|
||||
"status" "call_status" NOT NULL,
|
||||
"started_at" timestamp with time zone NOT NULL,
|
||||
"answered_at" timestamp with time zone,
|
||||
"ended_at" timestamp with time zone NOT NULL,
|
||||
"from_number" text,
|
||||
"from_name" text,
|
||||
"to_number" text,
|
||||
"did_id" uuid,
|
||||
"answered_by_extension_id" uuid,
|
||||
"talk_seconds" integer DEFAULT 0 NOT NULL,
|
||||
"recording_id" uuid,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "calls_linkedid_unique" UNIQUE("linkedid")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "cdr" (
|
||||
"id" bigserial PRIMARY KEY NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"uniqueid" text NOT NULL,
|
||||
"linkedid" text NOT NULL,
|
||||
"start_at" timestamp with time zone,
|
||||
"answer_at" timestamp with time zone,
|
||||
"end_at" timestamp with time zone,
|
||||
"duration" integer,
|
||||
"billsec" integer,
|
||||
"src" text,
|
||||
"dst" text,
|
||||
"dcontext" text,
|
||||
"clid" text,
|
||||
"channel" text,
|
||||
"dstchannel" text,
|
||||
"lastapp" text,
|
||||
"lastdata" text,
|
||||
"disposition" text,
|
||||
"accountcode" text,
|
||||
"userfield" text,
|
||||
"sp_did" text,
|
||||
"sp_direction" text,
|
||||
"sp_recording" text,
|
||||
"raw" jsonb NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "conferences" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"number" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"pin_enc" text,
|
||||
"admin_pin_enc" text,
|
||||
"max_members" integer DEFAULT 20 NOT NULL,
|
||||
"record" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "conferences_number_unique" UNIQUE("number")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "contacts" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"owner_user_id" uuid,
|
||||
"name" text NOT NULL,
|
||||
"company" text,
|
||||
"email" text,
|
||||
"numbers" jsonb NOT NULL,
|
||||
"notes" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "devices" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"extension_id" uuid NOT NULL,
|
||||
"mac" text NOT NULL,
|
||||
"vendor" "phone_vendor" NOT NULL,
|
||||
"model" text,
|
||||
"label" text,
|
||||
"provisioning_password_enc" text NOT NULL,
|
||||
"last_provisioned_at" timestamp with time zone,
|
||||
"last_provisioned_ip" "inet",
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "devices_mac_unique" UNIQUE("mac")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "did_sms_users" (
|
||||
"did_id" uuid NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "did_sms_users_did_id_user_id_pk" PRIMARY KEY("did_id","user_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "dids" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"number" text NOT NULL,
|
||||
"trunk_id" uuid NOT NULL,
|
||||
"label" text,
|
||||
"destination" jsonb NOT NULL,
|
||||
"caller_id_name_prefix" text,
|
||||
"record_inbound" boolean DEFAULT false NOT NULL,
|
||||
"sms_enabled" boolean DEFAULT false NOT NULL,
|
||||
"e911_address" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "dids_number_unique" UNIQUE("number")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "emergency_events" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"extension_number" text NOT NULL,
|
||||
"dialed" text NOT NULL,
|
||||
"caller_id_used" text NOT NULL,
|
||||
"location" text,
|
||||
"notified" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "extensions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"number" text NOT NULL,
|
||||
"user_id" uuid,
|
||||
"name" text NOT NULL,
|
||||
"sip_password_enc" text NOT NULL,
|
||||
"webrtc_enabled" boolean DEFAULT true NOT NULL,
|
||||
"max_contacts" integer DEFAULT 5 NOT NULL,
|
||||
"ring_seconds" integer DEFAULT 25 NOT NULL,
|
||||
"voicemail_enabled" boolean DEFAULT true NOT NULL,
|
||||
"voicemail_pin_enc" text NOT NULL,
|
||||
"voicemail_email" boolean DEFAULT true NOT NULL,
|
||||
"voicemail_attach_audio" boolean DEFAULT true NOT NULL,
|
||||
"voicemail_delete_after_email" boolean DEFAULT false NOT NULL,
|
||||
"outbound_class" "outbound_class" DEFAULT 'national' NOT NULL,
|
||||
"outbound_caller_id_did_id" uuid,
|
||||
"emergency_caller_id_did_id" uuid,
|
||||
"emergency_location" text,
|
||||
"record_mode" "record_mode" DEFAULT 'never' NOT NULL,
|
||||
"pickup_group" text,
|
||||
"language" text DEFAULT 'en' NOT NULL,
|
||||
"moh_class" text DEFAULT 'default' NOT NULL,
|
||||
"blf_numbers" text[] DEFAULT '{}' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "extensions_number_unique" UNIQUE("number"),
|
||||
CONSTRAINT "extensions_userId_unique" UNIQUE("user_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "generator_runs" (
|
||||
"id" bigserial PRIMARY KEY NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"trigger" text NOT NULL,
|
||||
"files_changed" text[] NOT NULL,
|
||||
"commands" text[] NOT NULL,
|
||||
"ok" boolean NOT NULL,
|
||||
"error" text,
|
||||
"duration_ms" integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "holidays" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"start_date" date NOT NULL,
|
||||
"end_date" date NOT NULL,
|
||||
"recurring_yearly" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "ivr_options" (
|
||||
"ivr_id" uuid NOT NULL,
|
||||
"digit" text NOT NULL,
|
||||
"destination" jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "ivr_options_ivr_id_digit_pk" PRIMARY KEY("ivr_id","digit"),
|
||||
CONSTRAINT "ivr_options_digit_check" CHECK ("ivr_options"."digit" ~ '^[0-9*#]$')
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "ivrs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"number" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"greeting_sound_id" uuid NOT NULL,
|
||||
"invalid_sound_id" uuid,
|
||||
"timeout_seconds" integer DEFAULT 5 NOT NULL,
|
||||
"max_retries" integer DEFAULT 3 NOT NULL,
|
||||
"allow_direct_dial" boolean DEFAULT true NOT NULL,
|
||||
"timeout_destination" jsonb NOT NULL,
|
||||
"invalid_destination" jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "ivrs_number_unique" UNIQUE("number")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "moh_classes" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "moh_classes_name_unique" UNIQUE("name")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "outbound_routes" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"position" integer NOT NULL,
|
||||
"enabled" boolean DEFAULT true NOT NULL,
|
||||
"patterns" jsonb NOT NULL,
|
||||
"required_class" "outbound_class" NOT NULL,
|
||||
"trunk_ids" uuid[] NOT NULL,
|
||||
"caller_id_did_id" uuid,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "paging_group_members" (
|
||||
"paging_group_id" uuid NOT NULL,
|
||||
"extension_id" uuid NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "paging_group_members_paging_group_id_extension_id_pk" PRIMARY KEY("paging_group_id","extension_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "paging_groups" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"number" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"duplex" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "paging_groups_number_unique" UNIQUE("number")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "queue_member_state" (
|
||||
"queue_id" uuid NOT NULL,
|
||||
"extension_id" uuid NOT NULL,
|
||||
"paused" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "queue_member_state_queue_id_extension_id_pk" PRIMARY KEY("queue_id","extension_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "queue_members" (
|
||||
"queue_id" uuid NOT NULL,
|
||||
"extension_id" uuid NOT NULL,
|
||||
"penalty" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "queue_members_queue_id_extension_id_pk" PRIMARY KEY("queue_id","extension_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "queues" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"number" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"strategy" "queue_strategy" DEFAULT 'rrmemory' NOT NULL,
|
||||
"member_ring_seconds" integer DEFAULT 15 NOT NULL,
|
||||
"retry_seconds" integer DEFAULT 5 NOT NULL,
|
||||
"wrapup_seconds" integer DEFAULT 10 NOT NULL,
|
||||
"max_wait_seconds" integer DEFAULT 300 NOT NULL,
|
||||
"max_callers" integer DEFAULT 0 NOT NULL,
|
||||
"moh_class" text DEFAULT 'default' NOT NULL,
|
||||
"announce_position" boolean DEFAULT true NOT NULL,
|
||||
"announce_every_seconds" integer DEFAULT 60 NOT NULL,
|
||||
"join_when_no_agents" boolean DEFAULT false NOT NULL,
|
||||
"record" boolean DEFAULT false NOT NULL,
|
||||
"sla_seconds" integer DEFAULT 20 NOT NULL,
|
||||
"caller_id_name_prefix" text,
|
||||
"timeout_destination" jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "queues_number_unique" UNIQUE("number")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "recordings" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"linkedid" text NOT NULL,
|
||||
"file_path" text NOT NULL,
|
||||
"started_at" timestamp with time zone NOT NULL,
|
||||
"duration_ms" integer,
|
||||
"size_bytes" bigint,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "recordings_filePath_unique" UNIQUE("file_path")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "ring_group_members" (
|
||||
"ring_group_id" uuid NOT NULL,
|
||||
"extension_id" uuid NOT NULL,
|
||||
"position" integer NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "ring_group_members_ring_group_id_extension_id_pk" PRIMARY KEY("ring_group_id","extension_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "ring_groups" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"number" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"strategy" "ring_strategy" DEFAULT 'ringall' NOT NULL,
|
||||
"ring_seconds" integer DEFAULT 25 NOT NULL,
|
||||
"caller_id_name_prefix" text,
|
||||
"music_instead_of_ring" boolean DEFAULT false NOT NULL,
|
||||
"no_answer_destination" jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "ring_groups_number_unique" UNIQUE("number")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sessions" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"ip" "inet",
|
||||
"user_agent" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "settings" (
|
||||
"key" text PRIMARY KEY NOT NULL,
|
||||
"value" jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sms_messages" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"did_id" uuid NOT NULL,
|
||||
"direction" text NOT NULL,
|
||||
"remote_number" text NOT NULL,
|
||||
"body" text NOT NULL,
|
||||
"media" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"status" text NOT NULL,
|
||||
"provider_id" text,
|
||||
"error" text,
|
||||
"sent_by_user_id" uuid,
|
||||
"read_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "sms_messages_direction_check" CHECK ("sms_messages"."direction" in ('in', 'out'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "sound_files" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"kind" text NOT NULL,
|
||||
"moh_class_id" uuid,
|
||||
"source" text NOT NULL,
|
||||
"tts_text" text,
|
||||
"original_filename" text,
|
||||
"asterisk_name" text NOT NULL,
|
||||
"duration_ms" integer NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "sound_files_asteriskName_unique" UNIQUE("asterisk_name"),
|
||||
CONSTRAINT "sound_files_kind_check" CHECK ("sound_files"."kind" in ('prompt', 'moh')),
|
||||
CONSTRAINT "sound_files_source_check" CHECK ("sound_files"."source" in ('upload', 'tts', 'phone'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "time_conditions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"timezone" text NOT NULL,
|
||||
"open_ranges" jsonb NOT NULL,
|
||||
"use_holidays" boolean DEFAULT true NOT NULL,
|
||||
"override_digit" smallint,
|
||||
"override_state" text,
|
||||
"open_destination" jsonb NOT NULL,
|
||||
"closed_destination" jsonb NOT NULL,
|
||||
"holiday_destination" jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "time_conditions_overrideDigit_unique" UNIQUE("override_digit"),
|
||||
CONSTRAINT "time_conditions_override_digit_check" CHECK ("time_conditions"."override_digit" between 1 and 9),
|
||||
CONSTRAINT "time_conditions_override_state_check" CHECK ("time_conditions"."override_state" in ('open', 'closed'))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "trunks" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"provider" "trunk_provider" DEFAULT 'voipms' NOT NULL,
|
||||
"enabled" boolean DEFAULT true NOT NULL,
|
||||
"server_host" text NOT NULL,
|
||||
"server_port" integer DEFAULT 5060 NOT NULL,
|
||||
"transport" "sip_transport" DEFAULT 'udp' NOT NULL,
|
||||
"auth_mode" "trunk_auth_mode" DEFAULT 'registration' NOT NULL,
|
||||
"username" text,
|
||||
"password_enc" text,
|
||||
"from_user" text,
|
||||
"from_domain" text,
|
||||
"codecs" text[] DEFAULT '{ulaw}' NOT NULL,
|
||||
"srtp" boolean DEFAULT false NOT NULL,
|
||||
"max_channels" integer,
|
||||
"voipms_api_username" text,
|
||||
"voipms_api_password_enc" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "trunks_name_unique" UNIQUE("name")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "user_runtime_state" (
|
||||
"extension_id" uuid PRIMARY KEY NOT NULL,
|
||||
"dnd" boolean DEFAULT false NOT NULL,
|
||||
"cfu" text,
|
||||
"cfb" text,
|
||||
"cfna" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "users" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"email" "citext" NOT NULL,
|
||||
"display_name" text NOT NULL,
|
||||
"password_hash" text NOT NULL,
|
||||
"role" "user_role" DEFAULT 'user' NOT NULL,
|
||||
"is_active" boolean DEFAULT true NOT NULL,
|
||||
"totp_secret_enc" text,
|
||||
"must_change_password" boolean DEFAULT false NOT NULL,
|
||||
"last_login_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "users_email_unique" UNIQUE("email")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "voicemail_messages" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"extension_id" uuid NOT NULL,
|
||||
"msg_id" text NOT NULL,
|
||||
"folder" text NOT NULL,
|
||||
"caller_number" text,
|
||||
"caller_name" text,
|
||||
"duration_s" integer NOT NULL,
|
||||
"received_at" timestamp with time zone NOT NULL,
|
||||
"file_path" text NOT NULL,
|
||||
"emailed_at" timestamp with time zone,
|
||||
"deleted_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "voicemail_messages_msgId_unique" UNIQUE("msg_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "webhook_deliveries" (
|
||||
"id" bigserial PRIMARY KEY NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"webhook_id" uuid NOT NULL,
|
||||
"event" text NOT NULL,
|
||||
"payload" jsonb NOT NULL,
|
||||
"attempt" integer DEFAULT 0 NOT NULL,
|
||||
"status" text NOT NULL,
|
||||
"response_code" integer,
|
||||
"next_attempt_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "webhooks" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"url" text NOT NULL,
|
||||
"secret_enc" text NOT NULL,
|
||||
"events" text[] NOT NULL,
|
||||
"enabled" boolean DEFAULT true NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "announcements" ADD CONSTRAINT "announcements_sound_id_sound_files_id_fk" FOREIGN KEY ("sound_id") REFERENCES "public"."sound_files"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "api_tokens" ADD CONSTRAINT "api_tokens_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_actor_user_id_users_id_fk" FOREIGN KEY ("actor_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "call_extensions" ADD CONSTRAINT "call_extensions_call_id_calls_id_fk" FOREIGN KEY ("call_id") REFERENCES "public"."calls"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "call_extensions" ADD CONSTRAINT "call_extensions_extension_id_extensions_id_fk" FOREIGN KEY ("extension_id") REFERENCES "public"."extensions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "calls" ADD CONSTRAINT "calls_did_id_dids_id_fk" FOREIGN KEY ("did_id") REFERENCES "public"."dids"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "calls" ADD CONSTRAINT "calls_answered_by_extension_id_extensions_id_fk" FOREIGN KEY ("answered_by_extension_id") REFERENCES "public"."extensions"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "calls" ADD CONSTRAINT "calls_recording_id_recordings_id_fk" FOREIGN KEY ("recording_id") REFERENCES "public"."recordings"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "contacts" ADD CONSTRAINT "contacts_owner_user_id_users_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "devices" ADD CONSTRAINT "devices_extension_id_extensions_id_fk" FOREIGN KEY ("extension_id") REFERENCES "public"."extensions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "did_sms_users" ADD CONSTRAINT "did_sms_users_did_id_dids_id_fk" FOREIGN KEY ("did_id") REFERENCES "public"."dids"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "did_sms_users" ADD CONSTRAINT "did_sms_users_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "dids" ADD CONSTRAINT "dids_trunk_id_trunks_id_fk" FOREIGN KEY ("trunk_id") REFERENCES "public"."trunks"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "extensions" ADD CONSTRAINT "extensions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "extensions" ADD CONSTRAINT "extensions_outbound_caller_id_did_id_dids_id_fk" FOREIGN KEY ("outbound_caller_id_did_id") REFERENCES "public"."dids"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "extensions" ADD CONSTRAINT "extensions_emergency_caller_id_did_id_dids_id_fk" FOREIGN KEY ("emergency_caller_id_did_id") REFERENCES "public"."dids"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "ivr_options" ADD CONSTRAINT "ivr_options_ivr_id_ivrs_id_fk" FOREIGN KEY ("ivr_id") REFERENCES "public"."ivrs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "ivrs" ADD CONSTRAINT "ivrs_greeting_sound_id_sound_files_id_fk" FOREIGN KEY ("greeting_sound_id") REFERENCES "public"."sound_files"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "ivrs" ADD CONSTRAINT "ivrs_invalid_sound_id_sound_files_id_fk" FOREIGN KEY ("invalid_sound_id") REFERENCES "public"."sound_files"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "outbound_routes" ADD CONSTRAINT "outbound_routes_caller_id_did_id_dids_id_fk" FOREIGN KEY ("caller_id_did_id") REFERENCES "public"."dids"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "paging_group_members" ADD CONSTRAINT "paging_group_members_paging_group_id_paging_groups_id_fk" FOREIGN KEY ("paging_group_id") REFERENCES "public"."paging_groups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "paging_group_members" ADD CONSTRAINT "paging_group_members_extension_id_extensions_id_fk" FOREIGN KEY ("extension_id") REFERENCES "public"."extensions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "queue_member_state" ADD CONSTRAINT "queue_member_state_queue_id_queues_id_fk" FOREIGN KEY ("queue_id") REFERENCES "public"."queues"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "queue_member_state" ADD CONSTRAINT "queue_member_state_extension_id_extensions_id_fk" FOREIGN KEY ("extension_id") REFERENCES "public"."extensions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "queue_members" ADD CONSTRAINT "queue_members_queue_id_queues_id_fk" FOREIGN KEY ("queue_id") REFERENCES "public"."queues"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "queue_members" ADD CONSTRAINT "queue_members_extension_id_extensions_id_fk" FOREIGN KEY ("extension_id") REFERENCES "public"."extensions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "ring_group_members" ADD CONSTRAINT "ring_group_members_ring_group_id_ring_groups_id_fk" FOREIGN KEY ("ring_group_id") REFERENCES "public"."ring_groups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "ring_group_members" ADD CONSTRAINT "ring_group_members_extension_id_extensions_id_fk" FOREIGN KEY ("extension_id") REFERENCES "public"."extensions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sms_messages" ADD CONSTRAINT "sms_messages_did_id_dids_id_fk" FOREIGN KEY ("did_id") REFERENCES "public"."dids"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sms_messages" ADD CONSTRAINT "sms_messages_sent_by_user_id_users_id_fk" FOREIGN KEY ("sent_by_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "sound_files" ADD CONSTRAINT "sound_files_moh_class_id_moh_classes_id_fk" FOREIGN KEY ("moh_class_id") REFERENCES "public"."moh_classes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "user_runtime_state" ADD CONSTRAINT "user_runtime_state_extension_id_extensions_id_fk" FOREIGN KEY ("extension_id") REFERENCES "public"."extensions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "voicemail_messages" ADD CONSTRAINT "voicemail_messages_extension_id_extensions_id_fk" FOREIGN KEY ("extension_id") REFERENCES "public"."extensions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "webhook_deliveries" ADD CONSTRAINT "webhook_deliveries_webhook_id_webhooks_id_fk" FOREIGN KEY ("webhook_id") REFERENCES "public"."webhooks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "calls_started_at_index" ON "calls" USING btree ("started_at" DESC NULLS LAST);--> statement-breakpoint
|
||||
CREATE INDEX "cdr_linkedid_index" ON "cdr" USING btree ("linkedid");--> statement-breakpoint
|
||||
CREATE INDEX "sessions_user_id_index" ON "sessions" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "sms_messages_did_id_remote_number_created_at_index" ON "sms_messages" USING btree ("did_id","remote_number","created_at" DESC NULLS LAST);
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "2346704b-51cc-4251-9e70-f9e166bd89b8",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"views": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1792267218726,
|
||||
"tag": "0000_citext",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1792267225335,
|
||||
"tag": "0001_initial",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,656 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Mirrors docs/spec/02-data-model.md §2 exactly. Column keys are camelCase; the db client and
|
||||
// drizzle-kit both use `casing: 'snake_case'`, so `displayName` is the column `display_name`.
|
||||
import { sql } from 'drizzle-orm';
|
||||
import {
|
||||
bigint,
|
||||
bigserial,
|
||||
boolean,
|
||||
check,
|
||||
customType,
|
||||
date,
|
||||
index,
|
||||
inet,
|
||||
integer,
|
||||
jsonb,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
smallint,
|
||||
text,
|
||||
timestamp,
|
||||
uuid,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
|
||||
const citext = customType<{ data: string }>({ dataType: () => 'citext' });
|
||||
const tstz = () => timestamp({ withTimezone: true });
|
||||
const createdAt = () => tstz().notNull().defaultNow();
|
||||
const timestamps = {
|
||||
createdAt: createdAt(),
|
||||
updatedAt: tstz()
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date()),
|
||||
};
|
||||
const id = () => uuid().primaryKey().defaultRandom();
|
||||
const emptyTextArray = () =>
|
||||
text()
|
||||
.array()
|
||||
.notNull()
|
||||
.default(sql`'{}'`);
|
||||
|
||||
// ───────────── settings ─────────────
|
||||
export const settings = pgTable('settings', {
|
||||
key: text().primaryKey(),
|
||||
value: jsonb().notNull(),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
// ───────────── people and auth ─────────────
|
||||
export const userRole = pgEnum('user_role', ['admin', 'user']);
|
||||
|
||||
export const users = pgTable('users', {
|
||||
id: id(),
|
||||
email: citext().notNull().unique(),
|
||||
displayName: text().notNull(),
|
||||
passwordHash: text().notNull(),
|
||||
role: userRole().notNull().default('user'),
|
||||
isActive: boolean().notNull().default(true),
|
||||
totpSecretEnc: text(),
|
||||
mustChangePassword: boolean().notNull().default(false),
|
||||
lastLoginAt: tstz(),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
export const sessions = pgTable(
|
||||
'sessions',
|
||||
{
|
||||
id: text().primaryKey(), // sha256(token) hex; raw token only in the cookie
|
||||
userId: uuid()
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
expiresAt: tstz().notNull(),
|
||||
ip: inet(),
|
||||
userAgent: text(),
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [index().on(t.userId)],
|
||||
);
|
||||
|
||||
export const apiTokens = pgTable('api_tokens', {
|
||||
id: id(),
|
||||
name: text().notNull(),
|
||||
tokenPrefix: text().notNull(),
|
||||
tokenHash: text().notNull().unique(),
|
||||
createdBy: uuid()
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
lastUsedAt: tstz(),
|
||||
revokedAt: tstz(),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
// ───────────── extensions ─────────────
|
||||
export const outboundClass = pgEnum('outbound_class', [
|
||||
'internal',
|
||||
'local',
|
||||
'national',
|
||||
'international',
|
||||
]);
|
||||
export const recordMode = pgEnum('record_mode', ['never', 'inbound', 'outbound', 'all']);
|
||||
|
||||
export const extensions = pgTable('extensions', {
|
||||
id: id(),
|
||||
number: text().notNull().unique(),
|
||||
userId: uuid()
|
||||
.unique()
|
||||
.references(() => users.id, { onDelete: 'set null' }),
|
||||
name: text().notNull(),
|
||||
sipPasswordEnc: text().notNull(),
|
||||
webrtcEnabled: boolean().notNull().default(true),
|
||||
maxContacts: integer().notNull().default(5),
|
||||
ringSeconds: integer().notNull().default(25),
|
||||
voicemailEnabled: boolean().notNull().default(true),
|
||||
voicemailPinEnc: text().notNull(),
|
||||
voicemailEmail: boolean().notNull().default(true),
|
||||
voicemailAttachAudio: boolean().notNull().default(true),
|
||||
voicemailDeleteAfterEmail: boolean().notNull().default(false),
|
||||
outboundClass: outboundClass().notNull().default('national'),
|
||||
outboundCallerIdDidId: uuid().references(() => dids.id, { onDelete: 'set null' }),
|
||||
emergencyCallerIdDidId: uuid().references(() => dids.id, { onDelete: 'set null' }),
|
||||
emergencyLocation: text(),
|
||||
recordMode: recordMode().notNull().default('never'),
|
||||
pickupGroup: text(),
|
||||
language: text().notNull().default('en'),
|
||||
mohClass: text().notNull().default('default'),
|
||||
blfNumbers: emptyTextArray(),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
export const userRuntimeState = pgTable('user_runtime_state', {
|
||||
extensionId: uuid()
|
||||
.primaryKey()
|
||||
.references(() => extensions.id, { onDelete: 'cascade' }),
|
||||
dnd: boolean().notNull().default(false),
|
||||
cfu: text(),
|
||||
cfb: text(),
|
||||
cfna: text(),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
// ───────────── trunks and numbers ─────────────
|
||||
export const trunkProvider = pgEnum('trunk_provider', ['voipms', 'generic']);
|
||||
export const sipTransport = pgEnum('sip_transport', ['udp', 'tcp', 'tls']);
|
||||
export const trunkAuthMode = pgEnum('trunk_auth_mode', ['registration', 'ip']);
|
||||
|
||||
export const trunks = pgTable('trunks', {
|
||||
id: id(),
|
||||
name: text().notNull().unique(),
|
||||
provider: trunkProvider().notNull().default('voipms'),
|
||||
enabled: boolean().notNull().default(true),
|
||||
serverHost: text().notNull(),
|
||||
serverPort: integer().notNull().default(5060),
|
||||
transport: sipTransport().notNull().default('udp'),
|
||||
authMode: trunkAuthMode().notNull().default('registration'),
|
||||
username: text(),
|
||||
passwordEnc: text(),
|
||||
fromUser: text(),
|
||||
fromDomain: text(),
|
||||
codecs: text()
|
||||
.array()
|
||||
.notNull()
|
||||
.default(sql`'{ulaw}'`),
|
||||
srtp: boolean().notNull().default(false),
|
||||
maxChannels: integer(),
|
||||
voipmsApiUsername: text(),
|
||||
voipmsApiPasswordEnc: text(),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
export const dids = pgTable('dids', {
|
||||
id: id(),
|
||||
number: text().notNull().unique(),
|
||||
trunkId: uuid()
|
||||
.notNull()
|
||||
.references(() => trunks.id, { onDelete: 'restrict' }),
|
||||
label: text(),
|
||||
destination: jsonb().notNull(),
|
||||
callerIdNamePrefix: text(),
|
||||
recordInbound: boolean().notNull().default(false),
|
||||
smsEnabled: boolean().notNull().default(false),
|
||||
e911Address: text(),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
export const didSmsUsers = pgTable(
|
||||
'did_sms_users',
|
||||
{
|
||||
didId: uuid()
|
||||
.notNull()
|
||||
.references(() => dids.id, { onDelete: 'cascade' }),
|
||||
userId: uuid()
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.didId, t.userId] })],
|
||||
);
|
||||
|
||||
export const outboundRoutes = pgTable('outbound_routes', {
|
||||
id: id(),
|
||||
name: text().notNull(),
|
||||
position: integer().notNull(),
|
||||
enabled: boolean().notNull().default(true),
|
||||
patterns: jsonb().notNull(),
|
||||
requiredClass: outboundClass().notNull(),
|
||||
trunkIds: uuid().array().notNull(),
|
||||
callerIdDidId: uuid().references(() => dids.id, { onDelete: 'set null' }),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
export const blockedNumbers = pgTable('blocked_numbers', {
|
||||
id: id(),
|
||||
number: text().notNull().unique(),
|
||||
reason: text(),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
// ───────────── call handling objects ─────────────
|
||||
export const ringStrategy = pgEnum('ring_strategy', ['ringall', 'hunt']);
|
||||
|
||||
export const ringGroups = pgTable('ring_groups', {
|
||||
id: id(),
|
||||
number: text().notNull().unique(),
|
||||
name: text().notNull(),
|
||||
strategy: ringStrategy().notNull().default('ringall'),
|
||||
ringSeconds: integer().notNull().default(25),
|
||||
callerIdNamePrefix: text(),
|
||||
musicInsteadOfRing: boolean().notNull().default(false),
|
||||
noAnswerDestination: jsonb().notNull(),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
export const ringGroupMembers = pgTable(
|
||||
'ring_group_members',
|
||||
{
|
||||
ringGroupId: uuid()
|
||||
.notNull()
|
||||
.references(() => ringGroups.id, { onDelete: 'cascade' }),
|
||||
extensionId: uuid()
|
||||
.notNull()
|
||||
.references(() => extensions.id, { onDelete: 'cascade' }),
|
||||
position: integer().notNull(),
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.ringGroupId, t.extensionId] })],
|
||||
);
|
||||
|
||||
export const queueStrategy = pgEnum('queue_strategy', [
|
||||
'ringall',
|
||||
'leastrecent',
|
||||
'fewestcalls',
|
||||
'random',
|
||||
'rrmemory',
|
||||
'linear',
|
||||
]);
|
||||
|
||||
export const queues = pgTable('queues', {
|
||||
id: id(),
|
||||
number: text().notNull().unique(),
|
||||
name: text().notNull(),
|
||||
strategy: queueStrategy().notNull().default('rrmemory'),
|
||||
memberRingSeconds: integer().notNull().default(15),
|
||||
retrySeconds: integer().notNull().default(5),
|
||||
wrapupSeconds: integer().notNull().default(10),
|
||||
maxWaitSeconds: integer().notNull().default(300),
|
||||
maxCallers: integer().notNull().default(0),
|
||||
mohClass: text().notNull().default('default'),
|
||||
announcePosition: boolean().notNull().default(true),
|
||||
announceEverySeconds: integer().notNull().default(60),
|
||||
joinWhenNoAgents: boolean().notNull().default(false),
|
||||
record: boolean().notNull().default(false),
|
||||
slaSeconds: integer().notNull().default(20),
|
||||
callerIdNamePrefix: text(),
|
||||
timeoutDestination: jsonb().notNull(),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
export const queueMembers = pgTable(
|
||||
'queue_members',
|
||||
{
|
||||
queueId: uuid()
|
||||
.notNull()
|
||||
.references(() => queues.id, { onDelete: 'cascade' }),
|
||||
extensionId: uuid()
|
||||
.notNull()
|
||||
.references(() => extensions.id, { onDelete: 'cascade' }),
|
||||
penalty: integer().notNull().default(0),
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.queueId, t.extensionId] })],
|
||||
);
|
||||
|
||||
export const queueMemberState = pgTable(
|
||||
'queue_member_state',
|
||||
{
|
||||
queueId: uuid()
|
||||
.notNull()
|
||||
.references(() => queues.id, { onDelete: 'cascade' }),
|
||||
extensionId: uuid()
|
||||
.notNull()
|
||||
.references(() => extensions.id, { onDelete: 'cascade' }),
|
||||
paused: boolean().notNull().default(false),
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.queueId, t.extensionId] })],
|
||||
);
|
||||
|
||||
export const mohClasses = pgTable('moh_classes', {
|
||||
id: id(),
|
||||
name: text().notNull().unique(),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
export const soundFiles = pgTable(
|
||||
'sound_files',
|
||||
{
|
||||
id: id(),
|
||||
name: text().notNull(),
|
||||
kind: text().notNull(),
|
||||
mohClassId: uuid().references(() => mohClasses.id, { onDelete: 'cascade' }),
|
||||
source: text().notNull(),
|
||||
ttsText: text(),
|
||||
originalFilename: text(),
|
||||
asteriskName: text().notNull().unique(),
|
||||
durationMs: integer().notNull(),
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [
|
||||
check('sound_files_kind_check', sql`${t.kind} in ('prompt', 'moh')`),
|
||||
check('sound_files_source_check', sql`${t.source} in ('upload', 'tts', 'phone')`),
|
||||
],
|
||||
);
|
||||
|
||||
export const ivrs = pgTable('ivrs', {
|
||||
id: id(),
|
||||
number: text().notNull().unique(),
|
||||
name: text().notNull(),
|
||||
greetingSoundId: uuid()
|
||||
.notNull()
|
||||
.references(() => soundFiles.id, { onDelete: 'restrict' }),
|
||||
invalidSoundId: uuid().references(() => soundFiles.id, { onDelete: 'set null' }),
|
||||
timeoutSeconds: integer().notNull().default(5),
|
||||
maxRetries: integer().notNull().default(3),
|
||||
allowDirectDial: boolean().notNull().default(true),
|
||||
timeoutDestination: jsonb().notNull(),
|
||||
invalidDestination: jsonb().notNull(),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
export const ivrOptions = pgTable(
|
||||
'ivr_options',
|
||||
{
|
||||
ivrId: uuid()
|
||||
.notNull()
|
||||
.references(() => ivrs.id, { onDelete: 'cascade' }),
|
||||
digit: text().notNull(),
|
||||
destination: jsonb().notNull(),
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [
|
||||
primaryKey({ columns: [t.ivrId, t.digit] }),
|
||||
check('ivr_options_digit_check', sql`${t.digit} ~ '^[0-9*#]$'`),
|
||||
],
|
||||
);
|
||||
|
||||
export const timeConditions = pgTable(
|
||||
'time_conditions',
|
||||
{
|
||||
id: id(),
|
||||
name: text().notNull(),
|
||||
timezone: text().notNull(),
|
||||
openRanges: jsonb().notNull(),
|
||||
useHolidays: boolean().notNull().default(true),
|
||||
overrideDigit: smallint().unique(),
|
||||
overrideState: text(),
|
||||
openDestination: jsonb().notNull(),
|
||||
closedDestination: jsonb().notNull(),
|
||||
holidayDestination: jsonb(),
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [
|
||||
check('time_conditions_override_digit_check', sql`${t.overrideDigit} between 1 and 9`),
|
||||
check('time_conditions_override_state_check', sql`${t.overrideState} in ('open', 'closed')`),
|
||||
],
|
||||
);
|
||||
|
||||
export const holidays = pgTable('holidays', {
|
||||
id: id(),
|
||||
name: text().notNull(),
|
||||
startDate: date().notNull(),
|
||||
endDate: date().notNull(),
|
||||
recurringYearly: boolean().notNull().default(false),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
export const announcements = pgTable('announcements', {
|
||||
id: id(),
|
||||
name: text().notNull(),
|
||||
soundId: uuid()
|
||||
.notNull()
|
||||
.references(() => soundFiles.id, { onDelete: 'restrict' }),
|
||||
destination: jsonb().notNull(),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
export const conferences = pgTable('conferences', {
|
||||
id: id(),
|
||||
number: text().notNull().unique(),
|
||||
name: text().notNull(),
|
||||
pinEnc: text(),
|
||||
adminPinEnc: text(),
|
||||
maxMembers: integer().notNull().default(20),
|
||||
record: boolean().notNull().default(false),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
export const pagingGroups = pgTable('paging_groups', {
|
||||
id: id(),
|
||||
number: text().notNull().unique(),
|
||||
name: text().notNull(),
|
||||
duplex: boolean().notNull().default(false),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
export const pagingGroupMembers = pgTable(
|
||||
'paging_group_members',
|
||||
{
|
||||
pagingGroupId: uuid()
|
||||
.notNull()
|
||||
.references(() => pagingGroups.id, { onDelete: 'cascade' }),
|
||||
extensionId: uuid()
|
||||
.notNull()
|
||||
.references(() => extensions.id, { onDelete: 'cascade' }),
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.pagingGroupId, t.extensionId] })],
|
||||
);
|
||||
|
||||
// ───────────── devices (provisioning) ─────────────
|
||||
export const phoneVendor = pgEnum('phone_vendor', ['yealink', 'grandstream', 'polycom', 'generic']);
|
||||
|
||||
export const devices = pgTable('devices', {
|
||||
id: id(),
|
||||
extensionId: uuid()
|
||||
.notNull()
|
||||
.references(() => extensions.id, { onDelete: 'cascade' }),
|
||||
mac: text().notNull().unique(),
|
||||
vendor: phoneVendor().notNull(),
|
||||
model: text(),
|
||||
label: text(),
|
||||
provisioningPasswordEnc: text().notNull(),
|
||||
lastProvisionedAt: tstz(),
|
||||
lastProvisionedIp: inet(),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
// ───────────── history, media, messaging ─────────────
|
||||
export const cdr = pgTable(
|
||||
'cdr',
|
||||
{
|
||||
id: bigserial({ mode: 'number' }).primaryKey(),
|
||||
createdAt: createdAt(),
|
||||
uniqueid: text().notNull(),
|
||||
linkedid: text().notNull(),
|
||||
startAt: tstz(),
|
||||
answerAt: tstz(),
|
||||
endAt: tstz(),
|
||||
duration: integer(),
|
||||
billsec: integer(),
|
||||
src: text(),
|
||||
dst: text(),
|
||||
dcontext: text(),
|
||||
clid: text(),
|
||||
channel: text(),
|
||||
dstchannel: text(),
|
||||
lastapp: text(),
|
||||
lastdata: text(),
|
||||
disposition: text(),
|
||||
accountcode: text(),
|
||||
userfield: text(),
|
||||
spDid: text(),
|
||||
spDirection: text(),
|
||||
spRecording: text(),
|
||||
raw: jsonb().notNull(),
|
||||
},
|
||||
(t) => [index().on(t.linkedid)],
|
||||
);
|
||||
|
||||
export const callDirection = pgEnum('call_direction', ['inbound', 'outbound', 'internal']);
|
||||
export const callStatus = pgEnum('call_status', [
|
||||
'answered',
|
||||
'missed',
|
||||
'voicemail',
|
||||
'abandoned',
|
||||
'busy',
|
||||
'failed',
|
||||
]);
|
||||
|
||||
export const recordings = pgTable('recordings', {
|
||||
id: id(),
|
||||
linkedid: text().notNull(),
|
||||
filePath: text().notNull().unique(),
|
||||
startedAt: tstz().notNull(),
|
||||
durationMs: integer(),
|
||||
sizeBytes: bigint({ mode: 'number' }),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
export const calls = pgTable(
|
||||
'calls',
|
||||
{
|
||||
id: id(),
|
||||
linkedid: text().notNull().unique(),
|
||||
direction: callDirection().notNull(),
|
||||
status: callStatus().notNull(),
|
||||
startedAt: tstz().notNull(),
|
||||
answeredAt: tstz(),
|
||||
endedAt: tstz().notNull(),
|
||||
fromNumber: text(),
|
||||
fromName: text(),
|
||||
toNumber: text(),
|
||||
didId: uuid().references(() => dids.id, { onDelete: 'set null' }),
|
||||
answeredByExtensionId: uuid().references(() => extensions.id, { onDelete: 'set null' }),
|
||||
talkSeconds: integer().notNull().default(0),
|
||||
recordingId: uuid().references(() => recordings.id, { onDelete: 'set null' }),
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [index().on(t.startedAt.desc())],
|
||||
);
|
||||
|
||||
export const callExtensions = pgTable(
|
||||
'call_extensions',
|
||||
{
|
||||
callId: uuid()
|
||||
.notNull()
|
||||
.references(() => calls.id, { onDelete: 'cascade' }),
|
||||
extensionId: uuid()
|
||||
.notNull()
|
||||
.references(() => extensions.id, { onDelete: 'cascade' }),
|
||||
role: text().notNull(),
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [
|
||||
primaryKey({ columns: [t.callId, t.extensionId, t.role] }),
|
||||
check('call_extensions_role_check', sql`${t.role} in ('caller', 'rang', 'answered')`),
|
||||
],
|
||||
);
|
||||
|
||||
export const voicemailMessages = pgTable('voicemail_messages', {
|
||||
id: id(),
|
||||
extensionId: uuid()
|
||||
.notNull()
|
||||
.references(() => extensions.id, { onDelete: 'cascade' }),
|
||||
msgId: text().notNull().unique(),
|
||||
folder: text().notNull(),
|
||||
callerNumber: text(),
|
||||
callerName: text(),
|
||||
durationS: integer().notNull(),
|
||||
receivedAt: tstz().notNull(),
|
||||
filePath: text().notNull(),
|
||||
emailedAt: tstz(),
|
||||
deletedAt: tstz(),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
export const contacts = pgTable('contacts', {
|
||||
id: id(),
|
||||
ownerUserId: uuid().references(() => users.id, { onDelete: 'cascade' }),
|
||||
name: text().notNull(),
|
||||
company: text(),
|
||||
email: text(),
|
||||
numbers: jsonb().notNull(),
|
||||
notes: text(),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
export const smsMessages = pgTable(
|
||||
'sms_messages',
|
||||
{
|
||||
id: id(),
|
||||
didId: uuid()
|
||||
.notNull()
|
||||
.references(() => dids.id, { onDelete: 'cascade' }),
|
||||
direction: text().notNull(),
|
||||
remoteNumber: text().notNull(),
|
||||
body: text().notNull(),
|
||||
media: jsonb().notNull().default([]),
|
||||
status: text().notNull(),
|
||||
providerId: text(),
|
||||
error: text(),
|
||||
sentByUserId: uuid().references(() => users.id, { onDelete: 'set null' }),
|
||||
readAt: tstz(),
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [
|
||||
index().on(t.didId, t.remoteNumber, t.createdAt.desc()),
|
||||
check('sms_messages_direction_check', sql`${t.direction} in ('in', 'out')`),
|
||||
],
|
||||
);
|
||||
|
||||
// ───────────── integration, audit, system ─────────────
|
||||
export const webhooks = pgTable('webhooks', {
|
||||
id: id(),
|
||||
url: text().notNull(),
|
||||
secretEnc: text().notNull(),
|
||||
events: text().array().notNull(),
|
||||
enabled: boolean().notNull().default(true),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
export const webhookDeliveries = pgTable('webhook_deliveries', {
|
||||
id: bigserial({ mode: 'number' }).primaryKey(),
|
||||
createdAt: createdAt(),
|
||||
webhookId: uuid()
|
||||
.notNull()
|
||||
.references(() => webhooks.id, { onDelete: 'cascade' }),
|
||||
event: text().notNull(),
|
||||
payload: jsonb().notNull(),
|
||||
attempt: integer().notNull().default(0),
|
||||
status: text().notNull(),
|
||||
responseCode: integer(),
|
||||
nextAttemptAt: tstz(),
|
||||
});
|
||||
|
||||
export const emergencyEvents = pgTable('emergency_events', {
|
||||
id: id(),
|
||||
extensionNumber: text().notNull(),
|
||||
dialed: text().notNull(),
|
||||
callerIdUsed: text().notNull(),
|
||||
location: text(),
|
||||
notified: jsonb().notNull().default([]),
|
||||
...timestamps,
|
||||
});
|
||||
|
||||
export const auditLog = pgTable('audit_log', {
|
||||
id: bigserial({ mode: 'number' }).primaryKey(),
|
||||
createdAt: createdAt(),
|
||||
actorUserId: uuid().references(() => users.id, { onDelete: 'set null' }),
|
||||
actorIp: inet(),
|
||||
action: text().notNull(),
|
||||
entityType: text(),
|
||||
entityId: text(),
|
||||
summary: text().notNull(),
|
||||
diff: jsonb(),
|
||||
});
|
||||
|
||||
export const generatorRuns = pgTable('generator_runs', {
|
||||
id: bigserial({ mode: 'number' }).primaryKey(),
|
||||
createdAt: createdAt(),
|
||||
trigger: text().notNull(),
|
||||
filesChanged: text().array().notNull(),
|
||||
commands: text().array().notNull(),
|
||||
ok: boolean().notNull(),
|
||||
error: text(),
|
||||
durationMs: integer().notNull(),
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Typed key/value settings (spec 02 §3). Missing rows return the key's default.
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import { SettingsSchemas, type SettingKey, type SettingValue } from '@selfphoneng/shared';
|
||||
import type { Db } from '../db/client.ts';
|
||||
import { settings } from '../db/schema.ts';
|
||||
import { randomAlnum } from '../crypto/secrets.ts';
|
||||
|
||||
function defaultFor<K extends SettingKey>(key: K): SettingValue<K> {
|
||||
if (key === 'general.timezone' && process.env.TZ) return process.env.TZ as SettingValue<K>;
|
||||
return SettingsSchemas[key].parse(undefined) as SettingValue<K>;
|
||||
}
|
||||
|
||||
export async function getSetting<K extends SettingKey>(db: Db, key: K): Promise<SettingValue<K>> {
|
||||
const [row] = await db.select().from(settings).where(eq(settings.key, key));
|
||||
if (row) return SettingsSchemas[key].parse(row.value) as SettingValue<K>;
|
||||
if (key === 'sms.webhookToken') {
|
||||
// Made on first read; on a race the first insert wins and both readers see it.
|
||||
await db
|
||||
.insert(settings)
|
||||
.values({ key, value: randomAlnum(32) })
|
||||
.onConflictDoNothing();
|
||||
return getSetting(db, key);
|
||||
}
|
||||
return defaultFor(key);
|
||||
}
|
||||
|
||||
/** Every key at once (for GET /api/settings), defaults filled in. */
|
||||
export async function getAllSettings(
|
||||
db: Db,
|
||||
): Promise<{ [K in Exclude<SettingKey, 'sms.webhookToken'>]: SettingValue<K> }> {
|
||||
const keys = (Object.keys(SettingsSchemas) as SettingKey[]).filter(
|
||||
(k) => k !== 'sms.webhookToken',
|
||||
);
|
||||
const rows = await db.select().from(settings).where(inArray(settings.key, keys));
|
||||
const stored = new Map(rows.map((r) => [r.key, r.value]));
|
||||
return Object.fromEntries(
|
||||
keys.map((k) => [k, stored.has(k) ? SettingsSchemas[k].parse(stored.get(k)) : defaultFor(k)]),
|
||||
) as never;
|
||||
}
|
||||
|
||||
export async function setSetting<K extends SettingKey>(
|
||||
db: Db,
|
||||
key: K,
|
||||
value: SettingValue<K>,
|
||||
): Promise<void> {
|
||||
const v = SettingsSchemas[key].parse(value);
|
||||
await db
|
||||
.insert(settings)
|
||||
.values({ key, value: v })
|
||||
.onConflictDoUpdate({ target: settings.key, set: { value: v, updatedAt: new Date() } });
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Throwaway databases for integration tests. Needs TEST_DATABASE_URL (a role that can CREATE DATABASE);
|
||||
// vitest.config.ts derives it from .env when `docker compose up -d postgres` is running.
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import pg from 'pg';
|
||||
import { createDb, runMigrations, type Db } from '../src/db/client.ts';
|
||||
|
||||
export const TEST_DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||||
export const hasTestDb = Boolean(TEST_DATABASE_URL);
|
||||
|
||||
async function admin<T>(fn: (c: pg.Client) => Promise<T>): Promise<T> {
|
||||
const c = new pg.Client({ connectionString: TEST_DATABASE_URL });
|
||||
await c.connect();
|
||||
try {
|
||||
return await fn(c);
|
||||
} finally {
|
||||
await c.end();
|
||||
}
|
||||
}
|
||||
|
||||
/** Creates an empty database, migrates it, and returns it with a drop() for afterAll. */
|
||||
export async function createTestDb(): Promise<{ db: Db; url: string; drop: () => Promise<void> }> {
|
||||
const name = `sp_test_${randomBytes(6).toString('hex')}`;
|
||||
await admin((c) => c.query(`create database ${name}`));
|
||||
const url = new URL(TEST_DATABASE_URL!);
|
||||
url.pathname = `/${name}`;
|
||||
const { db, pool } = createDb(url.toString());
|
||||
await runMigrations(db);
|
||||
return {
|
||||
db,
|
||||
url: url.toString(),
|
||||
drop: async () => {
|
||||
await pool.end();
|
||||
await admin((c) => c.query(`drop database ${name} with (force)`));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import type { Db } from '../src/db/client.ts';
|
||||
import { getAllSettings, getSetting, setSetting } from '../src/services/settings.ts';
|
||||
import { createTestDb, hasTestDb } from './db.ts';
|
||||
|
||||
describe.skipIf(!hasTestDb)('settings service', () => {
|
||||
let db: Db;
|
||||
let drop: () => Promise<void>;
|
||||
beforeAll(async () => ({ db, drop } = await createTestDb()));
|
||||
afterAll(() => drop?.());
|
||||
|
||||
it('returns defaults for missing rows', async () => {
|
||||
expect(await getSetting(db, 'general.companyName')).toBe('My Company');
|
||||
expect(await getSetting(db, 'numbering.users')).toEqual([100, 499]);
|
||||
expect(await getSetting(db, 'routing.unmatchedDidDestination')).toEqual({ type: 'hangup' });
|
||||
});
|
||||
|
||||
it('stores validated values and rejects bad ones', async () => {
|
||||
await setSetting(db, 'general.companyName', 'Acme');
|
||||
await setSetting(db, 'general.companyName', 'Acme Inc');
|
||||
expect(await getSetting(db, 'general.companyName')).toBe('Acme Inc');
|
||||
await expect(setSetting(db, 'network.localNets', ['not-a-cidr'])).rejects.toThrow();
|
||||
await expect(setSetting(db, 'backup.time', '25:00')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('makes sms.webhookToken once and keeps it', async () => {
|
||||
const a = await getSetting(db, 'sms.webhookToken');
|
||||
expect(a).toMatch(/^[A-Za-z0-9]{32}$/);
|
||||
expect(await getSetting(db, 'sms.webhookToken')).toBe(a);
|
||||
});
|
||||
|
||||
it('getAllSettings fills defaults and hides the webhook token', async () => {
|
||||
const all = await getAllSettings(db);
|
||||
expect(all['general.companyName']).toBe('Acme Inc');
|
||||
expect(all['sip.udpPort']).toBe(5060);
|
||||
expect('sms.webhookToken' in all).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "rootDir": "src", "outDir": "dist", "types": ["node"] },
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "rootDir": "src", "outDir": "dist", "types": ["node"] },
|
||||
"include": ["src"]
|
||||
"compilerOptions": { "noEmit": true, "types": ["node"] },
|
||||
"include": ["src", "test", "drizzle.config.ts"]
|
||||
}
|
||||
@@ -13,7 +13,8 @@
|
||||
"@selfphoneng/shared": "workspace:*",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-router": "^8.4.0"
|
||||
"react-router": "^8.4.0",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# 0002 — `pg` driver and `@fastify/cookie`
|
||||
|
||||
drizzle needs a PostgreSQL driver. We use `pg` (node-postgres): the most used Node driver, and the
|
||||
one drizzle's docs and migrator target first. `@fastify/cookie` reads and sets the `sp_session`
|
||||
cookie (04 §2.2); spec 04 §1 already names a cookie plugin but 00 §4 did not list it.
|
||||
@@ -160,6 +160,8 @@ AstDB keys (family/key). This list is complete; do not add keys without updating
|
||||
| WebSocket (api) | @fastify/websocket | latest |
|
||||
| Multipart uploads | @fastify/multipart | latest |
|
||||
| Rate limiting | @fastify/rate-limit | latest |
|
||||
| Cookies | @fastify/cookie | latest (decisions/0002) |
|
||||
| PostgreSQL driver | pg | latest (decisions/0002) |
|
||||
| QR codes | qrcode | latest |
|
||||
| Logging | pino | latest |
|
||||
| Tests | vitest | latest |
|
||||
|
||||
@@ -639,7 +639,8 @@ COPY --from=build /out/package.json ./package.json
|
||||
COPY --from=build /out/node_modules ./node_modules
|
||||
COPY --from=build /src/apps/api/dist ./dist
|
||||
COPY --from=build /src/apps/web/dist ./public
|
||||
# ponytail: migrations (M1) and provisioning templates (M10) get their COPY lines when they exist.
|
||||
COPY --from=build /src/apps/api/src/db/migrations ./dist/db/migrations
|
||||
# ponytail: provisioning templates (M10) get their COPY line when they exist.
|
||||
ENV NODE_ENV=production
|
||||
ENTRYPOINT ["/usr/bin/tini","--"]
|
||||
CMD ["node","dist/index.js"]
|
||||
|
||||
@@ -17,5 +17,8 @@
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
],
|
||||
"dependencies": {
|
||||
"zod": "^4.6.7"
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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';
|
||||
@@ -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]>;
|
||||
Generated
+1090
-16
File diff suppressed because it is too large.
Load diff
@@ -1,3 +1,7 @@
|
||||
packages:
|
||||
- apps/*
|
||||
- packages/*
|
||||
|
||||
# esbuild (via drizzle-kit) ships prebuilt binaries; its postinstall check is not needed.
|
||||
allowBuilds:
|
||||
esbuild: false
|
||||
+19
-1
@@ -1,6 +1,24 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
// DB integration tests use TEST_DATABASE_URL; derive it from .env for the dev postgres container.
|
||||
try {
|
||||
process.loadEnvFile('.env');
|
||||
} catch {
|
||||
// no .env (CI sets TEST_DATABASE_URL itself, or DB tests are skipped)
|
||||
}
|
||||
const env = process.env;
|
||||
if (!env.TEST_DATABASE_URL && env.POSTGRES_PASSWORD) {
|
||||
env.TEST_DATABASE_URL = `postgres://selfphoneng:${env.POSTGRES_PASSWORD}@127.0.0.1:${env.POSTGRES_PORT ?? 5433}/selfphoneng`;
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
test: { include: ['{apps,packages}/*/{src,test}/**/*.test.{ts,tsx}'], passWithNoTests: true },
|
||||
resolve: { conditions: ['source'] },
|
||||
ssr: { resolve: { conditions: ['source'], externalConditions: ['source'] } },
|
||||
test: {
|
||||
include: ['{apps,packages}/*/{src,test}/**/*.test.{ts,tsx}'],
|
||||
passWithNoTests: true,
|
||||
testTimeout: 20_000,
|
||||
hookTimeout: 60_000,
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user