Files
selfphoneng/docs/spec/02-data-model.md
T
iamdoubz 6f87d6f663 api: spam check settings, lookups and cache
SD1 (14 §9): the spam/lookups tables and calls columns, the
IPQualityScore and FTC lookups with the cache, the spam settings with
write-only keys, the provider Test routes and Settings → Spam check.
2026-10-27 15:32:31 -05:00

35 KiB
Raw Blame History

02 — Data model (PostgreSQL 18)

The DDL below is the contract. apps/api/src/db/schema.ts (drizzle) must produce exactly these tables. Generate migrations with drizzle-kit generate. Never edit an applied migration. Add a new one instead.

Conventions:

  • Primary keys: uuid with default gen_random_uuid() unless stated (built into PostgreSQL 18, so no pgcrypto extension is needed).
  • Every table has created_at timestamptz not null default now() and updated_at timestamptz not null default now() (left out below to save space, but add them). Exceptions: cdr, audit_log, system_events, webhook_deliveries, generator_runs, ftc_complaints (append-only, created_at only).
  • Columns ending in _enc hold secrets encrypted with AES-256-GCM using APP_SECRET (format: base64(iv[12] | tag[16] | ciphertext)). Only crypto/secrets.ts encrypts and decrypts. API responses never include _enc values. They return "********" or a hasPassword: true flag.
  • "Internal number" columns are text holding digits only (validated in the API).
  • External numbers use the normalized format from 00-overview.md §8.

1. The Destination type (used in many tables)

A destination says "where a call goes next". It is stored as jsonb and validated by the zod schema DestinationSchema in packages/shared/src/destination.ts:

export const DestinationSchema = z.discriminatedUnion('type', [
  z.object({ type: z.literal('user'),           id: z.string().uuid() }), // extensions.id; rings, then that user's no-answer handling
  z.object({ type: z.literal('voicemail'),      id: z.string().uuid() }), // extensions.id; straight to voicemail
  z.object({ type: z.literal('ring_group'),     id: z.string().uuid() }),
  z.object({ type: z.literal('queue'),          id: z.string().uuid() }),
  z.object({ type: z.literal('ivr'),            id: z.string().uuid() }),
  z.object({ type: z.literal('time_condition'), id: z.string().uuid() }),
  z.object({ type: z.literal('conference'),     id: z.string().uuid() }),
  z.object({ type: z.literal('announcement'),   id: z.string().uuid() }),
  z.object({ type: z.literal('external'),       number: z.string() }),    // normalized external number
  z.object({ type: z.literal('hangup') }),
]);

Because jsonb can't have foreign keys, the API must:

  • check the referenced row exists when saving (services/destinations.ts → assertDestinationExists)
  • refuse to delete any row that a destination points to, and return HTTP 409 with the list of places using it (services/destinations.ts → findReferences(type, id), which scans every jsonb destination column listed in this file).

Destination columns (keep this list in sync with findReferences): dids.destination, ring_groups.no_answer_destination, queues.timeout_destination, ivrs.timeout_destination, ivrs.invalid_destination, ivr_options.destination, time_conditions.open_destination, time_conditions.closed_destination, time_conditions.holiday_destination, announcements.destination, settings['routing.unmatchedDidDestination'].

2. DDL

create extension if not exists citext;

-- ───────────── settings ─────────────
-- Key/value app settings. Keys are listed in §3. Values are JSON.
create table settings (
  key   text primary key,
  value jsonb not null
);

-- ───────────── people and auth ─────────────
create type user_role as enum ('admin', 'user');

create table users (
  id               uuid primary key default gen_random_uuid(),
  email            citext not null unique,
  display_name     text not null,
  password_hash    text,                          -- argon2id; null: OpenID only (12 §3.3)
  role             user_role not null default 'user',
  is_active        boolean not null default true,
  totp_secret_enc  text,                          -- null = 2FA off (M11)
  totp_recovery_hashes text[] not null default '{}', -- sha256 of unused recovery codes (M11)
  totp_last_step   bigint,                        -- last 30 s step signed in with: no replay (M11)
  must_change_password boolean not null default false,
  last_login_at    timestamptz,
  contrack_token_enc text,                       -- their Contrack personal token (11 §4.3)
  contrack_log_calls boolean not null default true,     -- calls on their Contrack timelines (11 §4.4)
  contrack_log_summaries boolean not null default false, -- …with recorded calls' summaries
  contrack_log_since timestamptz                 -- calls that ended after this
);

create table sessions (
  id           text primary key,                  -- sha256(token) hex; raw token only in cookie
  user_id      uuid not null references users(id) on delete cascade,
  expires_at   timestamptz not null,
  ip           inet,
  user_agent   text,
  method       text not null default 'password'   -- password | ldap | oidc (12 §2)
               check (method in ('password', 'ldap', 'oidc')),
  idp_refresh_enc  text,                          -- oidc: refresh token, encrypted (12 §3.5)
  idp_id_token_enc text,                          -- oidc: ID token, encrypted, to sign out there
  idp_checked_at   timestamptz
);
create index on sessions (user_id);

-- One more way to sign in to a user (12 §2): a connected OpenID account or directory entry.
create type identity_kind as enum ('oidc', 'ldap');
create table user_identities (
  id           uuid primary key default gen_random_uuid(),
  user_id      uuid not null references users(id) on delete cascade,
  kind         identity_kind not null,
  subject      text not null,                     -- oidc: "<iss> <sub>"; ldap: the UUID attribute
  email        text,                              -- as the provider last said
  groups       text[] not null default '{}',      -- oidc: groupsClaim values at the last sign-in
  created_at   timestamptz not null default now(),
  last_used_at timestamptz,
  unique (kind, subject),
  unique (user_id, kind)
);

create table api_tokens (
  id            uuid primary key default gen_random_uuid(),
  name          text not null,
  token_prefix  text not null,                    -- first 8 chars, shown in UI
  token_hash    text not null unique,             -- sha256 hex
  created_by    uuid not null references users(id),
  last_used_at  timestamptz,
  revoked_at    timestamptz
);

-- Paired phones (13 §2, §3): a row waits with a pairing code, then holds the device token.
create table mobile_devices (
  id               uuid primary key default gen_random_uuid(),
  user_id          uuid not null references users(id) on delete cascade,
  name             text,                    -- "Pixel 9", from the app; null until paired
  platform         text check (platform in ('android', 'ios')),
  token_prefix     text,                    -- "spm_AbC1", shown on the web
  token_hash       text unique,             -- sha256 hex; null while waiting to pair
  pair_code_hash   text unique,             -- sha256 hex; null once paired
  pair_expires_at  timestamptz,
  last_used_at     timestamptz,
  created_at       timestamptz not null default now(),
  updated_at       timestamptz not null default now()
);
create index on mobile_devices (user_id);

-- ───────────── extensions (telephony identity of a user) ─────────────
create type outbound_class as enum ('internal', 'local', 'national', 'international');
create type record_mode   as enum ('never', 'inbound', 'outbound', 'all');
create type call_report   as enum ('off', 'hourly', 'daily', 'weekly');

create table extensions (
  id                        uuid primary key default gen_random_uuid(),
  number                    text not null unique,          -- e.g. '101'
  user_id                   uuid unique references users(id) on delete set null,
  name                      text not null,                 -- caller ID name, e.g. 'Dan Doe'
  sip_password_enc          text not null,                 -- generated, 24 chars, never user-chosen
  webrtc_enabled            boolean not null default true,
  max_contacts              int not null default 5,
  ring_seconds              int not null default 25,       -- 5..120
  voicemail_enabled         boolean not null default true,
  voicemail_pin_enc         text not null,                 -- 4–10 digits
  voicemail_email           boolean not null default true,  -- email new voicemails to user
  voicemail_attach_audio    boolean not null default true,
  voicemail_delete_after_email boolean not null default false,
  outbound_class            outbound_class not null default 'national',
  outbound_caller_id_did_id uuid references dids(id) on delete set null,  -- see note *
  emergency_caller_id_did_id uuid references dids(id) on delete set null,
  emergency_location        text,                          -- free text e.g. "Floor 2, Room 210"
  record_mode               record_mode not null default 'never',
  pickup_group              text,                          -- e.g. 'sales'; same group can pick each other up
  language                  text not null default 'en',
  moh_class                 text not null default 'default',
  blf_numbers               text[] not null default '{}',  -- internal numbers shown as BLF keys on their phones/web
  call_report               call_report not null default 'off',  -- the call report email (05 §4)
  call_report_time          text not null default '08:00', -- HH:MM company time, daily and weekly
  call_report_weekday       int not null default 1,        -- 0 = Sunday, weekly
  call_report_summaries     boolean not null default false, -- each recorded call's summary too (10 §3)
  call_report_sent_at       timestamptz                    -- end of the last period reported on
);
-- * dids is created below; in the migration create dids first or add these FKs after.

create table user_runtime_state (                          -- mirrored into AstDB, see 00 §3.3
  extension_id  uuid primary key references extensions(id) on delete cascade,
  dnd           boolean not null default false,
  cfu           text,      -- forward always to this number (internal or external)
  cfb           text,      -- forward on busy
  cfna          text       -- forward on no answer
);

-- ───────────── trunks and numbers ─────────────
create type trunk_provider  as enum ('voipms', 'generic');
create type sip_transport   as enum ('udp', 'tcp', 'tls');
create type trunk_auth_mode as enum ('registration', 'ip');

create table trunks (
  id                   uuid primary key default gen_random_uuid(),
  name                 text not null unique,
  provider             trunk_provider not null default 'voipms',
  enabled              boolean not null default true,
  server_host          text not null,                    -- e.g. 'chicago1.voip.ms'
  server_port          int not null default 5060,
  transport            sip_transport not null default 'udp',
  auth_mode            trunk_auth_mode not null default 'registration',
  username             text,                             -- voip.ms sub-account e.g. '123456_pbx'
  password_enc         text,
  from_user            text,                             -- defaults to username
  from_domain          text,                             -- defaults to server_host
  codecs               text[] not null default '{ulaw}', -- allowed: ulaw, alaw, g722
  srtp                 boolean not null default false,   -- only with transport=tls
  max_channels         int,                              -- null = unlimited
  -- voip.ms REST API (SMS, DID import). Optional.
  voipms_api_username  text,
  voipms_api_password_enc text
);

create table dids (
  id                   uuid primary key default gen_random_uuid(),
  number               text not null unique,             -- normalized, '13125551234'
  trunk_id             uuid not null references trunks(id) on delete restrict,
  label                text,                             -- 'Main line'
  destination          jsonb not null,                   -- Destination
  caller_id_name_prefix text,                            -- e.g. 'SALES:' prepended to caller name
  record_inbound       boolean not null default false,
  sms_enabled          boolean not null default false,
  e911_address         text                              -- informational; real address is set at voip.ms
);

create table did_sms_users (                             -- who may read/send SMS for a DID
  did_id  uuid not null references dids(id) on delete cascade,
  user_id uuid not null references users(id) on delete cascade,
  primary key (did_id, user_id)
);

create table outbound_routes (
  id                     uuid primary key default gen_random_uuid(),
  name                   text not null,
  position               int not null,                   -- lower = matched first
  enabled                boolean not null default true,
  patterns               jsonb not null,  -- [{ "match": "NXXNXXXXXX", "strip": 0, "prepend": "1" }]
  required_class         outbound_class not null,        -- caller's class must be >= this
  trunk_ids              uuid[] not null,                -- tried in order (failover)
  caller_id_did_id       uuid references dids(id) on delete set null  -- overrides user's CID
);

create table blocked_numbers (
  id      uuid primary key default gen_random_uuid(),
  number  text not null unique,                          -- normalized; mirrored to AstDB SP/BLOCK
  reason  text
);

-- ───────────── call handling objects ─────────────
create type ring_strategy as enum ('ringall', 'hunt');

create table ring_groups (
  id                    uuid primary key default gen_random_uuid(),
  number                text not null unique,
  name                  text not null,
  strategy              ring_strategy not null default 'ringall',
  ring_seconds          int not null default 25,         -- ringall: total; hunt: per member
  caller_id_name_prefix text,
  music_instead_of_ring boolean not null default false,
  no_answer_destination jsonb not null                   -- Destination
);

create table ring_group_members (
  ring_group_id  uuid not null references ring_groups(id) on delete cascade,
  extension_id   uuid not null references extensions(id) on delete cascade,
  position       int not null,
  primary key (ring_group_id, extension_id)
);

create type queue_strategy as enum ('ringall', 'leastrecent', 'fewestcalls', 'random', 'rrmemory', 'linear');

create table queues (
  id                    uuid primary key default gen_random_uuid(),
  number                text not null unique,
  name                  text not null,
  strategy              queue_strategy not null default 'rrmemory',
  member_ring_seconds   int not null default 15,
  retry_seconds         int not null default 5,
  wrapup_seconds        int not null default 10,
  max_wait_seconds      int not null default 300,
  max_callers           int not null default 0,          -- 0 = unlimited
  moh_class             text not null default 'default',
  announce_position     boolean not null default true,
  announce_every_seconds int not null default 60,
  join_when_no_agents   boolean not null default false,
  record                boolean not null default false,
  sla_seconds           int not null default 20,         -- for reports
  caller_id_name_prefix text,
  timeout_destination   jsonb not null                   -- Destination when max wait reached / no agents
);

create table queue_members (
  queue_id      uuid not null references queues(id) on delete cascade,
  extension_id  uuid not null references extensions(id) on delete cascade,
  penalty       int not null default 0,
  primary key (queue_id, extension_id)
);
-- Agent logged in/out lives in AstDB SP/QPAUSE and table queue_member_state:
create table queue_member_state (
  queue_id      uuid not null references queues(id) on delete cascade,
  extension_id  uuid not null references extensions(id) on delete cascade,
  paused        boolean not null default false,
  primary key (queue_id, extension_id)
);
-- One caller's stay in a queue, from app_queue's AMI events (QueueCallerJoin, AgentConnect,
-- QueueCallerAbandon) and the dialplan's UserEvent SpQueueExit (03 §6.8); for queue stats.
-- CDRs can't do this: when no agent rang, the Queue part of a call is merged into the next leg.
create type queue_outcome as enum ('answered', 'abandoned', 'timed_out');
create table queue_calls (
  id                 bigserial primary key,
  queue_id           uuid not null references queues(id) on delete cascade,
  uniqueid           text not null,                    -- the caller's channel
  linkedid           text not null,
  joined_at          timestamptz not null default now(),
  outcome            queue_outcome,                    -- null while waiting
  wait_seconds       int,
  agent_extension_id uuid references extensions(id) on delete set null
);
create index on queue_calls (queue_id, joined_at);
create index on queue_calls (uniqueid);

create table sound_files (
  id                uuid primary key default gen_random_uuid(),
  name              text not null,
  kind              text not null check (kind in ('prompt', 'moh')),
  moh_class_id      uuid references moh_classes(id) on delete cascade,  -- when kind='moh'
  source            text not null check (source in ('upload', 'tts', 'phone')),
  tts_text          text,
  original_filename text,
  asterisk_name     text not null unique,  -- path for Playback() without extension, e.g. 'custom/3f2a...'
  duration_ms       int not null
);
create table moh_classes (
  id    uuid primary key default gen_random_uuid(),
  name  text not null unique          -- slug [a-z0-9-] (same as other slugs); 'default' is built in and not stored here
);

create table ivrs (
  id                    uuid primary key default gen_random_uuid(),
  number                text not null unique,
  name                  text not null,
  greeting_sound_id     uuid not null references sound_files(id) on delete restrict,
  invalid_sound_id      uuid references sound_files(id) on delete set null,   -- null = stock 'pbx-invalid'
  timeout_seconds       int not null default 5,
  max_retries           int not null default 3,
  allow_direct_dial     boolean not null default true,     -- caller may dial an internal number
  timeout_destination   jsonb not null,
  invalid_destination   jsonb not null
);
create table ivr_options (
  ivr_id       uuid not null references ivrs(id) on delete cascade,
  digit        text not null check (digit ~ '^[0-9*#]$'),
  destination  jsonb not null,
  primary key (ivr_id, digit)
);

create table time_conditions (
  id                   uuid primary key default gen_random_uuid(),
  name                 text not null,
  timezone             text not null,          -- IANA, e.g. 'America/Chicago'
  open_ranges          jsonb not null,         -- [{ "days": ["mon","tue"], "start": "08:00", "end": "17:00" }]
  use_holidays         boolean not null default true,
  override_digit       smallint unique check (override_digit between 1 and 9),  -- feature codes *31N/*32N/*33N
  override_state       text check (override_state in ('open', 'closed')),     -- null = automatic; mirrored to AstDB SP/TCOVR
  open_destination     jsonb not null,
  closed_destination   jsonb not null,
  holiday_destination  jsonb                   -- null = use closed_destination
);

create table holidays (                        -- global, used by every time condition with use_holidays
  id          uuid primary key default gen_random_uuid(),
  name        text not null,
  start_date  date not null,
  end_date    date not null,                   -- inclusive; = start_date for one day
  recurring_yearly boolean not null default false
);

create table announcements (                   -- play a sound, then continue
  id           uuid primary key default gen_random_uuid(),
  name         text not null,
  sound_id     uuid not null references sound_files(id) on delete restrict,
  destination  jsonb not null
);

create table conferences (
  id           uuid primary key default gen_random_uuid(),
  number       text not null unique,
  name         text not null,
  pin_enc      text,                           -- null = no PIN
  admin_pin_enc text,
  max_members  int not null default 20,
  record       boolean not null default false
);

create table paging_groups (
  id          uuid primary key default gen_random_uuid(),
  number      text not null unique,
  name        text not null,
  duplex      boolean not null default false   -- false = one-way page, true = intercom
);
create table paging_group_members (
  paging_group_id uuid not null references paging_groups(id) on delete cascade,
  extension_id    uuid not null references extensions(id) on delete cascade,
  primary key (paging_group_id, extension_id)
);

-- ───────────── devices (provisioning) ─────────────
create type phone_vendor as enum ('yealink', 'grandstream', 'polycom', 'generic');

create table devices (
  id                       uuid primary key default gen_random_uuid(),
  extension_id             uuid not null references extensions(id) on delete cascade,
  mac                      text not null unique,      -- 12 lowercase hex chars, no separators
  vendor                   phone_vendor not null,
  model                    text,
  label                    text,
  provisioning_password_enc text not null,            -- HTTP basic auth password for this device
  last_provisioned_at      timestamptz,
  last_provisioned_ip      inet
);

-- ───────────── history, media, messaging ─────────────
create table cdr (                                    -- one row per AMI Cdr event, raw
  id           bigserial primary key,
  created_at   timestamptz not null default now(),
  uniqueid     text not null,
  linkedid     text not null,
  start_at     timestamptz,
  answer_at    timestamptz,
  end_at       timestamptz,
  duration     int, billsec int,
  src text, dst text, dcontext text, clid text,
  channel text, dstchannel text, lastapp text, lastdata text,
  disposition  text,                                  -- ANSWERED, NO ANSWER, BUSY, FAILED, CONGESTION
  accountcode  text, userfield text,
  sp_did       text, sp_direction text, sp_recording text,
  raw          jsonb not null
);
create index on cdr (linkedid);

create type call_direction as enum ('inbound', 'outbound', 'internal');
create type call_status    as enum ('answered', 'missed', 'voicemail', 'abandoned', 'busy', 'failed');

create table calls (                                  -- one row per logical call (linkedid)
  id                    uuid primary key default gen_random_uuid(),
  linkedid              text not null unique,
  direction             call_direction not null,
  status                call_status not null,
  started_at            timestamptz not null,
  answered_at           timestamptz,
  ended_at              timestamptz not null,
  from_number           text, from_name text,
  to_number             text,
  did_id                uuid references dids(id) on delete set null,
  answered_by_extension_id uuid references extensions(id) on delete set null,
  talk_seconds          int not null default 0,
  recording_id          uuid references recordings(id) on delete set null,
  spam_risk             text,                     -- 14 §6: 'low' | 'medium' | 'high'; null = not checked
  spam_info             jsonb                     -- 14 §6: what the check said
);
create index on calls (started_at desc);

-- Spam check (14 §7): IPQualityScore answers, and the local copy of the FTC complaints.
create table spam_lookups (            -- IPQualityScore answers (14 §4)
  number      text primary key,        -- 1NXXNXXXXXX
  provider    text not null,           -- 'ipqs'
  risk        text not null check (risk in ('low', 'medium', 'high')),
  info        jsonb not null,          -- the 14 §2.1 fields only
  expires_at  timestamptz not null,    -- updated_at is when it was checked
  created_at  timestamptz not null default now(),
  updated_at  timestamptz not null default now()
);
create index on spam_lookups (expires_at);

create table ftc_complaints (          -- the FTC copy (14 §2.2), kept windowDays; append-only
  id           text primary key,       -- the FTC's id
  number       text not null,          -- 1NXXNXXXXXX
  reported_at  timestamptz not null,   -- the complaint's created-date
  subject      text,
  robocall     boolean,
  created_at   timestamptz not null default now()
);
create index on ftc_complaints (number);
create index on ftc_complaints (reported_at);

create table call_extensions (                        -- which extensions were involved (for per-user history)
  call_id       uuid not null references calls(id) on delete cascade,
  extension_id  uuid not null references extensions(id) on delete cascade,
  role          text not null check (role in ('caller', 'rang', 'answered')),
  primary key (call_id, extension_id, role)
);

create table recordings (
  id           uuid primary key default gen_random_uuid(),
  linkedid     text not null,
  file_path    text not null unique,                  -- relative to recordings root
  started_at   timestamptz not null,
  duration_ms  int,
  size_bytes   bigint,
  transcript   jsonb,                                 -- [{side, at, text}] (10 §3)
  summary      text,
  ai_done_at   timestamptz,                           -- the transcript job is done with it
  ai_error     text                                   -- why there's no transcript/summary
);

create table voicemail_messages (                     -- index of files Asterisk wrote
  id             uuid primary key default gen_random_uuid(),
  extension_id   uuid not null references extensions(id) on delete cascade,
  msg_id         text not null unique,                -- 'msg_id' field from msgNNNN.txt
  folder         text not null,                       -- INBOX | Old | Urgent
  caller_number  text, caller_name text,
  duration_s     int not null,
  received_at    timestamptz not null,
  file_path      text not null,                       -- current path to .wav
  emailed_at     timestamptz,
  deleted_at     timestamptz,
  transcript     text                                 -- speech to text (10), when it's on
);

create table contrack_call_log (                         -- a call put on a Contrack timeline (11 §4.4)
  call_id        uuid not null references calls(id) on delete cascade,
  user_id        uuid not null references users(id) on delete cascade,
  interaction_id text,                                   -- null: no contact has the number, or refused
  error          text,
  logged_at      timestamptz not null default now(),
  primary key (call_id, user_id)
);

create table contact_shares (                            -- a contact offered to a colleague (11 §2.1)
  id            uuid primary key default gen_random_uuid(),
  from_user_id  uuid references users(id) on delete set null,
  to_user_id    uuid not null references users(id) on delete cascade,
  source_id     text not null,                           -- the contact's id in the sender's book
  contact       jsonb not null,                          -- a copy: { name, company, email, numbers, notes }
  status        text not null default 'pending' check (status in ('pending', 'accepted', 'denied')),
  decided_at    timestamptz
);
create index on contact_shares (to_user_id) where status = 'pending';

create table contacts (
  id            uuid primary key default gen_random_uuid(),
  owner_user_id uuid references users(id) on delete cascade,   -- null = company phonebook
  name          text not null,
  company       text,
  email         text,
  numbers       jsonb not null,                        -- [{ "label": "mobile", "number": "13125551234" }]
  notes         text
);

create table sms_messages (
  id               uuid primary key default gen_random_uuid(),
  did_id           uuid not null references dids(id) on delete cascade,
  direction        text not null check (direction in ('in', 'out')),
  remote_number    text not null,
  body             text not null,
  media            jsonb not null default '[]',        -- [{ "url": "...", "contentType": "image/jpeg" }]
  status           text not null,                      -- queued | sent | failed | received
  provider_id      text,
  error            text,
  sent_by_user_id  uuid references users(id) on delete set null,
  read_at          timestamptz
);
create index on sms_messages (did_id, remote_number, created_at desc);

-- ───────────── integration, audit, system ─────────────
create table webhooks (
  id          uuid primary key default gen_random_uuid(),
  url         text not null,
  secret_enc  text not null,                           -- HMAC-SHA256 signing secret
  events      text[] not null,                         -- see 04 §9
  enabled     boolean not null default true
);
create table webhook_deliveries (
  id            bigserial primary key,
  created_at    timestamptz not null default now(),
  webhook_id    uuid not null references webhooks(id) on delete cascade,
  event         text not null,
  payload       jsonb not null,
  attempt       int not null default 0,
  status        text not null,                         -- pending | ok | failed
  response_code int,
  next_attempt_at timestamptz
);

create table emergency_events (
  id               uuid primary key default gen_random_uuid(),
  extension_number text not null,
  dialed           text not null,
  caller_id_used   text not null,
  location         text,
  notified         jsonb not null default '[]'          -- [{ "channel": "email", "to": "...", "ok": true }]
);

create table audit_log (
  id           bigserial primary key,
  created_at   timestamptz not null default now(),
  actor_user_id uuid references users(id) on delete set null,
  actor_ip     inet,
  action       text not null,         -- 'create' | 'update' | 'delete' | 'login' | 'login_failed' | ...
  entity_type  text,
  entity_id    text,
  summary      text not null,
  diff         jsonb                  -- secrets replaced with "********"
);

-- Added 2026-10-20 (owner follow-ups to M11-T7): the System page's event log (05 §2), kept 90 days.
create table system_events (
  id          bigserial primary key,
  created_at  timestamptz not null default now(),
  severity    text not null check (severity in ('info', 'warning', 'error')),
  code        text not null,          -- 'trunk.down', 'telephony.restartNeeded', 'backup.failed', ...
  source      text not null,          -- 'Trunk', 'Telephony', 'Backup', ...
  message     text not null
);
create index on system_events (created_at);

create table generator_runs (
  id            bigserial primary key,
  created_at    timestamptz not null default now(),
  trigger       text not null,        -- 'api-change' | 'startup' | 'nightly' | 'manual'
  files_changed text[] not null,
  commands      text[] not null,      -- AMI commands sent
  ok            boolean not null,
  error         text,
  duration_ms   int not null
);

Table creation order in the migration must respect foreign keys: moh_classes before sound_files, trunks → dids → extensions, recordings before calls.

3. Settings keys

All values are JSON. services/settings.ts exposes get(key) / set(key, value) with a zod schema per key and a default. Missing rows return the default.

Key Type Default Notes
general.companyName string "My Company"
general.timezone string (IANA) value of env TZ
general.language string "en"
network.publicIp string | null null
network.localNets string[] ["192.168.0.0/16","10.0.0.0/8","172.16.0.0/12"]
network.lanIp string | null null server's own LAN address (ICE host candidate mapping)
network.publicIpWatch boolean false
network.stunServer string | null null e.g. stun.example.org:3478. null = no STUN
network.turn {url,username,credentialEnc} | null null Sent to browsers for WebRTC
sip.udpPort number 5060
sip.tcpEnabled boolean true
sip.tlsEnabled boolean false needs cert in /var/lib/selfphoneng/tls
sip.tlsPort number 5061
numbering.users [from,to] [100,499] ranges from 00 §7
numbering.ringGroups [from,to] [500,549]
numbering.queues [from,to] [550,599]
numbering.ivrs [from,to] [600,649]
numbering.conferences [from,to] [800,849]
numbering.paging [from,to] [850,869]
routing.unmatchedDidDestination Destination {type:'hangup'}
routing.defaultCallerIdDidId uuid | null null caller ID when no other rule sets one
smtp {host,port,secure,username,passwordEnc,from} | null null
voicemail.maxMessageSeconds number 300
voicemail.maxMessages number 100
voicemail.retentionDays number 0 0 = keep forever
recordings.retentionDays number 90
recordings.announcementSoundId uuid | null null "This call may be recorded"
emergency.defaultCallerIdDidId uuid | null null required before 911 works, see 06 §6
emergency.notifyEmails string[] []
emergency.notifyExtensionIds uuid[] [] these phones get a call/page
backup.enabled boolean true
backup.time "HH:MM" "03:30"
backup.keep number 7
backup.includeRecordings boolean false
webhooks.allowPrivate boolean false allow webhook URLs on private/loopback IPs
setup.completed boolean false first-run wizard done
auth.mode 'password'|'mixed'|'oidc' 'password' sign-in ways offered (12 §1.1)
auth.oidc object | null null the OpenID Connect provider, client secret encrypted (12 §3.1)
sms.webhookToken string random 32 chars, made on first read secret part of the voip.ms SMS callback URL
sip.restrictPhonesToLocalNets boolean false only accept phone registrations from network.localNets (+ sip.extraAllowedNets)
sip.extraAllowedNets string[] [] e.g. a VPN subnet
calls.maxPerExtension number 3 outside calls going out at once per extension (the caller, or whose forward it is); 0 = no limit (08 §1)
calls.maxTotal number 10 outside calls going out at once in total; 0 = no limit. 911 counts toward neither
provisioning.lanOnly boolean false only serve /prov to clients from local nets
provisioning.enabled boolean true
system.restartRequired boolean false set by generator
spam object off, no providers spam check (14 §7): enabled, ipqs {keyEnc}, ftc {keyEnc, syncHours, windowDays}, cache {flaggedDays, cleanDays}, deskPhoneLabels {high, medium}
spam.state object {} set by the system (14 §3, §2.2): IPQualityScore paused, the warning dismissed, the FTC sync's progress