Users report an outside call that rang them: the spam check's line when it said medium or high, else a reason they type. Admins block or dismiss each report on Blocked numbers, and the Dashboard counts them. Migration 0015 adds spam_reports.
692 lines
35 KiB
Markdown
692 lines
35 KiB
Markdown
# 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`:
|
||
|
||
```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
|
||
|
||
```sql
|
||
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 spam_reports ( -- a user's Report spam (05 §4), until an admin blocks or dismisses it
|
||
id uuid primary key default gen_random_uuid(),
|
||
number text not null, -- normalized, from the call
|
||
reason text not null, -- the spam check's line, or the user's words
|
||
reported_by uuid references users(id) on delete set null,
|
||
unique (number, reported_by)
|
||
);
|
||
|
||
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 |
|