From 32f45903691d87db7c57d29b2b45523d8f808384 Mon Sep 17 00:00:00 2001
From: iamdoubz
Date: Fri, 23 Oct 2026 14:53:21 -0500
Subject: [PATCH 1/6] spec: contacts (11), Simple or Contrack, picked at setup
Owner decisions 2026-10-23: vCard import for admins and users; desk-phone caller name while
ringing is its own task (C4); the company is one Contrack account shown to everyone, each user
has their own; sharing offers a copy the colleague accepts or denies; imports skip duplicates.
---
AGENTS.md | 1 +
docs/spec/09-roadmap.md | 3 +
docs/spec/11-contacts.md | 276 +++++++++++++++++++++++++++++++++++++++
3 files changed, 280 insertions(+)
create mode 100644 docs/spec/11-contacts.md
diff --git a/AGENTS.md b/AGENTS.md
index b8f0fab..58d0544 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -27,6 +27,7 @@ write the software around it: database, config generator, API, web UI.
| `docs/spec/07-provisioning.md` | Desk phone auto-provisioning (Yealink, Grandstream, Polycom) and mobile apps | Working on provisioning |
| `docs/spec/08-security-ops.md` | Security, backups, monitoring, legal requirements | Before any release, and when touching auth or networking |
| `docs/spec/09-roadmap.md` | Milestones, ordered tasks, acceptance criteria | Picking your next task |
+| `docs/spec/11-contacts.md` | Contacts: the builtin phonebook or Contrack, lookup, the Contrack timeline | Working on contacts or caller names |
## Hard rules
diff --git a/docs/spec/09-roadmap.md b/docs/spec/09-roadmap.md
index ef8a1d6..b905dc7 100644
--- a/docs/spec/09-roadmap.md
+++ b/docs/spec/09-roadmap.md
@@ -9,6 +9,9 @@ Legend: 📖 read · 🔨 build · ✅ done when
---
## Backlog (owner's order, 2026-10-21)
+0. **Contacts** (owner, 2026-10-23: needed next), tasks C1–C6 in [11 §6](11-contacts.md#6-order-of-work):
+ Simple (builtin phonebook) or Robust (Contrack), picked in the setup wizard. The owner uses
+ Contrack.
1. **Grandstream HT802 V2 set up automatically** (step 3 of
[Analog phone adapters](#analog-phone-adapters-owner-2026-10-21-wanted-the-owner-is-buying-an-ht802-v2)):
several lines on one device and the `grandstream-ata.ts` template with the dial plan. Starts
diff --git a/docs/spec/11-contacts.md b/docs/spec/11-contacts.md
new file mode 100644
index 0000000..2b92c07
--- /dev/null
+++ b/docs/spec/11-contacts.md
@@ -0,0 +1,276 @@
+# 11 — Contacts
+
+Owner, 2026-10-23: contacts come in two kinds, picked in the setup wizard and changeable later in
+Settings → Contacts (`contacts.provider`):
+
+- **Simple** (`builtin`): the `contacts` table (02), a phonebook inside SelfPhoneNG.
+- **Robust** (`contrack`): [Contrack](https://github.com/arvarik/contrack), a self-hosted contact
+ and relationship manager, running beside the PBX. SelfPhoneNG reads names from it and writes each
+ call to the contact's timeline. The owner's own install uses this.
+
+Both kinds feed the same features, so the rest of SelfPhoneNG asks one question, "who is
+13125551234 for this user?", and doesn't care which one answers.
+
+## 1. What contacts are used for (both kinds)
+
+| Feature | Where | Today |
+|---|---|---|
+| My contacts: list, search, click to call, add/edit | `/me/contacts` (05 §4) | not built |
+| Company phonebook, CSV and vCard (.vcf) import | `/admin/contacts` (05 §3) | not built |
+| My contacts: CSV and vCard (.vcf) import | `/me/contacts` | not built |
+| Caller name: softphone ringing, call history, voicemail list, call report email, transcripts | 05 §5, 04 §7.3, 10 §3 | extensions → caller ID name → number |
+| "Add to contacts" from a call or voicemail | `/me/calls`, `/me/voicemail` | not built |
+| Transfer search (contacts + extensions) | softphone (05 §5) | extensions only |
+| Desk phone phonebook | provisioning (07) | extensions only |
+
+Lookup order everywhere: **extensions → my contacts → company phonebook → caller ID name → number**.
+
+## 2. The provider seam (`apps/api/src/contacts/`)
+
+```ts
+type ContactHit = {
+ name: string;
+ company: string | null;
+ url: string | null; // Contrack: the contact's page
+ book: 'mine' | 'company';
+};
+
+interface ContactsProvider {
+ lookup(userId: string, number: string): Promise; // normalized E.164-ish digits
+ search(userId: string, q: string, limit: number): Promise;
+ list(userId: string, cursor?: string): Promise>;
+ create(userId: string, c: ContactInput): Promise;
+ update?(userId: string, id: string, c: ContactInput): Promise; // builtin only
+ remove?(userId: string, id: string): Promise; // builtin only
+ logCall?(userId: string, call: CallForLog): Promise; // contrack only (§4.4)
+}
+```
+
+- `builtin.ts` and `contrack.ts` implement it; `index.ts` picks one from `contacts.provider`.
+- `lookup` is **never a network call in the call path**. Contrack answers from a per-user cache
+ (§4.3); builtin answers from one indexed query.
+- Numbers are compared after `normalizeNumber` (00 §8) on both sides, so "+1 (312) 555-1234",
+ "312-555-1234" and "13125551234" match.
+- Owner, 2026-10-23: **every address book belongs to one owner**, in both modes, and the company is
+ one more owner. Simple: the builtin table, `owner_user_id` = the user, or null for the company.
+ Contrack: every Contrack account is private, so the company gets **its own Contrack account**
+ (the admin sets its token, §4.3) and each user connects **their own** account. SelfPhoneNG does
+ the sharing:
+ - the company book is shown read-only to everyone (lookup, search, desk phones);
+ - a personal book is private; its owner can share contacts from it (§2.1).
+
+### 2.1 Sharing contacts (owner, 2026-10-23)
+
+Sharing **offers a copy**: the recipient **accepts or denies** each shared contact. Accepted, it is
+imported into their own book, and from then on the two copies are separate (editing one doesn't
+change the other). Denied, nothing is added. Nothing lands in anyone's book without their click.
+Both modes.
+
+- `/me/contacts`: select one or more contacts → **Share** → pick colleagues, or **Everyone in the
+ company**. `POST /api/me/contacts/share { contactIds, userIds | everyone: true }` makes one
+ pending offer per contact per recipient. The answer says how many offers went to how many people.
+- The offer holds a **copy taken when it was shared** (name, company, email, numbers; not notes or
+ a Contrack timeline), so the sender editing or deleting the contact later doesn't change it:
+
+ ```sql
+ create table contact_shares (
+ 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,
+ contact jsonb not null, -- { name, company, email, numbers: [{ label, number }] }
+ status text not null default 'pending' check (status in ('pending', 'accepted', 'denied')),
+ decided_at timestamptz,
+ created_at timestamptz not null default now()
+ );
+ create index on contact_shares (to_user_id) where status = 'pending';
+ ```
+
+- The recipient sees **Shared with you** at the top of `/me/contacts` (a count on the Contacts
+ link and a line on Home): "Dan Doe shared Acme Corp (312) 555-1234", with **Accept** and
+ **Deny** on each, and **Accept all** / **Deny all** for one sender's batch.
+ `GET /api/me/contacts/shares`, `POST /api/me/contacts/shares/accept|deny { ids }` (one id or
+ a sender's batch).
+- Accept imports with the duplicate rule (§3.1: same name and same normalized number already in
+ the book → skipped, and the answer says so); the offer is `accepted` either way. Deny marks it
+ `denied`. Decided offers are deleted after 30 days; pending ones stay until decided.
+- The same contact offered again to someone with a pending offer of it replaces that offer, so a
+ recipient never sees two of one contact.
+- Contrack mode: the copy is read with the sender's token when sharing; Accept creates it with the
+ recipient's token. A recipient without a connected Contrack account, or with a read-only token,
+ can't accept yet: the offer says "Connect Contrack to accept" and waits.
+- Only users, not the company book: an admin adds to the company book directly.
+
+## 3. Simple (builtin)
+
+The `contacts` table already in 02. `numbers[].number` is stored normalized (as forwards are,
+04 §5.5) and shown with `formatNumber`. Lookup (`contacts/book.ts` `contactNames`) scans the two
+books' numbers with `jsonb_array_elements`; a GIN index when books reach tens of thousands.
+
+As built (C1): `GET /api/me/contacts` answers my whole book and the company's in one list (no
+paging); the page searches it in the browser, and the browser looks up names from it (call
+history, voicemail, Home, the ringing softphone). The call report email looks up on the server.
+
+API (04 §7.3, §7.4, as already listed there):
+
+| Route | Who | Does |
+|---|---|---|
+| `GET/POST/PATCH/DELETE /api/me/contacts[/:id]` | user | my contacts; `GET ?q=` searches name, company, number digits |
+| `GET/POST/PATCH/DELETE /api/contacts[/:id]` | admin | company phonebook |
+| `POST /api/contacts/import` | admin | CSV `name,company,email,label1,number1,...` (04 §7.4); preview, then commit |
+
+UI: `/me/contacts` (mine + company read-only, search, click to call, add/edit drawer, import,
+share), `/admin/contacts` (table, drawer, import), "Add to contacts" on calls and voicemail.
+
+### 3.1 Import: CSV and vCard (owner, 2026-10-23)
+
+Admins import into the company book, users into their own; same code, same screen.
+
+- `POST /api/contacts/import` (admin) and `POST /api/me/contacts/import` (user): `{ files: [{ name,
+ text }] }`, up to 20 files read as text in the browser (no multipart), `.csv` (04 §7.4 columns)
+ or `.vcf` (any number of cards per file). 6 MB and 5,000 contacts per import. `?commit=0`
+ answers a preview, `?commit=1` saves.
+- vCard 2.1, 3.0 and 4.0, read by our own parser (`contacts/vcard.ts`, no new dependency): line
+ unfolding, `QUOTED-PRINTABLE` and `CHARSET` (2.1), escaped `\,` `\;` `\n`, `item1.` groups.
+ Fields: `FN` (or `N` when `FN` is empty), `ORG`, first `EMAIL`, every `TEL` with its `TYPE` as
+ the label (cell → mobile, work, home, fax, other), `NOTE`. Everything else (photos, addresses,
+ birthdays) is ignored.
+- Preview: how many are new, how many are already in that book (same name and same normalized
+ number: skipped), how many have no phone number (imported anyway: they still have a name and
+ email), and the first rows that failed and why. Nothing is saved until **Import**.
+- Contrack mode: SelfPhoneNG's import buttons open Contrack's own import (it reads Apple, Google,
+ LinkedIn and vCard exports), for the company account too.
+
+## 4. Robust (Contrack)
+
+### 4.1 What Contrack is (checked 2026-10-23, v2.0.2 of 2026-10-18)
+
+- TypeScript (Node ≥ 26.10), React, SQLite with sqlite-vec, **AGPL-3.0-only**. One Docker image,
+ `ghcr.io/arvarik/contrack`, port 3210, data in `/app/data`.
+- REST API under `/api` (OpenAPI 3.1 in its `docs/openapi.json`), plus MCP.
+- Multi-user: every account has its own private address book. Credentials: the session cookie,
+ or a **personal token** `Authorization: Bearer ctk_…` (read-only or read-write, made in
+ Settings → Account). Tokens need sign-in on (`AUTH_REQUIRED=true`).
+- Contacts carry `phones: [{ phone, label, isPrimary }]` (free text such as "+49 30 1234567").
+- **Timeline**: `POST /api/contacts/:id/interactions` `{ type: 'call', title, content (HTML), date,
+ duration (minutes), actionItem? }`; `PATCH /api/interactions/:id` changes `title`/`content`.
+ A future `date` is refused.
+- Its own AI (Gemini/OpenAI/Anthropic, or an OpenAI-compatible server such as our Ollama), web
+ research (SearXNG), connectors (CardDAV, IMAP, calendar, Google), backups, CSV/vCard-style
+ imports.
+
+Endpoints SelfPhoneNG uses, and nothing else: `GET /healthz`, `GET /api/contacts?view=slim`,
+`GET /api/contacts/:id`, `POST /api/contacts`, `GET /api/search?q=`,
+`POST /api/contacts/:id/interactions`, `PATCH /api/interactions/:id`. A contract test checks them
+against the pinned version's `openapi.json`.
+
+### 4.2 Running it
+
+- `deploy/contrack/compose.yml`, its own stack like `deploy/ai/compose.yml`: the image **pinned to
+ a version tag** (not `latest`, decision 0001), `127.0.0.1:3210:3210`, a `contrack-data`
+ volume, and `AUTH_REQUIRED=true`, `TRUST_PROXY_HOPS=1`, `PUBLIC_URL=https://${CONTRACK_HOSTNAME}`.
+- People open Contrack on **its own host name** (`PUBLIC_URL` takes no path), e.g.
+ `contacts.example.com`. The Caddyfile gets a second site block when `CONTRACK_HOSTNAME` is set;
+ the nginx example gets a second server block.
+- The PBX reaches it at `http://127.0.0.1:3210`. `contacts.contrackUrl` must pass the same
+ local-address check as the AI endpoints (10 §1), so contact data and tokens stay on the network.
+- Backups: Contrack keeps its own snapshots in its volume. The SelfPhoneNG backup (pg_dump) does
+ not include it; the admin guide says to back up the volume too.
+- `docs/decisions/0003-contrack.md`: why an outside service (it already does relationships,
+ timelines, imports, CardDAV sync, dedupe and search far beyond a phonebook), why it's allowed
+ (AGPL, open source, a separate program over HTTP, so no license mixing and no code in our
+ dependency tree), and the risk (one maintainer, young project: pinned version + contract test).
+
+### 4.3 Connecting users
+
+- Admin, Settings → Contacts: provider, Contrack URL, **Test** (`/healthz`, then the token below
+ against `/api/contacts?view=slim&limit=1`).
+- Admin, same page: the **company Contrack account's token** (read-only is enough; read-write lets
+ "Add to company contacts" work), in `contacts.companyToken` {enc}. The admin makes that account
+ in Contrack (e.g. `company@…`) and fills it like any other.
+- Each user, My settings → Contacts: **paste a Contrack personal token** (read-write; read-only
+ allowed, then nothing is written to the timeline). Stored with `encrypt()` in
+ `extensions.contrack_token_enc`. Never logged, never returned (only "connected as …, token
+ ctk_AbCd…").
+ - Why not have the PBX create Contrack accounts: Contrack's admin routes refuse tokens
+ (`SESSION_REQUIRED`), so the PBX would have to keep an admin password. A pasted token is
+ explicit, revocable from Contrack, and scoped to one person.
+- **Lookup cache**: per connected user and for the company account, `GET /api/contacts?view=slim`, every phone through
+ `normalizeNumber`, held in memory as `Map`. Refreshed every 5 minutes, at
+ start, and right after SelfPhoneNG creates a contact. A user whose token fails (401) is marked
+ "disconnected" in My settings and skipped; no error email.
+- `/me/contacts` in this mode: search and list from Contrack (read-only rows, "Open in Contrack"
+ link to `${PUBLIC_URL}/contacts/:id`), plus the company book. "Add to contacts" does
+ `POST /api/contacts { name, phones: [number] }`. Editing happens in Contrack.
+
+### 4.4 Calls on the Contrack timeline
+
+New per-user options (My settings → Contacts, shown when connected):
+
+- **Log my calls in Contrack** (default on): each of my calls with a number that matches one of my
+ Contrack contacts becomes a `call` interaction on that contact:
+ - title: "Incoming call (answered)", "Missed call", "Outgoing call", "Voicemail left" …
+ - date: `calls.started_at`; duration: talk minutes, rounded up; content: number, who answered,
+ "recorded" (escaped HTML).
+- **Add the call summary** (default off, needs `ai.callSummaries`): the summary and action items
+ (10 §3) go in the content; the first action item also becomes a Contrack follow-up
+ (`actionItem`). Warning next to it: if Contrack's own AI uses a cloud provider, what was said on
+ the call reaches it. The admin guide recommends `AI_DISABLED=true` or our Ollama for Contrack.
+- Job `contrackLog` (every 60 s, 04 §8): calls ended more than 1 min ago and not logged yet. A
+ recorded call waits for its summary (`recordings.ai_done_at`) up to 30 minutes when the summary
+ option is on. One row per logged call:
+
+ ```sql
+ create table contrack_call_log (
+ 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 matching contact, or not logged
+ logged_at timestamptz not null default now(),
+ primary key (call_id, user_id)
+ );
+ ```
+
+ Contrack unreachable: try again next run, give up after 2 days (like call transcripts).
+- Unknown numbers are not added to Contrack automatically; "Add to contacts" does it, and calls
+ from then on are logged.
+
+### 4.5 Not in Contrack mode
+
+- Personal contacts are not on desk-phone phonebooks at first (company book only). Phase C6.
+- Editing a Contrack contact from SelfPhoneNG.
+- A contact in several books is shown once per book; lookup takes the first in §1's order.
+
+## 5. Setup wizard and switching
+
+- New step **Contacts**, after Users (05 §2.1): two cards.
+ - **Simple**: "A phonebook inside SelfPhoneNG: names, numbers, CSV import. Nothing else to run."
+ - **Contrack**: "A full contact and relationship manager next to the phone system: notes,
+ reminders, relationship tracking, imports from Google/Apple/LinkedIn, CardDAV sync. Every call
+ lands on the contact's timeline. Runs as its own container." Then the URL field, Test, and the
+ two commands to start `deploy/contrack/compose.yml`. Skippable: "Set it up later" keeps Simple.
+- Settings → Contacts can switch later. Builtin personal contacts are kept, not deleted; switching
+ to Contrack offers **Copy my contacts to Contrack** per user (`POST /api/contacts/bulk`,
+ ≤ 5,000), switching back just shows the builtin table again.
+
+## 6. Order of work
+
+| Task | What | Done when |
+|---|---|---|
+| C1 | Seam + Simple: migration, builtin provider, routes, `/me/contacts`, `/admin/contacts`, CSV and vCard import (§3.1), sharing (§2.1), "Add to contacts", lookup everywhere in §1 except desk phones | a contact's name shows in call history, voicemail, call report and the ringing softphone; an iPhone and a Google .vcf export import |
+| C2 | Company book on desk phones (07) | a Yealink shows company contacts in its directory. Already built in M10-T2 (`loadPhonebook`, the builtin company book); C3 adds the Contrack company account to it |
+| C3 | Contrack: decision 0003, `deploy/contrack`, Caddy/nginx blocks, Settings → Contacts, company token, per-user tokens, contrack provider + cache, sharing, setup wizard step | with the owner's Contrack, a call from a Contrack contact shows their name |
+| C4 | Caller name on desk phones while ringing (owner, 2026-10-23: its own task after C3): the dialplan asks the api (AGI or `func_curl`) and sets `CALLERID(name)` | a desk phone rings with the contact's name, from either kind of book |
+| C5 | Calls on the Contrack timeline (§4.4), summaries opt-in | an answered, recorded call shows up on the contact's Contrack timeline with its summary |
+| C6 | Copy builtin contacts to Contrack; personal and shared contacts in desk phone directories | optional |
+
+Tests: builtin lookup/search/import against the test DB; contrack provider against a fake HTTP
+server built from the pinned `openapi.json`; one e2e script against a real Contrack container.
+
+## 7. Decided
+
+- Owner, 2026-10-23: caller name on desk phones while ringing is its own task, C4, after C3.
+- Owner, 2026-10-23: in Contrack mode the company book is a company Contrack account shared with
+ everyone; each user has their own private Contrack account.
+- Owner, 2026-10-23: sharing offers a copy to picked colleagues or everyone, in both modes; each
+ recipient accepts (it goes into their own book) or denies (nothing is added). An import or an
+ accepted share skips contacts already in the book (§2.1, §3.1).
+- Owner, 2026-10-23: admins and users can import .vcf files (§3.1).
--
2.34.1
From a754974a90cf70b59b0b8aca92e12d62a650d908 Mon Sep 17 00:00:00 2001
From: iamdoubz
Date: Fri, 23 Oct 2026 14:53:21 -0500
Subject: [PATCH 2/6] contacts: my book, the company book, vCard/CSV import,
sharing with Accept/Deny
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
C1 (spec 11): /api/me/contacts and /api/contacts (admin), our own vCard 2.1/3.0/4.0 and CSV
readers with a preview, contact_shares (migration 0009) for offers, and contact names in call
history, voicemail, Home, the softphone and the call report email. Admin → Contacts.
---
apps/api/src/app.ts | 2 +
apps/api/src/contacts/book.ts | 123 +
apps/api/src/contacts/csv.ts | 68 +
apps/api/src/contacts/vcard.test.ts | 157 +
apps/api/src/contacts/vcard.ts | 149 +
.../src/db/migrations/0009_contact_shares.sql | 16 +
.../src/db/migrations/meta/0009_snapshot.json | 4248 +++++++++++++++++
apps/api/src/db/migrations/meta/_journal.json | 7 +
apps/api/src/db/schema.ts | 25 +
apps/api/src/jobs.ts | 2 +
apps/api/src/routes/contacts.ts | 274 ++
apps/api/src/services/callreport.ts | 14 +-
apps/api/test/contacts.test.ts | 204 +
apps/api/test/schema.test.ts | 4 +-
apps/web/src/api/client.ts | 28 +
apps/web/src/main.tsx | 3 +
apps/web/src/pages/admin/AdminLayout.tsx | 2 +-
apps/web/src/pages/user/ContactsPage.tsx | 620 +++
apps/web/src/pages/user/MePage.tsx | 39 +-
apps/web/src/pages/user/MyCallsPage.tsx | 10 +-
apps/web/src/pages/user/VoicemailPage.tsx | 24 +-
apps/web/src/softphone/SoftphoneDock.tsx | 6 +-
docs/spec/02-data-model.md | 11 +
docs/spec/04-backend-api.md | 4 +-
packages/shared/src/contacts.ts | 85 +
packages/shared/src/index.ts | 1 +
26 files changed, 6103 insertions(+), 23 deletions(-)
create mode 100644 apps/api/src/contacts/book.ts
create mode 100644 apps/api/src/contacts/csv.ts
create mode 100644 apps/api/src/contacts/vcard.test.ts
create mode 100644 apps/api/src/contacts/vcard.ts
create mode 100644 apps/api/src/db/migrations/0009_contact_shares.sql
create mode 100644 apps/api/src/db/migrations/meta/0009_snapshot.json
create mode 100644 apps/api/src/routes/contacts.ts
create mode 100644 apps/api/test/contacts.test.ts
create mode 100644 apps/web/src/pages/user/ContactsPage.tsx
create mode 100644 packages/shared/src/contacts.ts
diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts
index 42e8262..ee254f6 100644
--- a/apps/api/src/app.ts
+++ b/apps/api/src/app.ts
@@ -39,6 +39,7 @@ import { liveCallRoutes } from './routes/livecalls.ts';
import { integrationRoutes } from './routes/integrations.ts';
import { reportRoutes } from './routes/reports.ts';
import { aboutRoutes } from './routes/about.ts';
+import { contactRoutes } from './routes/contacts.ts';
declare module 'fastify' {
interface FastifyInstance {
@@ -146,6 +147,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
app.register(settingsRoutes);
app.register(liveRoutes);
app.register(meRoutes);
+ app.register(contactRoutes);
app.register(soundRoutes);
app.register(ringGroupRoutes);
app.register(queueRoutes);
diff --git a/apps/api/src/contacts/book.ts b/apps/api/src/contacts/book.ts
new file mode 100644
index 0000000..ddb156e
--- /dev/null
+++ b/apps/api/src/contacts/book.ts
@@ -0,0 +1,123 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// The builtin address books (11 §3): a user's (owner_user_id) and the company's (null).
+// ponytail: builtin only. Contrack (C3) adds its provider here, behind the same functions.
+import { asc, eq, isNull, or, sql } from 'drizzle-orm';
+import {
+ normalizeNumber,
+ type ContactData,
+ type ContactDto,
+ type ContactImportResult,
+} from '@selfphoneng/shared';
+import type { Db } from '../db/client.ts';
+import { contacts } from '../db/schema.ts';
+import { badRequest } from '../errors.ts';
+
+/** null = the company book. */
+export type Owner = string | null;
+const inBook = (owner: Owner) =>
+ owner ? eq(contacts.ownerUserId, owner) : isNull(contacts.ownerUserId);
+
+type Row = typeof contacts.$inferSelect;
+export const toDto = (r: Row): ContactDto => ({
+ id: r.id,
+ book: r.ownerUserId ? 'mine' : 'company',
+ name: r.name,
+ company: r.company,
+ email: r.email,
+ numbers: r.numbers as ContactData['numbers'],
+ notes: r.notes,
+});
+
+/** Normalized numbers (00 §8); `bad` gets the ones that aren't phone numbers. */
+export function cleanNumbers(c: ContactData, bad?: (n: string) => void): ContactData['numbers'] {
+ const out: ContactData['numbers'] = [];
+ for (const n of c.numbers)
+ try {
+ const number = normalizeNumber(n.number);
+ if (!out.some((o) => o.number === number)) out.push({ label: n.label, number });
+ } catch {
+ if (!bad) throw badRequest(`"${n.number}" isn't a phone number`);
+ bad(n.number);
+ }
+ return out;
+}
+
+/** My contacts, then the company's, by name. */
+export async function listBooks(db: Db, userId: string): Promise {
+ const rows = await db
+ .select()
+ .from(contacts)
+ .where(or(eq(contacts.ownerUserId, userId), isNull(contacts.ownerUserId)))
+ .orderBy(sql`${contacts.ownerUserId} is null`, asc(contacts.name));
+ return rows.map(toDto);
+}
+
+export async function listBook(db: Db, owner: Owner): Promise {
+ return (await db.select().from(contacts).where(inBook(owner)).orderBy(asc(contacts.name))).map(
+ toDto,
+ );
+}
+
+/** Same contact: same name (any case) and a number in common, or neither has a number. */
+const sameKey = (c: ContactData) => c.name.trim().toLowerCase();
+const isDuplicate = (a: ContactData, b: ContactData) =>
+ sameKey(a) === sameKey(b) &&
+ ((!a.numbers.length && !b.numbers.length) ||
+ a.numbers.some((n) => b.numbers.some((m) => m.number === n.number)));
+
+/**
+ * Adds contacts to a book, skipping ones it already has (11 §3.1). With `commit` false it only
+ * counts. Bad numbers are dropped and reported.
+ */
+export async function addToBook(
+ db: Pick, // a transaction too
+ owner: Owner,
+ list: ContactData[],
+ commit: boolean,
+ errors: string[] = [],
+): Promise {
+ const have = (await db.select().from(contacts).where(inBook(owner))).map(toDto);
+ const fresh: ContactData[] = [];
+ let duplicates = 0;
+ for (const c of list) {
+ const numbers = cleanNumbers(c, (n) =>
+ errors.push(`${c.name}: "${n}" isn't a phone number, left out`),
+ );
+ const clean = { ...c, numbers };
+ if ([...have, ...fresh].some((h) => isDuplicate(h, clean))) duplicates++;
+ else fresh.push(clean);
+ }
+ // ponytail: one insert of up to 5,000 rows (the import cap).
+ if (commit && fresh.length)
+ await db.insert(contacts).values(fresh.map((c) => ({ ...c, ownerUserId: owner })));
+ return {
+ committed: commit,
+ added: fresh.length,
+ duplicates,
+ noNumber: fresh.filter((c) => !c.numbers.length).length,
+ errors: errors.slice(0, 20),
+ };
+}
+
+/**
+ * Names for numbers, from my book first, then the company's (11 §1). Numbers as calls store them
+ * (normalized).
+ */
+export async function contactNames(
+ db: Db,
+ userId: string | null,
+ numbers: (string | null)[],
+): Promise> {
+ const want = [...new Set(numbers.filter((n): n is string => !!n))];
+ const out = new Map();
+ if (!want.length) return out;
+ // ponytail: a scan of the two books' numbers; a GIN index when books reach tens of thousands.
+ const { rows } = await db.execute<{ name: string; number: string; mine: boolean }>(sql`
+ select c.name, n->>'number' as number, c.owner_user_id is not null as mine
+ from ${contacts} c, jsonb_array_elements(c.numbers) n
+ where ${userId ? sql`(c.owner_user_id = ${userId} or c.owner_user_id is null)` : sql`c.owner_user_id is null`}
+ and n->>'number' in ${want}`);
+ for (const r of rows.sort((a, b) => Number(b.mine) - Number(a.mine)))
+ if (!out.has(r.number)) out.set(r.number, r.name);
+ return out;
+}
diff --git a/apps/api/src/contacts/csv.ts b/apps/api/src/contacts/csv.ts
new file mode 100644
index 0000000..48c0d02
--- /dev/null
+++ b/apps/api/src/contacts/csv.ts
@@ -0,0 +1,68 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// CSV to contacts (04 §7.4): name,company,email,label1,number1,label2,number2,… with a header row
+// (any order, any case; notes too).
+import type { ContactData } from '@selfphoneng/shared';
+
+/** RFC 4180 rows (blank ones too): quoted cells may hold commas, quotes ("") and line breaks. */
+export function csvRows(input: string): string[][] {
+ const rows: string[][] = [];
+ let row: string[] = [];
+ let cell = '';
+ let quoted = false;
+ const s = input.replace(/^\uFEFF/, '');
+ for (let i = 0; i < s.length; i++) {
+ const c = s[i]!;
+ if (quoted) {
+ if (c === '"' && s[i + 1] === '"') {
+ cell += '"';
+ i++;
+ } else if (c === '"') quoted = false;
+ else cell += c;
+ } else if (c === '"') quoted = true;
+ else if (c === ',') {
+ row.push(cell);
+ cell = '';
+ } else if (c === '\n' || c === '\r') {
+ if (c === '\r' && s[i + 1] === '\n') i++;
+ row.push(cell);
+ rows.push(row);
+ row = [];
+ cell = '';
+ } else cell += c;
+ }
+ if (cell || row.length) rows.push([...row, cell]);
+ return rows;
+}
+
+export function parseCsv(input: string): { contacts: ContactData[]; errors: string[] } {
+ const [header, ...rows] = csvRows(input);
+ if (!header) return { contacts: [], errors: [] };
+ const col = new Map(header.map((h, i) => [h.trim().toLowerCase(), i]));
+ if (!col.has('name'))
+ return { contacts: [], errors: ['The first row must name the columns, with a "name" column'] };
+ const contacts: ContactData[] = [];
+ const errors: string[] = [];
+ rows.forEach((r, i) => {
+ if (!r.some((c) => c.trim())) return;
+ const get = (k: string) => {
+ const at = col.get(k);
+ return at === undefined ? '' : (r[at] ?? '').trim();
+ };
+ const numbers: ContactData['numbers'] = [];
+ for (let n = 1; col.has(`number${n}`); n++)
+ if (get(`number${n}`)) numbers.push({ label: get(`label${n}`), number: get(`number${n}`) });
+ const name = get('name');
+ if (!name) {
+ errors.push(`Row ${i + 2}: no name, skipped`);
+ return;
+ }
+ contacts.push({
+ name,
+ company: get('company') || null,
+ email: get('email') || null,
+ numbers,
+ notes: get('notes') || null,
+ });
+ });
+ return { contacts, errors };
+}
diff --git a/apps/api/src/contacts/vcard.test.ts b/apps/api/src/contacts/vcard.test.ts
new file mode 100644
index 0000000..0902c18
--- /dev/null
+++ b/apps/api/src/contacts/vcard.test.ts
@@ -0,0 +1,157 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// C1: vCard and CSV imports read what phones and address books export (11 §3.1).
+import { describe, expect, it } from 'vitest';
+import { parseCsv } from './csv.ts';
+import { parseVcards } from './vcard.ts';
+
+const IPHONE = [
+ 'BEGIN:VCARD',
+ 'VERSION:3.0',
+ 'PRODID:-//Apple Inc.//iPhone OS 17.0//EN',
+ 'N:Doe;Jane;;;',
+ 'FN:Jane Doe',
+ 'ORG:Acme Corp;',
+ 'EMAIL;type=INTERNET;type=HOME;type=pref:jane@example.com',
+ 'TEL;type=CELL;type=VOICE;type=pref:(312) 555-1234',
+ 'item1.TEL:+1 312 555 0000',
+ 'item1.X-ABLabel:_$!!$_',
+ 'NOTE:Met at the fair\\, 2026\\nCall after 5',
+ 'PHOTO;ENCODING=b;TYPE=JPEG:/9j/4AAQSkZJRgABAQAAAQABAAD',
+ ' /2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJ',
+ 'END:VCARD',
+].join('\r\n');
+
+const GOOGLE = `BEGIN:VCARD
+VERSION:3.0
+FN:Bob Smith
+N:Smith;Bob;;;
+TEL;TYPE=WORK:+13125559999
+TEL;TYPE=HOME:3125558888
+EMAIL;TYPE=INTERNET:bob@example.com
+ORG:Widgets Inc.
+item1.URL:http\\://example.com
+END:VCARD
+BEGIN:VCARD
+VERSION:3.0
+N:;;;;
+TEL;TYPE=CELL:3125550101
+END:VCARD
+`;
+
+const V21 = `BEGIN:VCARD
+VERSION:2.1
+N;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:M=C3=BCller;J=C3=BCrgen;;Dr.;
+TEL;CELL:+49301234567
+TEL;WORK;FAX:+49301234500
+NOTE;ENCODING=QUOTED-PRINTABLE:Line one=0D=0ALine tw=
+o
+END:VCARD`;
+
+const V40 = `BEGIN:VCARD
+VERSION:4.0
+FN:Carol Lee
+TEL;VALUE=uri;TYPE="voice,cell":tel:+1-312-555-7777
+END:VCARD
+BEGIN:VCARD
+VERSION:4.0
+NOTE:nothing to call
+END:VCARD`;
+
+describe('parseVcards', () => {
+ it('reads an iPhone export: names, company, labels, note; skips the photo', () => {
+ expect(parseVcards(IPHONE)).toEqual({
+ contacts: [
+ {
+ name: 'Jane Doe',
+ company: 'Acme Corp',
+ email: 'jane@example.com',
+ numbers: [
+ { label: 'mobile', number: '(312) 555-1234' },
+ { label: 'main', number: '+1 312 555 0000' },
+ ],
+ notes: 'Met at the fair, 2026\nCall after 5',
+ },
+ ],
+ errors: [],
+ });
+ });
+
+ it('reads a Google export, and names a card with only a number after it', () => {
+ const { contacts } = parseVcards(GOOGLE);
+ expect(contacts.map((c) => [c.name, c.company, c.numbers])).toEqual([
+ [
+ 'Bob Smith',
+ 'Widgets Inc.',
+ [
+ { label: 'work', number: '+13125559999' },
+ { label: 'home', number: '3125558888' },
+ ],
+ ],
+ ['3125550101', null, [{ label: 'mobile', number: '3125550101' }]],
+ ]);
+ });
+
+ it('reads vCard 2.1: quoted-printable UTF-8, bare types, soft line breaks', () => {
+ const [c] = parseVcards(V21).contacts;
+ expect(c).toMatchObject({
+ name: 'Dr. Jürgen Müller',
+ numbers: [
+ { label: 'mobile', number: '+49301234567' },
+ { label: 'fax', number: '+49301234500' },
+ ],
+ notes: 'Line one\r\nLine two',
+ });
+ });
+
+ it('reads vCard 4.0 tel: URIs, and reports a card with nothing to call', () => {
+ expect(parseVcards(V40)).toEqual({
+ contacts: [
+ {
+ name: 'Carol Lee',
+ company: null,
+ email: null,
+ numbers: [{ label: 'mobile', number: '+1-312-555-7777' }],
+ notes: null,
+ },
+ ],
+ errors: ['Card 2: no name or number, skipped'],
+ });
+ });
+});
+
+describe('parseCsv', () => {
+ it('reads the 04 §7.4 columns in any order, with quotes and line breaks', () => {
+ const csv =
+ 'Number1,Name,label1,company,notes,number2,label2\r\n' +
+ '312-555-1234,"Doe, Jane",mobile,Acme,"two\nlines",,\r\n' +
+ ',,,,,,\r\n' +
+ '3125550000,,work,,,,\r\n' +
+ '+44 20 7946 0958,"Bob ""B"" Smith",,,,101,desk\r\n';
+ expect(parseCsv(csv)).toEqual({
+ contacts: [
+ {
+ name: 'Doe, Jane',
+ company: 'Acme',
+ email: null,
+ numbers: [{ label: 'mobile', number: '312-555-1234' }],
+ notes: 'two\nlines',
+ },
+ {
+ name: 'Bob "B" Smith',
+ company: null,
+ email: null,
+ numbers: [
+ { label: '', number: '+44 20 7946 0958' },
+ { label: 'desk', number: '101' },
+ ],
+ notes: null,
+ },
+ ],
+ errors: ['Row 4: no name, skipped'],
+ });
+ });
+
+ it('needs a header with a name column', () => {
+ expect(parseCsv('Jane,3125551234\n').errors[0]).toMatch(/"name" column/);
+ });
+});
diff --git a/apps/api/src/contacts/vcard.ts b/apps/api/src/contacts/vcard.ts
new file mode 100644
index 0000000..46fded3
--- /dev/null
+++ b/apps/api/src/contacts/vcard.ts
@@ -0,0 +1,149 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// vCard 2.1, 3.0 and 4.0 to contacts (11 §3.1): the name, company, first email, every phone and
+// the note. Everything else (photos, addresses, birthdays) is ignored.
+import type { ContactData } from '@selfphoneng/shared';
+
+type Prop = { group: string; name: string; params: Map; value: string };
+
+/** "ENCODING=QUOTED-PRINTABLE;CHARSET=utf-8;TYPE=CELL,VOICE;WORK" (2.1 allows bare types). */
+function parseParams(parts: string[]): Map {
+ const out = new Map();
+ for (const p of parts) {
+ const eq = p.indexOf('=');
+ const key = eq < 0 ? 'TYPE' : p.slice(0, eq).toUpperCase();
+ const raw = eq < 0 ? p : p.slice(eq + 1);
+ const vals = raw.replace(/"/g, '').split(',').filter(Boolean);
+ // 2.1's bare QUOTED-PRINTABLE is an encoding, not a type.
+ const k = eq < 0 && /^(QUOTED-PRINTABLE|BASE64|8BIT)$/i.test(raw) ? 'ENCODING' : key;
+ out.set(k, [...(out.get(k) ?? []), ...vals.map((v) => v.toUpperCase())]);
+ }
+ return out;
+}
+
+/** "[group.]NAME[;params]:value", with ':' allowed inside quoted params. */
+function parseLine(line: string): Prop | null {
+ let quoted = false;
+ let colon = -1;
+ for (let i = 0; i < line.length; i++) {
+ if (line[i] === '"') quoted = !quoted;
+ else if (line[i] === ':' && !quoted) {
+ colon = i;
+ break;
+ }
+ }
+ if (colon < 0) return null;
+ const [head = '', ...params] = line.slice(0, colon).split(';');
+ const dot = head.lastIndexOf('.');
+ return {
+ group: dot < 0 ? '' : head.slice(0, dot).toUpperCase(),
+ name: head.slice(dot + 1).toUpperCase(),
+ params: parseParams(params),
+ value: line.slice(colon + 1),
+ };
+}
+
+function decodeQp(v: string, charset: string): string {
+ const bytes: number[] = [];
+ for (let i = 0; i < v.length; i++) {
+ const hex = v.slice(i + 1, i + 3);
+ if (v[i] === '=' && /^[0-9A-F]{2}$/i.test(hex)) {
+ bytes.push(parseInt(hex, 16));
+ i += 2;
+ } else bytes.push(v.charCodeAt(i) & 0xff);
+ }
+ try {
+ return new TextDecoder(charset).decode(new Uint8Array(bytes));
+ } catch {
+ return new TextDecoder('utf-8').decode(new Uint8Array(bytes));
+ }
+}
+
+/** Splits on unescaped `sep`, then unescapes \n \, \; \\. */
+function components(v: string, sep: ';' | ','): string[] {
+ const out = [''];
+ for (let i = 0; i < v.length; i++) {
+ const c = v[i]!;
+ if (c === '\\' && i + 1 < v.length) {
+ const n = v[++i]!;
+ out[out.length - 1] += n === 'n' || n === 'N' ? '\n' : n;
+ } else if (c === sep) out.push('');
+ else out[out.length - 1] += c;
+ }
+ return out.map((s) => s.trim());
+}
+const text = (v: string) => components(v, ';').join(';').trim();
+
+/** cell → mobile, work, home, fax, else other; an Apple X-ABLabel wins. */
+function telLabel(types: string[], abLabel: string | undefined): string {
+ if (abLabel) {
+ const l = abLabel.replace(/^_\$!<|>!\$_$/g, '').trim();
+ if (l) return l.toLowerCase() === 'cell' ? 'mobile' : l.toLowerCase();
+ }
+ if (types.includes('FAX')) return 'fax';
+ if (types.some((t) => t === 'CELL' || t === 'MOBILE' || t === 'IPHONE')) return 'mobile';
+ if (types.includes('WORK')) return 'work';
+ if (types.includes('HOME')) return 'home';
+ if (types.includes('MAIN')) return 'main';
+ return 'other';
+}
+
+/** Every card in the text, in order. A card with no name, company or number is an error. */
+export function parseVcards(input: string): { contacts: ContactData[]; errors: string[] } {
+ // Unfold: a line starting with a space or tab continues the one before (and 2.1
+ // quoted-printable continues a line that ends with "=").
+ const lines: string[] = [];
+ for (const l of input.replace(/^\uFEFF/, '').split(/\r\n|\r|\n/)) {
+ const prev = lines.at(-1);
+ if (prev?.endsWith('=') && /QUOTED-PRINTABLE/i.test(prev.split(':')[0]!))
+ lines[lines.length - 1] = prev.slice(0, -1) + l.replace(/^[ \t]/, '');
+ else if (prev !== undefined && /^[ \t]/.test(l)) lines[lines.length - 1] = prev + l.slice(1);
+ else lines.push(l);
+ }
+
+ const contacts: ContactData[] = [];
+ const errors: string[] = [];
+ let card: Prop[] | null = null;
+ for (const line of lines) {
+ const p = parseLine(line);
+ if (!p) continue;
+ if (p.name === 'BEGIN' && p.value.toUpperCase() === 'VCARD') card = [];
+ else if (p.name === 'END' && p.value.toUpperCase() === 'VCARD' && card) {
+ const c = toContact(card);
+ if (c) contacts.push(c);
+ else errors.push(`Card ${contacts.length + errors.length + 1}: no name or number, skipped`);
+ card = null;
+ } else if (card) {
+ const enc = p.params.get('ENCODING') ?? [];
+ if (enc.includes('QUOTED-PRINTABLE'))
+ p.value = decodeQp(p.value, p.params.get('CHARSET')?.[0]?.toLowerCase() ?? 'utf-8');
+ card.push(p);
+ }
+ }
+ return { contacts, errors };
+}
+
+function toContact(props: Prop[]): ContactData | null {
+ const first = (n: string) => props.find((p) => p.name === n)?.value;
+ const fn = first('FN') ? text(first('FN')!) : '';
+ const n = first('N');
+ const [family = '', given = '', middle = '', prefix = '', suffix = ''] = n
+ ? components(n, ';')
+ : [];
+ const fromN = [prefix, given, middle, family, suffix].filter(Boolean).join(' ');
+ const org = first('ORG') ? components(first('ORG')!, ';')[0] || null : null;
+ const email = first('EMAIL') ? text(first('EMAIL')!) || null : null;
+ const abLabels = new Map(
+ props.filter((p) => p.name === 'X-ABLABEL' && p.group).map((p) => [p.group, text(p.value)]),
+ );
+ const numbers = props
+ .filter((p) => p.name === 'TEL')
+ .map((p) => ({
+ label: telLabel(p.params.get('TYPE') ?? [], p.group ? abLabels.get(p.group) : undefined),
+ number: text(p.value).replace(/^tel:/i, '').split(';')[0]!.trim(),
+ }))
+ .filter((t) => t.number);
+ const note = first('NOTE') ? text(first('NOTE')!) || null : null;
+ const name = fn || fromN || org || numbers[0]?.number;
+ if (!name) return null;
+ return { name, company: org, email, numbers, notes: note };
+}
diff --git a/apps/api/src/db/migrations/0009_contact_shares.sql b/apps/api/src/db/migrations/0009_contact_shares.sql
new file mode 100644
index 0000000..017129d
--- /dev/null
+++ b/apps/api/src/db/migrations/0009_contact_shares.sql
@@ -0,0 +1,16 @@
+CREATE TABLE "contact_shares" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "from_user_id" uuid,
+ "to_user_id" uuid NOT NULL,
+ "source_id" text NOT NULL,
+ "contact" jsonb NOT NULL,
+ "status" text DEFAULT 'pending' NOT NULL,
+ "decided_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 "contact_shares_status_check" CHECK ("contact_shares"."status" in ('pending', 'accepted', 'denied'))
+);
+--> statement-breakpoint
+ALTER TABLE "contact_shares" ADD CONSTRAINT "contact_shares_from_user_id_users_id_fk" FOREIGN KEY ("from_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "contact_shares" ADD CONSTRAINT "contact_shares_to_user_id_users_id_fk" FOREIGN KEY ("to_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+CREATE INDEX "contact_shares_to_user_id_index" ON "contact_shares" USING btree ("to_user_id") WHERE "contact_shares"."status" = 'pending';
\ No newline at end of file
diff --git a/apps/api/src/db/migrations/meta/0009_snapshot.json b/apps/api/src/db/migrations/meta/0009_snapshot.json
new file mode 100644
index 0000000..a3b21a3
--- /dev/null
+++ b/apps/api/src/db/migrations/meta/0009_snapshot.json
@@ -0,0 +1,4248 @@
+{
+ "id": "aae66860-cb88-440a-b88a-c5575ebcd24a",
+ "prevId": "75adba1b-ae0f-488f-8a5e-a573226446f4",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.announcements": {
+ "name": "announcements",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sound_id": {
+ "name": "sound_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "destination": {
+ "name": "destination",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "announcements_sound_id_sound_files_id_fk": {
+ "name": "announcements_sound_id_sound_files_id_fk",
+ "tableFrom": "announcements",
+ "tableTo": "sound_files",
+ "columnsFrom": [
+ "sound_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.api_tokens": {
+ "name": "api_tokens",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token_prefix": {
+ "name": "token_prefix",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "api_tokens_created_by_users_id_fk": {
+ "name": "api_tokens_created_by_users_id_fk",
+ "tableFrom": "api_tokens",
+ "tableTo": "users",
+ "columnsFrom": [
+ "created_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "api_tokens_tokenHash_unique": {
+ "name": "api_tokens_tokenHash_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token_hash"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.audit_log": {
+ "name": "audit_log",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "actor_user_id": {
+ "name": "actor_user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "actor_ip": {
+ "name": "actor_ip",
+ "type": "inet",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "entity_type": {
+ "name": "entity_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "entity_id": {
+ "name": "entity_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "diff": {
+ "name": "diff",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "audit_log_actor_user_id_users_id_fk": {
+ "name": "audit_log_actor_user_id_users_id_fk",
+ "tableFrom": "audit_log",
+ "tableTo": "users",
+ "columnsFrom": [
+ "actor_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.blocked_numbers": {
+ "name": "blocked_numbers",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "number": {
+ "name": "number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "blocked_numbers_number_unique": {
+ "name": "blocked_numbers_number_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "number"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.call_extensions": {
+ "name": "call_extensions",
+ "schema": "",
+ "columns": {
+ "call_id": {
+ "name": "call_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "extension_id": {
+ "name": "extension_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "call_extensions_call_id_calls_id_fk": {
+ "name": "call_extensions_call_id_calls_id_fk",
+ "tableFrom": "call_extensions",
+ "tableTo": "calls",
+ "columnsFrom": [
+ "call_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "call_extensions_extension_id_extensions_id_fk": {
+ "name": "call_extensions_extension_id_extensions_id_fk",
+ "tableFrom": "call_extensions",
+ "tableTo": "extensions",
+ "columnsFrom": [
+ "extension_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "call_extensions_call_id_extension_id_role_pk": {
+ "name": "call_extensions_call_id_extension_id_role_pk",
+ "columns": [
+ "call_id",
+ "extension_id",
+ "role"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "call_extensions_role_check": {
+ "name": "call_extensions_role_check",
+ "value": "\"call_extensions\".\"role\" in ('caller', 'rang', 'answered')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.calls": {
+ "name": "calls",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "linkedid": {
+ "name": "linkedid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "direction": {
+ "name": "direction",
+ "type": "call_direction",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "call_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "answered_at": {
+ "name": "answered_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ended_at": {
+ "name": "ended_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "from_number": {
+ "name": "from_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "from_name": {
+ "name": "from_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "to_number": {
+ "name": "to_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "did_id": {
+ "name": "did_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "answered_by_extension_id": {
+ "name": "answered_by_extension_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "talk_seconds": {
+ "name": "talk_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "recording_id": {
+ "name": "recording_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "calls_started_at_index": {
+ "name": "calls_started_at_index",
+ "columns": [
+ {
+ "expression": "started_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "calls_did_id_dids_id_fk": {
+ "name": "calls_did_id_dids_id_fk",
+ "tableFrom": "calls",
+ "tableTo": "dids",
+ "columnsFrom": [
+ "did_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "calls_answered_by_extension_id_extensions_id_fk": {
+ "name": "calls_answered_by_extension_id_extensions_id_fk",
+ "tableFrom": "calls",
+ "tableTo": "extensions",
+ "columnsFrom": [
+ "answered_by_extension_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "calls_recording_id_recordings_id_fk": {
+ "name": "calls_recording_id_recordings_id_fk",
+ "tableFrom": "calls",
+ "tableTo": "recordings",
+ "columnsFrom": [
+ "recording_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "calls_linkedid_unique": {
+ "name": "calls_linkedid_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "linkedid"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.cdr": {
+ "name": "cdr",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "uniqueid": {
+ "name": "uniqueid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "linkedid": {
+ "name": "linkedid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "start_at": {
+ "name": "start_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "answer_at": {
+ "name": "answer_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "end_at": {
+ "name": "end_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "duration": {
+ "name": "duration",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "billsec": {
+ "name": "billsec",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "src": {
+ "name": "src",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "dst": {
+ "name": "dst",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "dcontext": {
+ "name": "dcontext",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "clid": {
+ "name": "clid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "channel": {
+ "name": "channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "dstchannel": {
+ "name": "dstchannel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lastapp": {
+ "name": "lastapp",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lastdata": {
+ "name": "lastdata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "disposition": {
+ "name": "disposition",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "accountcode": {
+ "name": "accountcode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "userfield": {
+ "name": "userfield",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sp_did": {
+ "name": "sp_did",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sp_direction": {
+ "name": "sp_direction",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sp_recording": {
+ "name": "sp_recording",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "raw": {
+ "name": "raw",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "cdr_linkedid_index": {
+ "name": "cdr_linkedid_index",
+ "columns": [
+ {
+ "expression": "linkedid",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.conferences": {
+ "name": "conferences",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "number": {
+ "name": "number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "pin_enc": {
+ "name": "pin_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "admin_pin_enc": {
+ "name": "admin_pin_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "max_members": {
+ "name": "max_members",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 20
+ },
+ "record": {
+ "name": "record",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "conferences_number_unique": {
+ "name": "conferences_number_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "number"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.contact_shares": {
+ "name": "contact_shares",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "from_user_id": {
+ "name": "from_user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "to_user_id": {
+ "name": "to_user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "contact": {
+ "name": "contact",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "decided_at": {
+ "name": "decided_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "contact_shares_to_user_id_index": {
+ "name": "contact_shares_to_user_id_index",
+ "columns": [
+ {
+ "expression": "to_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"contact_shares\".\"status\" = 'pending'",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "contact_shares_from_user_id_users_id_fk": {
+ "name": "contact_shares_from_user_id_users_id_fk",
+ "tableFrom": "contact_shares",
+ "tableTo": "users",
+ "columnsFrom": [
+ "from_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "contact_shares_to_user_id_users_id_fk": {
+ "name": "contact_shares_to_user_id_users_id_fk",
+ "tableFrom": "contact_shares",
+ "tableTo": "users",
+ "columnsFrom": [
+ "to_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "contact_shares_status_check": {
+ "name": "contact_shares_status_check",
+ "value": "\"contact_shares\".\"status\" in ('pending', 'accepted', 'denied')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.contacts": {
+ "name": "contacts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "company": {
+ "name": "company",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "numbers": {
+ "name": "numbers",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "contacts_owner_user_id_users_id_fk": {
+ "name": "contacts_owner_user_id_users_id_fk",
+ "tableFrom": "contacts",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.devices": {
+ "name": "devices",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "extension_id": {
+ "name": "extension_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "mac": {
+ "name": "mac",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "vendor": {
+ "name": "vendor",
+ "type": "phone_vendor",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "model": {
+ "name": "model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "provisioning_password_enc": {
+ "name": "provisioning_password_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_provisioned_at": {
+ "name": "last_provisioned_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_provisioned_ip": {
+ "name": "last_provisioned_ip",
+ "type": "inet",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "devices_extension_id_extensions_id_fk": {
+ "name": "devices_extension_id_extensions_id_fk",
+ "tableFrom": "devices",
+ "tableTo": "extensions",
+ "columnsFrom": [
+ "extension_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "devices_mac_unique": {
+ "name": "devices_mac_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "mac"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.did_sms_users": {
+ "name": "did_sms_users",
+ "schema": "",
+ "columns": {
+ "did_id": {
+ "name": "did_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "did_sms_users_did_id_dids_id_fk": {
+ "name": "did_sms_users_did_id_dids_id_fk",
+ "tableFrom": "did_sms_users",
+ "tableTo": "dids",
+ "columnsFrom": [
+ "did_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "did_sms_users_user_id_users_id_fk": {
+ "name": "did_sms_users_user_id_users_id_fk",
+ "tableFrom": "did_sms_users",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "did_sms_users_did_id_user_id_pk": {
+ "name": "did_sms_users_did_id_user_id_pk",
+ "columns": [
+ "did_id",
+ "user_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.dids": {
+ "name": "dids",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "number": {
+ "name": "number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "trunk_id": {
+ "name": "trunk_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "destination": {
+ "name": "destination",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "caller_id_name_prefix": {
+ "name": "caller_id_name_prefix",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "record_inbound": {
+ "name": "record_inbound",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "sms_enabled": {
+ "name": "sms_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "e911_address": {
+ "name": "e911_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "dids_trunk_id_trunks_id_fk": {
+ "name": "dids_trunk_id_trunks_id_fk",
+ "tableFrom": "dids",
+ "tableTo": "trunks",
+ "columnsFrom": [
+ "trunk_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "dids_number_unique": {
+ "name": "dids_number_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "number"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.emergency_events": {
+ "name": "emergency_events",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "extension_number": {
+ "name": "extension_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "dialed": {
+ "name": "dialed",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "caller_id_used": {
+ "name": "caller_id_used",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "location": {
+ "name": "location",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "notified": {
+ "name": "notified",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.extensions": {
+ "name": "extensions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "number": {
+ "name": "number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sip_password_enc": {
+ "name": "sip_password_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "webrtc_enabled": {
+ "name": "webrtc_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "max_contacts": {
+ "name": "max_contacts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 5
+ },
+ "ring_seconds": {
+ "name": "ring_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 25
+ },
+ "voicemail_enabled": {
+ "name": "voicemail_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "voicemail_pin_enc": {
+ "name": "voicemail_pin_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "voicemail_email": {
+ "name": "voicemail_email",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "voicemail_attach_audio": {
+ "name": "voicemail_attach_audio",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "voicemail_delete_after_email": {
+ "name": "voicemail_delete_after_email",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "outbound_class": {
+ "name": "outbound_class",
+ "type": "outbound_class",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'national'"
+ },
+ "outbound_caller_id_did_id": {
+ "name": "outbound_caller_id_did_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "emergency_caller_id_did_id": {
+ "name": "emergency_caller_id_did_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "emergency_location": {
+ "name": "emergency_location",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "record_mode": {
+ "name": "record_mode",
+ "type": "record_mode",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'never'"
+ },
+ "pickup_group": {
+ "name": "pickup_group",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "language": {
+ "name": "language",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'en'"
+ },
+ "moh_class": {
+ "name": "moh_class",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'default'"
+ },
+ "blf_numbers": {
+ "name": "blf_numbers",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "call_report": {
+ "name": "call_report",
+ "type": "call_report",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'off'"
+ },
+ "call_report_time": {
+ "name": "call_report_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'08:00'"
+ },
+ "call_report_weekday": {
+ "name": "call_report_weekday",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "call_report_summaries": {
+ "name": "call_report_summaries",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "call_report_sent_at": {
+ "name": "call_report_sent_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "extensions_user_id_users_id_fk": {
+ "name": "extensions_user_id_users_id_fk",
+ "tableFrom": "extensions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "extensions_outbound_caller_id_did_id_dids_id_fk": {
+ "name": "extensions_outbound_caller_id_did_id_dids_id_fk",
+ "tableFrom": "extensions",
+ "tableTo": "dids",
+ "columnsFrom": [
+ "outbound_caller_id_did_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "extensions_emergency_caller_id_did_id_dids_id_fk": {
+ "name": "extensions_emergency_caller_id_did_id_dids_id_fk",
+ "tableFrom": "extensions",
+ "tableTo": "dids",
+ "columnsFrom": [
+ "emergency_caller_id_did_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "extensions_number_unique": {
+ "name": "extensions_number_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "number"
+ ]
+ },
+ "extensions_userId_unique": {
+ "name": "extensions_userId_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "user_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.generator_runs": {
+ "name": "generator_runs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "trigger": {
+ "name": "trigger",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "files_changed": {
+ "name": "files_changed",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "commands": {
+ "name": "commands",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ok": {
+ "name": "ok",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "duration_ms": {
+ "name": "duration_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.holidays": {
+ "name": "holidays",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "start_date": {
+ "name": "start_date",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "end_date": {
+ "name": "end_date",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "recurring_yearly": {
+ "name": "recurring_yearly",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.ivr_options": {
+ "name": "ivr_options",
+ "schema": "",
+ "columns": {
+ "ivr_id": {
+ "name": "ivr_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "digit": {
+ "name": "digit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "destination": {
+ "name": "destination",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "ivr_options_ivr_id_ivrs_id_fk": {
+ "name": "ivr_options_ivr_id_ivrs_id_fk",
+ "tableFrom": "ivr_options",
+ "tableTo": "ivrs",
+ "columnsFrom": [
+ "ivr_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "ivr_options_ivr_id_digit_pk": {
+ "name": "ivr_options_ivr_id_digit_pk",
+ "columns": [
+ "ivr_id",
+ "digit"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "ivr_options_digit_check": {
+ "name": "ivr_options_digit_check",
+ "value": "\"ivr_options\".\"digit\" ~ '^[0-9*#]$'"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.ivrs": {
+ "name": "ivrs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "number": {
+ "name": "number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "greeting_sound_id": {
+ "name": "greeting_sound_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "invalid_sound_id": {
+ "name": "invalid_sound_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "timeout_seconds": {
+ "name": "timeout_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 5
+ },
+ "max_retries": {
+ "name": "max_retries",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 3
+ },
+ "allow_direct_dial": {
+ "name": "allow_direct_dial",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "timeout_destination": {
+ "name": "timeout_destination",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "invalid_destination": {
+ "name": "invalid_destination",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "ivrs_greeting_sound_id_sound_files_id_fk": {
+ "name": "ivrs_greeting_sound_id_sound_files_id_fk",
+ "tableFrom": "ivrs",
+ "tableTo": "sound_files",
+ "columnsFrom": [
+ "greeting_sound_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ },
+ "ivrs_invalid_sound_id_sound_files_id_fk": {
+ "name": "ivrs_invalid_sound_id_sound_files_id_fk",
+ "tableFrom": "ivrs",
+ "tableTo": "sound_files",
+ "columnsFrom": [
+ "invalid_sound_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "ivrs_number_unique": {
+ "name": "ivrs_number_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "number"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.moh_classes": {
+ "name": "moh_classes",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "shuffle": {
+ "name": "shuffle",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "moh_classes_name_unique": {
+ "name": "moh_classes_name_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "name"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.outbound_routes": {
+ "name": "outbound_routes",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "patterns": {
+ "name": "patterns",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "required_class": {
+ "name": "required_class",
+ "type": "outbound_class",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "trunk_ids": {
+ "name": "trunk_ids",
+ "type": "uuid[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "caller_id_did_id": {
+ "name": "caller_id_did_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "outbound_routes_caller_id_did_id_dids_id_fk": {
+ "name": "outbound_routes_caller_id_did_id_dids_id_fk",
+ "tableFrom": "outbound_routes",
+ "tableTo": "dids",
+ "columnsFrom": [
+ "caller_id_did_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.paging_group_members": {
+ "name": "paging_group_members",
+ "schema": "",
+ "columns": {
+ "paging_group_id": {
+ "name": "paging_group_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "extension_id": {
+ "name": "extension_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "paging_group_members_paging_group_id_paging_groups_id_fk": {
+ "name": "paging_group_members_paging_group_id_paging_groups_id_fk",
+ "tableFrom": "paging_group_members",
+ "tableTo": "paging_groups",
+ "columnsFrom": [
+ "paging_group_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "paging_group_members_extension_id_extensions_id_fk": {
+ "name": "paging_group_members_extension_id_extensions_id_fk",
+ "tableFrom": "paging_group_members",
+ "tableTo": "extensions",
+ "columnsFrom": [
+ "extension_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "paging_group_members_paging_group_id_extension_id_pk": {
+ "name": "paging_group_members_paging_group_id_extension_id_pk",
+ "columns": [
+ "paging_group_id",
+ "extension_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.paging_groups": {
+ "name": "paging_groups",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "number": {
+ "name": "number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "duplex": {
+ "name": "duplex",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "paging_groups_number_unique": {
+ "name": "paging_groups_number_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "number"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.queue_calls": {
+ "name": "queue_calls",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "queue_id": {
+ "name": "queue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "uniqueid": {
+ "name": "uniqueid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "linkedid": {
+ "name": "linkedid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "joined_at": {
+ "name": "joined_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "outcome": {
+ "name": "outcome",
+ "type": "queue_outcome",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "wait_seconds": {
+ "name": "wait_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "agent_extension_id": {
+ "name": "agent_extension_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "queue_calls_queue_id_joined_at_index": {
+ "name": "queue_calls_queue_id_joined_at_index",
+ "columns": [
+ {
+ "expression": "queue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "joined_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "queue_calls_uniqueid_index": {
+ "name": "queue_calls_uniqueid_index",
+ "columns": [
+ {
+ "expression": "uniqueid",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "queue_calls_queue_id_queues_id_fk": {
+ "name": "queue_calls_queue_id_queues_id_fk",
+ "tableFrom": "queue_calls",
+ "tableTo": "queues",
+ "columnsFrom": [
+ "queue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "queue_calls_agent_extension_id_extensions_id_fk": {
+ "name": "queue_calls_agent_extension_id_extensions_id_fk",
+ "tableFrom": "queue_calls",
+ "tableTo": "extensions",
+ "columnsFrom": [
+ "agent_extension_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.queue_member_state": {
+ "name": "queue_member_state",
+ "schema": "",
+ "columns": {
+ "queue_id": {
+ "name": "queue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "extension_id": {
+ "name": "extension_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "paused": {
+ "name": "paused",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "queue_member_state_queue_id_queues_id_fk": {
+ "name": "queue_member_state_queue_id_queues_id_fk",
+ "tableFrom": "queue_member_state",
+ "tableTo": "queues",
+ "columnsFrom": [
+ "queue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "queue_member_state_extension_id_extensions_id_fk": {
+ "name": "queue_member_state_extension_id_extensions_id_fk",
+ "tableFrom": "queue_member_state",
+ "tableTo": "extensions",
+ "columnsFrom": [
+ "extension_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "queue_member_state_queue_id_extension_id_pk": {
+ "name": "queue_member_state_queue_id_extension_id_pk",
+ "columns": [
+ "queue_id",
+ "extension_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.queue_members": {
+ "name": "queue_members",
+ "schema": "",
+ "columns": {
+ "queue_id": {
+ "name": "queue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "extension_id": {
+ "name": "extension_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "penalty": {
+ "name": "penalty",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "queue_members_queue_id_queues_id_fk": {
+ "name": "queue_members_queue_id_queues_id_fk",
+ "tableFrom": "queue_members",
+ "tableTo": "queues",
+ "columnsFrom": [
+ "queue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "queue_members_extension_id_extensions_id_fk": {
+ "name": "queue_members_extension_id_extensions_id_fk",
+ "tableFrom": "queue_members",
+ "tableTo": "extensions",
+ "columnsFrom": [
+ "extension_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "queue_members_queue_id_extension_id_pk": {
+ "name": "queue_members_queue_id_extension_id_pk",
+ "columns": [
+ "queue_id",
+ "extension_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.queues": {
+ "name": "queues",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "number": {
+ "name": "number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "strategy": {
+ "name": "strategy",
+ "type": "queue_strategy",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'rrmemory'"
+ },
+ "member_ring_seconds": {
+ "name": "member_ring_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 15
+ },
+ "retry_seconds": {
+ "name": "retry_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 5
+ },
+ "wrapup_seconds": {
+ "name": "wrapup_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 10
+ },
+ "max_wait_seconds": {
+ "name": "max_wait_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 300
+ },
+ "max_callers": {
+ "name": "max_callers",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "moh_class": {
+ "name": "moh_class",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'default'"
+ },
+ "announce_position": {
+ "name": "announce_position",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "announce_every_seconds": {
+ "name": "announce_every_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 60
+ },
+ "join_when_no_agents": {
+ "name": "join_when_no_agents",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "record": {
+ "name": "record",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "sla_seconds": {
+ "name": "sla_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 20
+ },
+ "caller_id_name_prefix": {
+ "name": "caller_id_name_prefix",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "timeout_destination": {
+ "name": "timeout_destination",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "queues_number_unique": {
+ "name": "queues_number_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "number"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.recordings": {
+ "name": "recordings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "linkedid": {
+ "name": "linkedid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "file_path": {
+ "name": "file_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "duration_ms": {
+ "name": "duration_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "size_bytes": {
+ "name": "size_bytes",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "transcript": {
+ "name": "transcript",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ai_done_at": {
+ "name": "ai_done_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ai_error": {
+ "name": "ai_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "recordings_filePath_unique": {
+ "name": "recordings_filePath_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "file_path"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.ring_group_members": {
+ "name": "ring_group_members",
+ "schema": "",
+ "columns": {
+ "ring_group_id": {
+ "name": "ring_group_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "extension_id": {
+ "name": "extension_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "ring_group_members_ring_group_id_ring_groups_id_fk": {
+ "name": "ring_group_members_ring_group_id_ring_groups_id_fk",
+ "tableFrom": "ring_group_members",
+ "tableTo": "ring_groups",
+ "columnsFrom": [
+ "ring_group_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "ring_group_members_extension_id_extensions_id_fk": {
+ "name": "ring_group_members_extension_id_extensions_id_fk",
+ "tableFrom": "ring_group_members",
+ "tableTo": "extensions",
+ "columnsFrom": [
+ "extension_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "ring_group_members_ring_group_id_extension_id_pk": {
+ "name": "ring_group_members_ring_group_id_extension_id_pk",
+ "columns": [
+ "ring_group_id",
+ "extension_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.ring_groups": {
+ "name": "ring_groups",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "number": {
+ "name": "number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "strategy": {
+ "name": "strategy",
+ "type": "ring_strategy",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'ringall'"
+ },
+ "ring_seconds": {
+ "name": "ring_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 25
+ },
+ "caller_id_name_prefix": {
+ "name": "caller_id_name_prefix",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "music_instead_of_ring": {
+ "name": "music_instead_of_ring",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "no_answer_destination": {
+ "name": "no_answer_destination",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "ring_groups_number_unique": {
+ "name": "ring_groups_number_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "number"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sessions": {
+ "name": "sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip": {
+ "name": "ip",
+ "type": "inet",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "sessions_user_id_index": {
+ "name": "sessions_user_id_index",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "sessions_user_id_users_id_fk": {
+ "name": "sessions_user_id_users_id_fk",
+ "tableFrom": "sessions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.settings": {
+ "name": "settings",
+ "schema": "",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sms_messages": {
+ "name": "sms_messages",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "did_id": {
+ "name": "did_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "direction": {
+ "name": "direction",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "remote_number": {
+ "name": "remote_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "media": {
+ "name": "media",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sent_by_user_id": {
+ "name": "sent_by_user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "read_at": {
+ "name": "read_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "sms_messages_did_id_remote_number_created_at_index": {
+ "name": "sms_messages_did_id_remote_number_created_at_index",
+ "columns": [
+ {
+ "expression": "did_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "remote_number",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "sms_messages_did_id_dids_id_fk": {
+ "name": "sms_messages_did_id_dids_id_fk",
+ "tableFrom": "sms_messages",
+ "tableTo": "dids",
+ "columnsFrom": [
+ "did_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "sms_messages_sent_by_user_id_users_id_fk": {
+ "name": "sms_messages_sent_by_user_id_users_id_fk",
+ "tableFrom": "sms_messages",
+ "tableTo": "users",
+ "columnsFrom": [
+ "sent_by_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "sms_messages_direction_check": {
+ "name": "sms_messages_direction_check",
+ "value": "\"sms_messages\".\"direction\" in ('in', 'out')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.sound_files": {
+ "name": "sound_files",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "moh_class_id": {
+ "name": "moh_class_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tts_text": {
+ "name": "tts_text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "original_filename": {
+ "name": "original_filename",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "asterisk_name": {
+ "name": "asterisk_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "duration_ms": {
+ "name": "duration_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "sound_files_moh_class_id_moh_classes_id_fk": {
+ "name": "sound_files_moh_class_id_moh_classes_id_fk",
+ "tableFrom": "sound_files",
+ "tableTo": "moh_classes",
+ "columnsFrom": [
+ "moh_class_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "sound_files_asteriskName_unique": {
+ "name": "sound_files_asteriskName_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "asterisk_name"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {
+ "sound_files_kind_check": {
+ "name": "sound_files_kind_check",
+ "value": "\"sound_files\".\"kind\" in ('prompt', 'moh')"
+ },
+ "sound_files_source_check": {
+ "name": "sound_files_source_check",
+ "value": "\"sound_files\".\"source\" in ('upload', 'tts', 'phone')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.system_events": {
+ "name": "system_events",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "severity": {
+ "name": "severity",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "code": {
+ "name": "code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "system_events_created_at_index": {
+ "name": "system_events_created_at_index",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "system_events_severity_check": {
+ "name": "system_events_severity_check",
+ "value": "\"system_events\".\"severity\" in ('info', 'warning', 'error')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.time_conditions": {
+ "name": "time_conditions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "timezone": {
+ "name": "timezone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "open_ranges": {
+ "name": "open_ranges",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "use_holidays": {
+ "name": "use_holidays",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "override_digit": {
+ "name": "override_digit",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "override_state": {
+ "name": "override_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "open_destination": {
+ "name": "open_destination",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "closed_destination": {
+ "name": "closed_destination",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "holiday_destination": {
+ "name": "holiday_destination",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "time_conditions_overrideDigit_unique": {
+ "name": "time_conditions_overrideDigit_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "override_digit"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {
+ "time_conditions_override_digit_check": {
+ "name": "time_conditions_override_digit_check",
+ "value": "\"time_conditions\".\"override_digit\" between 1 and 9"
+ },
+ "time_conditions_override_state_check": {
+ "name": "time_conditions_override_state_check",
+ "value": "\"time_conditions\".\"override_state\" in ('open', 'closed')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.trunks": {
+ "name": "trunks",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider": {
+ "name": "provider",
+ "type": "trunk_provider",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'voipms'"
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "server_host": {
+ "name": "server_host",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "server_port": {
+ "name": "server_port",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 5060
+ },
+ "transport": {
+ "name": "transport",
+ "type": "sip_transport",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'udp'"
+ },
+ "auth_mode": {
+ "name": "auth_mode",
+ "type": "trunk_auth_mode",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'registration'"
+ },
+ "username": {
+ "name": "username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password_enc": {
+ "name": "password_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "from_user": {
+ "name": "from_user",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "from_domain": {
+ "name": "from_domain",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "codecs": {
+ "name": "codecs",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{ulaw}'"
+ },
+ "srtp": {
+ "name": "srtp",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "max_channels": {
+ "name": "max_channels",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "voipms_api_username": {
+ "name": "voipms_api_username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "voipms_api_password_enc": {
+ "name": "voipms_api_password_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "trunks_name_unique": {
+ "name": "trunks_name_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "name"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_runtime_state": {
+ "name": "user_runtime_state",
+ "schema": "",
+ "columns": {
+ "extension_id": {
+ "name": "extension_id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "dnd": {
+ "name": "dnd",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "cfu": {
+ "name": "cfu",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cfb": {
+ "name": "cfb",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cfna": {
+ "name": "cfna",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_runtime_state_extension_id_extensions_id_fk": {
+ "name": "user_runtime_state_extension_id_extensions_id_fk",
+ "tableFrom": "user_runtime_state",
+ "tableTo": "extensions",
+ "columnsFrom": [
+ "extension_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "email": {
+ "name": "email",
+ "type": "citext",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "display_name": {
+ "name": "display_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "password_hash": {
+ "name": "password_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "user_role",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'user'"
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "totp_secret_enc": {
+ "name": "totp_secret_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "totp_recovery_hashes": {
+ "name": "totp_recovery_hashes",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "totp_last_step": {
+ "name": "totp_last_step",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "must_change_password": {
+ "name": "must_change_password",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "last_login_at": {
+ "name": "last_login_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "users_email_unique": {
+ "name": "users_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.voicemail_messages": {
+ "name": "voicemail_messages",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "extension_id": {
+ "name": "extension_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "msg_id": {
+ "name": "msg_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "folder": {
+ "name": "folder",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "caller_number": {
+ "name": "caller_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "caller_name": {
+ "name": "caller_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "duration_s": {
+ "name": "duration_s",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "received_at": {
+ "name": "received_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "file_path": {
+ "name": "file_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "emailed_at": {
+ "name": "emailed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "transcript": {
+ "name": "transcript",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "voicemail_messages_extension_id_extensions_id_fk": {
+ "name": "voicemail_messages_extension_id_extensions_id_fk",
+ "tableFrom": "voicemail_messages",
+ "tableTo": "extensions",
+ "columnsFrom": [
+ "extension_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "voicemail_messages_msgId_unique": {
+ "name": "voicemail_messages_msgId_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "msg_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.webhook_deliveries": {
+ "name": "webhook_deliveries",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "webhook_id": {
+ "name": "webhook_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "event": {
+ "name": "event",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "attempt": {
+ "name": "attempt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "response_code": {
+ "name": "response_code",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "next_attempt_at": {
+ "name": "next_attempt_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "webhook_deliveries_webhook_id_webhooks_id_fk": {
+ "name": "webhook_deliveries_webhook_id_webhooks_id_fk",
+ "tableFrom": "webhook_deliveries",
+ "tableTo": "webhooks",
+ "columnsFrom": [
+ "webhook_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.webhooks": {
+ "name": "webhooks",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "secret_enc": {
+ "name": "secret_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "events": {
+ "name": "events",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.call_direction": {
+ "name": "call_direction",
+ "schema": "public",
+ "values": [
+ "inbound",
+ "outbound",
+ "internal"
+ ]
+ },
+ "public.call_report": {
+ "name": "call_report",
+ "schema": "public",
+ "values": [
+ "off",
+ "hourly",
+ "daily",
+ "weekly"
+ ]
+ },
+ "public.call_status": {
+ "name": "call_status",
+ "schema": "public",
+ "values": [
+ "answered",
+ "missed",
+ "voicemail",
+ "abandoned",
+ "busy",
+ "failed"
+ ]
+ },
+ "public.outbound_class": {
+ "name": "outbound_class",
+ "schema": "public",
+ "values": [
+ "internal",
+ "local",
+ "national",
+ "international"
+ ]
+ },
+ "public.phone_vendor": {
+ "name": "phone_vendor",
+ "schema": "public",
+ "values": [
+ "yealink",
+ "grandstream",
+ "polycom",
+ "generic"
+ ]
+ },
+ "public.queue_outcome": {
+ "name": "queue_outcome",
+ "schema": "public",
+ "values": [
+ "answered",
+ "abandoned",
+ "timed_out"
+ ]
+ },
+ "public.queue_strategy": {
+ "name": "queue_strategy",
+ "schema": "public",
+ "values": [
+ "ringall",
+ "leastrecent",
+ "fewestcalls",
+ "random",
+ "rrmemory",
+ "linear"
+ ]
+ },
+ "public.record_mode": {
+ "name": "record_mode",
+ "schema": "public",
+ "values": [
+ "never",
+ "inbound",
+ "outbound",
+ "all"
+ ]
+ },
+ "public.ring_strategy": {
+ "name": "ring_strategy",
+ "schema": "public",
+ "values": [
+ "ringall",
+ "hunt"
+ ]
+ },
+ "public.sip_transport": {
+ "name": "sip_transport",
+ "schema": "public",
+ "values": [
+ "udp",
+ "tcp",
+ "tls"
+ ]
+ },
+ "public.trunk_auth_mode": {
+ "name": "trunk_auth_mode",
+ "schema": "public",
+ "values": [
+ "registration",
+ "ip"
+ ]
+ },
+ "public.trunk_provider": {
+ "name": "trunk_provider",
+ "schema": "public",
+ "values": [
+ "voipms",
+ "generic"
+ ]
+ },
+ "public.user_role": {
+ "name": "user_role",
+ "schema": "public",
+ "values": [
+ "admin",
+ "user"
+ ]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/apps/api/src/db/migrations/meta/_journal.json b/apps/api/src/db/migrations/meta/_journal.json
index f9e9a2e..b49b2de 100644
--- a/apps/api/src/db/migrations/meta/_journal.json
+++ b/apps/api/src/db/migrations/meta/_journal.json
@@ -64,6 +64,13 @@
"when": 1792781321577,
"tag": "0008_call_reports",
"breakpoints": true
+ },
+ {
+ "idx": 9,
+ "version": "7",
+ "when": 1792784242837,
+ "tag": "0009_contact_shares",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts
index b920ef8..98ce92c 100644
--- a/apps/api/src/db/schema.ts
+++ b/apps/api/src/db/schema.ts
@@ -620,6 +620,31 @@ export const contacts = pgTable('contacts', {
...timestamps,
});
+/** A contact offered to a colleague, waiting for Accept or Deny (11 §2.1). */
+export const contactShares = pgTable(
+ 'contact_shares',
+ {
+ id: id(),
+ fromUserId: uuid().references(() => users.id, { onDelete: 'set null' }),
+ toUserId: uuid()
+ .notNull()
+ .references(() => users.id, { onDelete: 'cascade' }),
+ /** The shared contact's id in the sender's book: offering it again replaces a pending offer. */
+ sourceId: text().notNull(),
+ /** A copy taken when shared: { name, company, email, numbers, notes }. */
+ contact: jsonb().notNull(),
+ status: text().notNull().default('pending'),
+ decidedAt: tstz(),
+ ...timestamps,
+ },
+ (t) => [
+ index()
+ .on(t.toUserId)
+ .where(sql`${t.status} = 'pending'`),
+ check('contact_shares_status_check', sql`${t.status} in ('pending', 'accepted', 'denied')`),
+ ],
+);
+
export const smsMessages = pgTable(
'sms_messages',
{
diff --git a/apps/api/src/jobs.ts b/apps/api/src/jobs.ts
index 7d7d756..678f1ab 100644
--- a/apps/api/src/jobs.ts
+++ b/apps/api/src/jobs.ts
@@ -19,6 +19,7 @@ import {
import type { Generator } from './generator/index.ts';
import { todayIn } from './generator/load.ts';
import type { LiveState } from './live/state.ts';
+import { cleanupContactShares } from './routes/contacts.ts';
import { cleanupRecordings } from './routes/recordings.ts';
import { transcribeCalls } from './ai/calls.ts';
import { audit } from './services/audit.ts';
@@ -108,6 +109,7 @@ export async function retentionCleanup(d: JobDeps) {
await d.db
.delete(systemEvents)
.where(lt(systemEvents.createdAt, sql`now() - interval '90 days'`));
+ await cleanupContactShares(d.db);
if (recordings || voicemail) d.log.info({ recordings, voicemail }, 'old messages deleted');
}
diff --git a/apps/api/src/routes/contacts.ts b/apps/api/src/routes/contacts.ts
new file mode 100644
index 0000000..eecf541
--- /dev/null
+++ b/apps/api/src/routes/contacts.ts
@@ -0,0 +1,274 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// /api/me/contacts (my book, shares) and /api/contacts (the company book, admin): spec 11.
+import { and, desc, eq, inArray, isNull, lt, ne, sql } from 'drizzle-orm';
+import type { FastifyInstance } from 'fastify';
+import { z } from 'zod';
+import {
+ ContactImportSchema,
+ ContactInputSchema,
+ ContactShareDecisionSchema,
+ ContactShareSchema,
+ type ContactData,
+ type ContactDto,
+ type ContactImportResult,
+ type ContactShareDto,
+ type ListResponse,
+} from '@selfphoneng/shared';
+import { requireAdmin, requireAuth } from '../auth/guards.ts';
+import {
+ addToBook,
+ cleanNumbers,
+ listBook,
+ listBooks,
+ toDto,
+ type Owner,
+} from '../contacts/book.ts';
+import { parseCsv } from '../contacts/csv.ts';
+import { parseVcards } from '../contacts/vcard.ts';
+import { contactShares, contacts, extensions, users } from '../db/schema.ts';
+import { badRequest, notFound } from '../errors.ts';
+import { audit } from '../services/audit.ts';
+
+const IdParams = z.object({ id: z.uuid() });
+const CommitQuery = z.object({ commit: z.enum(['0', '1']).default('0') });
+/** 5,000 contacts or 5 MB of files per import (11 §3.1). */
+const MAX_IMPORT = 5000;
+const IMPORT_BODY = 6 * 1024 * 1024;
+
+/** Every contact in the files, and what couldn't be read. */
+function readFiles(files: { name: string; text: string }[]) {
+ const list: ContactData[] = [];
+ const errors: string[] = [];
+ for (const f of files) {
+ const vcf = /\.vcf$/i.test(f.name) || /^\s*BEGIN:VCARD/i.test(f.text);
+ const r = vcf ? parseVcards(f.text) : parseCsv(f.text);
+ list.push(...r.contacts);
+ errors.push(...r.errors.map((e) => `${f.name}: ${e}`));
+ }
+ if (list.length > MAX_IMPORT) throw badRequest(`At most ${MAX_IMPORT} contacts per import`);
+ return { list, errors };
+}
+
+export async function contactRoutes(app: FastifyInstance) {
+ const { db } = app;
+
+ // ───── the routes both books have; `owner` says whose ─────
+ const book = (
+ prefix: string,
+ guard: typeof requireAuth,
+ ownerOf: (u: { id: string }) => Owner,
+ ) => {
+ const mine = (u: { id: string }, id: string) =>
+ db
+ .select()
+ .from(contacts)
+ .where(
+ and(
+ eq(contacts.id, id),
+ ownerOf(u) ? eq(contacts.ownerUserId, ownerOf(u)!) : isNull(contacts.ownerUserId),
+ ),
+ )
+ .then(([r]) => {
+ if (!r) throw notFound('Contact not found');
+ return r;
+ });
+ const company = (u: { id: string }) => ownerOf(u) === null;
+ const log = (
+ req: { user: { id: string } | null; ip: string },
+ action: 'create' | 'update' | 'delete',
+ id: string | undefined,
+ summary: string,
+ ) =>
+ company(req.user!)
+ ? audit(db, {
+ actorUserId: req.user!.id,
+ actorIp: req.ip,
+ action,
+ entityType: 'contact',
+ entityId: id,
+ summary,
+ })
+ : undefined;
+
+ app.post(prefix, { preHandler: guard }, async (req, reply) => {
+ const c = ContactInputSchema.parse(req.body);
+ const [row] = await db
+ .insert(contacts)
+ .values({ ...c, numbers: cleanNumbers(c), ownerUserId: ownerOf(req.user!) })
+ .returning();
+ await log(req, 'create', row!.id, `Added ${c.name} to the company contacts`);
+ return reply.code(201).send(toDto(row!));
+ });
+
+ app.patch(`${prefix}/:id`, { preHandler: guard }, async (req): Promise => {
+ const { id } = IdParams.parse(req.params);
+ await mine(req.user!, id);
+ const c = ContactInputSchema.parse(req.body);
+ const [row] = await db
+ .update(contacts)
+ .set({ ...c, numbers: cleanNumbers(c), updatedAt: new Date() })
+ .where(eq(contacts.id, id))
+ .returning();
+ await log(req, 'update', id, `Changed the company contact ${c.name}`);
+ return toDto(row!);
+ });
+
+ app.delete(`${prefix}/:id`, { preHandler: guard }, async (req) => {
+ const { id } = IdParams.parse(req.params);
+ const r = await mine(req.user!, id);
+ await db.delete(contacts).where(eq(contacts.id, id));
+ await log(req, 'delete', id, `Deleted the company contact ${r.name}`);
+ return { ok: true };
+ });
+
+ app.post(
+ `${prefix}/import`,
+ { preHandler: guard, bodyLimit: IMPORT_BODY },
+ async (req): Promise => {
+ const { files } = ContactImportSchema.parse(req.body);
+ const commit = CommitQuery.parse(req.query).commit === '1';
+ const { list, errors } = readFiles(files);
+ const r = await addToBook(db, ownerOf(req.user!), list, commit, errors);
+ if (commit && r.added)
+ await log(req, 'create', undefined, `Imported ${r.added} company contacts`);
+ return r;
+ },
+ );
+ };
+
+ // ───── my book ─────
+ /** Mine and the company's: the page filters, and the browser looks up names from it. */
+ app.get(
+ '/api/me/contacts',
+ { preHandler: requireAuth },
+ async (req): Promise> => ({
+ items: await listBooks(db, req.user!.id),
+ nextCursor: null,
+ }),
+ );
+ book('/api/me/contacts', requireAuth, (u) => u.id);
+
+ // ───── the company book ─────
+ app.get(
+ '/api/contacts',
+ { preHandler: requireAdmin },
+ async (): Promise> => ({
+ items: await listBook(db, null),
+ nextCursor: null,
+ }),
+ );
+ book('/api/contacts', requireAdmin, () => null);
+
+ // ───── sharing: an offer each, accepted or denied by the recipient (11 §2.1) ─────
+ app.post('/api/me/contacts/share', { preHandler: requireAuth }, async (req) => {
+ const s = ContactShareSchema.parse(req.body);
+ const me = req.user!.id;
+ const picked = await db
+ .select()
+ .from(contacts)
+ .where(and(eq(contacts.ownerUserId, me), inArray(contacts.id, s.contactIds)));
+ if (picked.length !== s.contactIds.length) throw notFound('Contact not found');
+ const to = s.everyone
+ ? await db
+ .select({ id: users.id })
+ .from(users)
+ .where(and(eq(users.isActive, true), ne(users.id, me)))
+ : await db
+ .select({ id: users.id })
+ .from(extensions)
+ .innerJoin(users, eq(users.id, extensions.userId))
+ .where(
+ and(
+ inArray(extensions.number, s.extensions),
+ eq(users.isActive, true),
+ ne(users.id, me),
+ ),
+ );
+ if (!to.length) throw badRequest('Nobody to share with');
+ await db.transaction(async (tx) => {
+ // The same contact offered again replaces the pending offer.
+ await tx.delete(contactShares).where(
+ and(
+ eq(contactShares.status, 'pending'),
+ inArray(
+ contactShares.toUserId,
+ to.map((u) => u.id),
+ ),
+ inArray(
+ contactShares.sourceId,
+ picked.map((c) => c.id),
+ ),
+ ),
+ );
+ await tx.insert(contactShares).values(
+ to.flatMap((u) =>
+ picked.map((c) => {
+ const { id: _i, book: _b, ...contact } = toDto(c);
+ return { fromUserId: me, toUserId: u.id, sourceId: c.id, contact };
+ }),
+ ),
+ );
+ });
+ return { offers: picked.length * to.length, people: to.length };
+ });
+
+ app.get(
+ '/api/me/contacts/shares',
+ { preHandler: requireAuth },
+ async (req): Promise> => {
+ const rows = await db
+ .select({ s: contactShares, fromName: users.displayName })
+ .from(contactShares)
+ .leftJoin(users, eq(users.id, contactShares.fromUserId))
+ .where(and(eq(contactShares.toUserId, req.user!.id), eq(contactShares.status, 'pending')))
+ .orderBy(desc(contactShares.createdAt));
+ return {
+ items: rows.map(({ s, fromName }) => ({
+ id: s.id,
+ from: s.fromUserId && fromName ? { userId: s.fromUserId, name: fromName } : null,
+ contact: s.contact as ContactData,
+ createdAt: s.createdAt.toISOString(),
+ })),
+ nextCursor: null,
+ };
+ },
+ );
+
+ for (const decision of ['accept', 'deny'] as const)
+ app.post(`/api/me/contacts/shares/${decision}`, { preHandler: requireAuth }, async (req) => {
+ const { ids } = ContactShareDecisionSchema.parse(req.body);
+ const me = req.user!.id;
+ return db.transaction(async (tx) => {
+ const offers = await tx
+ .update(contactShares)
+ .set({ status: decision === 'accept' ? 'accepted' : 'denied', decidedAt: new Date() })
+ .where(
+ and(
+ eq(contactShares.toUserId, me),
+ eq(contactShares.status, 'pending'),
+ inArray(contactShares.id, ids),
+ ),
+ )
+ .returning();
+ if (decision === 'deny') return { denied: offers.length };
+ const r = await addToBook(
+ tx,
+ me,
+ offers.map((o) => o.contact as ContactData),
+ true,
+ );
+ return { added: r.added, duplicates: r.duplicates };
+ });
+ });
+}
+
+/** Decided offers go after 30 days (11 §2.1); retentionCleanup calls it. */
+export const cleanupContactShares = (db: FastifyInstance['db']) =>
+ db
+ .delete(contactShares)
+ .where(
+ and(
+ ne(contactShares.status, 'pending'),
+ lt(contactShares.decidedAt, sql`now() - interval '30 days'`),
+ ),
+ );
diff --git a/apps/api/src/services/callreport.ts b/apps/api/src/services/callreport.ts
index d06e2b3..5d8ef85 100644
--- a/apps/api/src/services/callreport.ts
+++ b/apps/api/src/services/callreport.ts
@@ -4,6 +4,7 @@
import { and, asc, eq, gte, inArray, lt, ne, sql } from 'drizzle-orm';
import { alias } from 'drizzle-orm/pg-core';
import { formatNumber, type CallReport } from '@selfphoneng/shared';
+import { contactNames } from '../contacts/book.ts';
import type { Db } from '../db/client.ts';
import { callExtensions, calls, extensions, recordings, users } from '../db/schema.ts';
import type { SendMail } from './mail.ts';
@@ -56,6 +57,8 @@ export type ReportCall = {
status: string;
fromNumber: string | null;
fromName: string | null;
+ /** The other side's name in my contacts or the company's (11 §1). */
+ contactName: string | null;
toNumber: string | null;
talkSeconds: number;
answeredBy: string | null;
@@ -80,9 +83,10 @@ export function reportText(
`${list.length} call${list.length === 1 ? '' : 's'} from ${when.format(period.from)} to ${when.format(period.to)}:`,
];
for (const c of list) {
+ const name = c.contactName ?? (c.out ? null : c.fromName);
const who = c.out
- ? `Outgoing to ${num(c.toNumber)}`
- : `Incoming from ${num(c.fromNumber)}${c.fromName ? ` (${c.fromName})` : ''} to ${num(c.toNumber)}`;
+ ? `Outgoing to ${num(c.toNumber)}${name ? ` (${name})` : ''}`
+ : `Incoming from ${num(c.fromNumber)}${name ? ` (${name})` : ''} to ${num(c.toNumber)}`;
const facts = [
c.status === 'answered' && c.answeredBy ? `answered by ${c.answeredBy}` : c.status,
duration(c.talkSeconds),
@@ -158,12 +162,18 @@ export async function sendCallReports(d: {
.set({ callReportSentAt: period.to })
.where(eq(extensions.id, e.id));
if (!rows.length) continue;
+ const names = await contactNames(
+ d.db,
+ e.userId,
+ rows.map((r) => (r.out ? r.c.toNumber : r.c.fromNumber)),
+ );
const list = rows.map((r): ReportCall => ({
startedAt: r.c.startedAt,
out: r.out,
status: r.c.status,
fromNumber: r.c.fromNumber,
fromName: r.c.fromName,
+ contactName: names.get((r.out ? r.c.toNumber : r.c.fromNumber) ?? '') ?? null,
toNumber: r.c.toNumber,
talkSeconds: r.c.talkSeconds,
answeredBy: r.answeredById && r.answeredById !== e.id ? r.answeredBy : null,
diff --git a/apps/api/test/contacts.test.ts b/apps/api/test/contacts.test.ts
new file mode 100644
index 0000000..8962788
--- /dev/null
+++ b/apps/api/test/contacts.test.ts
@@ -0,0 +1,204 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// C1: my contacts, the company's, imports and shares (spec 11).
+import type { LightMyRequestResponse } from 'fastify';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { contactNames } from '../src/contacts/book.ts';
+import { hasTestDb } from './db.ts';
+import { ADMIN, H, createTestApp } from './app.ts';
+
+type Req = (method: string, url: string, payload?: unknown) => Promise;
+
+describe.skipIf(!hasTestDb)('contacts', () => {
+ let t: Awaited>;
+ let admin: Req;
+ let dan: Req;
+ let sue: Req;
+ let danId: string;
+ const vcf = (name: string, tel: string) =>
+ `BEGIN:VCARD\r\nVERSION:3.0\r\nFN:${name}\r\nTEL;TYPE=CELL:${tel}\r\nEND:VCARD\r\n`;
+
+ beforeAll(async () => {
+ t = await createTestApp();
+ await t.clearMustChange(ADMIN.email);
+ const as =
+ (cookie: string): Req =>
+ (method, url, payload) =>
+ t.app.inject({
+ method: method as 'GET',
+ url,
+ payload: payload as object,
+ headers: { ...H, cookie },
+ });
+ admin = as(await t.login(ADMIN.email, ADMIN.password));
+ danId = (await t.addUser('dan@example.com', 'dan-password-123')).id;
+ const sueId = (await t.addUser('sue@example.com', 'sue-password-123')).id;
+ await admin('POST', '/api/extensions', { number: '101', name: 'Dan Doe', userId: danId });
+ await admin('POST', '/api/extensions', { number: '102', name: 'Sue Smith', userId: sueId });
+ dan = as(await t.login('dan@example.com', 'dan-password-123'));
+ sue = as(await t.login('sue@example.com', 'sue-password-123'));
+ });
+ afterAll(() => t?.close());
+
+ it('my book and the company book: numbers normalized, users only edit their own', async () => {
+ const mine = await dan('POST', '/api/me/contacts', {
+ name: 'Jane Doe',
+ company: 'Acme',
+ email: '',
+ numbers: [{ label: 'mobile', number: '(312) 555-1234' }],
+ });
+ expect(mine.statusCode, mine.body).toBe(201);
+ expect(mine.json()).toMatchObject({
+ book: 'mine',
+ email: null,
+ numbers: [{ label: 'mobile', number: '13125551234' }],
+ });
+ expect(
+ (
+ await dan('POST', '/api/me/contacts', { name: 'X', numbers: [{ number: 'call me' }] })
+ ).json().error.message,
+ ).toMatch(/isn't a phone number/);
+
+ const co = await admin('POST', '/api/contacts', {
+ name: 'Pizza Place',
+ numbers: [{ label: 'main', number: '312-555-0000' }],
+ });
+ expect(co.statusCode, co.body).toBe(201);
+ expect((await dan('GET', '/api/contacts')).statusCode).toBe(403);
+ // Dan sees the company contact but can't change it.
+ const list = (await dan('GET', '/api/me/contacts')).json().items;
+ expect(list.map((c: { name: string; book: string }) => [c.name, c.book])).toEqual([
+ ['Jane Doe', 'mine'],
+ ['Pizza Place', 'company'],
+ ]);
+ expect((await dan('DELETE', `/api/me/contacts/${co.json().id}`)).statusCode).toBe(404);
+ expect(
+ (await sue('PATCH', `/api/me/contacts/${mine.json().id}`, { name: 'Mine now' })).statusCode,
+ ).toBe(404);
+ expect((await sue('GET', '/api/me/contacts')).json().items).toHaveLength(1); // company only
+ });
+
+ it('import: preview first, then save; what is already there is skipped', async () => {
+ const files = [
+ {
+ name: 'phone.vcf',
+ text:
+ vcf('Jane Doe', '+1 312 555 1234') + // already in Dan's book
+ vcf('Bob Smith', '312.555.9999') +
+ 'BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Nobody Home\r\nEMAIL:n@example.com\r\nEND:VCARD\r\n',
+ },
+ { name: 'more.csv', text: 'name,number1\nBob Smith,3125559999\nCarl,12ab\n' },
+ ];
+ const preview = await dan('POST', '/api/me/contacts/import', { files });
+ expect(preview.json()).toEqual({
+ committed: false,
+ added: 3,
+ duplicates: 2,
+ noNumber: 2,
+ errors: ['Carl: "12ab" isn\'t a phone number, left out'],
+ });
+ expect((await dan('GET', '/api/me/contacts')).json().items).toHaveLength(2);
+ const saved = await dan('POST', '/api/me/contacts/import?commit=1', { files });
+ expect(saved.json()).toMatchObject({ committed: true, added: 3, duplicates: 2 });
+ expect((await dan('POST', '/api/me/contacts/import?commit=1', { files })).json()).toMatchObject(
+ {
+ added: 0,
+ duplicates: 5,
+ },
+ );
+ // An admin imports into the company book.
+ const co = await admin('POST', '/api/contacts/import?commit=1', {
+ files: [{ name: 'co.vcf', text: vcf('Plumber', '3125557777') }],
+ });
+ expect(co.json()).toMatchObject({ added: 1 });
+ expect(
+ (await sue('GET', '/api/me/contacts')).json().items.map((c: { name: string }) => c.name),
+ ).toEqual(['Pizza Place', 'Plumber']);
+ });
+
+ it('sharing offers a copy; nothing lands until Accept, Deny adds nothing', async () => {
+ const book = (await dan('GET', '/api/me/contacts')).json().items as {
+ id: string;
+ name: string;
+ book: string;
+ }[];
+ const jane = book.find((c) => c.name === 'Jane Doe')!;
+ const bob = book.find((c) => c.name === 'Bob Smith')!;
+ expect(
+ (
+ await dan('POST', '/api/me/contacts/share', {
+ contactIds: [book.find((c) => c.book === 'company')!.id],
+ everyone: true,
+ })
+ ).statusCode,
+ ).toBe(404); // not Dan's to share
+ const r = await dan('POST', '/api/me/contacts/share', {
+ contactIds: [jane.id, bob.id],
+ extensions: ['102'],
+ });
+ expect(r.json()).toEqual({ offers: 2, people: 1 });
+ // Shared again: still one offer per contact.
+ await dan('POST', '/api/me/contacts/share', { contactIds: [jane.id, bob.id], everyone: true });
+
+ const before = (await sue('GET', '/api/me/contacts')).json().items.length;
+ const offers = (await sue('GET', '/api/me/contacts/shares')).json().items as {
+ id: string;
+ from: { name: string };
+ contact: { name: string };
+ }[];
+ expect(offers.map((o) => [o.from.name, o.contact.name]).sort()).toEqual([
+ ['dan@example.com', 'Bob Smith'],
+ ['dan@example.com', 'Jane Doe'],
+ ]);
+ expect((await sue('GET', '/api/me/contacts')).json().items).toHaveLength(before);
+
+ const janeOffer = offers.find((o) => o.contact.name === 'Jane Doe')!;
+ const bobOffer = offers.find((o) => o.contact.name === 'Bob Smith')!;
+ expect(
+ (await sue('POST', '/api/me/contacts/shares/deny', { ids: [bobOffer.id] })).json(),
+ ).toEqual({ denied: 1 });
+ expect(
+ (await sue('POST', '/api/me/contacts/shares/accept', { ids: [janeOffer.id] })).json(),
+ ).toEqual({
+ added: 1,
+ duplicates: 0,
+ });
+ // Decided offers can't be decided again, or by someone else.
+ expect(
+ (await sue('POST', '/api/me/contacts/shares/accept', { ids: [bobOffer.id] })).json(),
+ ).toEqual({
+ added: 0,
+ duplicates: 0,
+ });
+ const names = (await sue('GET', '/api/me/contacts'))
+ .json()
+ .items.map((c: { name: string }) => c.name);
+ expect(names).toContain('Jane Doe');
+ expect(names).not.toContain('Bob Smith');
+ expect((await sue('GET', '/api/me/contacts/shares')).json().items).toEqual([]);
+ // The copy is Sue's own: Dan editing his doesn't change it.
+ await dan('PATCH', `/api/me/contacts/${jane.id}`, { name: 'Jane D.', numbers: [] });
+ expect(
+ (await sue('GET', '/api/me/contacts')).json().items.map((c: { name: string }) => c.name),
+ ).toContain('Jane Doe');
+ });
+
+ it('looks up names: my book first, then the company', async () => {
+ await admin('POST', '/api/contacts', {
+ name: 'Front desk',
+ numbers: [{ number: '3125551234' }],
+ });
+ await dan('POST', '/api/me/contacts', { name: 'My Jane', numbers: [{ number: '3125551234' }] });
+ const names = await contactNames(t.db, danId, [
+ '13125551234',
+ '13125550000',
+ '19995550000',
+ null,
+ ]);
+ expect(Object.fromEntries(names)).toEqual({
+ '13125551234': 'My Jane',
+ '13125550000': 'Pizza Place',
+ });
+ const company = await contactNames(t.db, null, ['13125551234']);
+ expect(company.get('13125551234')).toBe('Front desk');
+ });
+});
diff --git a/apps/api/test/schema.test.ts b/apps/api/test/schema.test.ts
index 49a7257..e7c56c0 100644
--- a/apps/api/test/schema.test.ts
+++ b/apps/api/test/schema.test.ts
@@ -111,11 +111,11 @@ describe.skipIf(!hasTestDb)('schema matches spec 02', () => {
beforeAll(async () => ({ db, drop } = await createTestDb()));
afterAll(() => drop?.());
- it('creates all 41 tables', async () => {
+ it('creates all 42 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: 41 });
+ expect(r.rows[0]).toEqual({ n: 42 });
});
it.each(Object.keys(EXPECTED))('%s columns', async (table) => {
diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts
index 3ec1084..709af59 100644
--- a/apps/web/src/api/client.ts
+++ b/apps/web/src/api/client.ts
@@ -22,6 +22,11 @@ import type {
CallDetailDto,
CallDto,
CallFilter,
+ ContactDto,
+ ContactImportResult,
+ ContactInput,
+ ContactShare,
+ ContactShareDto,
MyCallDto,
RecordingDto,
AuthUser,
@@ -284,6 +289,29 @@ export const meApi = {
deleteGreeting: (kind: GreetingKind) => api<{ ok: true }>('DELETE', `/api/me/greetings/${kind}`),
};
+/** My book is /api/me/contacts (it lists the company's too); the company's is /api/contacts. */
+export type ContactBase = '/api/me/contacts' | '/api/contacts';
+
+export const contactsApi = {
+ list: (base: ContactBase) => api>('GET', base).then((r) => r.items),
+ create: (base: ContactBase, c: ContactInput) => api('POST', base, c),
+ update: (base: ContactBase, id: string, c: ContactInput) =>
+ api('PATCH', `${base}/${id}`, c),
+ remove: (base: ContactBase, id: string) => api<{ ok: true }>('DELETE', `${base}/${id}`),
+ import: (base: ContactBase, files: { name: string; text: string }[], commit: boolean) =>
+ api('POST', `${base}/import?commit=${commit ? 1 : 0}`, { files }),
+ share: (s: ContactShare) =>
+ api<{ offers: number; people: number }>('POST', '/api/me/contacts/share', s),
+ shares: () =>
+ api>('GET', '/api/me/contacts/shares').then((r) => r.items),
+ decide: (decision: 'accept' | 'deny', ids: string[]) =>
+ api<{ added?: number; duplicates?: number; denied?: number }>(
+ 'POST',
+ `/api/me/contacts/shares/${decision}`,
+ { ids },
+ ),
+};
+
export const soundsApi = {
list: () => api>('GET', '/api/sounds').then((r) => r.items),
audioUrl: (id: string) => `/api/sounds/${id}/audio`,
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index 7207dac..3476200 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -27,6 +27,7 @@ import { OfficeHoursPage } from './pages/admin/OfficeHoursPage.tsx';
import { BlockedPage } from './pages/admin/BlockedPage.tsx';
import { CallsPage } from './pages/admin/CallsPage.tsx';
import { RecordingsPage } from './pages/admin/RecordingsPage.tsx';
+import { CompanyContactsPage } from './pages/user/ContactsPage.tsx';
import { ReportsPage } from './pages/admin/ReportsPage.tsx';
import { SystemPage } from './pages/admin/SystemPage.tsx';
import { MePage } from './pages/user/MePage.tsx';
@@ -75,6 +76,8 @@ const router = createBrowserRouter([
) : p.path === 'blocked' ? (
+ ) : p.path === 'contacts' ? (
+
) : p.path === 'calls' ? (
) : p.path === 'recordings' ? (
diff --git a/apps/web/src/pages/admin/AdminLayout.tsx b/apps/web/src/pages/admin/AdminLayout.tsx
index ffabdde..f60f390 100644
--- a/apps/web/src/pages/admin/AdminLayout.tsx
+++ b/apps/web/src/pages/admin/AdminLayout.tsx
@@ -23,7 +23,7 @@ export const ADMIN_PAGES = [
{ path: 'paging', label: 'Paging', milestone: null },
{ path: 'sounds', label: 'Sounds & music', milestone: null },
{ path: 'devices', label: 'Phones', milestone: null },
- { path: 'contacts', label: 'Contacts', milestone: 'M10' },
+ { path: 'contacts', label: 'Contacts', milestone: null },
{ path: 'calls', label: 'Call history', milestone: null },
{ path: 'recordings', label: 'Recordings', milestone: null },
{ path: 'reports', label: 'Reports', milestone: null },
diff --git a/apps/web/src/pages/user/ContactsPage.tsx b/apps/web/src/pages/user/ContactsPage.tsx
new file mode 100644
index 0000000..9003b3c
--- /dev/null
+++ b/apps/web/src/pages/user/ContactsPage.tsx
@@ -0,0 +1,620 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// Contacts (spec 11): my book with the company's (read-only) at /me/contacts, the company's at
+// /admin/contacts. Add, edit, import (.vcf, .csv), share (an offer the colleague accepts or
+// denies), click to call. useContactName() gives every page names for numbers.
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useMemo, useState } from 'react';
+import {
+ formatNumber,
+ normalizeNumber,
+ type ContactDto,
+ type ContactImportResult,
+ type ContactInput,
+} from '@selfphoneng/shared';
+import { contactsApi, type ContactBase } from '../../api/client.ts';
+import { Dialog, ConfirmDialog } from '../../components/Dialog.tsx';
+import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
+import { useLive } from '../../live/store.ts';
+import { useMe } from './MeSettingsPage.tsx';
+import { useCallBack } from './MyCallsPage.tsx';
+
+const MINE: ContactBase = '/api/me/contacts';
+const COMPANY: ContactBase = '/api/contacts';
+const key = (base: ContactBase) => ['contacts', base];
+const show = (n: string) => (n.length > 6 ? formatNumber(n) : n);
+
+/** Name for a number from my book, then the company's (11 §1); undefined when there's none. */
+export function useContactName() {
+ const list = useQuery({ queryKey: key(MINE), queryFn: () => contactsApi.list(MINE) });
+ const names = useMemo(() => {
+ const m = new Map();
+ for (const c of list.data ?? [])
+ for (const n of c.numbers) if (!m.has(n.number)) m.set(n.number, c.name);
+ return m;
+ }, [list.data]);
+ return (number: string | null | undefined) => {
+ if (!number) return undefined;
+ try {
+ return names.get(normalizeNumber(number)) ?? names.get(number);
+ } catch {
+ return names.get(number);
+ }
+ };
+}
+
+/** "Add to contacts" from a call or a voicemail: the drawer, filled with the number. */
+export function AddToContacts({ number, name }: { number: string; name?: string | null }) {
+ const [open, setOpen] = useState(false);
+ return (
+ <>
+ setOpen(true)}>
+ Add to contacts
+
+ {open && (
+ setOpen(false)}
+ />
+ )}
+ >
+ );
+}
+
+export function ContactsPage() {
+ return (
+
+
+
+
+ );
+}
+
+export function CompanyContactsPage() {
+ return ;
+}
+
+function ContactBook({ base, title }: { base: ContactBase; title: string }) {
+ const qc = useQueryClient();
+ const list = useQuery({ queryKey: key(base), queryFn: () => contactsApi.list(base) });
+ const { call, note } = useCallBack();
+ const [q, setQ] = useState('');
+ const [editing, setEditing] = useState<(ContactInput & { id?: string }) | null>(null);
+ const [deleting, setDeleting] = useState(null);
+ const [importing, setImporting] = useState(false);
+ const [picked, setPicked] = useState>(new Set());
+ const [sharing, setSharing] = useState(false);
+ const remove = useMutation({
+ mutationFn: (id: string) => contactsApi.remove(base, id),
+ onSuccess: async () => {
+ setDeleting(null);
+ await qc.invalidateQueries({ queryKey: ['contacts'] });
+ },
+ });
+ const words = q.trim().toLowerCase();
+ const digits = q.replace(/\D/g, '');
+ // ponytail: the whole book is loaded and filtered here; page it on the server if books get huge.
+ const shown = (list.data ?? []).filter(
+ (c) =>
+ !words ||
+ `${c.name} ${c.company ?? ''} ${c.email ?? ''}`.toLowerCase().includes(words) ||
+ (digits.length >= 3 && c.numbers.some((n) => n.number.includes(digits))),
+ );
+ const canShare = base === MINE;
+ const mine = (c: ContactDto) => base === COMPANY || c.book === 'mine';
+
+ return (
+
+
+
+ setQ(e.target.value)}
+ />
+ setEditing({ name: '', numbers: [{ label: 'mobile', number: '' }] })}
+ >
+ Add contact
+
+ setImporting(true)}>Import
+ {canShare && (
+ setSharing(true)}>
+ Share{picked.size ? ` (${picked.size})` : ''}
+
+ )}
+
+ {note && (
+
+ {note}
+
+ )}
+ {list.error && {list.error.message} }
+ {list.data && shown.length === 0 && (
+
+ {q ? 'No contact matches that.' : 'No contacts yet.'}
+
+ )}
+
+ {editing && (
+ ({ ...n, number: show(n.number) })),
+ }}
+ onClose={() => setEditing(null)}
+ />
+ )}
+ {importing && setImporting(false)} />}
+ {sharing && (
+ {
+ setSharing(false);
+ if (done) setPicked(new Set());
+ }}
+ />
+ )}
+ Delete {deleting?.name}? This can't be undone.
}
+ confirmLabel="Delete"
+ busy={remove.isPending}
+ error={remove.error?.message}
+ onCancel={() => setDeleting(null)}
+ onConfirm={() => deleting && remove.mutate(deleting.id)}
+ />
+
+ );
+}
+
+const LABELS = ['mobile', 'work', 'home', 'main', 'fax', 'other'];
+
+function ContactDrawer(props: {
+ base: ContactBase;
+ contact: ContactInput & { id?: string };
+ onClose: () => void;
+}) {
+ const qc = useQueryClient();
+ const { id, ...start } = props.contact;
+ const [form, setForm] = useState({
+ name: start.name,
+ company: start.company ?? '',
+ email: start.email ?? '',
+ notes: start.notes ?? '',
+ numbers: start.numbers?.length
+ ? start.numbers.map((n) => ({ label: n.label ?? '', number: n.number }))
+ : [{ label: 'mobile', number: '' }],
+ });
+ const save = useMutation({
+ mutationFn: () => {
+ const c = { ...form, numbers: form.numbers.filter((n) => n.number.trim()) };
+ return id ? contactsApi.update(props.base, id, c) : contactsApi.create(props.base, c);
+ },
+ onSuccess: async () => {
+ await qc.invalidateQueries({ queryKey: ['contacts'] });
+ props.onClose();
+ },
+ });
+ const setNumber = (i: number, patch: Partial<{ label: string; number: string }>) =>
+ setForm({ ...form, numbers: form.numbers.map((n, j) => (j === i ? { ...n, ...patch } : n)) });
+ return (
+
+ Cancel
+
+ Save
+
+ >
+ }
+ >
+
+
+ );
+}
+
+function ImportDialog({ base, onClose }: { base: ContactBase; onClose: () => void }) {
+ const qc = useQueryClient();
+ const [files, setFiles] = useState<{ name: string; text: string }[]>([]);
+ const [preview, setPreview] = useState(null);
+ const run = useMutation({
+ mutationFn: (commit: boolean) => contactsApi.import(base, files, commit),
+ onSuccess: async (r) => {
+ setPreview(r);
+ if (r.committed) await qc.invalidateQueries({ queryKey: ['contacts'] });
+ },
+ });
+ const done = preview?.committed;
+ return (
+
+ Done
+
+ ) : (
+ <>
+ Cancel
+ run.mutate(true)}
+ >
+ Import {preview?.added ?? ''}
+
+ >
+ )
+ }
+ >
+
+
+ vCard files (.vcf) from a phone, Google Contacts, Outlook or Apple Contacts, or a CSV with
+ a header row: name, company, email, label1, number1, label2, number2…
+
+
{
+ const picked = [...(e.target.files ?? [])];
+ const read = await Promise.all(
+ picked.map(async (f) => ({ name: f.name, text: await f.text() })),
+ );
+ setFiles(read);
+ setPreview(null);
+ if (read.length) run.mutate(false);
+ }}
+ />
+ {run.isPending &&
Reading…
}
+ {preview && (
+
+
+ {done ? 'Imported' : 'New'}: {preview.added}
+ {preview.duplicates > 0 && <> · already in the book, skipped: {preview.duplicates}>}
+ {preview.noNumber > 0 && <> · without a phone number: {preview.noNumber}>}
+
+ {preview.errors.length > 0 && (
+
+ {preview.errors.map((e) => (
+ {e}
+ ))}
+
+ )}
+
+ )}
+ {run.error &&
{run.error.message} }
+
+
+ );
+}
+
+function ShareDialog({ ids, onClose }: { ids: string[]; onClose: (done: boolean) => void }) {
+ const people = Object.values(useLive((s) => s.extensions)).sort((a, b) =>
+ a.name.localeCompare(b.name),
+ );
+ const myNumber = useMe().data?.extension.number;
+ const [everyone, setEveryone] = useState(false);
+ const [to, setTo] = useState>(new Set());
+ const share = useMutation({
+ mutationFn: () => contactsApi.share({ contactIds: ids, everyone, extensions: [...to] }),
+ });
+ return (
+ onClose(share.isSuccess)}
+ footer={
+ share.isSuccess ? (
+ onClose(true)}>
+ Done
+
+ ) : (
+ <>
+ onClose(false)}>Cancel
+ share.mutate()}
+ >
+ Share
+
+ >
+ )
+ }
+ >
+ {share.isSuccess ? (
+
+ Sent to {share.data.people} {share.data.people === 1 ? 'person' : 'people'}. They'll see
+ it under "Shared with you" and choose to accept or deny it.
+
+ ) : (
+
+
+ Each person gets a copy to accept into their own contacts, or deny.
+
+
+ setEveryone(e.target.checked)}
+ />
+ Everyone in the company
+
+ {!everyone &&
+ people
+ .filter((p) => p.number !== myNumber)
+ .map((p) => (
+
+ {
+ const next = new Set(to);
+ if (e.target.checked) next.add(p.number);
+ else next.delete(p.number);
+ setTo(next);
+ }}
+ />
+ {p.name} ({p.number})
+
+ ))}
+ {share.error &&
{share.error.message} }
+
+ )}
+
+ );
+}
+
+/** Pending offers from colleagues: nothing is added until Accept (11 §2.1). */
+export function useContactShares() {
+ return useQuery({ queryKey: ['contacts', 'shares'], queryFn: contactsApi.shares });
+}
+
+function SharedWithYou() {
+ const qc = useQueryClient();
+ const shares = useContactShares();
+ const [result, setResult] = useState(null);
+ const decide = useMutation({
+ mutationFn: ({ decision, ids }: { decision: 'accept' | 'deny'; ids: string[] }) =>
+ contactsApi.decide(decision, ids),
+ onSuccess: async (r) => {
+ setResult(
+ r.added !== undefined
+ ? `Added ${r.added}${r.duplicates ? `; ${r.duplicates} you already had` : ''}.`
+ : `Denied ${r.denied}.`,
+ );
+ await qc.invalidateQueries({ queryKey: ['contacts'] });
+ },
+ });
+ if (!shares.data?.length && !result) return null;
+ const bySender = new Map();
+ for (const s of shares.data ?? []) {
+ const k = s.from?.name ?? 'Someone who left';
+ bySender.set(k, [...(bySender.get(k) ?? []), s]);
+ }
+ return (
+
+
+ Shared with you
+
+ {result && (
+
+ {result}
+
+ )}
+ {[...bySender].map(([from, list]) => (
+
+
+ From {from}
+ {list!.length > 1 && (
+ <>
+ decide.mutate({ decision: 'accept', ids: list!.map((s) => s.id) })}
+ >
+ Accept all {list!.length}
+
+ decide.mutate({ decision: 'deny', ids: list!.map((s) => s.id) })}
+ >
+ Deny all
+
+ >
+ )}
+
+
+ {list!.map((s) => (
+
+
+ {s.contact.name}
+ {s.contact.company && (
+ · {s.contact.company}
+ )}
+ {s.contact.numbers.map((n) => (
+
+ {' '}
+ · {show(n.number)}
+
+ ))}
+
+ decide.mutate({ decision: 'accept', ids: [s.id] })}
+ >
+ Accept
+
+ decide.mutate({ decision: 'deny', ids: [s.id] })}
+ >
+ Deny
+
+
+ ))}
+
+
+ ))}
+ {decide.error && {decide.error.message} }
+
+ );
+}
diff --git a/apps/web/src/pages/user/MePage.tsx b/apps/web/src/pages/user/MePage.tsx
index d0f84a0..2baee2b 100644
--- a/apps/web/src/pages/user/MePage.tsx
+++ b/apps/web/src/pages/user/MePage.tsx
@@ -1,6 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
-// My Phone (05 §4). M4: Home and Voicemail. Calls, contacts, settings and the softphone follow
-// with their milestones (M5–M7).
+// My Phone (05 §4): Home, Calls, Voicemail, Contacts (11), Settings, People, and the softphone.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { Link, NavLink, Route, Routes, useNavigate } from 'react-router';
@@ -16,6 +15,7 @@ import { MyCallsPage, otherParty, useCallBack } from './MyCallsPage.tsx';
import { fmtNumber } from '../admin/CallsPage.tsx';
import { ParkedCalls } from '../../components/ParkedCalls.tsx';
import { DndSwitch, MeSettingsPage, showTarget, useMe } from './MeSettingsPage.tsx';
+import { ContactsPage, useContactName, useContactShares } from './ContactsPage.tsx';
const link = 'text-sky-700 underline dark:text-sky-400';
@@ -34,6 +34,7 @@ export function MePage({ user }: { user: AuthUser }) {
void qc.invalidateQueries({ queryKey: ['me', 'settings'] });
}, [myDnd, qc]);
const toasts = useLive((s) => s.toasts);
+ const offers = useContactShares().data?.length ?? 0;
const tab = ({ isActive }: { isActive: boolean }) =>
`rounded-md px-3 py-2 text-sm font-medium ${isActive ? 'bg-zinc-100 dark:bg-zinc-800' : 'hover:bg-zinc-100 dark:hover:bg-zinc-800'}`;
@@ -78,6 +79,15 @@ export function MePage({ user }: { user: AuthUser }) {
Voicemail
+
+ Contacts
+ {offers > 0 && (
+
+ , shared with you:
+ {offers}
+
+ )}
+
Settings
@@ -111,6 +121,7 @@ export function MePage({ user }: { user: AuthUser }) {
} />
} />
} />
+ } />
} />
} />
@@ -127,9 +138,11 @@ export function MePage({ user }: { user: AuthUser }) {
function Home({ user }: { user: AuthUser }) {
const list = useQuery({ queryKey: ['me', 'voicemail'], queryFn: meApi.voicemail });
const fresh = list.data?.filter(isNew) ?? [];
+ const nameOf = useContactName();
return (
Hi {user.displayName}.
+
{user.extension &&
}
{user.extension &&
}
{user.extension &&
}
@@ -142,7 +155,7 @@ function Home({ user }: { user: AuthUser }) {
);
@@ -355,7 +357,7 @@ function SettingsStep({
next,
skippable,
}: {
- tab: 'email' | 'emergency';
+ tab: 'email' | 'emergency' | 'contacts';
next: () => void;
skippable?: boolean;
}) {
@@ -369,7 +371,13 @@ function SettingsStep({
!s.data['emergency.defaultCallerIdDidId'];
return (
<>
- {tab === 'email' ? : }
+ {tab === 'email' ? (
+
+ ) : tab === 'contacts' ? (
+
+ ) : (
+
+ )}
void }) {
return (
<>
- Add the phone numbers (DIDs) you have at voip.ms. You choose where their calls go in step 8.
+ Add the phone numbers (DIDs) you have at voip.ms. You choose where their calls go in step 9.
{trunks.data[0]!.hasApiPassword && }
{dids.data.length > 0 && (
@@ -728,7 +736,7 @@ function DoneStep({ onFinish, busy }: { onFinish: () => void; busy: boolean }) {
Set up each desk phone or softphone app with the SIP details under Users & Extensions
→ Credentials.
- Call your main number from a mobile phone: the phone you chose in step 8 rings.
+ Call your main number from a mobile phone: the phone you chose in step 9 rings.
From a desk phone, call your mobile: it shows your main number as caller ID.
Everything here can be changed later in the admin pages.
diff --git a/apps/web/src/pages/admin/SettingsPage.tsx b/apps/web/src/pages/admin/SettingsPage.tsx
index 8fc73b1..c39d7f6 100644
--- a/apps/web/src/pages/admin/SettingsPage.tsx
+++ b/apps/web/src/pages/admin/SettingsPage.tsx
@@ -15,6 +15,7 @@ import {
import {
aiApi,
apiTokensApi,
+ contactsApi,
didsApi,
extensionsApi,
settingsApi,
@@ -36,6 +37,7 @@ const TABS = [
{ id: 'webhooks', label: 'Webhooks' },
{ id: 'tokens', label: 'API tokens' },
{ id: 'ai', label: 'AI' },
+ { id: 'contacts', label: 'Contacts' },
] as const;
export function SettingsPage() {
@@ -71,6 +73,7 @@ export function SettingsPage() {
{settings.data && tab === 'webhooks' && }
{tab === 'tokens' && }
{settings.data && tab === 'ai' && }
+ {settings.data && tab === 'contacts' && }
);
}
@@ -1215,3 +1218,146 @@ function TokensTab() {
);
}
+
+/** Contacts (spec 11): the builtin books, or Contrack running beside the PBX. Also a setup step. */
+export function ContactsTab({ s }: { s: SettingsDto }) {
+ const save = useSave();
+ const ctk = s['contacts.contrack'];
+ const [provider, setProvider] = useState(s['contacts.provider']);
+ const [v, setV] = useState({
+ url: ctk?.url ?? 'http://127.0.0.1:3210',
+ publicUrl: ctk?.publicUrl ?? '',
+ companyToken: '',
+ });
+ const test = useMutation({
+ mutationFn: () => contactsApi.testContrack(v.url, v.companyToken || undefined),
+ });
+ const set = (k: keyof typeof v) => (e: { target: { value: string } }) =>
+ setV({ ...v, [k]: e.target.value });
+ return (
+ {
+ e.preventDefault();
+ save.mutate({
+ 'contacts.provider': provider,
+ ...(provider === 'contrack'
+ ? {
+ 'contacts.contrack': {
+ url: v.url,
+ publicUrl: v.publicUrl,
+ companyToken: v.companyToken || undefined,
+ },
+ }
+ : {}),
+ });
+ setV({ ...v, companyToken: '' });
+ }}
+ >
+
+ Where contacts live
+
+ setProvider('builtin')}
+ />
+
+ Simple : a phonebook inside SelfPhoneNG. Names,
+ numbers, .vcf and CSV import, sharing. Nothing else to run.
+
+
+
+ setProvider('contrack')}
+ />
+
+ Contrack : a full contact and relationship manager
+ next to the phone system (notes, reminders, relationship tracking, imports from Google,
+ Apple and LinkedIn, CardDAV sync). Each person has their own Contrack account, and the
+ company has one everyone sees. It runs as its own container:{' '}
+ docker compose --env-file .env -f deploy/contrack/compose.yml up -d.
+
+
+
+ {provider === 'contrack' && (
+
+ Contrack
+
+ {(a) => }
+
+
+ {(a) => (
+
+ )}
+
+
+ {(a) => (
+
+ )}
+
+
+ test.mutate()} disabled={test.isPending}>
+ Test
+
+ {test.data && (
+
+ {test.data.ok ? test.data.detail : test.data.error}
+
+ )}
+
+
+ Each person then connects their own Contrack account in My settings, Contacts. If the AI
+ inside Contrack uses a cloud provider, what is stored there reaches that provider: turn
+ it off in Contrack, or point it at your local AI.
+
+
+ )}
+ {save.isError && {save.error.message} }
+ {save.isSuccess && (
+
+ Saved.
+
+ )}
+
+
+ Save
+
+
+
+ );
+}
diff --git a/apps/web/src/pages/user/ContactsPage.tsx b/apps/web/src/pages/user/ContactsPage.tsx
index 9003b3c..00537f3 100644
--- a/apps/web/src/pages/user/ContactsPage.tsx
+++ b/apps/web/src/pages/user/ContactsPage.tsx
@@ -28,7 +28,7 @@ export function useContactName() {
const list = useQuery({ queryKey: key(MINE), queryFn: () => contactsApi.list(MINE) });
const names = useMemo(() => {
const m = new Map();
- for (const c of list.data ?? [])
+ for (const c of list.data?.items ?? [])
for (const n of c.numbers) if (!m.has(n.number)) m.set(n.number, c.name);
return m;
}, [list.data]);
@@ -94,14 +94,16 @@ function ContactBook({ base, title }: { base: ContactBase; title: string }) {
const words = q.trim().toLowerCase();
const digits = q.replace(/\D/g, '');
// ponytail: the whole book is loaded and filtered here; page it on the server if books get huge.
- const shown = (list.data ?? []).filter(
+ const ctk = list.data?.contrack ?? null;
+ const shown = (list.data?.items ?? []).filter(
(c) =>
!words ||
`${c.name} ${c.company ?? ''} ${c.email ?? ''}`.toLowerCase().includes(words) ||
(digits.length >= 3 && c.numbers.some((n) => n.number.includes(digits))),
);
const canShare = base === MINE;
- const mine = (c: ContactDto) => base === COMPANY || c.book === 'mine';
+ // Contrack mode: contacts are changed in Contrack, so rows link there (11 §4.3).
+ const mine = (c: ContactDto) => !c.url && (base === COMPANY || c.book === 'mine');
return (
@@ -123,7 +125,20 @@ function ContactBook({ base, title }: { base: ContactBase; title: string }) {
>
Add contact
- setImporting(true)}>Import
+ {!ctk ? (
+ setImporting(true)}>Import
+ ) : (
+ ctk.publicUrl && (
+
+ Import in Contrack
+
+ )
+ )}
{canShare && (
setSharing(true)}>
Share{picked.size ? ` (${picked.size})` : ''}
@@ -136,6 +151,14 @@ function ContactBook({ base, title }: { base: ContactBase; title: string }) {
)}
{list.error && {list.error.message} }
+ {ctk && !ctk.connected && (
+
+ {ctk.error ??
+ (base === MINE
+ ? 'Your contacts are in Contrack: connect your account in Settings → Contacts to see and add them.'
+ : 'The company Contrack account has no token yet: Settings → Contacts.')}
+
+ )}
{list.data && shown.length === 0 && (
{q ? 'No contact matches that.' : 'No contacts yet.'}
@@ -181,6 +204,16 @@ function ContactBook({ base, title }: { base: ContactBase; title: string }) {
))}
+ {c.url && (
+
+ Open in Contrack
+
+ )}
{mine(c) && (
setEditing(c)}>
diff --git a/apps/web/src/pages/user/MeSettingsPage.tsx b/apps/web/src/pages/user/MeSettingsPage.tsx
index 210b538..2a5127c 100644
--- a/apps/web/src/pages/user/MeSettingsPage.tsx
+++ b/apps/web/src/pages/user/MeSettingsPage.tsx
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// My settings (05 §4): call forwarding, do not disturb, voicemail PIN and email options, the call
-// report email.
+// report email, my Contrack account (11 §4.3).
// Softphone devices and notifications live in the softphone panel. "Set up a phone app" (07 §5).
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
@@ -63,6 +63,7 @@ function SettingsForms({ me }: { me: MeDto }) {
{me.voicemailEnabled && }
+ {me.contrack && }
Set up a phone app
@@ -302,3 +303,82 @@ function CallReportForm({ me }: { me: MeDto }) {
);
}
+
+/** Contrack mode (11 §4.3): my own Contrack account, through a personal token I make there. */
+function ContrackConnect({ contrack }: { contrack: NonNullable }) {
+ const qc = useQueryClient();
+ const [token, setToken] = useState('');
+ const done = (me: MeDto) => {
+ qc.setQueryData(['me', 'settings'], me);
+ setToken('');
+ void qc.invalidateQueries({ queryKey: ['contacts'] });
+ };
+ const connect = useMutation({ mutationFn: () => meApi.connectContrack(token), onSuccess: done });
+ const disconnect = useMutation({ mutationFn: meApi.disconnectContrack, onSuccess: done });
+ const where = contrack.publicUrl ? (
+
+ Contrack
+
+ ) : (
+ 'Contrack'
+ );
+ return (
+
+ );
+}
diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile
index e52b839..4c04f29 100644
--- a/deploy/caddy/Caddyfile
+++ b/deploy/caddy/Caddyfile
@@ -8,3 +8,10 @@
reverse_proxy 127.0.0.1:8080
}
}
+
+# Contrack (spec 11 §4), when you run deploy/contrack: uncomment, and set CONTRACK_HOSTNAME in .env
+# (its own DNS name; Contrack can't live under a path).
+# {$CONTRACK_HOSTNAME} {
+# encode zstd gzip
+# reverse_proxy 127.0.0.1:3210
+# }
diff --git a/deploy/contrack/compose.yml b/deploy/contrack/compose.yml
new file mode 100644
index 0000000..fc7e04a
--- /dev/null
+++ b/deploy/contrack/compose.yml
@@ -0,0 +1,32 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+# Contrack (spec 11 §4, admin guide: Contacts): the "Robust" contacts, its own stack beside the PBX
+# on the same server. Contrack is a separate open source program (AGPL-3.0) we talk to over HTTP.
+#
+# docker compose --env-file .env -f deploy/contrack/compose.yml up -d
+#
+# It listens on 127.0.0.1 only: the PBX (host networking) reaches it there, and people reach it
+# through the reverse proxy at CONTRACK_HOSTNAME (deploy/caddy/Caddyfile, deploy/nginx).
+# Then Settings → Contacts → Contrack, address http://127.0.0.1:3210.
+services:
+ contrack:
+ # Pinned: SelfPhoneNG is tested against this version's API (decision 0003).
+ image: ghcr.io/arvarik/contrack:2.0.2
+ restart: unless-stopped
+ ports: ['127.0.0.1:3210:3210']
+ environment:
+ AUTH_REQUIRED: 'true' # everyone signs in; tokens need it
+ TRUST_PROXY_HOPS: '1'
+ PUBLIC_URL: https://${CONTRACK_HOSTNAME:?set CONTRACK_HOSTNAME in .env}
+ # Contrack's own AI and address lookups are outside SelfPhoneNG's "nothing leaves the
+ # server" rule. Uncomment to keep everything here (or point its AI at your local Ollama in
+ # Contrack's Settings → AI):
+ # AI_DISABLED: 'true'
+ # GEOCODING_DISABLED: 'true'
+ volumes:
+ - contrack-data:/app/data # its database, uploads and its own backups: back this up too
+ logging:
+ driver: json-file
+ options: { max-size: 10m, max-file: '3' }
+
+volumes:
+ contrack-data:
diff --git a/deploy/nginx/selfphoneng.conf.example b/deploy/nginx/selfphoneng.conf.example
index 9fb4f75..13b0c84 100644
--- a/deploy/nginx/selfphoneng.conf.example
+++ b/deploy/nginx/selfphoneng.conf.example
@@ -45,3 +45,19 @@ server {
proxy_read_timeout 3600s;
}
}
+
+# Contrack (spec 11 §4), when you run deploy/contrack: its own name (it can't live under a path).
+# server {
+# listen 443 ssl;
+# http2 on;
+# server_name contacts.example.com;
+# ssl_certificate /etc/letsencrypt/live/contacts.example.com/fullchain.pem;
+# ssl_certificate_key /etc/letsencrypt/live/contacts.example.com/privkey.pem;
+# client_max_body_size 50m; # contact imports and attachments
+# location / {
+# proxy_pass http://127.0.0.1:3210;
+# proxy_set_header Host $host;
+# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+# proxy_set_header X-Forwarded-Proto $scheme;
+# }
+# }
diff --git a/docker-compose.yml b/docker-compose.yml
index 4a38d4c..4f98da1 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -87,6 +87,7 @@ services:
network_mode: host
environment:
PUBLIC_HOSTNAME: ${PUBLIC_HOSTNAME:?set in .env}
+ CONTRACK_HOSTNAME: ${CONTRACK_HOSTNAME:-} # deploy/caddy/Caddyfile, when Contrack runs
volumes:
- ./deploy/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- ./data/caddy:/data
diff --git a/docs/admin-guide/README.md b/docs/admin-guide/README.md
index a5fef36..230bdbe 100644
--- a/docs/admin-guide/README.md
+++ b/docs/admin-guide/README.md
@@ -11,6 +11,7 @@ keeping the system safe, legal and up to date.
6. [Updating, backups and monitoring](maintenance.md)
7. [Analog phones (ATAs)](ata.md)
8. [Local AI](ai.md): voicemail transcripts
+9. [Contacts](contacts.md): the builtin phonebook, or Contrack
---
diff --git a/docs/admin-guide/contacts.md b/docs/admin-guide/contacts.md
new file mode 100644
index 0000000..c8cac90
--- /dev/null
+++ b/docs/admin-guide/contacts.md
@@ -0,0 +1,85 @@
+# Contacts
+
+Contacts put names on numbers: in call history, voicemail, the browser phone while it rings, the
+call report email, and the directory on desk phones (the company contacts). Pick where they live
+in the setup wizard or in **Settings → Contacts**:
+
+- **Simple**: a phonebook inside SelfPhoneNG. Nothing else to run.
+- **Contrack**: [Contrack](https://github.com/arvarik/contrack), a full contact and relationship
+ manager, running next to the phone system as its own container.
+
+Everyone has their own contacts, and there are **company contacts** that everyone sees (only
+admins change them, in **Admin → Contacts**).
+
+## Simple
+
+- **My Phone → Contacts**: add, edit, search, click a number to call. Calls and voicemails from a
+ number that isn't a contact have **Add to contacts**.
+- **Import**: one or more `.vcf` files (exported from an iPhone, Android, Google Contacts,
+ Outlook or Apple Contacts) or a CSV with a header row (`name, company, email, label1, number1,
+ label2, number2, …`). You see what will be added first; contacts already there (same name and
+ number) are skipped. Up to 5,000 contacts at a time.
+- **Share**: tick contacts, **Share**, pick colleagues or everyone. They get "Shared with you" at
+ the top of their Contacts page and on Home, and choose **Accept** (a copy goes into their
+ contacts) or **Deny**. Nothing is added to anyone's contacts without their click.
+
+## Contrack
+
+Contrack keeps each person's contacts in their own private Contrack account, and the company's in
+a Contrack account of its own. SelfPhoneNG reads them (names on calls, the directory on desk
+phones), adds to them ("Add to contacts", accepted shares), and links to them. Editing, importing,
+notes and the rest happen in Contrack.
+
+### Set it up
+
+1. Give it a DNS name of its own, such as `contacts.example.com` (Contrack can't live under a
+ path of the phone system's name), pointing at this server. Set it in `.env`:
+
+ ```bash
+ CONTRACK_HOSTNAME=contacts.example.com
+ ```
+
+2. Start it:
+
+ ```bash
+ docker compose --env-file .env -f deploy/contrack/compose.yml up -d
+ ```
+
+ It listens on `127.0.0.1:3210` only.
+3. Put it behind your reverse proxy: with the bundled Caddy, uncomment the Contrack block at the
+ end of `deploy/caddy/Caddyfile` and restart Caddy; with your own nginx, see the end of
+ `deploy/nginx/selfphoneng.conf.example`.
+4. Open `https://contacts.example.com`. The first visit creates the first (admin) account. Then,
+ in Contrack's **Settings → Administration → Invitations**, invite each person, and make one
+ account for the company, such as `company@example.com`.
+5. Signed in to Contrack as the company account: **Settings → Account → API tokens → Create
+ token**. Under **Expires** choose **Never** (or 1 year, and replace it before then): the
+ default is 90 days, and names stop showing when a token expires. **Read only** is enough;
+ **Read and write** lets admins add company contacts from SelfPhoneNG.
+6. In SelfPhoneNG, **Settings → Contacts → Contrack**: address `http://127.0.0.1:3210`, the
+ address people open (`https://contacts.example.com`), the company token, **Test**, **Save**.
+7. Each person: in Contrack, **Settings → Account → API tokens → Create token**, **Read and
+ write**, expiring **Never** (or 1 year), then in SelfPhoneNG **My settings → Contacts**, paste
+ it, **Connect**. My settings says so when a token stops working.
+
+Names refresh from Contrack every 5 minutes (at once after SelfPhoneNG adds a contact). If
+Contrack is down, the last names it gave keep working.
+
+### Privacy
+
+SelfPhoneNG only talks to Contrack on this server or the local network, and keeps the tokens
+encrypted. Contrack itself can use AI (cloud providers, or your local Ollama) and look addresses
+up on OpenStreetMap: those are Contrack's settings, outside SelfPhoneNG's "nothing leaves the
+server" rule. To keep everything here, set `AI_DISABLED` and `GEOCODING_DISABLED` in
+`deploy/contrack/compose.yml`, or point Contrack's AI at your local AI in its **Settings →
+Administration → AI**.
+
+### Backups
+
+SelfPhoneNG's backup doesn't include Contrack. Contrack makes its own snapshots in its
+`contrack-data` volume (its **Settings → Backups**); back up that volume too.
+
+### Going back to Simple
+
+**Settings → Contacts → Simple**. The builtin contacts are still there, and the Contrack tokens
+are kept in case you switch back.
diff --git a/docs/decisions/0003-contrack.md b/docs/decisions/0003-contrack.md
new file mode 100644
index 0000000..37f96a2
--- /dev/null
+++ b/docs/decisions/0003-contrack.md
@@ -0,0 +1,15 @@
+# 0003 — Contrack for "Robust" contacts
+
+Owner, 2026-10-23: contacts come in two kinds (spec 11), and the robust one is
+[Contrack](https://github.com/arvarik/contrack), a self-hosted contact and relationship manager.
+It already does what a phonebook would take years to grow into: notes, timelines, reminders,
+relationship tracking, duplicate merging, search, imports from Google, Apple and LinkedIn, and
+CardDAV sync. It's allowed because it is open source (AGPL-3.0-only) and runs as **its own
+program** (`deploy/contrack/compose.yml`, image pinned to 2.0.2) that SelfPhoneNG talks to over
+HTTP on 127.0.0.1: none of its code is in our dependency tree, and there is no license mixing.
+The risk is a young project with one maintainer: we use seven of its routes (`/healthz`,
+`/api/contacts` list and create, and in C5 the timeline routes), pin the version, and
+`apps/api/test/contrack.test.ts` keeps a fake of exactly those routes, and
+`apps/api/test/contrack-live.test.ts` runs the same flows against a real Contrack
+(`CONTRACK_LIVE_URL`, a fresh container; passed against 2.0.2 on 2026-10-23). A new Contrack
+version is a deliberate update: read its CHANGELOG, run the live test against it, bump the tag.
diff --git a/docs/spec/02-data-model.md b/docs/spec/02-data-model.md
index f3901ff..d0949d0 100644
--- a/docs/spec/02-data-model.md
+++ b/docs/spec/02-data-model.md
@@ -76,7 +76,8 @@ create table users (
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
+ last_login_at timestamptz,
+ contrack_token_enc text -- their Contrack personal token (11 §4.3)
);
create table sessions (
diff --git a/docs/spec/11-contacts.md b/docs/spec/11-contacts.md
index 2b92c07..3850827 100644
--- a/docs/spec/11-contacts.md
+++ b/docs/spec/11-contacts.md
@@ -170,8 +170,10 @@ against the pinned version's `openapi.json`.
volume, and `AUTH_REQUIRED=true`, `TRUST_PROXY_HOPS=1`, `PUBLIC_URL=https://${CONTRACK_HOSTNAME}`.
- People open Contrack on **its own host name** (`PUBLIC_URL` takes no path), e.g.
`contacts.example.com`. The Caddyfile gets a second site block when `CONTRACK_HOSTNAME` is set;
- the nginx example gets a second server block.
-- The PBX reaches it at `http://127.0.0.1:3210`. `contacts.contrackUrl` must pass the same
+ the nginx example gets a second server block. As built: both are commented blocks to uncomment
+ (a Caddy site with an empty name would fail), and `docker compose --env-file .env -f
+ deploy/contrack/compose.yml up -d` (a compose file in a subfolder reads no `.env` by itself).
+- The PBX reaches it at `http://127.0.0.1:3210`. `contacts.contrack.url` must pass the same
local-address check as the AI endpoints (10 §1), so contact data and tokens stay on the network.
- Backups: Contrack keeps its own snapshots in its volume. The SelfPhoneNG backup (pg_dump) does
not include it; the admin guide says to back up the volume too.
@@ -182,25 +184,35 @@ against the pinned version's `openapi.json`.
### 4.3 Connecting users
-- Admin, Settings → Contacts: provider, Contrack URL, **Test** (`/healthz`, then the token below
- against `/api/contacts?view=slim&limit=1`).
+- Admin, Settings → Contacts (`contacts.provider`, `contacts.contrack` { url, publicUrl,
+ companyTokenEnc }): **Test** (`POST /api/contacts/contrack/test`: `/healthz`, then the token
+ against `/api/contacts?view=slim`; Contrack's `/api/auth/me` refuses tokens, so a token is
+ checked by reading contacts).
- Admin, same page: the **company Contrack account's token** (read-only is enough; read-write lets
- "Add to company contacts" work), in `contacts.companyToken` {enc}. The admin makes that account
- in Contrack (e.g. `company@…`) and fills it like any other.
+ "Add to company contacts" work), write-only like the SMTP password (`hasCompanyToken` in GET).
+ The admin makes that account in Contrack (e.g. `company@…`) and fills it like any other.
+- Contrack tokens expire in 90 days unless made with **Never**: the guide and the forms say to pick
+ Never (or a year).
- Each user, My settings → Contacts: **paste a Contrack personal token** (read-write; read-only
- allowed, then nothing is written to the timeline). Stored with `encrypt()` in
- `extensions.contrack_token_enc`. Never logged, never returned (only "connected as …, token
- ctk_AbCd…").
+ allowed, then nothing is written to the timeline). `PUT /api/me/contrack { token }` checks it
+ reads contacts, then stores it with `encrypt()` in `users.contrack_token_enc` (contacts are a
+ person's, not an extension's); `DELETE` disconnects. Never logged, never returned: `GET /api/me`
+ has `contrack: { connected, tokenPrefix: "ctk_AbCdEfGh…", publicUrl }`.
- Why not have the PBX create Contrack accounts: Contrack's admin routes refuse tokens
(`SESSION_REQUIRED`), so the PBX would have to keep an admin password. A pasted token is
explicit, revocable from Contrack, and scoped to one person.
-- **Lookup cache**: per connected user and for the company account, `GET /api/contacts?view=slim`, every phone through
- `normalizeNumber`, held in memory as `Map`. Refreshed every 5 minutes, at
- start, and right after SelfPhoneNG creates a contact. A user whose token fails (401) is marked
- "disconnected" in My settings and skipped; no error email.
+- **Lookup cache** (`contacts/contrack.ts`): per connected user and for the company account,
+ `GET /api/contacts?view=slim`, every phone through `normalizeNumber` (text that isn't a number
+ is left out), held in memory. As built: fetched on use when older than 5 minutes (no timer),
+ forgotten when SelfPhoneNG adds a contact or the token changes. A failing Contrack keeps the
+ last good list and the error, and is tried again 5 minutes later; a refused token (401) shows as
+ "doesn't work any more" in My settings. No error email.
- `/me/contacts` in this mode: search and list from Contrack (read-only rows, "Open in Contrack"
- link to `${PUBLIC_URL}/contacts/:id`), plus the company book. "Add to contacts" does
- `POST /api/contacts { name, phones: [number] }`. Editing happens in Contrack.
+ link to `${publicUrl}/contact/:id`), plus the company book. "Add to contacts" does
+ `POST /api/contacts { name, company, emails, phones: [{ phone, label }] }`. Editing, deleting
+ and importing answer 409 in this mode: they happen in Contrack. Desk phone directories (07) get
+ the company account's contacts. Accepting a share creates the copy with the recipient's token;
+ without one it answers 409 and the offer waits.
### 4.4 Calls on the Contrack timeline
diff --git a/packages/shared/src/contacts.ts b/packages/shared/src/contacts.ts
index 0caebba..bcf36a9 100644
--- a/packages/shared/src/contacts.ts
+++ b/packages/shared/src/contacts.ts
@@ -40,9 +40,20 @@ export type ContactData = {
};
export type ContactDto = ContactData & {
+ /** A uuid, or a Contrack contact id such as jane-smith-f60e8536. */
id: string;
/** mine: I edit it; company: read-only for users (admins edit it in Admin → Contacts). */
book: 'mine' | 'company';
+ /** Contrack: the contact's page there (editing happens in Contrack). */
+ url: string | null;
+};
+
+/** GET /api/me/contacts and /api/contacts: the book(s), and where they live. */
+export type ContactBookDto = {
+ items: ContactDto[];
+ provider: 'builtin' | 'contrack';
+ /** Contrack mode: the address people open, and whether this book's token works. */
+ contrack: { publicUrl: string | null; connected: boolean; error: string | null } | null;
};
/** POST …/contacts/import: the files' text, read in the browser. */
@@ -66,7 +77,7 @@ export type ContactImportResult = {
export const ContactShareSchema = z
.object({
- contactIds: z.array(z.uuid()).min(1).max(500),
+ contactIds: z.array(z.string().min(1).max(100)).min(1).max(500),
/** Colleagues by extension number. */
extensions: z.array(z.string().max(20)).max(500).default([]),
everyone: z.boolean().default(false),
diff --git a/packages/shared/src/me.ts b/packages/shared/src/me.ts
index d6fcdc3..502c823 100644
--- a/packages/shared/src/me.ts
+++ b/packages/shared/src/me.ts
@@ -55,4 +55,6 @@ export type MeDto = {
callReportTime: string;
callReportWeekday: number;
callReportSummaries: boolean;
+ /** Contacts in Contrack mode (11 §4.3): my token, never shown again; null in builtin mode. */
+ contrack: { connected: boolean; tokenPrefix: string | null; publicUrl: string | null } | null;
};
diff --git a/packages/shared/src/settings.ts b/packages/shared/src/settings.ts
index ad58aaf..d20df7b 100644
--- a/packages/shared/src/settings.ts
+++ b/packages/shared/src/settings.ts
@@ -104,6 +104,18 @@ export const SettingsSchemas = {
'ai.callTranscripts': z.boolean().default(false),
/** …and summarized by ai.llm. */
'ai.callSummaries': z.boolean().default(false),
+ /** Contacts (spec 11): the builtin books, or Contrack. */
+ 'contacts.provider': z.enum(['builtin', 'contrack']).default('builtin'),
+ /** Contrack (11 §4.2): `url` the api calls (local only), `publicUrl` the one people open. */
+ 'contacts.contrack': z
+ .object({
+ url: z.string().min(1),
+ publicUrl: z.string().nullable(),
+ /** The company account's personal token (11 §4.3), encrypted; '' = none. */
+ companyTokenEnc: z.string(),
+ })
+ .nullable()
+ .default(null),
// Not in 02 §3's table but used by the generator (03 §3).
'system.transportHash': z.string().nullable().default(null),
};
@@ -122,11 +134,26 @@ export const SmtpInputSchema = z.object({
});
export type SmtpInput = z.input;
-/** GET /api/settings: every key, secrets replaced by hasPassword. */
+/** PATCH /api/settings `contacts.contrack`: the company token is write-only (empty keeps it). */
+export const ContrackInputSchema = z.object({
+ url: z.url({ protocol: /^https?$/ }).trim(),
+ publicUrl: z
+ .url({ protocol: /^https?$/ })
+ .trim()
+ .nullish()
+ .or(z.literal(''))
+ .transform((s) => s?.replace(/\/+$/, '') || null),
+ companyToken: z.string().trim().max(200).optional(),
+ clearCompanyToken: z.boolean().optional(),
+});
+export type ContrackInput = z.input;
+
+/** GET /api/settings: every key, secrets replaced by hasPassword / hasCompanyToken. */
export type SettingsDto = {
- [K in Exclude]: SettingValue;
+ [K in Exclude]: SettingValue;
} & {
smtp: (Omit & { hasPassword: boolean }) | null;
+ 'contacts.contrack': { url: string; publicUrl: string | null; hasCompanyToken: boolean } | null;
};
/** POST /api/ai/test: one endpoint tried with a tiny request. `audio`: the TTS test, a data: URL. */
--
2.34.1
From b05361e840e5158474fe8d1a550c33b18bc3fdfb Mon Sep 17 00:00:00 2001
From: iamdoubz
Date: Fri, 23 Oct 2026 15:45:36 -0500
Subject: [PATCH 4/6] contacts: caller names on desk phones while they ring
(C4)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Spec 11 §6.1: sp-sub-dial-user asks /api/internal/caller-name (func_curl, 2 s at most; this
machine only, with an APP_SECRET-derived key, not logged) for the ringing user's name for the
caller, and sets it on each ringing leg (pre-dial handler, CONNECTEDLINE), never on the call, so
forwards and other members don't see someone else's contacts. Ring group/queue/number prefixes
kept. Contrack lookups never wait (cache, contactsWarm every 5 min). Proven on real Asterisk
with baresip: apps/api/test/asterisk/cnam-test.ts.
---
apps/api/src/contacts/book.ts | 5 +-
apps/api/src/contacts/callername.ts | 26 ++
apps/api/src/contacts/contrack.ts | 29 +-
.../extensions_globals_selfphoneng.conf | 2 +
.../expected/extensions_selfphoneng.conf | 24 +-
.../extensions_globals_selfphoneng.conf | 2 +
.../expected/extensions_selfphoneng.conf | 24 +-
.../extensions_globals_selfphoneng.conf | 2 +
.../expected/extensions_selfphoneng.conf | 24 +-
.../extensions_globals_selfphoneng.conf | 2 +
.../expected/extensions_selfphoneng.conf | 24 +-
.../extensions_globals_selfphoneng.conf | 2 +
.../expected/extensions_selfphoneng.conf | 24 +-
.../extensions_globals_selfphoneng.conf | 2 +
.../ivr/expected/extensions_selfphoneng.conf | 24 +-
.../extensions_globals_selfphoneng.conf | 2 +
.../expected/extensions_selfphoneng.conf | 24 +-
.../extensions_globals_selfphoneng.conf | 2 +
.../expected/extensions_selfphoneng.conf | 24 +-
.../extensions_globals_selfphoneng.conf | 2 +
.../expected/extensions_selfphoneng.conf | 24 +-
.../extensions_globals_selfphoneng.conf | 2 +
.../expected/extensions_selfphoneng.conf | 25 +-
.../extensions_globals_selfphoneng.conf | 2 +
.../expected/extensions_selfphoneng.conf | 25 +-
.../extensions_globals_selfphoneng.conf | 2 +
.../expected/extensions_selfphoneng.conf | 24 +-
.../extensions_globals_selfphoneng.conf | 2 +
.../expected/extensions_selfphoneng.conf | 24 +-
.../extensions_globals_selfphoneng.conf | 2 +
.../expected/extensions_selfphoneng.conf | 25 +-
.../__golden__/voipms-basic/snapshot.json | 6 +-
.../extensions_globals_selfphoneng.conf | 2 +
.../expected/extensions_selfphoneng.conf | 24 +-
apps/api/src/generator/dialplan/inbound.ts | 8 +-
apps/api/src/generator/dialplan/local.ts | 3 +
apps/api/src/generator/dialplan/queues.ts | 8 +-
apps/api/src/generator/dialplan/ringgroups.ts | 8 +-
apps/api/src/generator/dialplan/static.ts | 24 +-
apps/api/src/generator/golden-case.ts | 2 +
apps/api/src/generator/index.ts | 8 +-
apps/api/src/generator/load.ts | 2 +
apps/api/src/generator/sanitize.ts | 6 +
apps/api/src/index.ts | 5 +
apps/api/src/jobs.ts | 17 +-
apps/api/src/routes/contacts.ts | 34 ++
apps/api/test/asterisk/cnam-test.ts | 415 ++++++++++++++++++
apps/api/test/contacts.test.ts | 24 +
docs/spec/03-config-generator.md | 24 +-
docs/spec/04-backend-api.md | 1 +
docs/spec/11-contacts.md | 23 +-
51 files changed, 1040 insertions(+), 31 deletions(-)
create mode 100644 apps/api/src/contacts/callername.ts
create mode 100644 apps/api/test/asterisk/cnam-test.ts
diff --git a/apps/api/src/contacts/book.ts b/apps/api/src/contacts/book.ts
index 84348d8..15cff3a 100644
--- a/apps/api/src/contacts/book.ts
+++ b/apps/api/src/contacts/book.ts
@@ -177,6 +177,7 @@ export async function contactNames(
db: Db,
userId: string | null,
numbers: (string | null)[],
+ opts: { wait?: boolean } = {},
): Promise> {
const want = [...new Set(numbers.filter((n): n is string => !!n))];
const out = new Map();
@@ -184,8 +185,8 @@ export async function contactNames(
const c = await contrackSettings(db);
if (c) {
const books = [
- userId ? await contrackBook(db, c, userId) : null,
- await contrackBook(db, c, null),
+ userId ? await contrackBook(db, c, userId, opts) : null,
+ await contrackBook(db, c, null, opts),
];
for (const b of books)
for (const ct of b?.contacts ?? [])
diff --git a/apps/api/src/contacts/callername.ts b/apps/api/src/contacts/callername.ts
new file mode 100644
index 0000000..6234aed
--- /dev/null
+++ b/apps/api/src/contacts/callername.ts
@@ -0,0 +1,26 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// Caller names on desk phones while they ring (11 §6 C4): before ringing a user's phones, the
+// dialplan asks GET /api/internal/caller-name (func_curl, 2 s at most) for that user's name for
+// the caller, and the phones show it.
+import { createHmac, timingSafeEqual } from 'node:crypto';
+
+/** The dialplan's key for the route, derived from APP_SECRET (stable across restarts). */
+export const callerNameToken = (appSecret: string) =>
+ createHmac('sha256', appSecret).update('selfphoneng caller-name').digest('hex');
+
+export const sameToken = (a: string, b: string) =>
+ a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
+
+/**
+ * What a phone can show and the dialplan can carry: letters, digits, spaces and . ' & @ -, at
+ * most 40 characters. Nothing that Dial's options (, ^ ( ) |) or a SIP header (" < > \ ;) could
+ * read as syntax.
+ */
+export const phoneSafeName = (name: string) =>
+ name
+ .normalize('NFC')
+ .replace(/[^\p{L}\p{M}\p{N} .'&@-]+/gu, ' ')
+ .replace(/\s+/g, ' ')
+ .trim()
+ .slice(0, 40)
+ .trim();
diff --git a/apps/api/src/contacts/contrack.ts b/apps/api/src/contacts/contrack.ts
index 558f895..0247569 100644
--- a/apps/api/src/contacts/contrack.ts
+++ b/apps/api/src/contacts/contrack.ts
@@ -119,21 +119,42 @@ const TTL = 5 * 60_000;
/** Forget a book (after we add to it, or a token changes). */
export const forgetBook = (owner: string | null) => cache.delete(owner ?? '');
+const inflight = new Map>();
+
/**
* A Contrack book, from the cache when it's under 5 minutes old. Null: no token. A failing
- * Contrack keeps the last good list and says why.
+ * Contrack keeps the last good list and says why. `wait: false` (a phone ringing, 11 §6 C4)
+ * never waits on Contrack: it answers what the cache has and refreshes in the background.
*/
export async function contrackBook(
db: Db,
c: Contrack,
owner: string | null,
- now = Date.now(),
+ { now = Date.now(), wait = true }: { now?: number; wait?: boolean } = {},
): Promise {
const token = await tokenFor(db, c, owner);
if (!token) return null;
const key = owner ?? '';
const hit = cache.get(key);
- if (hit && hit.token === token && now - hit.at < TTL) return hit;
+ const usable = hit && hit.token === token ? hit : undefined;
+ if (usable && now - usable.at < TTL) return usable;
+ if (!inflight.has(key))
+ inflight.set(
+ key,
+ fetchBook(c, owner, token, usable, now).finally(() => inflight.delete(key)),
+ );
+ if (!wait) return usable ?? null;
+ return inflight.get(key)!;
+}
+
+async function fetchBook(
+ c: Contrack,
+ owner: string | null,
+ token: string,
+ hit: Book | undefined,
+ now: number,
+): Promise {
+ const key = owner ?? '';
const book = owner === null ? 'company' : 'mine';
try {
const rows = await ctk(c.url, token, 'api/contacts?view=slim');
@@ -142,7 +163,7 @@ export async function contrackBook(
return fresh;
} catch (err) {
const stale = {
- contacts: hit?.token === token ? hit.contacts : [],
+ contacts: hit?.contacts ?? [],
at: now, // tried now: the next try is in 5 minutes, not on every lookup
error: (err as Error).message,
token,
diff --git a/apps/api/src/generator/__golden__/conferences/expected/extensions_globals_selfphoneng.conf b/apps/api/src/generator/__golden__/conferences/expected/extensions_globals_selfphoneng.conf
index e10b558..3cddc14 100644
--- a/apps/api/src/generator/__golden__/conferences/expected/extensions_globals_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/conferences/expected/extensions_globals_selfphoneng.conf
@@ -3,4 +3,6 @@ SP_DEFAULT_CID=
SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE=
SP_REC_SIDES=
+SP_CNAM_URL=
+SP_CNAM_TOKEN=
SP_CLASS_101=2
diff --git a/apps/api/src/generator/__golden__/conferences/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/conferences/expected/extensions_selfphoneng.conf
index 34d9ed7..4e455b4 100644
--- a/apps/api/src/generator/__golden__/conferences/expected/extensions_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/conferences/expected/extensions_selfphoneng.conf
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
+; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
+; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
+; never sees someone else's contact name.
[sp-sub-dial-user]
exten => s,1,Set(DS=)
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
same => n,GotoIf($["${DS}" = ""]?unavail)
- same => n,Dial(${DS},${ARG2},tx)
+ same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
+ same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
+ same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
+ same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
+ same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
+ same => n,Set(_SP_CNAM=)
+ same => n,Return()
+ same => n(plain),Dial(${DS},${ARG2},tx)
same => n,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
same => n,Return()
+; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
+; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
+[sp-sub-cnam]
+exten => s,1,Set(CURLOPT(conntimeout)=1)
+ same => n,Set(CURLOPT(httptimeout)=2)
+ same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
+
+; ─── Pre-dial handler on each ringing leg: the name its phone shows.
+[sp-predial-cnam]
+exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
+ same => n,Return()
+
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP//. Not PJSIP_DIAL_CONTACTS: iOS
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
diff --git a/apps/api/src/generator/__golden__/empty/expected/extensions_globals_selfphoneng.conf b/apps/api/src/generator/__golden__/empty/expected/extensions_globals_selfphoneng.conf
index e5b4f80..9319c87 100644
--- a/apps/api/src/generator/__golden__/empty/expected/extensions_globals_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/empty/expected/extensions_globals_selfphoneng.conf
@@ -3,3 +3,5 @@ SP_DEFAULT_CID=
SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE=
SP_REC_SIDES=
+SP_CNAM_URL=
+SP_CNAM_TOKEN=
diff --git a/apps/api/src/generator/__golden__/empty/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/empty/expected/extensions_selfphoneng.conf
index 8ba2d1e..99289e6 100644
--- a/apps/api/src/generator/__golden__/empty/expected/extensions_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/empty/expected/extensions_selfphoneng.conf
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
+; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
+; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
+; never sees someone else's contact name.
[sp-sub-dial-user]
exten => s,1,Set(DS=)
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
same => n,GotoIf($["${DS}" = ""]?unavail)
- same => n,Dial(${DS},${ARG2},tx)
+ same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
+ same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
+ same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
+ same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
+ same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
+ same => n,Set(_SP_CNAM=)
+ same => n,Return()
+ same => n(plain),Dial(${DS},${ARG2},tx)
same => n,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
same => n,Return()
+; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
+; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
+[sp-sub-cnam]
+exten => s,1,Set(CURLOPT(conntimeout)=1)
+ same => n,Set(CURLOPT(httptimeout)=2)
+ same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
+
+; ─── Pre-dial handler on each ringing leg: the name its phone shows.
+[sp-predial-cnam]
+exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
+ same => n,Return()
+
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP//. Not PJSIP_DIAL_CONTACTS: iOS
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
diff --git a/apps/api/src/generator/__golden__/holidays/expected/extensions_globals_selfphoneng.conf b/apps/api/src/generator/__golden__/holidays/expected/extensions_globals_selfphoneng.conf
index 4da4068..0d975b4 100644
--- a/apps/api/src/generator/__golden__/holidays/expected/extensions_globals_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/holidays/expected/extensions_globals_selfphoneng.conf
@@ -3,5 +3,7 @@ SP_DEFAULT_CID=
SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE=
SP_REC_SIDES=
+SP_CNAM_URL=
+SP_CNAM_TOKEN=
SP_CLASS_101=2
SP_TCADMIN_101=1
diff --git a/apps/api/src/generator/__golden__/holidays/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/holidays/expected/extensions_selfphoneng.conf
index 5291b40..af3d13a 100644
--- a/apps/api/src/generator/__golden__/holidays/expected/extensions_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/holidays/expected/extensions_selfphoneng.conf
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
+; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
+; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
+; never sees someone else's contact name.
[sp-sub-dial-user]
exten => s,1,Set(DS=)
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
same => n,GotoIf($["${DS}" = ""]?unavail)
- same => n,Dial(${DS},${ARG2},tx)
+ same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
+ same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
+ same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
+ same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
+ same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
+ same => n,Set(_SP_CNAM=)
+ same => n,Return()
+ same => n(plain),Dial(${DS},${ARG2},tx)
same => n,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
same => n,Return()
+; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
+; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
+[sp-sub-cnam]
+exten => s,1,Set(CURLOPT(conntimeout)=1)
+ same => n,Set(CURLOPT(httptimeout)=2)
+ same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
+
+; ─── Pre-dial handler on each ringing leg: the name its phone shows.
+[sp-predial-cnam]
+exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
+ same => n,Return()
+
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP//. Not PJSIP_DIAL_CONTACTS: iOS
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
diff --git a/apps/api/src/generator/__golden__/hostile-names/expected/extensions_globals_selfphoneng.conf b/apps/api/src/generator/__golden__/hostile-names/expected/extensions_globals_selfphoneng.conf
index 52ddf49..7735298 100644
--- a/apps/api/src/generator/__golden__/hostile-names/expected/extensions_globals_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/hostile-names/expected/extensions_globals_selfphoneng.conf
@@ -3,6 +3,8 @@ SP_DEFAULT_CID=
SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE=
SP_REC_SIDES=
+SP_CNAM_URL=
+SP_CNAM_TOKEN=
SP_CLASS_103=2
SP_CLASS_104=2
SP_CLASS_105=2
diff --git a/apps/api/src/generator/__golden__/hostile-names/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/hostile-names/expected/extensions_selfphoneng.conf
index 691b31b..89fc562 100644
--- a/apps/api/src/generator/__golden__/hostile-names/expected/extensions_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/hostile-names/expected/extensions_selfphoneng.conf
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
+; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
+; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
+; never sees someone else's contact name.
[sp-sub-dial-user]
exten => s,1,Set(DS=)
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
same => n,GotoIf($["${DS}" = ""]?unavail)
- same => n,Dial(${DS},${ARG2},tx)
+ same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
+ same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
+ same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
+ same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
+ same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
+ same => n,Set(_SP_CNAM=)
+ same => n,Return()
+ same => n(plain),Dial(${DS},${ARG2},tx)
same => n,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
same => n,Return()
+; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
+; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
+[sp-sub-cnam]
+exten => s,1,Set(CURLOPT(conntimeout)=1)
+ same => n,Set(CURLOPT(httptimeout)=2)
+ same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
+
+; ─── Pre-dial handler on each ringing leg: the name its phone shows.
+[sp-predial-cnam]
+exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
+ same => n,Return()
+
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP//. Not PJSIP_DIAL_CONTACTS: iOS
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
diff --git a/apps/api/src/generator/__golden__/ivr-business-hours/expected/extensions_globals_selfphoneng.conf b/apps/api/src/generator/__golden__/ivr-business-hours/expected/extensions_globals_selfphoneng.conf
index b6c991a..9f1e342 100644
--- a/apps/api/src/generator/__golden__/ivr-business-hours/expected/extensions_globals_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/ivr-business-hours/expected/extensions_globals_selfphoneng.conf
@@ -3,6 +3,8 @@ SP_DEFAULT_CID=
SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE=
SP_REC_SIDES=
+SP_CNAM_URL=
+SP_CNAM_TOKEN=
SP_CLASS_101=2
SP_CLASS_102=2
SP_CLASS_103=2
diff --git a/apps/api/src/generator/__golden__/ivr-business-hours/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/ivr-business-hours/expected/extensions_selfphoneng.conf
index 6dd6221..73c048e 100644
--- a/apps/api/src/generator/__golden__/ivr-business-hours/expected/extensions_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/ivr-business-hours/expected/extensions_selfphoneng.conf
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
+; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
+; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
+; never sees someone else's contact name.
[sp-sub-dial-user]
exten => s,1,Set(DS=)
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
same => n,GotoIf($["${DS}" = ""]?unavail)
- same => n,Dial(${DS},${ARG2},tx)
+ same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
+ same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
+ same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
+ same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
+ same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
+ same => n,Set(_SP_CNAM=)
+ same => n,Return()
+ same => n(plain),Dial(${DS},${ARG2},tx)
same => n,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
same => n,Return()
+; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
+; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
+[sp-sub-cnam]
+exten => s,1,Set(CURLOPT(conntimeout)=1)
+ same => n,Set(CURLOPT(httptimeout)=2)
+ same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
+
+; ─── Pre-dial handler on each ringing leg: the name its phone shows.
+[sp-predial-cnam]
+exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
+ same => n,Return()
+
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP//. Not PJSIP_DIAL_CONTACTS: iOS
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
diff --git a/apps/api/src/generator/__golden__/ivr/expected/extensions_globals_selfphoneng.conf b/apps/api/src/generator/__golden__/ivr/expected/extensions_globals_selfphoneng.conf
index ca1762b..06ede72 100644
--- a/apps/api/src/generator/__golden__/ivr/expected/extensions_globals_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/ivr/expected/extensions_globals_selfphoneng.conf
@@ -3,5 +3,7 @@ SP_DEFAULT_CID=
SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE=
SP_REC_SIDES=
+SP_CNAM_URL=
+SP_CNAM_TOKEN=
SP_CLASS_101=2
SP_CLASS_102=2
diff --git a/apps/api/src/generator/__golden__/ivr/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/ivr/expected/extensions_selfphoneng.conf
index 420f28f..f2b3179 100644
--- a/apps/api/src/generator/__golden__/ivr/expected/extensions_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/ivr/expected/extensions_selfphoneng.conf
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
+; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
+; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
+; never sees someone else's contact name.
[sp-sub-dial-user]
exten => s,1,Set(DS=)
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
same => n,GotoIf($["${DS}" = ""]?unavail)
- same => n,Dial(${DS},${ARG2},tx)
+ same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
+ same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
+ same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
+ same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
+ same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
+ same => n,Set(_SP_CNAM=)
+ same => n,Return()
+ same => n(plain),Dial(${DS},${ARG2},tx)
same => n,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
same => n,Return()
+; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
+; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
+[sp-sub-cnam]
+exten => s,1,Set(CURLOPT(conntimeout)=1)
+ same => n,Set(CURLOPT(httptimeout)=2)
+ same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
+
+; ─── Pre-dial handler on each ringing leg: the name its phone shows.
+[sp-predial-cnam]
+exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
+ same => n,Return()
+
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP//. Not PJSIP_DIAL_CONTACTS: iOS
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
diff --git a/apps/api/src/generator/__golden__/one-user/expected/extensions_globals_selfphoneng.conf b/apps/api/src/generator/__golden__/one-user/expected/extensions_globals_selfphoneng.conf
index 3385f31..330edda 100644
--- a/apps/api/src/generator/__golden__/one-user/expected/extensions_globals_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/one-user/expected/extensions_globals_selfphoneng.conf
@@ -3,5 +3,7 @@ SP_DEFAULT_CID=13125551234
SP_DEFAULT_E911_CID=13125551234
SP_REC_ANNOUNCE=
SP_REC_SIDES=
+SP_CNAM_URL=
+SP_CNAM_TOKEN=
SP_CLASS_101=2
SP_TCADMIN_101=1
diff --git a/apps/api/src/generator/__golden__/one-user/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/one-user/expected/extensions_selfphoneng.conf
index 935e840..7760f81 100644
--- a/apps/api/src/generator/__golden__/one-user/expected/extensions_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/one-user/expected/extensions_selfphoneng.conf
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
+; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
+; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
+; never sees someone else's contact name.
[sp-sub-dial-user]
exten => s,1,Set(DS=)
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
same => n,GotoIf($["${DS}" = ""]?unavail)
- same => n,Dial(${DS},${ARG2},tx)
+ same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
+ same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
+ same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
+ same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
+ same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
+ same => n,Set(_SP_CNAM=)
+ same => n,Return()
+ same => n(plain),Dial(${DS},${ARG2},tx)
same => n,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
same => n,Return()
+; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
+; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
+[sp-sub-cnam]
+exten => s,1,Set(CURLOPT(conntimeout)=1)
+ same => n,Set(CURLOPT(httptimeout)=2)
+ same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
+
+; ─── Pre-dial handler on each ringing leg: the name its phone shows.
+[sp-predial-cnam]
+exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
+ same => n,Return()
+
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP//. Not PJSIP_DIAL_CONTACTS: iOS
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
diff --git a/apps/api/src/generator/__golden__/paging/expected/extensions_globals_selfphoneng.conf b/apps/api/src/generator/__golden__/paging/expected/extensions_globals_selfphoneng.conf
index e2ed0d6..2a02788 100644
--- a/apps/api/src/generator/__golden__/paging/expected/extensions_globals_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/paging/expected/extensions_globals_selfphoneng.conf
@@ -3,6 +3,8 @@ SP_DEFAULT_CID=
SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE=
SP_REC_SIDES=
+SP_CNAM_URL=
+SP_CNAM_TOKEN=
SP_CLASS_99=2
SP_CLASS_101=2
SP_CLASS_102=2
diff --git a/apps/api/src/generator/__golden__/paging/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/paging/expected/extensions_selfphoneng.conf
index a1ef75a..8cd7f72 100644
--- a/apps/api/src/generator/__golden__/paging/expected/extensions_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/paging/expected/extensions_selfphoneng.conf
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
+; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
+; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
+; never sees someone else's contact name.
[sp-sub-dial-user]
exten => s,1,Set(DS=)
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
same => n,GotoIf($["${DS}" = ""]?unavail)
- same => n,Dial(${DS},${ARG2},tx)
+ same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
+ same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
+ same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
+ same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
+ same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
+ same => n,Set(_SP_CNAM=)
+ same => n,Return()
+ same => n(plain),Dial(${DS},${ARG2},tx)
same => n,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
same => n,Return()
+; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
+; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
+[sp-sub-cnam]
+exten => s,1,Set(CURLOPT(conntimeout)=1)
+ same => n,Set(CURLOPT(httptimeout)=2)
+ same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
+
+; ─── Pre-dial handler on each ringing leg: the name its phone shows.
+[sp-predial-cnam]
+exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
+ same => n,Return()
+
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP//. Not PJSIP_DIAL_CONTACTS: iOS
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
diff --git a/apps/api/src/generator/__golden__/phone-acl/expected/extensions_globals_selfphoneng.conf b/apps/api/src/generator/__golden__/phone-acl/expected/extensions_globals_selfphoneng.conf
index e10b558..3cddc14 100644
--- a/apps/api/src/generator/__golden__/phone-acl/expected/extensions_globals_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/phone-acl/expected/extensions_globals_selfphoneng.conf
@@ -3,4 +3,6 @@ SP_DEFAULT_CID=
SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE=
SP_REC_SIDES=
+SP_CNAM_URL=
+SP_CNAM_TOKEN=
SP_CLASS_101=2
diff --git a/apps/api/src/generator/__golden__/phone-acl/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/phone-acl/expected/extensions_selfphoneng.conf
index ca71e11..3b30f19 100644
--- a/apps/api/src/generator/__golden__/phone-acl/expected/extensions_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/phone-acl/expected/extensions_selfphoneng.conf
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
+; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
+; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
+; never sees someone else's contact name.
[sp-sub-dial-user]
exten => s,1,Set(DS=)
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
same => n,GotoIf($["${DS}" = ""]?unavail)
- same => n,Dial(${DS},${ARG2},tx)
+ same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
+ same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
+ same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
+ same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
+ same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
+ same => n,Set(_SP_CNAM=)
+ same => n,Return()
+ same => n(plain),Dial(${DS},${ARG2},tx)
same => n,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
same => n,Return()
+; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
+; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
+[sp-sub-cnam]
+exten => s,1,Set(CURLOPT(conntimeout)=1)
+ same => n,Set(CURLOPT(httptimeout)=2)
+ same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
+
+; ─── Pre-dial handler on each ringing leg: the name its phone shows.
+[sp-predial-cnam]
+exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
+ same => n,Return()
+
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP//. Not PJSIP_DIAL_CONTACTS: iOS
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
diff --git a/apps/api/src/generator/__golden__/queue/expected/extensions_globals_selfphoneng.conf b/apps/api/src/generator/__golden__/queue/expected/extensions_globals_selfphoneng.conf
index 736348e..63b7ac4 100644
--- a/apps/api/src/generator/__golden__/queue/expected/extensions_globals_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/queue/expected/extensions_globals_selfphoneng.conf
@@ -3,6 +3,8 @@ SP_DEFAULT_CID=
SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE=
SP_REC_SIDES=
+SP_CNAM_URL=
+SP_CNAM_TOKEN=
SP_CLASS_101=2
SP_CLASS_102=2
SP_CLASS_103=2
diff --git a/apps/api/src/generator/__golden__/queue/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/queue/expected/extensions_selfphoneng.conf
index 006b7ad..15bd3c2 100644
--- a/apps/api/src/generator/__golden__/queue/expected/extensions_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/queue/expected/extensions_selfphoneng.conf
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
+; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
+; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
+; never sees someone else's contact name.
[sp-sub-dial-user]
exten => s,1,Set(DS=)
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
same => n,GotoIf($["${DS}" = ""]?unavail)
- same => n,Dial(${DS},${ARG2},tx)
+ same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
+ same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
+ same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
+ same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
+ same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
+ same => n,Set(_SP_CNAM=)
+ same => n,Return()
+ same => n(plain),Dial(${DS},${ARG2},tx)
same => n,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
same => n,Return()
+; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
+; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
+[sp-sub-cnam]
+exten => s,1,Set(CURLOPT(conntimeout)=1)
+ same => n,Set(CURLOPT(httptimeout)=2)
+ same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
+
+; ─── Pre-dial handler on each ringing leg: the name its phone shows.
+[sp-predial-cnam]
+exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
+ same => n,Return()
+
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP//. Not PJSIP_DIAL_CONTACTS: iOS
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
@@ -260,6 +282,7 @@ exten => 551,1,Goto(sp-queue-44444444-4444-4444-8444-444444444551,s,1)
exten => s,1,Set(__SP_TARGET=550)
same => n,Answer()
same => n,Set(CALLERID(name)=Support: ${CALLERID(name)})
+ same => n,Set(__SP_CNAM_PREFIX=Support: ${SP_CNAM_PREFIX})
same => n,Gosub(sp-sub-record,s,1)
same => n,Queue(550,t,,,300)
same => n,GotoIf($["${QUEUESTATUS}" = "TIMEOUT" | "${QUEUESTATUS}" = "FULL" | "${QUEUESTATUS}" = "JOINEMPTY" | "${QUEUESTATUS}" = "LEAVEEMPTY" | "${QUEUESTATUS}" = "JOINUNAVAIL" | "${QUEUESTATUS}" = "LEAVEUNAVAIL"]?fail)
diff --git a/apps/api/src/generator/__golden__/ringgroups/expected/extensions_globals_selfphoneng.conf b/apps/api/src/generator/__golden__/ringgroups/expected/extensions_globals_selfphoneng.conf
index 736348e..63b7ac4 100644
--- a/apps/api/src/generator/__golden__/ringgroups/expected/extensions_globals_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/ringgroups/expected/extensions_globals_selfphoneng.conf
@@ -3,6 +3,8 @@ SP_DEFAULT_CID=
SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE=
SP_REC_SIDES=
+SP_CNAM_URL=
+SP_CNAM_TOKEN=
SP_CLASS_101=2
SP_CLASS_102=2
SP_CLASS_103=2
diff --git a/apps/api/src/generator/__golden__/ringgroups/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/ringgroups/expected/extensions_selfphoneng.conf
index 1fda7b8..1dbe3f2 100644
--- a/apps/api/src/generator/__golden__/ringgroups/expected/extensions_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/ringgroups/expected/extensions_selfphoneng.conf
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
+; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
+; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
+; never sees someone else's contact name.
[sp-sub-dial-user]
exten => s,1,Set(DS=)
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
same => n,GotoIf($["${DS}" = ""]?unavail)
- same => n,Dial(${DS},${ARG2},tx)
+ same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
+ same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
+ same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
+ same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
+ same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
+ same => n,Set(_SP_CNAM=)
+ same => n,Return()
+ same => n(plain),Dial(${DS},${ARG2},tx)
same => n,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
same => n,Return()
+; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
+; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
+[sp-sub-cnam]
+exten => s,1,Set(CURLOPT(conntimeout)=1)
+ same => n,Set(CURLOPT(httptimeout)=2)
+ same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
+
+; ─── Pre-dial handler on each ringing leg: the name its phone shows.
+[sp-predial-cnam]
+exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
+ same => n,Return()
+
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP//. Not PJSIP_DIAL_CONTACTS: iOS
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
@@ -260,6 +282,7 @@ exten => 502,1,Goto(sp-rg-22222222-2222-4222-8222-222222222502,s,1)
[sp-rg-22222222-2222-4222-8222-222222222500]
exten => s,1,Set(__SP_TARGET=500)
same => n,Set(CALLERID(name)=Sales: ${CALLERID(name)})
+ same => n,Set(__SP_CNAM_PREFIX=Sales: ${SP_CNAM_PREFIX})
same => n,Dial(Local/101@sp-rg-member&Local/102@sp-rg-member&Local/103@sp-rg-member,25,tm)
same => n,GotoIf($["${DIALSTATUS}" = "ANSWER"]?done)
same => n,Goto(sp-vm-direct,101,1)
diff --git a/apps/api/src/generator/__golden__/short-numbers/expected/extensions_globals_selfphoneng.conf b/apps/api/src/generator/__golden__/short-numbers/expected/extensions_globals_selfphoneng.conf
index ebc3a7b..ad8734a 100644
--- a/apps/api/src/generator/__golden__/short-numbers/expected/extensions_globals_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/short-numbers/expected/extensions_globals_selfphoneng.conf
@@ -3,5 +3,7 @@ SP_DEFAULT_CID=
SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE=
SP_REC_SIDES=
+SP_CNAM_URL=
+SP_CNAM_TOKEN=
SP_CLASS_1=2
SP_CLASS_12=2
diff --git a/apps/api/src/generator/__golden__/short-numbers/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/short-numbers/expected/extensions_selfphoneng.conf
index 5c4430a..6e7c01f 100644
--- a/apps/api/src/generator/__golden__/short-numbers/expected/extensions_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/short-numbers/expected/extensions_selfphoneng.conf
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
+; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
+; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
+; never sees someone else's contact name.
[sp-sub-dial-user]
exten => s,1,Set(DS=)
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
same => n,GotoIf($["${DS}" = ""]?unavail)
- same => n,Dial(${DS},${ARG2},tx)
+ same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
+ same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
+ same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
+ same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
+ same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
+ same => n,Set(_SP_CNAM=)
+ same => n,Return()
+ same => n(plain),Dial(${DS},${ARG2},tx)
same => n,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
same => n,Return()
+; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
+; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
+[sp-sub-cnam]
+exten => s,1,Set(CURLOPT(conntimeout)=1)
+ same => n,Set(CURLOPT(httptimeout)=2)
+ same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
+
+; ─── Pre-dial handler on each ringing leg: the name its phone shows.
+[sp-predial-cnam]
+exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
+ same => n,Return()
+
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP//. Not PJSIP_DIAL_CONTACTS: iOS
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
diff --git a/apps/api/src/generator/__golden__/trunk-variants/expected/extensions_globals_selfphoneng.conf b/apps/api/src/generator/__golden__/trunk-variants/expected/extensions_globals_selfphoneng.conf
index e5b4f80..9319c87 100644
--- a/apps/api/src/generator/__golden__/trunk-variants/expected/extensions_globals_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/trunk-variants/expected/extensions_globals_selfphoneng.conf
@@ -3,3 +3,5 @@ SP_DEFAULT_CID=
SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE=
SP_REC_SIDES=
+SP_CNAM_URL=
+SP_CNAM_TOKEN=
diff --git a/apps/api/src/generator/__golden__/trunk-variants/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/trunk-variants/expected/extensions_selfphoneng.conf
index 3929f5f..34200b7 100644
--- a/apps/api/src/generator/__golden__/trunk-variants/expected/extensions_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/trunk-variants/expected/extensions_selfphoneng.conf
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
+; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
+; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
+; never sees someone else's contact name.
[sp-sub-dial-user]
exten => s,1,Set(DS=)
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
same => n,GotoIf($["${DS}" = ""]?unavail)
- same => n,Dial(${DS},${ARG2},tx)
+ same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
+ same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
+ same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
+ same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
+ same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
+ same => n,Set(_SP_CNAM=)
+ same => n,Return()
+ same => n(plain),Dial(${DS},${ARG2},tx)
same => n,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
same => n,Return()
+; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
+; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
+[sp-sub-cnam]
+exten => s,1,Set(CURLOPT(conntimeout)=1)
+ same => n,Set(CURLOPT(httptimeout)=2)
+ same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
+
+; ─── Pre-dial handler on each ringing leg: the name its phone shows.
+[sp-predial-cnam]
+exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
+ same => n,Return()
+
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP//. Not PJSIP_DIAL_CONTACTS: iOS
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
diff --git a/apps/api/src/generator/__golden__/voipms-basic/expected/extensions_globals_selfphoneng.conf b/apps/api/src/generator/__golden__/voipms-basic/expected/extensions_globals_selfphoneng.conf
index dc8f4e0..90730d7 100644
--- a/apps/api/src/generator/__golden__/voipms-basic/expected/extensions_globals_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/voipms-basic/expected/extensions_globals_selfphoneng.conf
@@ -3,4 +3,6 @@ SP_DEFAULT_CID=13125551234
SP_DEFAULT_E911_CID=13125551234
SP_REC_ANNOUNCE=custom/44444444-4444-4444-8444-444444444448
SP_REC_SIDES=
+SP_CNAM_URL=http://127.0.0.1:8080/api/internal/caller-name
+SP_CNAM_TOKEN=0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f
SP_CLASS_101=2
diff --git a/apps/api/src/generator/__golden__/voipms-basic/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/voipms-basic/expected/extensions_selfphoneng.conf
index 5303c42..c39ef34 100644
--- a/apps/api/src/generator/__golden__/voipms-basic/expected/extensions_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/voipms-basic/expected/extensions_selfphoneng.conf
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
+; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
+; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
+; never sees someone else's contact name.
[sp-sub-dial-user]
exten => s,1,Set(DS=)
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
same => n,GotoIf($["${DS}" = ""]?unavail)
- same => n,Dial(${DS},${ARG2},tx)
+ same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
+ same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
+ same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
+ same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
+ same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
+ same => n,Set(_SP_CNAM=)
+ same => n,Return()
+ same => n(plain),Dial(${DS},${ARG2},tx)
same => n,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
same => n,Return()
+; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
+; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
+[sp-sub-cnam]
+exten => s,1,Set(CURLOPT(conntimeout)=1)
+ same => n,Set(CURLOPT(httptimeout)=2)
+ same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
+
+; ─── Pre-dial handler on each ringing leg: the name its phone shows.
+[sp-predial-cnam]
+exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
+ same => n,Return()
+
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP//. Not PJSIP_DIAL_CONTACTS: iOS
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
@@ -284,6 +306,7 @@ exten => 13125551234,1,Set(__SP_DID=13125551234)
exten => 13125559876,1,Set(__SP_DID=13125559876)
same => n,Set(CDR(SP_DID)=13125559876)
same => n,Set(CALLERID(name)=SALES: ${CALLERID(name)})
+ same => n,Set(__SP_CNAM_PREFIX=SALES: ${SP_CNAM_PREFIX})
same => n,Goto(sp-ext-forward,13125550000,1)
; anything else
exten => _[0-9a-z].,1,Hangup()
diff --git a/apps/api/src/generator/__golden__/voipms-basic/snapshot.json b/apps/api/src/generator/__golden__/voipms-basic/snapshot.json
index 9951770..04a05de 100644
--- a/apps/api/src/generator/__golden__/voipms-basic/snapshot.json
+++ b/apps/api/src/generator/__golden__/voipms-basic/snapshot.json
@@ -117,5 +117,9 @@
"trunkIds": ["7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37"]
}
],
- "recordingAnnouncement": "custom/44444444-4444-4444-8444-444444444448"
+ "recordingAnnouncement": "custom/44444444-4444-4444-8444-444444444448",
+ "callerNames": {
+ "url": "http://127.0.0.1:8080/api/internal/caller-name",
+ "token": "0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f"
+ }
}
diff --git a/apps/api/src/generator/__golden__/webrtc-disabled/expected/extensions_globals_selfphoneng.conf b/apps/api/src/generator/__golden__/webrtc-disabled/expected/extensions_globals_selfphoneng.conf
index c402163..fd06a99 100644
--- a/apps/api/src/generator/__golden__/webrtc-disabled/expected/extensions_globals_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/webrtc-disabled/expected/extensions_globals_selfphoneng.conf
@@ -3,5 +3,7 @@ SP_DEFAULT_CID=
SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE=
SP_REC_SIDES=
+SP_CNAM_URL=
+SP_CNAM_TOKEN=
SP_CLASS_99=0
SP_CLASS_102=1
diff --git a/apps/api/src/generator/__golden__/webrtc-disabled/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/webrtc-disabled/expected/extensions_selfphoneng.conf
index 1aa689a..746bbcd 100644
--- a/apps/api/src/generator/__golden__/webrtc-disabled/expected/extensions_selfphoneng.conf
+++ b/apps/api/src/generator/__golden__/webrtc-disabled/expected/extensions_selfphoneng.conf
@@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
+; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
+; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
+; never sees someone else's contact name.
[sp-sub-dial-user]
exten => s,1,Set(DS=)
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
same => n,GotoIf($["${DS}" = ""]?unavail)
- same => n,Dial(${DS},${ARG2},tx)
+ same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
+ same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
+ same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
+ same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
+ same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
+ same => n,Set(_SP_CNAM=)
+ same => n,Return()
+ same => n(plain),Dial(${DS},${ARG2},tx)
same => n,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
same => n,Return()
+; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
+; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
+[sp-sub-cnam]
+exten => s,1,Set(CURLOPT(conntimeout)=1)
+ same => n,Set(CURLOPT(httptimeout)=2)
+ same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
+
+; ─── Pre-dial handler on each ringing leg: the name its phone shows.
+[sp-predial-cnam]
+exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
+ same => n,Return()
+
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP//. Not PJSIP_DIAL_CONTACTS: iOS
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
diff --git a/apps/api/src/generator/dialplan/inbound.ts b/apps/api/src/generator/dialplan/inbound.ts
index 1473979..50c7651 100644
--- a/apps/api/src/generator/dialplan/inbound.ts
+++ b/apps/api/src/generator/dialplan/inbound.ts
@@ -95,7 +95,13 @@ export function renderDids(snap: Snapshot): string {
`; DID ${num}${d.label ? ` "${s.name(d.label)}"` : ''}`,
`exten => ${num},1,Set(__SP_DID=${num})`,
` same => n,Set(CDR(SP_DID)=${num})`,
- ...(prefix ? [` same => n,Set(CALLERID(name)=${prefix} \${CALLERID(name)})`] : []),
+ ...(prefix
+ ? [
+ ` same => n,Set(CALLERID(name)=${prefix} \${CALLERID(name)})`,
+ // …and before a contact's name on ringing phones (sp-sub-dial-user, 11 §6 C4).
+ ` same => n,Set(__SP_CNAM_PREFIX=${prefix} \${SP_CNAM_PREFIX})`,
+ ]
+ : []),
// Recorded number: the caller hears the notice (if set) before the recording starts.
...(d.recordInbound && snap.recordingAnnouncement
? [' same => n,Answer()', ` same => n,Playback(${s.sound(snap.recordingAnnouncement)})`]
diff --git a/apps/api/src/generator/dialplan/local.ts b/apps/api/src/generator/dialplan/local.ts
index 61bc22b..a7537fd 100644
--- a/apps/api/src/generator/dialplan/local.ts
+++ b/apps/api/src/generator/dialplan/local.ts
@@ -75,6 +75,9 @@ export function renderGlobals(snap: Snapshot): string {
`SP_REC_ANNOUNCE=${snap.recordingAnnouncement ? s.sound(snap.recordingAnnouncement) : ''}`,
// Spec 10 §3: also record each side on its own, for the transcript job.
`SP_REC_SIDES=${st['ai.callTranscripts'] && st['ai.stt'] ? '1' : ''}`,
+ // 11 §6 C4: where sp-sub-cnam asks for a caller's name in the ringing user's contacts.
+ `SP_CNAM_URL=${snap.callerNames ? s.localUrl(snap.callerNames.url) : ''}`,
+ `SP_CNAM_TOKEN=${snap.callerNames ? s.secret(snap.callerNames.token) : ''}`,
];
const exts = [...snap.extensions].sort(byNumber);
for (const e of exts) lines.push(`SP_CLASS_${s.digits(e.number)}=${CLASS_RANK[e.outboundClass]}`);
diff --git a/apps/api/src/generator/dialplan/queues.ts b/apps/api/src/generator/dialplan/queues.ts
index b4c168c..a9fb0de 100644
--- a/apps/api/src/generator/dialplan/queues.ts
+++ b/apps/api/src/generator/dialplan/queues.ts
@@ -18,7 +18,13 @@ export function renderQueueDialplan(snap: Snapshot): string {
`[sp-queue-${s.ident(q.id)}]`,
`exten => s,1,Set(__SP_TARGET=${num})`,
' same => n,Answer()',
- ...(prefix ? [` same => n,Set(CALLERID(name)=${prefix} \${CALLERID(name)})`] : []),
+ ...(prefix
+ ? [
+ ` same => n,Set(CALLERID(name)=${prefix} \${CALLERID(name)})`,
+ // …and before a contact's name on ringing phones (sp-sub-dial-user, 11 §6 C4).
+ ` same => n,Set(__SP_CNAM_PREFIX=${prefix} \${SP_CNAM_PREFIX})`,
+ ]
+ : []),
...(q.record ? [' same => n,Gosub(sp-sub-record,s,1)'] : []),
` same => n,Queue(${num},t,,,${s.digits(String(q.maxWaitSeconds))})`,
` same => n,GotoIf($[${FAILED.map((f) => `"\${QUEUESTATUS}" = "${f}"`).join(' | ')}]?fail)`,
diff --git a/apps/api/src/generator/dialplan/ringgroups.ts b/apps/api/src/generator/dialplan/ringgroups.ts
index aa9dbb7..efe1cc7 100644
--- a/apps/api/src/generator/dialplan/ringgroups.ts
+++ b/apps/api/src/generator/dialplan/ringgroups.ts
@@ -22,7 +22,13 @@ export function renderRingGroups(snap: Snapshot): string {
`; ─── Ring group ${num} "${s.name(g.name)}" (${g.strategy})`,
`[sp-rg-${s.ident(g.id)}]`,
`exten => s,1,Set(__SP_TARGET=${num})`,
- ...(prefix ? [` same => n,Set(CALLERID(name)=${prefix} \${CALLERID(name)})`] : []),
+ ...(prefix
+ ? [
+ ` same => n,Set(CALLERID(name)=${prefix} \${CALLERID(name)})`,
+ // …and before a contact's name on ringing phones (sp-sub-dial-user, 11 §6 C4).
+ ` same => n,Set(__SP_CNAM_PREFIX=${prefix} \${SP_CNAM_PREFIX})`,
+ ]
+ : []),
...(g.members.length === 0
? []
: g.strategy === 'ringall'
diff --git a/apps/api/src/generator/dialplan/static.ts b/apps/api/src/generator/dialplan/static.ts
index e983361..c5f7d87 100644
--- a/apps/api/src/generator/dialplan/static.ts
+++ b/apps/api/src/generator/dialplan/static.ts
@@ -12,16 +12,38 @@ exten => s,1,Set(N=\${FILTER(0123456789+,\${ARG1})})
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
+; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
+; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
+; never sees someone else's contact name.
[sp-sub-dial-user]
exten => s,1,Set(DS=)
same => n,Gosub(sp-sub-contacts,s,1(\${ARG1}))
same => n,Gosub(sp-sub-contacts,s,1(\${ARG1}-web))
same => n,GotoIf($["\${DS}" = ""]?unavail)
- same => n,Dial(\${DS},\${ARG2},tx)
+ same => n,GotoIf($["\${SP_DIRECTION}" != "inbound" | "\${SP_CNAM_URL}" = ""]?plain)
+ same => n,Gosub(sp-sub-cnam,s,1(\${ARG1}))
+ same => n,GotoIf($["\${GOSUB_RETVAL}" = ""]?plain)
+ same => n,Set(_SP_CNAM=\${SP_CNAM_PREFIX}\${GOSUB_RETVAL})
+ same => n,Dial(\${DS},\${ARG2},txb(sp-predial-cnam^s^1))
+ same => n,Set(_SP_CNAM=)
+ same => n,Return()
+ same => n(plain),Dial(\${DS},\${ARG2},tx)
same => n,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
same => n,Return()
+; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
+; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
+[sp-sub-cnam]
+exten => s,1,Set(CURLOPT(conntimeout)=1)
+ same => n,Set(CURLOPT(httptimeout)=2)
+ same => n,Return(\${CURL(\${SP_CNAM_URL}?ext=\${FILTER(0-9,\${ARG1})}&number=\${FILTER(0-9,\${CALLERID(num)})}&token=\${SP_CNAM_TOKEN})})
+
+; ─── Pre-dial handler on each ringing leg: the name its phone shows.
+[sp-predial-cnam]
+exten => s,1,Set(CONNECTEDLINE(name,i)=\${SP_CNAM})
+ same => n,Return()
+
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP//. Not PJSIP_DIAL_CONTACTS: iOS
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
diff --git a/apps/api/src/generator/golden-case.ts b/apps/api/src/generator/golden-case.ts
index d64f8ed..4f31003 100644
--- a/apps/api/src/generator/golden-case.ts
+++ b/apps/api/src/generator/golden-case.ts
@@ -26,6 +26,7 @@ export function loadCase(name: string): Snapshot {
holidays?: Snapshot['holidays'];
announcements?: Snapshot['announcements'];
mohClasses?: { name: string; shuffle?: boolean; files: string[] }[];
+ callerNames?: Snapshot['callerNames'];
};
const settings = Object.fromEntries(
(Object.keys(SettingsSchemas) as SettingKey[])
@@ -34,6 +35,7 @@ export function loadCase(name: string): Snapshot {
) as Snapshot['settings'];
return {
today: raw.today ?? '2026-10-17',
+ ...(raw.callerNames ? { callerNames: raw.callerNames } : {}),
settings,
dids: (raw.dids ?? []).map((d) => ({
trunkId: '7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37',
diff --git a/apps/api/src/generator/index.ts b/apps/api/src/generator/index.ts
index 38fbd64..52fd65a 100644
--- a/apps/api/src/generator/index.ts
+++ b/apps/api/src/generator/index.ts
@@ -103,6 +103,8 @@ export type GeneratorDeps = {
debounceMs?: number;
/** RTP_PORT_START/END (config.ts). */
rtpPorts?: { start: number; end: number };
+ /** The caller-name lookup (11 §6 C4). */
+ callerNames?: { url: string; token: string };
};
const sha256 = (s: string) => createHash('sha256').update(s).digest('hex');
@@ -167,7 +169,11 @@ export function createGenerator(deps: GeneratorDeps) {
restartRequired: false,
};
try {
- const snap = { ...(await loadSnapshot(db)), rtpPorts: deps.rtpPorts };
+ const snap = {
+ ...(await loadSnapshot(db)),
+ rtpPorts: deps.rtpPorts,
+ callerNames: deps.callerNames,
+ };
const files = render(snap);
await mkdir(join(dir, '.prev'), { recursive: true });
const current = new Map();
diff --git a/apps/api/src/generator/load.ts b/apps/api/src/generator/load.ts
index 8dc4e2d..cac8831 100644
--- a/apps/api/src/generator/load.ts
+++ b/apps/api/src/generator/load.ts
@@ -205,6 +205,8 @@ export type Snapshot = {
recordingAnnouncement: string | null;
/** Call audio ports from the environment (RTP_PORT_START/END), not the database. */
rtpPorts?: { start: number; end: number };
+ /** Caller names on ringing phones (11 §6 C4): the api's lookup URL and its key. */
+ callerNames?: { url: string; token: string };
};
export const DEFAULT_RTP_PORTS = { start: 10000, end: 20000 };
diff --git a/apps/api/src/generator/sanitize.ts b/apps/api/src/generator/sanitize.ts
index b9dde33..fd5c31e 100644
--- a/apps/api/src/generator/sanitize.ts
+++ b/apps/api/src/generator/sanitize.ts
@@ -29,6 +29,12 @@ export const sound = check(/^[a-z0-9/_-]+$/, 'sound', 100);
/** Context-name parts (uuids), MOH class names. */
export const ident = check(/^[a-z0-9-]+$/, 'ident', 64);
/** Generated SIP passwords. */
+/** The api on this machine, e.g. http://127.0.0.1:8080/api/internal/caller-name. */
+export const localUrl = check(
+ /^http:\/\/(?:127\.0\.0\.1|localhost):[0-9]{1,5}\/[a-z0-9/_-]+$/,
+ 'local url',
+ 100,
+);
export const secret = check(/^[A-Za-z0-9]+$/, 'secret', 64);
/** Trunk usernames (voip.ms sub-accounts like 123456_pbx). */
export const sipUser = check(/^[A-Za-z0-9_.-]+$/, 'sip user', 64);
diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts
index 12064d5..b142129 100644
--- a/apps/api/src/index.ts
+++ b/apps/api/src/index.ts
@@ -21,6 +21,7 @@ import { watchPinChanges } from './services/vmpin.ts';
import { startJobs } from './jobs.ts';
import { watchWebhooks } from './services/webhooks.ts';
import { watchEvents } from './services/events.ts';
+import { callerNameToken } from './contacts/callername.ts';
let config;
try {
@@ -47,6 +48,10 @@ const generator = createGenerator({
ami,
dir: resolve(config.ASTERISK_CONFIG_DIR),
rtpPorts: { start: config.RTP_PORT_START, end: config.RTP_PORT_END },
+ callerNames: {
+ url: `http://127.0.0.1:${config.API_PORT}/api/internal/caller-name`,
+ token: callerNameToken(config.APP_SECRET),
+ },
log: app.log,
onApplied: (r) => {
live.emit('message', {
diff --git a/apps/api/src/jobs.ts b/apps/api/src/jobs.ts
index 678f1ab..47b4012 100644
--- a/apps/api/src/jobs.ts
+++ b/apps/api/src/jobs.ts
@@ -3,7 +3,7 @@
// ponytail: setInterval instead of croner: every schedule in 04 §8 is a whole number of minutes.
import { statfs } from 'node:fs/promises';
import { resolve } from 'node:path';
-import { and, eq, isNull, lt, sql } from 'drizzle-orm';
+import { and, eq, isNotNull, isNull, lt, sql } from 'drizzle-orm';
import { z } from 'zod';
import type { Config } from './config.ts';
import type { Db } from './db/client.ts';
@@ -25,6 +25,7 @@ import { transcribeCalls } from './ai/calls.ts';
import { audit } from './services/audit.ts';
import { backupDue, runBackup } from './services/backup.ts';
import { sendCallReports } from './services/callreport.ts';
+import { contrackBook, contrackSettings } from './contacts/contrack.ts';
import { logEvent } from './services/events.ts';
import { deliverDue } from './services/webhooks.ts';
import type { SendMail } from './services/mail.ts';
@@ -240,11 +241,22 @@ export function createJobs(d: JobDeps) {
);
};
+ /** Contrack books warmed every 5 minutes, so a ringing phone's lookup finds them (11 §6 C4). */
+ const contactsWarm = async () => {
+ const c = await contrackSettings(d.db);
+ if (!c) return;
+ const owners = await d.db
+ .select({ id: users.id })
+ .from(users)
+ .where(and(eq(users.isActive, true), isNotNull(users.contrackTokenEnc)));
+ for (const owner of [null, ...owners.map((o) => o.id)]) await contrackBook(d.db, c, owner);
+ };
+
const callReports = async () => {
await sendCallReports({ db: d.db, send: d.send, log: d.log, now: now() });
};
- return { daily, backup, publicIpWatch, trunkWatch, diskWatch, callReports };
+ return { daily, backup, publicIpWatch, trunkWatch, diskWatch, callReports, contactsWarm };
}
/** Starts the jobs; returns a stop function. */
@@ -267,6 +279,7 @@ export function startJobs(d: JobDeps): () => void {
if (minute % 5 === 0) run('publicIpWatch', j.publicIpWatch);
if (minute % 60 === 0) run('diskWatch', j.diskWatch);
run('callReports', j.callReports);
+ if (minute % 5 === 0) run('contactsWarm', j.contactsWarm);
run('callTranscripts', async () => {
await transcribeCalls({ db: d.db, dir: resolve(d.config.RECORDINGS_DIR), log: d.log });
});
diff --git a/apps/api/src/routes/contacts.ts b/apps/api/src/routes/contacts.ts
index c7b7532..28613c4 100644
--- a/apps/api/src/routes/contacts.ts
+++ b/apps/api/src/routes/contacts.ts
@@ -20,6 +20,7 @@ import {
addToBook,
assertBuiltin,
cleanNumbers,
+ contactNames,
createContact,
readBooks,
toDto,
@@ -33,6 +34,7 @@ import { audit } from '../services/audit.ts';
import { getSetting } from '../services/settings.ts';
import { decrypt } from '../crypto/secrets.ts';
import { contrackCheck } from '../contacts/contrack.ts';
+import { callerNameToken, phoneSafeName, sameToken } from '../contacts/callername.ts';
const IdParams = z.object({ id: z.uuid() });
const CommitQuery = z.object({ commit: z.enum(['0', '1']).default('0') });
@@ -154,6 +156,38 @@ export async function contactRoutes(app: FastifyInstance) {
);
book('/api/contacts', requireAdmin, () => null);
+ /**
+ * The dialplan's caller-name lookup (11 §6 C4): only Asterisk on this machine (no proxy in
+ * between) with the token. Never waits on Contrack. Not logged: the token is in the query.
+ */
+ app.get('/api/internal/caller-name', { logLevel: 'silent' }, async (req, reply) => {
+ const q = z
+ .object({
+ ext: z.string().regex(/^\d{1,10}$/),
+ number: z.string().regex(/^\d{3,20}$/),
+ token: z.string().max(100),
+ })
+ .safeParse(req.query);
+ const local = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(req.socket.remoteAddress ?? '');
+ if (
+ !local ||
+ req.headers['x-forwarded-for'] ||
+ !q.success ||
+ !sameToken(q.data.token, callerNameToken(app.config.APP_SECRET))
+ )
+ return reply.code(404).send('');
+ const [e] = await db
+ .select({ userId: extensions.userId })
+ .from(extensions)
+ .where(eq(extensions.number, q.data.ext));
+ const names = await contactNames(db, e?.userId ?? null, [q.data.number], { wait: false }).catch(
+ () => new Map(),
+ );
+ return reply
+ .type('text/plain; charset=utf-8')
+ .send(phoneSafeName(names.get(q.data.number) ?? ''));
+ });
+
/** Settings → Contacts → Test (11 §4.3): the form's address, and its token or the saved one. */
app.post('/api/contacts/contrack/test', { preHandler: requireAdmin }, async (req) => {
const b = z
diff --git a/apps/api/test/asterisk/cnam-test.ts b/apps/api/test/asterisk/cnam-test.ts
new file mode 100644
index 0000000..af2600d
--- /dev/null
+++ b/apps/api/test/asterisk/cnam-test.ts
@@ -0,0 +1,415 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// 11 §6 C4 against real Asterisk: a call from outside rings 101's phone with the caller's name
+// from 101's contacts. A second Asterisk plays the provider, a baresip phone is 101, and a tiny
+// stand-in for the api's /api/internal/caller-name runs in the PBX's network namespace (so it is
+// 127.0.0.1 to Asterisk, as in production). What the phone sees is read from the INVITE the PBX
+// sends it (pjsip logger).
+// Checks: a contact's name; no contact keeps the provider's name; a ring group's "Sales:" prefix
+// stays in front; the api not answering still rings with the provider's name; 101's contact name
+// does not follow a forward to 102.
+// Usage: node --conditions=source apps/api/test/asterisk/cnam-test.ts
+import { spawnSync } from 'node:child_process';
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { SettingsSchemas, type SettingKey } from '@selfphoneng/shared';
+import { AmiClient } from '../../src/ami/client.ts';
+import { GENERATED_FILES, render } from '../../src/generator/index.ts';
+import type { Snapshot } from '../../src/generator/load.ts';
+import { providerPjsip } from './provider.ts';
+
+const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk';
+const NET = 'sp-cnam';
+const PBX = 'sp-cnam-pbx';
+const PROVIDER = 'sp-cnam-provider';
+const PHONES = 'sp-cnam-phones';
+const API = 'sp-cnam-api';
+const AMI_PORT = 15041;
+const AMI_SECRET = 'cnam-test-secret';
+const TRUNK = '7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37';
+const SUB = { user: '123456_pbx', pass: 'ProviderSecret42' };
+const TOKEN = 'ab'.repeat(32);
+const EXT = {
+ '101': '00000000-0000-4000-8000-000000000101',
+ '102': '00000000-0000-4000-8000-000000000102',
+};
+const PASS = { '101': 'CnamTest101xxxxxxxxxxxxx', '102': 'CnamTest102xxxxxxxxxxxxx' };
+const DID_USER = '13125551234'; // rings 101
+const DID_GROUP = '13125551235'; // "Sales:" ring group 600 → 101
+const KNOWN = '3125559999'; // Jane Doe in 101's contacts (10 digits, as voip.ms sends)
+const UNKNOWN = '3125550001';
+
+const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
+const docker = (...args: string[]) => {
+ const r = spawnSync('docker', args, { encoding: 'utf8' });
+ return { ok: r.status === 0, out: `${r.stdout ?? ''}${r.stderr ?? ''}` };
+};
+const log = (...a: unknown[]) => console.log('[cnam-test]', ...a);
+
+function snapshot(): Snapshot {
+ const settings = Object.fromEntries(
+ (Object.keys(SettingsSchemas) as SettingKey[])
+ .filter((k) => k !== 'sms.webhookToken')
+ .map((k) => [k, SettingsSchemas[k].parse(undefined)]),
+ ) as Snapshot['settings'];
+ const ext = (number: '101' | '102', name: string) => ({
+ id: EXT[number],
+ number,
+ name,
+ sipPassword: PASS[number],
+ webrtcEnabled: false,
+ maxContacts: 5,
+ ringSeconds: 6, // the forward check waits for 101's no-answer
+ voicemailEnabled: false,
+ voicemailPin: '4826',
+ outboundClass: 'national' as const,
+ outboundCallerId: null,
+ emergencyCallerId: null,
+ recordMode: 'never' as const,
+ pickupGroup: null,
+ language: 'en',
+ mohClass: 'default',
+ isAdmin: false,
+ });
+ return {
+ today: '2026-10-23',
+ settings,
+ callerNames: { url: 'http://127.0.0.1:8080/api/internal/caller-name', token: TOKEN },
+ dids: [
+ {
+ id: '11111111-1111-4111-8111-111111111111',
+ number: DID_USER,
+ trunkId: TRUNK,
+ label: null,
+ destination: { type: 'user', id: EXT['101'] },
+ callerIdNamePrefix: null,
+ recordInbound: false,
+ },
+ {
+ id: '11111111-1111-4111-8111-111111111112',
+ number: DID_GROUP,
+ trunkId: TRUNK,
+ label: null,
+ destination: { type: 'ring_group', id: '22222222-2222-4222-8222-222222222222' },
+ callerIdNamePrefix: null,
+ recordInbound: false,
+ },
+ ],
+ extensions: [ext('101', 'Alice'), ext('102', 'Bob')],
+ trunks: [
+ {
+ id: TRUNK,
+ name: 'voip.ms',
+ enabled: true,
+ serverHost: PROVIDER,
+ serverPort: 5060,
+ transport: 'udp',
+ authMode: 'registration',
+ username: SUB.user,
+ password: SUB.pass,
+ fromUser: null,
+ fromDomain: null,
+ codecs: ['ulaw'],
+ srtp: false,
+ maxChannels: null,
+ },
+ ],
+ ringGroups: [
+ {
+ id: '22222222-2222-4222-8222-222222222222',
+ number: '600',
+ name: 'Sales',
+ strategy: 'ringall',
+ ringSeconds: 20,
+ callerIdNamePrefix: 'Sales:',
+ musicInsteadOfRing: false,
+ members: ['101'],
+ noAnswerDestination: { type: 'hangup' },
+ },
+ ],
+ queues: [],
+ conferences: [],
+ pagingGroups: [],
+ mohClasses: [],
+ ivrs: [],
+ timeConditions: [],
+ holidays: [],
+ announcements: [],
+ recordingAnnouncement: null,
+ routes: [],
+ };
+}
+
+/** The provider: "Mobile" calls one of our DIDs; ${CALLER} picks who calls. */
+const providerDialplan = `[provider-out]
+exten => _X.,1,Set(CALLERID(all)="Mobile" <\${CALLER}>)
+ same => n,Dial(PJSIP/\${EXTEN}@${SUB.user},30)
+
+[provider-wait]
+exten => s,1,Answer()
+ same => n,Wait(30)
+
+[provider-in]
+exten => _X.,1,Answer()
+ same => n,Wait(30)
+`;
+
+/** baresip phones as 101 and 102: they ring, nobody answers (the INVITE is what we read). */
+function phoneFiles(dir: string) {
+ for (const n of ['101', '102'] as const) {
+ const d = join(dir, `phone-${n}`);
+ mkdirSync(d, { recursive: true });
+ writeFileSync(
+ join(d, 'config'),
+ [
+ 'poll_method epoll',
+ `sip_listen 0.0.0.0:${n === '101' ? 5070 : 5072}`,
+ 'module_path /usr/lib/baresip/modules',
+ 'module g711.so',
+ 'module ausine.so',
+ 'module aufile.so',
+ 'module menu.so',
+ 'module account.so',
+ 'audio_source ausine,440',
+ 'audio_player aufile,/dev/null',
+ 'audio_alert aufile,/dev/null',
+ '',
+ ].join('\n'),
+ );
+ writeFileSync(
+ join(d, 'accounts'),
+ `;auth_pass=${PASS[n]};answermode=manual;audio_codecs=PCMU;regint=60\n`,
+ );
+ }
+}
+
+/** The api's caller-name route, faked: Jane Doe for KNOWN in 101's book, with the right key. */
+const FAKE_API = `
+const http = require('http');
+http.createServer((req, res) => {
+ const u = new URL(req.url, 'http://x');
+ const ok = u.pathname === '/api/internal/caller-name' && u.searchParams.get('token') === '${TOKEN}';
+ const name = ok && u.searchParams.get('ext') === '101' && u.searchParams.get('number') === '1${KNOWN}' ? 'Jane Doe' : '';
+ console.log('lookup ' + u.searchParams.get('ext') + ' ' + u.searchParams.get('number') + ' ' + (ok ? 'key-ok' : 'key-bad') + ' -> ' + JSON.stringify(name));
+ if (process.env.SLOW) return setTimeout(() => res.end(name), 5000);
+ res.end(name);
+}).listen(8080, '127.0.0.1');
+`;
+
+async function waitFor(what: string, fn: () => Promise, seconds = 60) {
+ for (let i = 0; i < seconds; i++) {
+ if (await fn()) return;
+ await sleep(1000);
+ }
+ throw new Error(`timed out waiting for ${what}`);
+}
+
+async function main() {
+ const dir = mkdtempSync(join(tmpdir(), 'sp-cnam-'));
+ const pbxConf = join(dir, 'pbx');
+ const provConf = join(dir, 'provider');
+ mkdirSync(pbxConf);
+ mkdirSync(provConf);
+ const files = render(snapshot());
+ for (const f of GENERATED_FILES) {
+ writeFileSync(join(pbxConf, f), files[f]);
+ writeFileSync(
+ join(provConf, f),
+ f === 'pjsip_selfphoneng.conf'
+ ? providerPjsip(SUB)
+ : f === 'extensions_selfphoneng.conf'
+ ? providerDialplan
+ : '',
+ );
+ }
+ phoneFiles(dir);
+ writeFileSync(
+ join(dir, 'manager.conf.template'),
+ '[general]\nenabled = yes\nport = 5038\nbindaddr = 0.0.0.0\n\n[selfphoneng]\nsecret = ${AMI_SECRET}\npermit = 0.0.0.0/0.0.0.0\nread = all\nwrite = all\n',
+ );
+ const problems: string[] = [];
+ const ami = new AmiClient({
+ host: '127.0.0.1',
+ port: AMI_PORT,
+ username: 'selfphoneng',
+ secret: AMI_SECRET,
+ logger: { info() {}, warn() {}, error() {}, debug() {} },
+ backoffMs: [500],
+ });
+ const asterisk = (name: string, conf: string, extra: string[]) =>
+ docker(
+ 'run',
+ '-d',
+ '--rm',
+ '--name',
+ name,
+ '--network',
+ NET,
+ ...extra,
+ '-e',
+ `AMI_SECRET=${AMI_SECRET}`,
+ '-v',
+ `${conf}:/etc/asterisk/selfphoneng`,
+ IMAGE,
+ );
+ const booted = (name: string) =>
+ waitFor(
+ `${name} to boot`,
+ async () => docker('exec', name, 'asterisk', '-rx', 'core waitfullybooted').ok,
+ );
+ const startApi = (slow: boolean) => {
+ docker('rm', '-f', API);
+ const r = docker(
+ 'run',
+ '-d',
+ '--rm',
+ '--name',
+ API,
+ '--network',
+ `container:${PBX}`,
+ ...(slow ? ['-e', 'SLOW=1'] : []),
+ 'node:22-alpine',
+ 'node',
+ '-e',
+ FAKE_API,
+ );
+ if (!r.ok) throw new Error(`fake api: ${r.out}`);
+ };
+
+ /** The provider calls `did` as `caller`; returns the From display name of the INVITE to `ext`. */
+ async function ring(did: string, caller: string, ext: '101' | '102'): Promise {
+ // channel originate can't set a variable: the provider's dialplan reads a global.
+ docker('exec', PROVIDER, 'asterisk', '-rx', `dialplan set global CALLER ${caller}`);
+ const since = new Date().toISOString();
+ await ami.command('pjsip set logger on');
+ const o = docker(
+ 'exec',
+ PROVIDER,
+ 'asterisk',
+ '-rx',
+ `channel originate Local/${did.slice(1)}@provider-out extension s@provider-wait`,
+ );
+ if (!o.ok) throw new Error(`originate: ${o.out}`);
+ let from: string | null = null;
+ await waitFor(
+ `the INVITE to ${ext}`,
+ async () => {
+ const logs = docker('logs', '--since', since, PBX).out;
+ const invites = logs
+ .split(/<--- Transmitting SIP request/)
+ .slice(1)
+ .filter((b) => new RegExp(`^INVITE sip:${ext}[-@]`, 'm').test(b));
+ const m = invites.map((b) => /^From: (?:"([^"]*)" )? ami.once('connected', r));
+ await waitFor('the trunk to register', async () =>
+ (await ami.actionList({ Action: 'PJSIPShowRegistrationsOutbound' })).some(
+ (m) => m.Status === 'Registered',
+ ),
+ );
+
+ log('starting baresip phones 101 and 102 (installs baresip on first run)…');
+ const phones = docker(
+ 'run',
+ '-d',
+ '--rm',
+ '--name',
+ PHONES,
+ '--network',
+ NET,
+ '-v',
+ `${dir}:/phones`,
+ 'debian:trixie-slim',
+ 'sh',
+ '-c',
+ 'apt-get update -qq >/dev/null && apt-get install -y -qq --no-install-recommends baresip-core >/dev/null 2>&1 && ' +
+ '(baresip -f /phones/phone-101 > /phones/phone-101/log 2>&1 &) && (baresip -f /phones/phone-102 > /phones/phone-102/log 2>&1 &) && sleep 900',
+ );
+ if (!phones.ok) throw new Error(`phones: ${phones.out}`);
+ await waitFor(
+ '101 and 102 to register',
+ async () => {
+ const c = await ami.command('pjsip show contacts');
+ return /101\/sip:.*Avail/.test(c) && /102\/sip:.*Avail/.test(c);
+ },
+ 240,
+ );
+
+ const expect = (what: string, got: string | null, want: string) => {
+ log(`${what}: the phone shows ${JSON.stringify(got)}`);
+ if (got !== want)
+ problems.push(
+ `${what}: phone showed ${JSON.stringify(got)}, expected ${JSON.stringify(want)}`,
+ );
+ };
+ expect('a contact calls 101', await ring(DID_USER, KNOWN, '101'), 'Jane Doe');
+ expect('a stranger calls 101', await ring(DID_USER, UNKNOWN, '101'), 'Mobile');
+ expect(
+ 'a contact calls the Sales group',
+ await ring(DID_GROUP, KNOWN, '101'),
+ 'Sales: Jane Doe',
+ );
+
+ // 101 doesn't answer and forwards to 102 (AstDB SP/CFNA/101, as My settings writes it): 101's
+ // phone showed Jane Doe, and 102 must not see 101's private contact name.
+ await ami.command('database put SP CFNA/101 102');
+ expect('101 unanswered, forwarded to 102', await ring(DID_USER, KNOWN, '102'), 'Mobile');
+ await ami.command('database del SP CFNA/101');
+
+ const lookups = docker('logs', API).out;
+ if (!/lookup 101 13125559999 key-ok -> "Jane Doe"/.test(lookups))
+ problems.push(`no keyed lookup for 101:\n${lookups}`);
+ if (/key-bad/.test(lookups)) problems.push(`a lookup without the key:\n${lookups}`);
+
+ // The api too slow (5 s, the dialplan waits 2): the phone still rings, with the provider's name.
+ startApi(true);
+ await sleep(1000);
+ const t0 = Date.now();
+ expect('the api does not answer in time', await ring(DID_USER, KNOWN, '101'), 'Mobile');
+ log(`rang after ${Math.round((Date.now() - t0) / 100) / 10} s with the api hanging`);
+ } finally {
+ ami.stop();
+ if (process.env.KEEP) log(`KEEP: containers left running, config in ${dir}`);
+ else {
+ docker('rm', '-f', PBX, PROVIDER, PHONES, API);
+ docker('network', 'rm', NET);
+ rmSync(dir, { recursive: true, force: true });
+ }
+ }
+ if (problems.length) {
+ console.error(`[cnam-test] FAILED:\n- ${problems.join('\n- ')}`);
+ process.exit(1);
+ }
+ log('PASS');
+}
+
+main().catch((e: unknown) => {
+ console.error('[cnam-test]', e);
+ process.exit(1);
+});
diff --git a/apps/api/test/contacts.test.ts b/apps/api/test/contacts.test.ts
index 8962788..5d2201e 100644
--- a/apps/api/test/contacts.test.ts
+++ b/apps/api/test/contacts.test.ts
@@ -3,6 +3,7 @@
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { contactNames } from '../src/contacts/book.ts';
+import { callerNameToken, phoneSafeName } from '../src/contacts/callername.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
@@ -201,4 +202,27 @@ describe.skipIf(!hasTestDb)('contacts', () => {
const company = await contactNames(t.db, null, ['13125551234']);
expect(company.get('13125551234')).toBe('Front desk');
});
+
+ it('the dialplan lookup (C4): only with the key, never through the proxy, phone-safe names', async () => {
+ const token = callerNameToken(t.app.config.APP_SECRET);
+ const look = (q: string, headers: Record = {}) =>
+ t.app.inject({ method: 'GET', url: `/api/internal/caller-name?${q}`, headers });
+ const mine = await look(`ext=101&number=13125551234&token=${token}`);
+ expect([mine.statusCode, mine.body]).toEqual([200, 'My Jane']);
+ expect((await look(`ext=102&number=13125551234&token=${token}`)).body).toBe('Jane Doe'); // Sue's own copy, from the accepted share
+ expect((await look(`ext=999&number=13125551234&token=${token}`)).body).toBe('Front desk'); // no user
+ expect((await look(`ext=101&number=19995550000&token=${token}`)).body).toBe('');
+ expect((await look('ext=101&number=13125551234&token=wrong')).statusCode).toBe(404);
+ expect(
+ (
+ await look(`ext=101&number=13125551234&token=${token}`, {
+ 'x-forwarded-for': '203.0.113.9',
+ })
+ ).statusCode,
+ ).toBe(404);
+ expect(phoneSafeName(String.raw`Jane "J" , Inc.; (x)^|\ Ünïcode-Smith`)).toBe(
+ 'Jane J Doe Inc. x Ünïcode-Smith',
+ );
+ expect(phoneSafeName('A'.repeat(60))).toHaveLength(40);
+ });
});
diff --git a/docs/spec/03-config-generator.md b/docs/spec/03-config-generator.md
index f07cf78..ecd0ac0 100644
--- a/docs/spec/03-config-generator.md
+++ b/docs/spec/03-config-generator.md
@@ -389,16 +389,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
+; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each
+; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member
+; never sees someone else's contact name.
[sp-sub-dial-user]
exten => s,1,Set(DS=)
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}))
same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web))
same => n,GotoIf($["${DS}" = ""]?unavail)
- same => n,Dial(${DS},${ARG2},tx)
+ same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain)
+ same => n,Gosub(sp-sub-cnam,s,1(${ARG1}))
+ same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain)
+ same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL})
+ same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1))
+ same => n,Set(_SP_CNAM=)
+ same => n,Return()
+ same => n(plain),Dial(${DS},${ARG2},tx)
same => n,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
same => n,Return()
+; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s).
+; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back.
+[sp-sub-cnam]
+exten => s,1,Set(CURLOPT(conntimeout)=1)
+ same => n,Set(CURLOPT(httptimeout)=2)
+ same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})})
+
+; ─── Pre-dial handler on each ringing leg: the name its phone shows.
+[sp-predial-cnam]
+exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM})
+ same => n,Return()
+
; ─── Add AOR ARG1's reachable contacts to DS as PJSIP//. Not PJSIP_DIAL_CONTACTS: iOS
; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list.
; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts
diff --git a/docs/spec/04-backend-api.md b/docs/spec/04-backend-api.md
index 1ee4df8..321aacc 100644
--- a/docs/spec/04-backend-api.md
+++ b/docs/spec/04-backend-api.md
@@ -457,6 +457,7 @@ Provisioning and inbound hooks (outside `/api`, no session):
| `webhookDelivery` | every 10 s | send pending deliveries (§9) |
| `trunkWatch` | every 60 s | if a trunk is unregistered for more than 5 min → email admins once per outage |
| `diskWatch` | hourly | if free disk < 10% → email admins once a day |
+| `contactsWarm` | every 5 min, Contrack mode | refresh the company's and each connected person's Contrack book, so a ringing phone's caller-name lookup (11 §6.1) finds them |
| `callReports` | every 60 s | each user whose call report period (05 §4) ended over 5 min ago and isn't reported yet: email their calls of that period (by hang-up time) if there were any; `extensions.call_report_sent_at` = the period's end. Changing the schedule sets it to now, so the first report is the next period's |
Jobs must never overlap with themselves (use a running flag).
diff --git a/docs/spec/11-contacts.md b/docs/spec/11-contacts.md
index 3850827..5499688 100644
--- a/docs/spec/11-contacts.md
+++ b/docs/spec/11-contacts.md
@@ -270,10 +270,31 @@ New per-user options (My settings → Contacts, shown when connected):
| C1 | Seam + Simple: migration, builtin provider, routes, `/me/contacts`, `/admin/contacts`, CSV and vCard import (§3.1), sharing (§2.1), "Add to contacts", lookup everywhere in §1 except desk phones | a contact's name shows in call history, voicemail, call report and the ringing softphone; an iPhone and a Google .vcf export import |
| C2 | Company book on desk phones (07) | a Yealink shows company contacts in its directory. Already built in M10-T2 (`loadPhonebook`, the builtin company book); C3 adds the Contrack company account to it |
| C3 | Contrack: decision 0003, `deploy/contrack`, Caddy/nginx blocks, Settings → Contacts, company token, per-user tokens, contrack provider + cache, sharing, setup wizard step | with the owner's Contrack, a call from a Contrack contact shows their name |
-| C4 | Caller name on desk phones while ringing (owner, 2026-10-23: its own task after C3): the dialplan asks the api (AGI or `func_curl`) and sets `CALLERID(name)` | a desk phone rings with the contact's name, from either kind of book |
+| C4 | Caller name on desk phones while ringing (owner, 2026-10-23: its own task after C3): the dialplan asks the api (AGI or `func_curl`) and sets `CALLERID(name)` | a desk phone rings with the contact's name, from either kind of book. Done, see §6.1 |
| C5 | Calls on the Contrack timeline (§4.4), summaries opt-in | an answered, recorded call shows up on the contact's Contrack timeline with its summary |
| C6 | Copy builtin contacts to Contrack; personal and shared contacts in desk phone directories | optional |
+### 6.1 As built: C4
+
+- `sp-sub-dial-user` (03 §6.2), for calls from outside only: `sp-sub-cnam` asks
+ `GET /api/internal/caller-name?ext=&number=&token=` with `func_curl` (connect 1 s, answer 2 s).
+ A name goes on each ringing leg with a pre-dial handler, `CONNECTEDLINE(name,i)` (what a
+ PJSIP phone shows in the INVITE's From), not on the call: the next ring group member, a queue
+ agent or a forward does its own lookup in its own user's book, and never sees another's.
+- The prefix of a number, ring group or queue ("Sales:") is kept in front:
+ `__SP_CNAM_PREFIX` grows the same way `CALLERID(name)` does.
+- Globals `SP_CNAM_URL` (http://127.0.0.1:API_PORT/…) and `SP_CNAM_TOKEN` (HMAC-SHA256 of
+ APP_SECRET, so it survives restarts). The route answers 404 unless the request comes from this
+ machine without `X-Forwarded-For` (not through the reverse proxy) and with the key, and isn't
+ logged (the key is in the query). Names come back as letters, digits, spaces and `. ' & @ -`,
+ at most 40 characters, so nothing can be read as dialplan or SIP syntax.
+- It never waits on Contrack: a stale book answers what it has and refreshes in the background,
+ and the `contactsWarm` job refreshes every connected book every 5 minutes.
+- `apps/api/test/asterisk/cnam-test.ts` proves it on real Asterisk with a baresip phone: a
+ contact's name, a stranger's provider name, "Sales: Jane Doe" through a ring group, 101's
+ contact name not reaching 102 after a no-answer forward, and the phone still ringing (with the
+ provider's name) when the api hangs.
+
Tests: builtin lookup/search/import against the test DB; contrack provider against a fake HTTP
server built from the pinned `openapi.json`; one e2e script against a real Contrack container.
--
2.34.1
From e6a1373f36201b09f519884161b83fa724dc06d0 Mon Sep 17 00:00:00 2001
From: iamdoubz
Date: Fri, 23 Oct 2026 15:59:53 -0500
Subject: [PATCH 5/6] contacts: calls on the Contrack timeline, summaries
opt-in (C5)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Spec 11 §4.4: the contrackLog job puts each connected person's calls with their Contrack
contacts on that contact's timeline once (contrack_call_log, migration 0011), from when they
connected. With summaries on (My settings → Contacts, needs call summaries), the escaped summary
and action items go in, and the first action item becomes a follow-up due the next day. Waits up
to 30 min for a summary; Contrack down is retried, refusals noted once. Tested against a fake and
a real Contrack 2.0.2.
---
apps/api/src/contacts/contrack.ts | 16 +
apps/api/src/contacts/timeline.test.ts | 62 +
apps/api/src/contacts/timeline.ts | 218 +
.../db/migrations/0011_contrack_call_log.sql | 16 +
.../src/db/migrations/meta/0011_snapshot.json | 4353 +++++++++++++++++
apps/api/src/db/migrations/meta/_journal.json | 7 +
apps/api/src/db/schema.ts | 23 +
apps/api/src/jobs.ts | 17 +-
apps/api/src/routes/me.ts | 36 +-
apps/api/test/contrack-live.test.ts | 72 +
apps/api/test/contrack.test.ts | 175 +-
apps/api/test/schema.test.ts | 7 +-
apps/web/src/api/client.ts | 2 +
apps/web/src/pages/user/MeSettingsPage.tsx | 40 +-
docs/admin-guide/contacts.md | 10 +
docs/spec/02-data-model.md | 14 +-
docs/spec/04-backend-api.md | 1 +
docs/spec/11-contacts.md | 22 +-
packages/shared/src/me.ts | 18 +-
19 files changed, 5096 insertions(+), 13 deletions(-)
create mode 100644 apps/api/src/contacts/timeline.test.ts
create mode 100644 apps/api/src/contacts/timeline.ts
create mode 100644 apps/api/src/db/migrations/0011_contrack_call_log.sql
create mode 100644 apps/api/src/db/migrations/meta/0011_snapshot.json
diff --git a/apps/api/src/contacts/contrack.ts b/apps/api/src/contacts/contrack.ts
index 0247569..f1aff35 100644
--- a/apps/api/src/contacts/contrack.ts
+++ b/apps/api/src/contacts/contrack.ts
@@ -204,6 +204,22 @@ export async function contrackCreate(
return toDto(c, row, owner === null ? 'company' : 'mine');
}
+/** A timeline entry on a contact (11 §4.4); answers its id. */
+export async function contrackInteraction(
+ c: Contrack,
+ token: string,
+ contactId: string,
+ body: Record,
+): Promise {
+ const r = await ctk<{ id: string }>(
+ c.url,
+ token,
+ `api/contacts/${encodeURIComponent(contactId)}/interactions`,
+ { method: 'POST', body: JSON.stringify(body) },
+ );
+ return r.id;
+}
+
/** Settings → Contacts → Test, and connecting a token: reachable, and the token reads contacts. */
export async function contrackCheck(url: string, token: string | null): Promise {
try {
diff --git a/apps/api/src/contacts/timeline.test.ts b/apps/api/src/contacts/timeline.test.ts
new file mode 100644
index 0000000..fd3504c
--- /dev/null
+++ b/apps/api/src/contacts/timeline.test.ts
@@ -0,0 +1,62 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// C5: what a call looks like on a Contrack timeline (11 §4.4).
+import { describe, expect, it } from 'vitest';
+import { splitSummary, timelineEntry, type TimelineCall } from './timeline.ts';
+
+const call = (c: Partial): TimelineCall => ({
+ out: false,
+ status: 'answered',
+ fromNumber: '13125551234',
+ toNumber: '13125550000',
+ talkSeconds: 192,
+ answeredBy: null,
+ recorded: false,
+ ...c,
+});
+
+describe('timelineEntry', () => {
+ it('titles calls from where the person sat', () => {
+ const t = (c: Partial) => timelineEntry(call(c), null).title;
+ expect(t({})).toBe('Incoming call');
+ expect(t({ answeredBy: 'Sue Smith' })).toBe('Incoming call, answered by Sue Smith');
+ expect(t({ status: 'missed', talkSeconds: 0 })).toBe('Missed call');
+ expect(t({ status: 'voicemail', talkSeconds: 0 })).toBe('Missed call, voicemail left');
+ expect(t({ out: true })).toBe('Outgoing call');
+ expect(t({ out: true, status: 'busy', talkSeconds: 0 })).toBe('Outgoing call, busy');
+ expect(t({ out: true, status: 'failed', talkSeconds: 0 })).toBe('Outgoing call, no answer');
+ });
+
+ it('writes the facts, the summary and its action items as escaped HTML', () => {
+ const e = timelineEntry(
+ call({ recorded: true }),
+ 'Acme a quote & a call back.\nAction items:\n- Dan sends the quote\n- Sue books a visit',
+ );
+ expect(e).toEqual({
+ title: 'Incoming call',
+ content:
+ 'From (312) 555-1234 to (312) 555-0000 · 3:12 · recorded
' +
+ 'Acme <wants> a quote & a call back.
' +
+ 'Action items:
Dan sends the quote Sue books a visit ',
+ duration: 4,
+ firstItem: 'Dan sends the quote',
+ });
+ const missed = timelineEntry(call({ status: 'missed', talkSeconds: 0 }), null);
+ expect([missed.content, missed.duration, missed.firstItem]).toEqual([
+ 'From (312) 555-1234 to (312) 555-0000
',
+ null,
+ null,
+ ]);
+ expect(timelineEntry(call({ out: true }), null).content).toBe(
+ 'To (312) 555-0000 · 3:12
',
+ );
+ });
+
+ it('reads "Action items: none" as none', () => {
+ expect(splitSummary('Wrong number.\nAction items: none')).toEqual({
+ text: ['Wrong number.'],
+ items: [],
+ });
+ expect(splitSummary('Just chatted.')).toEqual({ text: ['Just chatted.'], items: [] });
+ expect(splitSummary('X.\nAction items:\n- none')).toEqual({ text: ['X.'], items: [] });
+ });
+});
diff --git a/apps/api/src/contacts/timeline.ts b/apps/api/src/contacts/timeline.ts
new file mode 100644
index 0000000..649e063
--- /dev/null
+++ b/apps/api/src/contacts/timeline.ts
@@ -0,0 +1,218 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// Calls on the Contrack timeline (11 §4.4): each call of a connected person with one of their
+// Contrack contacts becomes a "call" interaction on that contact, with the recorded call's
+// summary if they asked for it. Unknown numbers are not added to Contrack.
+import { and, asc, eq, gt, isNotNull, lt, ne, sql } from 'drizzle-orm';
+import { alias } from 'drizzle-orm/pg-core';
+import { formatNumber } from '@selfphoneng/shared';
+import { decrypt } from '../crypto/secrets.ts';
+import type { Db } from '../db/client.ts';
+import {
+ callExtensions,
+ calls,
+ contrackCallLog,
+ extensions,
+ recordings,
+ users,
+} from '../db/schema.ts';
+import { todayIn } from '../generator/load.ts';
+import { getSetting } from '../services/settings.ts';
+import { ContrackError, contrackBook, contrackInteraction, contrackSettings } from './contrack.ts';
+
+/** A recorded call waits this long for its summary before it's logged without one. */
+const SUMMARY_WAIT_MS = 30 * 60_000;
+
+const esc = (s: string) =>
+ s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+const num = (n: string | null) => (n ? (n.length > 6 ? formatNumber(n) : n) : 'an unknown number');
+const mmss = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
+
+export type TimelineCall = {
+ out: boolean;
+ status: string;
+ fromNumber: string | null;
+ toNumber: string | null;
+ talkSeconds: number;
+ /** Someone else answered (a ring group or queue colleague). */
+ answeredBy: string | null;
+ recorded: boolean;
+};
+
+/** "Action items:" lines of a summary (10 §3), and the text before them. */
+export function splitSummary(summary: string): { text: string[]; items: string[] } {
+ const lines = summary
+ .split('\n')
+ .map((l) => l.trim())
+ .filter(Boolean);
+ const at = lines.findIndex((l) => /^action items:/i.test(l));
+ if (at < 0) return { text: lines, items: [] };
+ return {
+ text: lines.slice(0, at),
+ items: lines
+ .slice(at + 1)
+ .filter((l) => /^[-*•]\s+/.test(l))
+ .map((l) => l.replace(/^[-*•]\s+/, ''))
+ .filter((l) => !/^none\.?$/i.test(l)),
+ };
+}
+
+/** The interaction for one call: title, HTML content, minutes, the first action item. */
+export function timelineEntry(c: TimelineCall, summary: string | null) {
+ const missed = ['missed', 'voicemail', 'abandoned'].includes(c.status);
+ const title = c.out
+ ? c.status === 'answered'
+ ? 'Outgoing call'
+ : `Outgoing call, ${c.status === 'busy' ? 'busy' : 'no answer'}`
+ : c.status === 'voicemail'
+ ? 'Missed call, voicemail left'
+ : missed || c.status !== 'answered'
+ ? 'Missed call'
+ : c.answeredBy
+ ? `Incoming call, answered by ${c.answeredBy}`
+ : 'Incoming call';
+ const facts = [
+ c.out ? `To ${num(c.toNumber)}` : `From ${num(c.fromNumber)} to ${num(c.toNumber)}`,
+ ...(c.talkSeconds ? [mmss(c.talkSeconds)] : []),
+ ...(c.recorded ? ['recorded'] : []),
+ ];
+ const parts = [`${esc(facts.join(' · '))}
`];
+ const s = summary ? splitSummary(summary) : null;
+ if (s?.text.length) parts.push(...s.text.map((l) => `${esc(l)}
`));
+ if (s?.items.length)
+ parts.push(`Action items:
${s.items.map((i) => `${esc(i)} `).join('')} `);
+ return {
+ title,
+ content: parts.join(''),
+ duration: c.talkSeconds ? Math.ceil(c.talkSeconds / 60) : null,
+ firstItem: s?.items[0] ?? null,
+ };
+}
+
+const answerer = alias(extensions, 'answerer');
+
+/**
+ * One run of the contrackLog job: calls that ended over a minute ago (and within 2 days), of
+ * people who connected Contrack and log calls, not looked at yet. Contrack down: tried again on
+ * the next run.
+ */
+export async function logCallsToContrack(d: {
+ db: Db;
+ log: { error(o: object, m: string): void };
+ now?: Date;
+}): Promise {
+ const c = await contrackSettings(d.db);
+ if (!c) return 0;
+ const now = d.now ?? new Date();
+ const tz = await getSetting(d.db, 'general.timezone');
+ const summariesOn =
+ (await getSetting(d.db, 'ai.callTranscripts')) && (await getSetting(d.db, 'ai.callSummaries'));
+ const rows = await d.db
+ .select({
+ call: calls,
+ role: callExtensions.role,
+ userId: users.id,
+ withSummary: users.contrackLogSummaries,
+ tokenEnc: users.contrackTokenEnc,
+ extId: extensions.id,
+ answeredById: answerer.id,
+ answeredBy: answerer.name,
+ summary: recordings.summary,
+ aiDoneAt: recordings.aiDoneAt,
+ })
+ .from(calls)
+ .innerJoin(callExtensions, eq(callExtensions.callId, calls.id))
+ .innerJoin(extensions, eq(extensions.id, callExtensions.extensionId))
+ .innerJoin(users, eq(users.id, extensions.userId))
+ .leftJoin(answerer, eq(answerer.id, calls.answeredByExtensionId))
+ .leftJoin(recordings, eq(recordings.id, calls.recordingId))
+ .where(
+ and(
+ isNotNull(users.contrackTokenEnc),
+ eq(users.contrackLogCalls, true),
+ eq(users.isActive, true),
+ ne(calls.direction, 'internal'),
+ gt(calls.endedAt, users.contrackLogSince),
+ lt(calls.endedAt, new Date(now.getTime() - 60_000)),
+ gt(calls.endedAt, new Date(now.getTime() - 2 * 24 * 3600_000)),
+ sql`not exists (select 1 from ${contrackCallLog} l where l.call_id = ${calls.id} and l.user_id = ${users.id})`,
+ ),
+ )
+ .orderBy(asc(calls.endedAt))
+ .limit(500);
+
+ // One entry per call and person (a person can be in a call as caller, rang and answered).
+ const todo = new Map();
+ for (const r of rows) {
+ const k = `${r.call.id}|${r.userId}`;
+ const out = r.role === 'caller' || todo.get(k)?.out === true;
+ todo.set(k, { ...r, out });
+ }
+ const skipUser = new Set(); // Contrack down or token refused: try again next run
+ let logged = 0;
+ for (const r of todo.values()) {
+ if (skipUser.has(r.userId)) continue;
+ const record = (interactionId: string | null, error: string | null = null) =>
+ d.db
+ .insert(contrackCallLog)
+ .values({ callId: r.call.id, userId: r.userId, interactionId, error })
+ .onConflictDoNothing();
+ const book = await contrackBook(d.db, c, r.userId);
+ if (!book || book.error) {
+ skipUser.add(r.userId);
+ continue;
+ }
+ const other = r.out ? r.call.toNumber : r.call.fromNumber;
+ const contact = other
+ ? book.contacts.find((x) => x.numbers.some((n) => n.number === other))
+ : undefined;
+ if (!contact) {
+ await record(null);
+ continue;
+ }
+ const wantSummary = r.withSummary && summariesOn && r.call.recordingId !== null;
+ if (wantSummary && !r.aiDoneAt && now.getTime() - r.call.endedAt.getTime() < SUMMARY_WAIT_MS)
+ continue; // the transcript job hasn't summarized it yet
+ const e = timelineEntry(
+ {
+ out: r.out,
+ status: r.call.status,
+ fromNumber: r.call.fromNumber,
+ toNumber: r.call.toNumber,
+ talkSeconds: r.call.talkSeconds,
+ answeredBy: r.answeredById && r.answeredById !== r.extId ? r.answeredBy : null,
+ recorded: r.call.recordingId !== null,
+ },
+ wantSummary ? r.summary : null,
+ );
+ try {
+ const id = await contrackInteraction(c, decrypt(r.tokenEnc!), contact.id, {
+ type: 'call',
+ title: e.title,
+ content: e.content,
+ date: r.call.startedAt.toISOString(),
+ duration: e.duration,
+ source: 'selfphoneng',
+ ...(e.firstItem
+ ? {
+ actionItem: {
+ title: e.firstItem.slice(0, 200),
+ dueAt: todayIn(tz, new Date(r.call.startedAt.getTime() + 24 * 3600_000)),
+ },
+ }
+ : {}),
+ });
+ await record(id);
+ logged++;
+ } catch (err) {
+ const status = err instanceof ContrackError ? err.status : undefined;
+ // Refused (bad request, a read-only token, a deleted contact): noted, not retried.
+ if (status && status >= 400 && status < 500 && status !== 401 && status !== 429)
+ await record(null, (err as Error).message);
+ else {
+ skipUser.add(r.userId);
+ d.log.error({ err, user: r.userId }, 'contrack: call not logged yet');
+ }
+ }
+ }
+ return logged;
+}
diff --git a/apps/api/src/db/migrations/0011_contrack_call_log.sql b/apps/api/src/db/migrations/0011_contrack_call_log.sql
new file mode 100644
index 0000000..6b2e340
--- /dev/null
+++ b/apps/api/src/db/migrations/0011_contrack_call_log.sql
@@ -0,0 +1,16 @@
+CREATE TABLE "contrack_call_log" (
+ "call_id" uuid NOT NULL,
+ "user_id" uuid NOT NULL,
+ "interaction_id" text,
+ "error" text,
+ "logged_at" timestamp with time zone DEFAULT now() NOT NULL,
+ CONSTRAINT "contrack_call_log_call_id_user_id_pk" PRIMARY KEY("call_id","user_id")
+);
+--> statement-breakpoint
+ALTER TABLE "users" ADD COLUMN "contrack_log_calls" boolean DEFAULT true NOT NULL;--> statement-breakpoint
+ALTER TABLE "users" ADD COLUMN "contrack_log_summaries" boolean DEFAULT false NOT NULL;--> statement-breakpoint
+ALTER TABLE "users" ADD COLUMN "contrack_log_since" timestamp with time zone;--> statement-breakpoint
+ALTER TABLE "contrack_call_log" ADD CONSTRAINT "contrack_call_log_call_id_calls_id_fk" FOREIGN KEY ("call_id") REFERENCES "public"."calls"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "contrack_call_log" ADD CONSTRAINT "contrack_call_log_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+-- People already connected (C3) start logging now, not with the last 2 days of calls.
+UPDATE "users" SET "contrack_log_since" = now() WHERE "contrack_token_enc" IS NOT NULL;
\ No newline at end of file
diff --git a/apps/api/src/db/migrations/meta/0011_snapshot.json b/apps/api/src/db/migrations/meta/0011_snapshot.json
new file mode 100644
index 0000000..9f77d39
--- /dev/null
+++ b/apps/api/src/db/migrations/meta/0011_snapshot.json
@@ -0,0 +1,4353 @@
+{
+ "id": "e22c65f0-c8ee-42c8-aa4f-667a4831350d",
+ "prevId": "f785afac-008e-455e-a203-59d8c1c46102",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.announcements": {
+ "name": "announcements",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sound_id": {
+ "name": "sound_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "destination": {
+ "name": "destination",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "announcements_sound_id_sound_files_id_fk": {
+ "name": "announcements_sound_id_sound_files_id_fk",
+ "tableFrom": "announcements",
+ "tableTo": "sound_files",
+ "columnsFrom": [
+ "sound_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.api_tokens": {
+ "name": "api_tokens",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token_prefix": {
+ "name": "token_prefix",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "api_tokens_created_by_users_id_fk": {
+ "name": "api_tokens_created_by_users_id_fk",
+ "tableFrom": "api_tokens",
+ "tableTo": "users",
+ "columnsFrom": [
+ "created_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "api_tokens_tokenHash_unique": {
+ "name": "api_tokens_tokenHash_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token_hash"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.audit_log": {
+ "name": "audit_log",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "actor_user_id": {
+ "name": "actor_user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "actor_ip": {
+ "name": "actor_ip",
+ "type": "inet",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "entity_type": {
+ "name": "entity_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "entity_id": {
+ "name": "entity_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "diff": {
+ "name": "diff",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "audit_log_actor_user_id_users_id_fk": {
+ "name": "audit_log_actor_user_id_users_id_fk",
+ "tableFrom": "audit_log",
+ "tableTo": "users",
+ "columnsFrom": [
+ "actor_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.blocked_numbers": {
+ "name": "blocked_numbers",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "number": {
+ "name": "number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "blocked_numbers_number_unique": {
+ "name": "blocked_numbers_number_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "number"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.call_extensions": {
+ "name": "call_extensions",
+ "schema": "",
+ "columns": {
+ "call_id": {
+ "name": "call_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "extension_id": {
+ "name": "extension_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "call_extensions_call_id_calls_id_fk": {
+ "name": "call_extensions_call_id_calls_id_fk",
+ "tableFrom": "call_extensions",
+ "tableTo": "calls",
+ "columnsFrom": [
+ "call_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "call_extensions_extension_id_extensions_id_fk": {
+ "name": "call_extensions_extension_id_extensions_id_fk",
+ "tableFrom": "call_extensions",
+ "tableTo": "extensions",
+ "columnsFrom": [
+ "extension_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "call_extensions_call_id_extension_id_role_pk": {
+ "name": "call_extensions_call_id_extension_id_role_pk",
+ "columns": [
+ "call_id",
+ "extension_id",
+ "role"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "call_extensions_role_check": {
+ "name": "call_extensions_role_check",
+ "value": "\"call_extensions\".\"role\" in ('caller', 'rang', 'answered')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.calls": {
+ "name": "calls",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "linkedid": {
+ "name": "linkedid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "direction": {
+ "name": "direction",
+ "type": "call_direction",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "call_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "answered_at": {
+ "name": "answered_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ended_at": {
+ "name": "ended_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "from_number": {
+ "name": "from_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "from_name": {
+ "name": "from_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "to_number": {
+ "name": "to_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "did_id": {
+ "name": "did_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "answered_by_extension_id": {
+ "name": "answered_by_extension_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "talk_seconds": {
+ "name": "talk_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "recording_id": {
+ "name": "recording_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "calls_started_at_index": {
+ "name": "calls_started_at_index",
+ "columns": [
+ {
+ "expression": "started_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "calls_did_id_dids_id_fk": {
+ "name": "calls_did_id_dids_id_fk",
+ "tableFrom": "calls",
+ "tableTo": "dids",
+ "columnsFrom": [
+ "did_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "calls_answered_by_extension_id_extensions_id_fk": {
+ "name": "calls_answered_by_extension_id_extensions_id_fk",
+ "tableFrom": "calls",
+ "tableTo": "extensions",
+ "columnsFrom": [
+ "answered_by_extension_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "calls_recording_id_recordings_id_fk": {
+ "name": "calls_recording_id_recordings_id_fk",
+ "tableFrom": "calls",
+ "tableTo": "recordings",
+ "columnsFrom": [
+ "recording_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "calls_linkedid_unique": {
+ "name": "calls_linkedid_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "linkedid"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.cdr": {
+ "name": "cdr",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "uniqueid": {
+ "name": "uniqueid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "linkedid": {
+ "name": "linkedid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "start_at": {
+ "name": "start_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "answer_at": {
+ "name": "answer_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "end_at": {
+ "name": "end_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "duration": {
+ "name": "duration",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "billsec": {
+ "name": "billsec",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "src": {
+ "name": "src",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "dst": {
+ "name": "dst",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "dcontext": {
+ "name": "dcontext",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "clid": {
+ "name": "clid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "channel": {
+ "name": "channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "dstchannel": {
+ "name": "dstchannel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lastapp": {
+ "name": "lastapp",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lastdata": {
+ "name": "lastdata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "disposition": {
+ "name": "disposition",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "accountcode": {
+ "name": "accountcode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "userfield": {
+ "name": "userfield",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sp_did": {
+ "name": "sp_did",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sp_direction": {
+ "name": "sp_direction",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sp_recording": {
+ "name": "sp_recording",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "raw": {
+ "name": "raw",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "cdr_linkedid_index": {
+ "name": "cdr_linkedid_index",
+ "columns": [
+ {
+ "expression": "linkedid",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.conferences": {
+ "name": "conferences",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "number": {
+ "name": "number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "pin_enc": {
+ "name": "pin_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "admin_pin_enc": {
+ "name": "admin_pin_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "max_members": {
+ "name": "max_members",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 20
+ },
+ "record": {
+ "name": "record",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "conferences_number_unique": {
+ "name": "conferences_number_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "number"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.contact_shares": {
+ "name": "contact_shares",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "from_user_id": {
+ "name": "from_user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "to_user_id": {
+ "name": "to_user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "contact": {
+ "name": "contact",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "decided_at": {
+ "name": "decided_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "contact_shares_to_user_id_index": {
+ "name": "contact_shares_to_user_id_index",
+ "columns": [
+ {
+ "expression": "to_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"contact_shares\".\"status\" = 'pending'",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "contact_shares_from_user_id_users_id_fk": {
+ "name": "contact_shares_from_user_id_users_id_fk",
+ "tableFrom": "contact_shares",
+ "tableTo": "users",
+ "columnsFrom": [
+ "from_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "contact_shares_to_user_id_users_id_fk": {
+ "name": "contact_shares_to_user_id_users_id_fk",
+ "tableFrom": "contact_shares",
+ "tableTo": "users",
+ "columnsFrom": [
+ "to_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "contact_shares_status_check": {
+ "name": "contact_shares_status_check",
+ "value": "\"contact_shares\".\"status\" in ('pending', 'accepted', 'denied')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.contacts": {
+ "name": "contacts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "company": {
+ "name": "company",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "numbers": {
+ "name": "numbers",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "contacts_owner_user_id_users_id_fk": {
+ "name": "contacts_owner_user_id_users_id_fk",
+ "tableFrom": "contacts",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.contrack_call_log": {
+ "name": "contrack_call_log",
+ "schema": "",
+ "columns": {
+ "call_id": {
+ "name": "call_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "interaction_id": {
+ "name": "interaction_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "logged_at": {
+ "name": "logged_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "contrack_call_log_call_id_calls_id_fk": {
+ "name": "contrack_call_log_call_id_calls_id_fk",
+ "tableFrom": "contrack_call_log",
+ "tableTo": "calls",
+ "columnsFrom": [
+ "call_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "contrack_call_log_user_id_users_id_fk": {
+ "name": "contrack_call_log_user_id_users_id_fk",
+ "tableFrom": "contrack_call_log",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "contrack_call_log_call_id_user_id_pk": {
+ "name": "contrack_call_log_call_id_user_id_pk",
+ "columns": [
+ "call_id",
+ "user_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.devices": {
+ "name": "devices",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "extension_id": {
+ "name": "extension_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "mac": {
+ "name": "mac",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "vendor": {
+ "name": "vendor",
+ "type": "phone_vendor",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "model": {
+ "name": "model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "provisioning_password_enc": {
+ "name": "provisioning_password_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_provisioned_at": {
+ "name": "last_provisioned_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_provisioned_ip": {
+ "name": "last_provisioned_ip",
+ "type": "inet",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "devices_extension_id_extensions_id_fk": {
+ "name": "devices_extension_id_extensions_id_fk",
+ "tableFrom": "devices",
+ "tableTo": "extensions",
+ "columnsFrom": [
+ "extension_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "devices_mac_unique": {
+ "name": "devices_mac_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "mac"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.did_sms_users": {
+ "name": "did_sms_users",
+ "schema": "",
+ "columns": {
+ "did_id": {
+ "name": "did_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "did_sms_users_did_id_dids_id_fk": {
+ "name": "did_sms_users_did_id_dids_id_fk",
+ "tableFrom": "did_sms_users",
+ "tableTo": "dids",
+ "columnsFrom": [
+ "did_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "did_sms_users_user_id_users_id_fk": {
+ "name": "did_sms_users_user_id_users_id_fk",
+ "tableFrom": "did_sms_users",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "did_sms_users_did_id_user_id_pk": {
+ "name": "did_sms_users_did_id_user_id_pk",
+ "columns": [
+ "did_id",
+ "user_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.dids": {
+ "name": "dids",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "number": {
+ "name": "number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "trunk_id": {
+ "name": "trunk_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "destination": {
+ "name": "destination",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "caller_id_name_prefix": {
+ "name": "caller_id_name_prefix",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "record_inbound": {
+ "name": "record_inbound",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "sms_enabled": {
+ "name": "sms_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "e911_address": {
+ "name": "e911_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "dids_trunk_id_trunks_id_fk": {
+ "name": "dids_trunk_id_trunks_id_fk",
+ "tableFrom": "dids",
+ "tableTo": "trunks",
+ "columnsFrom": [
+ "trunk_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "dids_number_unique": {
+ "name": "dids_number_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "number"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.emergency_events": {
+ "name": "emergency_events",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "extension_number": {
+ "name": "extension_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "dialed": {
+ "name": "dialed",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "caller_id_used": {
+ "name": "caller_id_used",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "location": {
+ "name": "location",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "notified": {
+ "name": "notified",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.extensions": {
+ "name": "extensions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "number": {
+ "name": "number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sip_password_enc": {
+ "name": "sip_password_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "webrtc_enabled": {
+ "name": "webrtc_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "max_contacts": {
+ "name": "max_contacts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 5
+ },
+ "ring_seconds": {
+ "name": "ring_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 25
+ },
+ "voicemail_enabled": {
+ "name": "voicemail_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "voicemail_pin_enc": {
+ "name": "voicemail_pin_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "voicemail_email": {
+ "name": "voicemail_email",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "voicemail_attach_audio": {
+ "name": "voicemail_attach_audio",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "voicemail_delete_after_email": {
+ "name": "voicemail_delete_after_email",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "outbound_class": {
+ "name": "outbound_class",
+ "type": "outbound_class",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'national'"
+ },
+ "outbound_caller_id_did_id": {
+ "name": "outbound_caller_id_did_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "emergency_caller_id_did_id": {
+ "name": "emergency_caller_id_did_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "emergency_location": {
+ "name": "emergency_location",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "record_mode": {
+ "name": "record_mode",
+ "type": "record_mode",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'never'"
+ },
+ "pickup_group": {
+ "name": "pickup_group",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "language": {
+ "name": "language",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'en'"
+ },
+ "moh_class": {
+ "name": "moh_class",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'default'"
+ },
+ "blf_numbers": {
+ "name": "blf_numbers",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "call_report": {
+ "name": "call_report",
+ "type": "call_report",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'off'"
+ },
+ "call_report_time": {
+ "name": "call_report_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'08:00'"
+ },
+ "call_report_weekday": {
+ "name": "call_report_weekday",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "call_report_summaries": {
+ "name": "call_report_summaries",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "call_report_sent_at": {
+ "name": "call_report_sent_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "extensions_user_id_users_id_fk": {
+ "name": "extensions_user_id_users_id_fk",
+ "tableFrom": "extensions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "extensions_outbound_caller_id_did_id_dids_id_fk": {
+ "name": "extensions_outbound_caller_id_did_id_dids_id_fk",
+ "tableFrom": "extensions",
+ "tableTo": "dids",
+ "columnsFrom": [
+ "outbound_caller_id_did_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "extensions_emergency_caller_id_did_id_dids_id_fk": {
+ "name": "extensions_emergency_caller_id_did_id_dids_id_fk",
+ "tableFrom": "extensions",
+ "tableTo": "dids",
+ "columnsFrom": [
+ "emergency_caller_id_did_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "extensions_number_unique": {
+ "name": "extensions_number_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "number"
+ ]
+ },
+ "extensions_userId_unique": {
+ "name": "extensions_userId_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "user_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.generator_runs": {
+ "name": "generator_runs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "trigger": {
+ "name": "trigger",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "files_changed": {
+ "name": "files_changed",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "commands": {
+ "name": "commands",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ok": {
+ "name": "ok",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "duration_ms": {
+ "name": "duration_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.holidays": {
+ "name": "holidays",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "start_date": {
+ "name": "start_date",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "end_date": {
+ "name": "end_date",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "recurring_yearly": {
+ "name": "recurring_yearly",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.ivr_options": {
+ "name": "ivr_options",
+ "schema": "",
+ "columns": {
+ "ivr_id": {
+ "name": "ivr_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "digit": {
+ "name": "digit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "destination": {
+ "name": "destination",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "ivr_options_ivr_id_ivrs_id_fk": {
+ "name": "ivr_options_ivr_id_ivrs_id_fk",
+ "tableFrom": "ivr_options",
+ "tableTo": "ivrs",
+ "columnsFrom": [
+ "ivr_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "ivr_options_ivr_id_digit_pk": {
+ "name": "ivr_options_ivr_id_digit_pk",
+ "columns": [
+ "ivr_id",
+ "digit"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "ivr_options_digit_check": {
+ "name": "ivr_options_digit_check",
+ "value": "\"ivr_options\".\"digit\" ~ '^[0-9*#]$'"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.ivrs": {
+ "name": "ivrs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "number": {
+ "name": "number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "greeting_sound_id": {
+ "name": "greeting_sound_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "invalid_sound_id": {
+ "name": "invalid_sound_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "timeout_seconds": {
+ "name": "timeout_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 5
+ },
+ "max_retries": {
+ "name": "max_retries",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 3
+ },
+ "allow_direct_dial": {
+ "name": "allow_direct_dial",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "timeout_destination": {
+ "name": "timeout_destination",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "invalid_destination": {
+ "name": "invalid_destination",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "ivrs_greeting_sound_id_sound_files_id_fk": {
+ "name": "ivrs_greeting_sound_id_sound_files_id_fk",
+ "tableFrom": "ivrs",
+ "tableTo": "sound_files",
+ "columnsFrom": [
+ "greeting_sound_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ },
+ "ivrs_invalid_sound_id_sound_files_id_fk": {
+ "name": "ivrs_invalid_sound_id_sound_files_id_fk",
+ "tableFrom": "ivrs",
+ "tableTo": "sound_files",
+ "columnsFrom": [
+ "invalid_sound_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "ivrs_number_unique": {
+ "name": "ivrs_number_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "number"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.moh_classes": {
+ "name": "moh_classes",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "shuffle": {
+ "name": "shuffle",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "moh_classes_name_unique": {
+ "name": "moh_classes_name_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "name"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.outbound_routes": {
+ "name": "outbound_routes",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "patterns": {
+ "name": "patterns",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "required_class": {
+ "name": "required_class",
+ "type": "outbound_class",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "trunk_ids": {
+ "name": "trunk_ids",
+ "type": "uuid[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "caller_id_did_id": {
+ "name": "caller_id_did_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "outbound_routes_caller_id_did_id_dids_id_fk": {
+ "name": "outbound_routes_caller_id_did_id_dids_id_fk",
+ "tableFrom": "outbound_routes",
+ "tableTo": "dids",
+ "columnsFrom": [
+ "caller_id_did_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.paging_group_members": {
+ "name": "paging_group_members",
+ "schema": "",
+ "columns": {
+ "paging_group_id": {
+ "name": "paging_group_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "extension_id": {
+ "name": "extension_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "paging_group_members_paging_group_id_paging_groups_id_fk": {
+ "name": "paging_group_members_paging_group_id_paging_groups_id_fk",
+ "tableFrom": "paging_group_members",
+ "tableTo": "paging_groups",
+ "columnsFrom": [
+ "paging_group_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "paging_group_members_extension_id_extensions_id_fk": {
+ "name": "paging_group_members_extension_id_extensions_id_fk",
+ "tableFrom": "paging_group_members",
+ "tableTo": "extensions",
+ "columnsFrom": [
+ "extension_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "paging_group_members_paging_group_id_extension_id_pk": {
+ "name": "paging_group_members_paging_group_id_extension_id_pk",
+ "columns": [
+ "paging_group_id",
+ "extension_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.paging_groups": {
+ "name": "paging_groups",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "number": {
+ "name": "number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "duplex": {
+ "name": "duplex",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "paging_groups_number_unique": {
+ "name": "paging_groups_number_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "number"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.queue_calls": {
+ "name": "queue_calls",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "queue_id": {
+ "name": "queue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "uniqueid": {
+ "name": "uniqueid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "linkedid": {
+ "name": "linkedid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "joined_at": {
+ "name": "joined_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "outcome": {
+ "name": "outcome",
+ "type": "queue_outcome",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "wait_seconds": {
+ "name": "wait_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "agent_extension_id": {
+ "name": "agent_extension_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "queue_calls_queue_id_joined_at_index": {
+ "name": "queue_calls_queue_id_joined_at_index",
+ "columns": [
+ {
+ "expression": "queue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "joined_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "queue_calls_uniqueid_index": {
+ "name": "queue_calls_uniqueid_index",
+ "columns": [
+ {
+ "expression": "uniqueid",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "queue_calls_queue_id_queues_id_fk": {
+ "name": "queue_calls_queue_id_queues_id_fk",
+ "tableFrom": "queue_calls",
+ "tableTo": "queues",
+ "columnsFrom": [
+ "queue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "queue_calls_agent_extension_id_extensions_id_fk": {
+ "name": "queue_calls_agent_extension_id_extensions_id_fk",
+ "tableFrom": "queue_calls",
+ "tableTo": "extensions",
+ "columnsFrom": [
+ "agent_extension_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.queue_member_state": {
+ "name": "queue_member_state",
+ "schema": "",
+ "columns": {
+ "queue_id": {
+ "name": "queue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "extension_id": {
+ "name": "extension_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "paused": {
+ "name": "paused",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "queue_member_state_queue_id_queues_id_fk": {
+ "name": "queue_member_state_queue_id_queues_id_fk",
+ "tableFrom": "queue_member_state",
+ "tableTo": "queues",
+ "columnsFrom": [
+ "queue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "queue_member_state_extension_id_extensions_id_fk": {
+ "name": "queue_member_state_extension_id_extensions_id_fk",
+ "tableFrom": "queue_member_state",
+ "tableTo": "extensions",
+ "columnsFrom": [
+ "extension_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "queue_member_state_queue_id_extension_id_pk": {
+ "name": "queue_member_state_queue_id_extension_id_pk",
+ "columns": [
+ "queue_id",
+ "extension_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.queue_members": {
+ "name": "queue_members",
+ "schema": "",
+ "columns": {
+ "queue_id": {
+ "name": "queue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "extension_id": {
+ "name": "extension_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "penalty": {
+ "name": "penalty",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "queue_members_queue_id_queues_id_fk": {
+ "name": "queue_members_queue_id_queues_id_fk",
+ "tableFrom": "queue_members",
+ "tableTo": "queues",
+ "columnsFrom": [
+ "queue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "queue_members_extension_id_extensions_id_fk": {
+ "name": "queue_members_extension_id_extensions_id_fk",
+ "tableFrom": "queue_members",
+ "tableTo": "extensions",
+ "columnsFrom": [
+ "extension_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "queue_members_queue_id_extension_id_pk": {
+ "name": "queue_members_queue_id_extension_id_pk",
+ "columns": [
+ "queue_id",
+ "extension_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.queues": {
+ "name": "queues",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "number": {
+ "name": "number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "strategy": {
+ "name": "strategy",
+ "type": "queue_strategy",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'rrmemory'"
+ },
+ "member_ring_seconds": {
+ "name": "member_ring_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 15
+ },
+ "retry_seconds": {
+ "name": "retry_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 5
+ },
+ "wrapup_seconds": {
+ "name": "wrapup_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 10
+ },
+ "max_wait_seconds": {
+ "name": "max_wait_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 300
+ },
+ "max_callers": {
+ "name": "max_callers",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "moh_class": {
+ "name": "moh_class",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'default'"
+ },
+ "announce_position": {
+ "name": "announce_position",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "announce_every_seconds": {
+ "name": "announce_every_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 60
+ },
+ "join_when_no_agents": {
+ "name": "join_when_no_agents",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "record": {
+ "name": "record",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "sla_seconds": {
+ "name": "sla_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 20
+ },
+ "caller_id_name_prefix": {
+ "name": "caller_id_name_prefix",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "timeout_destination": {
+ "name": "timeout_destination",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "queues_number_unique": {
+ "name": "queues_number_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "number"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.recordings": {
+ "name": "recordings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "linkedid": {
+ "name": "linkedid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "file_path": {
+ "name": "file_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "duration_ms": {
+ "name": "duration_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "size_bytes": {
+ "name": "size_bytes",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "transcript": {
+ "name": "transcript",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ai_done_at": {
+ "name": "ai_done_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ai_error": {
+ "name": "ai_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "recordings_filePath_unique": {
+ "name": "recordings_filePath_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "file_path"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.ring_group_members": {
+ "name": "ring_group_members",
+ "schema": "",
+ "columns": {
+ "ring_group_id": {
+ "name": "ring_group_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "extension_id": {
+ "name": "extension_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "ring_group_members_ring_group_id_ring_groups_id_fk": {
+ "name": "ring_group_members_ring_group_id_ring_groups_id_fk",
+ "tableFrom": "ring_group_members",
+ "tableTo": "ring_groups",
+ "columnsFrom": [
+ "ring_group_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "ring_group_members_extension_id_extensions_id_fk": {
+ "name": "ring_group_members_extension_id_extensions_id_fk",
+ "tableFrom": "ring_group_members",
+ "tableTo": "extensions",
+ "columnsFrom": [
+ "extension_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "ring_group_members_ring_group_id_extension_id_pk": {
+ "name": "ring_group_members_ring_group_id_extension_id_pk",
+ "columns": [
+ "ring_group_id",
+ "extension_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.ring_groups": {
+ "name": "ring_groups",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "number": {
+ "name": "number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "strategy": {
+ "name": "strategy",
+ "type": "ring_strategy",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'ringall'"
+ },
+ "ring_seconds": {
+ "name": "ring_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 25
+ },
+ "caller_id_name_prefix": {
+ "name": "caller_id_name_prefix",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "music_instead_of_ring": {
+ "name": "music_instead_of_ring",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "no_answer_destination": {
+ "name": "no_answer_destination",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "ring_groups_number_unique": {
+ "name": "ring_groups_number_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "number"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sessions": {
+ "name": "sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip": {
+ "name": "ip",
+ "type": "inet",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "sessions_user_id_index": {
+ "name": "sessions_user_id_index",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "sessions_user_id_users_id_fk": {
+ "name": "sessions_user_id_users_id_fk",
+ "tableFrom": "sessions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.settings": {
+ "name": "settings",
+ "schema": "",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sms_messages": {
+ "name": "sms_messages",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "did_id": {
+ "name": "did_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "direction": {
+ "name": "direction",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "remote_number": {
+ "name": "remote_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "media": {
+ "name": "media",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sent_by_user_id": {
+ "name": "sent_by_user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "read_at": {
+ "name": "read_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "sms_messages_did_id_remote_number_created_at_index": {
+ "name": "sms_messages_did_id_remote_number_created_at_index",
+ "columns": [
+ {
+ "expression": "did_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "remote_number",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "sms_messages_did_id_dids_id_fk": {
+ "name": "sms_messages_did_id_dids_id_fk",
+ "tableFrom": "sms_messages",
+ "tableTo": "dids",
+ "columnsFrom": [
+ "did_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "sms_messages_sent_by_user_id_users_id_fk": {
+ "name": "sms_messages_sent_by_user_id_users_id_fk",
+ "tableFrom": "sms_messages",
+ "tableTo": "users",
+ "columnsFrom": [
+ "sent_by_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "sms_messages_direction_check": {
+ "name": "sms_messages_direction_check",
+ "value": "\"sms_messages\".\"direction\" in ('in', 'out')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.sound_files": {
+ "name": "sound_files",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "moh_class_id": {
+ "name": "moh_class_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tts_text": {
+ "name": "tts_text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "original_filename": {
+ "name": "original_filename",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "asterisk_name": {
+ "name": "asterisk_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "duration_ms": {
+ "name": "duration_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "sound_files_moh_class_id_moh_classes_id_fk": {
+ "name": "sound_files_moh_class_id_moh_classes_id_fk",
+ "tableFrom": "sound_files",
+ "tableTo": "moh_classes",
+ "columnsFrom": [
+ "moh_class_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "sound_files_asteriskName_unique": {
+ "name": "sound_files_asteriskName_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "asterisk_name"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {
+ "sound_files_kind_check": {
+ "name": "sound_files_kind_check",
+ "value": "\"sound_files\".\"kind\" in ('prompt', 'moh')"
+ },
+ "sound_files_source_check": {
+ "name": "sound_files_source_check",
+ "value": "\"sound_files\".\"source\" in ('upload', 'tts', 'phone')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.system_events": {
+ "name": "system_events",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "severity": {
+ "name": "severity",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "code": {
+ "name": "code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "system_events_created_at_index": {
+ "name": "system_events_created_at_index",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "system_events_severity_check": {
+ "name": "system_events_severity_check",
+ "value": "\"system_events\".\"severity\" in ('info', 'warning', 'error')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.time_conditions": {
+ "name": "time_conditions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "timezone": {
+ "name": "timezone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "open_ranges": {
+ "name": "open_ranges",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "use_holidays": {
+ "name": "use_holidays",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "override_digit": {
+ "name": "override_digit",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "override_state": {
+ "name": "override_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "open_destination": {
+ "name": "open_destination",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "closed_destination": {
+ "name": "closed_destination",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "holiday_destination": {
+ "name": "holiday_destination",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "time_conditions_overrideDigit_unique": {
+ "name": "time_conditions_overrideDigit_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "override_digit"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {
+ "time_conditions_override_digit_check": {
+ "name": "time_conditions_override_digit_check",
+ "value": "\"time_conditions\".\"override_digit\" between 1 and 9"
+ },
+ "time_conditions_override_state_check": {
+ "name": "time_conditions_override_state_check",
+ "value": "\"time_conditions\".\"override_state\" in ('open', 'closed')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.trunks": {
+ "name": "trunks",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider": {
+ "name": "provider",
+ "type": "trunk_provider",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'voipms'"
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "server_host": {
+ "name": "server_host",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "server_port": {
+ "name": "server_port",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 5060
+ },
+ "transport": {
+ "name": "transport",
+ "type": "sip_transport",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'udp'"
+ },
+ "auth_mode": {
+ "name": "auth_mode",
+ "type": "trunk_auth_mode",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'registration'"
+ },
+ "username": {
+ "name": "username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password_enc": {
+ "name": "password_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "from_user": {
+ "name": "from_user",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "from_domain": {
+ "name": "from_domain",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "codecs": {
+ "name": "codecs",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{ulaw}'"
+ },
+ "srtp": {
+ "name": "srtp",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "max_channels": {
+ "name": "max_channels",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "voipms_api_username": {
+ "name": "voipms_api_username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "voipms_api_password_enc": {
+ "name": "voipms_api_password_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "trunks_name_unique": {
+ "name": "trunks_name_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "name"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_runtime_state": {
+ "name": "user_runtime_state",
+ "schema": "",
+ "columns": {
+ "extension_id": {
+ "name": "extension_id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "dnd": {
+ "name": "dnd",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "cfu": {
+ "name": "cfu",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cfb": {
+ "name": "cfb",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cfna": {
+ "name": "cfna",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_runtime_state_extension_id_extensions_id_fk": {
+ "name": "user_runtime_state_extension_id_extensions_id_fk",
+ "tableFrom": "user_runtime_state",
+ "tableTo": "extensions",
+ "columnsFrom": [
+ "extension_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "email": {
+ "name": "email",
+ "type": "citext",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "display_name": {
+ "name": "display_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "password_hash": {
+ "name": "password_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "user_role",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'user'"
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "totp_secret_enc": {
+ "name": "totp_secret_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "totp_recovery_hashes": {
+ "name": "totp_recovery_hashes",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "totp_last_step": {
+ "name": "totp_last_step",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "must_change_password": {
+ "name": "must_change_password",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "last_login_at": {
+ "name": "last_login_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "contrack_token_enc": {
+ "name": "contrack_token_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "contrack_log_calls": {
+ "name": "contrack_log_calls",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "contrack_log_summaries": {
+ "name": "contrack_log_summaries",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "contrack_log_since": {
+ "name": "contrack_log_since",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "users_email_unique": {
+ "name": "users_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.voicemail_messages": {
+ "name": "voicemail_messages",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "extension_id": {
+ "name": "extension_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "msg_id": {
+ "name": "msg_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "folder": {
+ "name": "folder",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "caller_number": {
+ "name": "caller_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "caller_name": {
+ "name": "caller_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "duration_s": {
+ "name": "duration_s",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "received_at": {
+ "name": "received_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "file_path": {
+ "name": "file_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "emailed_at": {
+ "name": "emailed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "transcript": {
+ "name": "transcript",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "voicemail_messages_extension_id_extensions_id_fk": {
+ "name": "voicemail_messages_extension_id_extensions_id_fk",
+ "tableFrom": "voicemail_messages",
+ "tableTo": "extensions",
+ "columnsFrom": [
+ "extension_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "voicemail_messages_msgId_unique": {
+ "name": "voicemail_messages_msgId_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "msg_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.webhook_deliveries": {
+ "name": "webhook_deliveries",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "webhook_id": {
+ "name": "webhook_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "event": {
+ "name": "event",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "attempt": {
+ "name": "attempt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "response_code": {
+ "name": "response_code",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "next_attempt_at": {
+ "name": "next_attempt_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "webhook_deliveries_webhook_id_webhooks_id_fk": {
+ "name": "webhook_deliveries_webhook_id_webhooks_id_fk",
+ "tableFrom": "webhook_deliveries",
+ "tableTo": "webhooks",
+ "columnsFrom": [
+ "webhook_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.webhooks": {
+ "name": "webhooks",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "secret_enc": {
+ "name": "secret_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "events": {
+ "name": "events",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.call_direction": {
+ "name": "call_direction",
+ "schema": "public",
+ "values": [
+ "inbound",
+ "outbound",
+ "internal"
+ ]
+ },
+ "public.call_report": {
+ "name": "call_report",
+ "schema": "public",
+ "values": [
+ "off",
+ "hourly",
+ "daily",
+ "weekly"
+ ]
+ },
+ "public.call_status": {
+ "name": "call_status",
+ "schema": "public",
+ "values": [
+ "answered",
+ "missed",
+ "voicemail",
+ "abandoned",
+ "busy",
+ "failed"
+ ]
+ },
+ "public.outbound_class": {
+ "name": "outbound_class",
+ "schema": "public",
+ "values": [
+ "internal",
+ "local",
+ "national",
+ "international"
+ ]
+ },
+ "public.phone_vendor": {
+ "name": "phone_vendor",
+ "schema": "public",
+ "values": [
+ "yealink",
+ "grandstream",
+ "polycom",
+ "generic"
+ ]
+ },
+ "public.queue_outcome": {
+ "name": "queue_outcome",
+ "schema": "public",
+ "values": [
+ "answered",
+ "abandoned",
+ "timed_out"
+ ]
+ },
+ "public.queue_strategy": {
+ "name": "queue_strategy",
+ "schema": "public",
+ "values": [
+ "ringall",
+ "leastrecent",
+ "fewestcalls",
+ "random",
+ "rrmemory",
+ "linear"
+ ]
+ },
+ "public.record_mode": {
+ "name": "record_mode",
+ "schema": "public",
+ "values": [
+ "never",
+ "inbound",
+ "outbound",
+ "all"
+ ]
+ },
+ "public.ring_strategy": {
+ "name": "ring_strategy",
+ "schema": "public",
+ "values": [
+ "ringall",
+ "hunt"
+ ]
+ },
+ "public.sip_transport": {
+ "name": "sip_transport",
+ "schema": "public",
+ "values": [
+ "udp",
+ "tcp",
+ "tls"
+ ]
+ },
+ "public.trunk_auth_mode": {
+ "name": "trunk_auth_mode",
+ "schema": "public",
+ "values": [
+ "registration",
+ "ip"
+ ]
+ },
+ "public.trunk_provider": {
+ "name": "trunk_provider",
+ "schema": "public",
+ "values": [
+ "voipms",
+ "generic"
+ ]
+ },
+ "public.user_role": {
+ "name": "user_role",
+ "schema": "public",
+ "values": [
+ "admin",
+ "user"
+ ]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/apps/api/src/db/migrations/meta/_journal.json b/apps/api/src/db/migrations/meta/_journal.json
index cfdc9e4..2997d7f 100644
--- a/apps/api/src/db/migrations/meta/_journal.json
+++ b/apps/api/src/db/migrations/meta/_journal.json
@@ -78,6 +78,13 @@
"when": 1792785318384,
"tag": "0010_contrack_token",
"breakpoints": true
+ },
+ {
+ "idx": 11,
+ "version": "7",
+ "when": 1792788393381,
+ "tag": "0011_contrack_call_log",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts
index e98855e..a3d9433 100644
--- a/apps/api/src/db/schema.ts
+++ b/apps/api/src/db/schema.ts
@@ -65,6 +65,11 @@ export const users = pgTable('users', {
lastLoginAt: tstz(),
/** Contrack mode (11 §4.3): this person's own Contrack personal token, encrypted. */
contrackTokenEnc: text(),
+ /** 11 §4.4: my calls on my Contrack contacts' timelines, from contrackLogSince on. */
+ contrackLogCalls: boolean().notNull().default(true),
+ /** …with each recorded call's summary (10 §3). */
+ contrackLogSummaries: boolean().notNull().default(false),
+ contrackLogSince: tstz(),
...timestamps,
});
@@ -622,6 +627,24 @@ export const contacts = pgTable('contacts', {
...timestamps,
});
+/** A call put on (or checked for) a person's Contrack timeline (11 §4.4): never twice. */
+export const contrackCallLog = pgTable(
+ 'contrack_call_log',
+ {
+ callId: uuid()
+ .notNull()
+ .references(() => calls.id, { onDelete: 'cascade' }),
+ userId: uuid()
+ .notNull()
+ .references(() => users.id, { onDelete: 'cascade' }),
+ /** Contrack's interaction id; null: no contact has that number, or it couldn't be written. */
+ interactionId: text(),
+ error: text(),
+ loggedAt: tstz().notNull().defaultNow(),
+ },
+ (t) => [primaryKey({ columns: [t.callId, t.userId] })],
+);
+
/** A contact offered to a colleague, waiting for Accept or Deny (11 §2.1). */
export const contactShares = pgTable(
'contact_shares',
diff --git a/apps/api/src/jobs.ts b/apps/api/src/jobs.ts
index 47b4012..998cc3a 100644
--- a/apps/api/src/jobs.ts
+++ b/apps/api/src/jobs.ts
@@ -26,6 +26,7 @@ import { audit } from './services/audit.ts';
import { backupDue, runBackup } from './services/backup.ts';
import { sendCallReports } from './services/callreport.ts';
import { contrackBook, contrackSettings } from './contacts/contrack.ts';
+import { logCallsToContrack } from './contacts/timeline.ts';
import { logEvent } from './services/events.ts';
import { deliverDue } from './services/webhooks.ts';
import type { SendMail } from './services/mail.ts';
@@ -256,7 +257,20 @@ export function createJobs(d: JobDeps) {
await sendCallReports({ db: d.db, send: d.send, log: d.log, now: now() });
};
- return { daily, backup, publicIpWatch, trunkWatch, diskWatch, callReports, contactsWarm };
+ const contrackLog = async () => {
+ await logCallsToContrack({ db: d.db, log: d.log, now: now() });
+ };
+
+ return {
+ daily,
+ backup,
+ publicIpWatch,
+ trunkWatch,
+ diskWatch,
+ callReports,
+ contactsWarm,
+ contrackLog,
+ };
}
/** Starts the jobs; returns a stop function. */
@@ -280,6 +294,7 @@ export function startJobs(d: JobDeps): () => void {
if (minute % 60 === 0) run('diskWatch', j.diskWatch);
run('callReports', j.callReports);
if (minute % 5 === 0) run('contactsWarm', j.contactsWarm);
+ run('contrackLog', j.contrackLog);
run('callTranscripts', async () => {
await transcribeCalls({ db: d.db, dir: resolve(d.config.RECORDINGS_DIR), log: d.log });
});
diff --git a/apps/api/src/routes/me.ts b/apps/api/src/routes/me.ts
index 4b426b0..02e35c1 100644
--- a/apps/api/src/routes/me.ts
+++ b/apps/api/src/routes/me.ts
@@ -3,10 +3,11 @@
import { existsSync } from 'node:fs';
import { mkdir, rm } from 'node:fs/promises';
import { join } from 'node:path';
-import { and, desc, eq, isNull } from 'drizzle-orm';
+import { and, desc, eq, isNull, sql } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import {
+ ContrackOptionsSchema,
GREETINGS,
MeUpdateSchema,
type ListResponse,
@@ -133,10 +134,18 @@ export async function meRoutes(app: FastifyInstance) {
if (!c) return null;
const token = await tokenFor(db, c, userId);
const book = token ? await contrackBook(db, c, userId) : null;
+ const [u] = await db
+ .select({ logCalls: users.contrackLogCalls, logSummaries: users.contrackLogSummaries })
+ .from(users)
+ .where(eq(users.id, userId));
return {
connected: !!book && !book.error,
tokenPrefix: token ? `${token.slice(0, 12)}…` : null,
publicUrl: c.publicUrl,
+ logCalls: u?.logCalls ?? true,
+ logSummaries: u?.logSummaries ?? false,
+ summariesAvailable:
+ (await getSetting(db, 'ai.callTranscripts')) && (await getSetting(db, 'ai.callSummaries')),
};
}
@@ -148,9 +157,13 @@ export async function meRoutes(app: FastifyInstance) {
await contrackCheck(c.url, token).catch((err: Error) => {
throw badRequest(err.message);
});
+ // Calls are logged from now on, not the ones before (11 §4.4).
await db
.update(users)
- .set({ contrackTokenEnc: encrypt(token) })
+ .set({
+ contrackTokenEnc: encrypt(token),
+ contrackLogSince: sql`coalesce(${users.contrackLogSince}, now())`,
+ })
.where(eq(users.id, req.user!.id));
forgetBook(req.user!.id);
await audit(db, {
@@ -164,6 +177,25 @@ export async function meRoutes(app: FastifyInstance) {
return myState(req.user!.id);
});
+ /** Log my calls in Contrack, with or without their summaries (11 §4.4). */
+ app.patch('/api/me/contrack', async (req) => {
+ const o = ContrackOptionsSchema.parse(req.body);
+ const [u] = await db
+ .select({ logCalls: users.contrackLogCalls })
+ .from(users)
+ .where(eq(users.id, req.user!.id));
+ await db
+ .update(users)
+ .set({
+ ...(o.logCalls !== undefined ? { contrackLogCalls: o.logCalls } : {}),
+ ...(o.logSummaries !== undefined ? { contrackLogSummaries: o.logSummaries } : {}),
+ // Turned back on: from now, not the calls while it was off.
+ ...(o.logCalls && !u?.logCalls ? { contrackLogSince: new Date() } : {}),
+ })
+ .where(eq(users.id, req.user!.id));
+ return myState(req.user!.id);
+ });
+
app.delete('/api/me/contrack', async (req) => {
await db.update(users).set({ contrackTokenEnc: null }).where(eq(users.id, req.user!.id));
forgetBook(req.user!.id);
diff --git a/apps/api/test/contrack-live.test.ts b/apps/api/test/contrack-live.test.ts
index 03c52c2..94acbed 100644
--- a/apps/api/test/contrack-live.test.ts
+++ b/apps/api/test/contrack-live.test.ts
@@ -8,7 +8,11 @@
// CONTRACK_LIVE_URL=http://127.0.0.1:3210 pnpm vitest run apps/api/test/contrack-live.test.ts
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { eq } from 'drizzle-orm';
import { contactNames } from '../src/contacts/book.ts';
+import { logCallsToContrack } from '../src/contacts/timeline.ts';
+import { callExtensions, calls, extensions, recordings, users } from '../src/db/schema.ts';
+import { setSetting } from '../src/services/settings.ts';
import { loadPhonebook } from '../src/provisioning/context.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
@@ -235,6 +239,74 @@ describe.skipIf(!hasTestDb || !LIVE)('contacts in a real Contrack', () => {
).toContain('Bob Smith');
});
+ it('a call with Jane lands on her Contrack timeline, with the summary and a follow-up (C5)', async () => {
+ const at = (minAgo: number) => new Date(Date.now() - minAgo * 60_000);
+ await t.db
+ .update(users)
+ .set({ contrackLogSince: at(60) })
+ .where(eq(users.id, danId));
+ await setSetting(t.db, 'ai.callTranscripts', true);
+ await setSetting(t.db, 'ai.callSummaries', true);
+ expect((await dan('PATCH', '/api/me/contrack', { logSummaries: true })).statusCode).toBe(200);
+ const [ext] = await t.db.select().from(extensions).where(eq(extensions.number, '101'));
+ const [rec] = await t.db
+ .insert(recordings)
+ .values({
+ linkedid: 'live-r1',
+ filePath: 'live-r1.wav',
+ startedAt: at(20),
+ summary:
+ 'Jane asked for a & delivery dates.\nAction items:\n- Dan sends the quote by Friday',
+ aiDoneAt: at(15),
+ })
+ .returning();
+ const [c] = await t.db
+ .insert(calls)
+ .values({
+ linkedid: 'live-c1',
+ direction: 'inbound',
+ status: 'answered',
+ startedAt: at(20),
+ endedAt: at(17),
+ fromNumber: '13125551234',
+ toNumber: '13125550000',
+ talkSeconds: 185,
+ answeredByExtensionId: ext!.id,
+ recordingId: rec!.id,
+ })
+ .returning();
+ await t.db
+ .insert(callExtensions)
+ .values({ callId: c!.id, extensionId: ext!.id, role: 'answered' });
+ expect(await logCallsToContrack({ db: t.db, log: t.app.log })).toBe(1);
+ expect(await logCallsToContrack({ db: t.db, log: t.app.log })).toBe(0);
+
+ const jane = (
+ (await ctk('/api/contacts?view=slim', { token: tok.dan })).body as unknown as {
+ id: string;
+ name: string;
+ }[]
+ ).find((x) => x.name === 'Jane Doe')!;
+ const timeline = (await ctk(`/api/contacts/${jane.id}/timeline`, { token: tok.dan }))
+ .body as unknown as Record[];
+ expect(timeline).toHaveLength(1);
+ expect(timeline[0]).toMatchObject({
+ type: 'call',
+ title: 'Incoming call',
+ date: c!.startedAt.toISOString(),
+ duration: expect.anything(), // Contrack answers it as "4.0"
+ actionItems: [
+ expect.objectContaining({ title: 'Dan sends the quote by Friday', completedAt: null }),
+ ],
+ });
+ expect(Number(timeline[0]!.duration)).toBe(4);
+ const content = String(timeline[0]!.content);
+ expect(content).toContain('From (312) 555-1234 to (312) 555-0000 · 3:05 · recorded');
+ expect(content).toContain('Jane asked for a <quote> & delivery dates.');
+ expect(content).toContain('Dan sends the quote by Friday ');
+ expect(content).not.toContain('');
+ });
+
it('a share is accepted into the recipient’s own Contrack account', async () => {
const jane = (await dan('GET', '/api/me/contacts'))
.json()
diff --git a/apps/api/test/contrack.test.ts b/apps/api/test/contrack.test.ts
index 34f28ad..a749e3e 100644
--- a/apps/api/test/contrack.test.ts
+++ b/apps/api/test/contrack.test.ts
@@ -4,7 +4,19 @@ import { createServer, type Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { eq } from 'drizzle-orm';
import { contactNames } from '../src/contacts/book.ts';
+import { forgetBook } from '../src/contacts/contrack.ts';
+import { logCallsToContrack } from '../src/contacts/timeline.ts';
+import {
+ callExtensions,
+ calls,
+ contrackCallLog,
+ extensions,
+ recordings,
+ users,
+} from '../src/db/schema.ts';
+import { setSetting } from '../src/services/settings.ts';
import { loadPhonebook } from '../src/provisioning/context.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
@@ -42,14 +54,34 @@ function fakeContrack() {
ctk_sue_0001: [],
};
let n = 0;
+ /** Timeline entries by contact id; `down` makes every API call fail with 503. */
+ const timeline: Record[]> = {};
+ const state = { down: false, readOnly: new Set() };
const server: Server = createServer((req, res) => {
const send = (status: number, body: unknown) => {
res.writeHead(status, { 'content-type': 'application/json' });
res.end(JSON.stringify(body));
};
if (req.url === '/healthz') return send(200, { ok: true });
- const book = books[(req.headers.authorization ?? '').replace(/^Bearer /, '')];
+ if (state.down) return send(503, { error: { code: 'DB_BUSY', message: 'Busy' } });
+ const token = (req.headers.authorization ?? '').replace(/^Bearer /, '');
+ const book = books[token];
if (!book) return send(401, { error: { code: 'UNAUTHORIZED', message: 'Unauthorized' } });
+ const into = /^\/api\/contacts\/([^/]+)\/interactions$/.exec(req.url ?? '');
+ if (req.method === 'POST' && into) {
+ if (state.readOnly.has(token))
+ return send(403, { error: { code: 'TOKEN_READ_ONLY', message: 'Read-only token' } });
+ if (!book.some((c) => c.id === into[1]))
+ return send(404, { error: { code: 'NOT_FOUND', message: 'Contact not found' } });
+ let raw = '';
+ req.on('data', (c) => (raw += c));
+ req.on('end', () => {
+ const id = `int-${++n}`;
+ (timeline[into[1]!] ??= []).push({ id, ...(JSON.parse(raw) as object) });
+ send(201, { id });
+ });
+ return;
+ }
if (req.method === 'GET' && req.url === '/api/contacts?view=slim') return send(200, book);
if (req.method === 'POST' && req.url === '/api/contacts') {
let raw = '';
@@ -75,7 +107,7 @@ function fakeContrack() {
}
send(404, { error: { code: 'ROUTE_NOT_FOUND', message: 'No route' } });
});
- return { server, books };
+ return { server, books, timeline, state };
}
describe.skipIf(!hasTestDb)('contacts in Contrack', () => {
@@ -165,6 +197,9 @@ describe.skipIf(!hasTestDb)('contacts in Contrack', () => {
connected: false,
tokenPrefix: null,
publicUrl: 'https://contacts.example.com',
+ logCalls: true,
+ logSummaries: false,
+ summariesAvailable: false,
});
const before = (await dan('GET', '/api/me/contacts')).json();
expect(before.provider).toBe('contrack');
@@ -265,6 +300,142 @@ describe.skipIf(!hasTestDb)('contacts in Contrack', () => {
expect((await sue('GET', '/api/me/contacts/shares')).json().items).toEqual([]);
});
+ it("calls go on the contact's timeline once, the summary when asked for (C5)", async () => {
+ const now = Date.now();
+ const at = (minAgo: number) => new Date(now - minAgo * 60_000);
+ // Dan connected a moment ago: his calls from an hour back count for this test.
+ await t.db
+ .update(users)
+ .set({ contrackLogSince: at(60) })
+ .where(eq(users.id, danId));
+ await setSetting(t.db, 'ai.callTranscripts', true);
+ await setSetting(t.db, 'ai.callSummaries', true);
+ const [ext] = await t.db.select().from(extensions).where(eq(extensions.number, '101'));
+ const [done, pending] = await t.db
+ .insert(recordings)
+ .values([
+ {
+ linkedid: 'r1',
+ filePath: 'r1.wav',
+ startedAt: at(30),
+ summary: 'Acme wants a quote.\nAction items:\n- Dan sends the quote',
+ aiDoneAt: at(25),
+ },
+ { linkedid: 'r2', filePath: 'r2.wav', startedAt: at(10) },
+ ])
+ .returning();
+ const call = (linkedid: string, minAgo: number, v: Partial) => ({
+ linkedid,
+ direction: 'inbound' as const,
+ status: 'answered' as const,
+ startedAt: at(minAgo + 3),
+ endedAt: at(minAgo),
+ fromNumber: '13125551234', // Jane Doe
+ toNumber: '13125550000',
+ talkSeconds: 125,
+ ...v,
+ });
+ const rows = await t.db
+ .insert(calls)
+ .values([
+ call('k1', 30, { recordingId: done!.id }), // answered, recorded, summarized
+ call('k2', 20, { status: 'missed', talkSeconds: 0 }),
+ call('k3', 15, { direction: 'outbound', fromNumber: '101', toNumber: '13125551234' }),
+ call('k4', 12, { fromNumber: '13125550101' }), // not a contact
+ call('k5', 10, { recordingId: pending!.id }), // not summarized yet
+ call('k6', 90, {}), // before Dan connected
+ call('k7', 0.2, {}), // still being written (under a minute ago)
+ ])
+ .returning();
+ const role = (i: number) => (rows[i]!.direction === 'outbound' ? 'caller' : 'answered');
+ await t.db
+ .insert(callExtensions)
+ .values(rows.map((r, i) => ({ callId: r.id, extensionId: ext!.id, role: role(i) })));
+ const run = (min = 0) =>
+ logCallsToContrack({ db: t.db, log: t.app.log, now: new Date(now + min * 60_000) });
+
+ // Summaries off (the default): no summary, and nothing waits for one.
+ expect(await run()).toBe(4); // k1, k2, k3, k5 (k4 a stranger, k6 too early, k7 too fresh)
+ const jane = fake.timeline['jane-doe-00000001']!;
+ expect(jane.map((e) => e.title)).toEqual([
+ 'Incoming call',
+ 'Missed call',
+ 'Outgoing call',
+ 'Incoming call',
+ ]);
+ expect(jane[0]).toMatchObject({
+ type: 'call',
+ source: 'selfphoneng',
+ date: rows[0]!.startedAt.toISOString(),
+ duration: 3,
+ content: 'From (312) 555-1234 to (312) 555-0000 · 2:05 · recorded
',
+ });
+ expect(jane[0]).not.toHaveProperty('actionItem');
+ expect(await run()).toBe(0); // once
+
+ // With summaries: the next recorded call waits for its summary, then carries it.
+ await dan('PATCH', '/api/me/contrack', { logSummaries: true });
+ const [k8] = await t.db
+ .insert(calls)
+ .values(call('k8', 5, { recordingId: done!.id }))
+ .returning();
+ const [k9] = await t.db
+ .insert(calls)
+ .values(call('k9', 4, { recordingId: pending!.id }))
+ .returning();
+ await t.db
+ .insert(callExtensions)
+ .values([k8!, k9!].map((r) => ({ callId: r.id, extensionId: ext!.id, role: 'answered' })));
+ expect(await run()).toBe(1); // k8; k9 waits
+ expect(jane.at(-1)).toMatchObject({
+ content:
+ 'From (312) 555-1234 to (312) 555-0000 · 2:05 · recorded
Acme wants a quote.
Action items:
',
+ actionItem: {
+ title: 'Dan sends the quote',
+ dueAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
+ },
+ });
+ // 40 minutes on: k9 without a summary (it waited 30), and k7 is old enough now.
+ expect(await run(40)).toBe(2);
+ expect(jane.find((e) => e.date === k9!.startedAt.toISOString())!.content).toBe(
+ 'From (312) 555-1234 to (312) 555-0000 · 2:05 · recorded
',
+ );
+ expect(jane).toHaveLength(7);
+
+ // Contrack down: nothing is lost, the call goes in on a later run.
+ const [k10] = await t.db
+ .insert(calls)
+ .values(call('k10', 3, {}))
+ .returning();
+ await t.db
+ .insert(callExtensions)
+ .values({ callId: k10!.id, extensionId: ext!.id, role: 'answered' });
+ forgetBook(danId);
+ fake.state.down = true;
+ expect(await run()).toBe(0);
+ fake.state.down = false;
+ forgetBook(danId);
+ expect(await run()).toBe(1);
+ // A read-only token: noted once, not retried every minute.
+ fake.state.readOnly.add('ctk_dan_0001');
+ const [k11] = await t.db
+ .insert(calls)
+ .values(call('k11', 2, {}))
+ .returning();
+ await t.db
+ .insert(callExtensions)
+ .values({ callId: k11!.id, extensionId: ext!.id, role: 'answered' });
+ expect(await run()).toBe(0);
+ const [noted] = await t.db
+ .select()
+ .from(contrackCallLog)
+ .where(eq(contrackCallLog.callId, k11!.id));
+ expect(noted).toMatchObject({ interactionId: null, error: 'Contrack: Read-only token' });
+ fake.state.readOnly.clear();
+ expect(await run()).toBe(0);
+ expect(jane).toHaveLength(8);
+ });
+
it('back to builtin: the table again, tokens kept for later', async () => {
await admin('PATCH', '/api/settings', { 'contacts.provider': 'builtin' });
const book = (await dan('GET', '/api/me/contacts')).json();
diff --git a/apps/api/test/schema.test.ts b/apps/api/test/schema.test.ts
index 39a8050..3e512df 100644
--- a/apps/api/test/schema.test.ts
+++ b/apps/api/test/schema.test.ts
@@ -24,6 +24,9 @@ const EXPECTED: Record> = {
must_change_password: 'boolean|NO|false',
last_login_at: 'timestamp with time zone|YES|',
contrack_token_enc: 'text|YES|',
+ contrack_log_calls: 'boolean|NO|true',
+ contrack_log_summaries: 'boolean|NO|false',
+ contrack_log_since: 'timestamp with time zone|YES|',
...ts,
},
extensions: {
@@ -112,11 +115,11 @@ describe.skipIf(!hasTestDb)('schema matches spec 02', () => {
beforeAll(async () => ({ db, drop } = await createTestDb()));
afterAll(() => drop?.());
- it('creates all 42 tables', async () => {
+ it('creates all 43 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: 42 });
+ expect(r.rows[0]).toEqual({ n: 43 });
});
it.each(Object.keys(EXPECTED))('%s columns', async (table) => {
diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts
index 0696ad3..916755f 100644
--- a/apps/web/src/api/client.ts
+++ b/apps/web/src/api/client.ts
@@ -23,6 +23,7 @@ import type {
CallDto,
CallFilter,
ContactBookDto,
+ ContrackOptions,
ContactDto,
ContactImportResult,
ContactInput,
@@ -273,6 +274,7 @@ export const meApi = {
update: (m: MeUpdate) => api('PATCH', '/api/me', m),
connectContrack: (token: string) => api('PUT', '/api/me/contrack', { token }),
disconnectContrack: () => api('DELETE', '/api/me/contrack'),
+ contrackOptions: (o: ContrackOptions) => api('PATCH', '/api/me/contrack', o),
webrtc: () => api('GET', '/api/me/webrtc'),
/** Log in to (unpause) or out of (pause) a queue I'm an agent of. */
setQueuePaused: (queueId: string, paused: boolean) =>
diff --git a/apps/web/src/pages/user/MeSettingsPage.tsx b/apps/web/src/pages/user/MeSettingsPage.tsx
index 2a5127c..c1ff22e 100644
--- a/apps/web/src/pages/user/MeSettingsPage.tsx
+++ b/apps/web/src/pages/user/MeSettingsPage.tsx
@@ -315,6 +315,7 @@ function ContrackConnect({ contrack }: { contrack: NonNullable meApi.connectContrack(token), onSuccess: done });
const disconnect = useMutation({ mutationFn: meApi.disconnectContrack, onSuccess: done });
+ const options = useMutation({ mutationFn: meApi.contrackOptions, onSuccess: done });
const where = contrack.publicUrl ? (
Contacts
- {contrack.tokenPrefix ? (
+ {contrack.tokenPrefix && (
{contrack.connected ? 'Connected to your' : 'Your token for'} {where} account (
@@ -345,7 +346,40 @@ function ContrackConnect({ contrack }: { contrack: NonNullable
- ) : (
+ )}
+ {contrack.tokenPrefix && (
+
+
+ options.mutate({ logCalls: e.target.checked })}
+ />
+ Put my calls on my contacts' timelines in Contrack
+
+
+ options.mutate({ logSummaries: e.target.checked })}
+ />
+
+ With the summary and action items of each recorded call
+ {!contrack.summariesAvailable && (
+ (when your admin turns on call summaries)
+ )}
+
+ What was said goes into Contrack. If Contrack's own AI uses a cloud provider, it can
+ reach that provider.
+
+
+
+
+ )}
+ {!contrack.tokenPrefix && (
Your contacts live in your own {where} account. There, open Settings → Account → API
tokens → Create token, with Read and write (so contacts shared with you can be added) and
@@ -376,7 +410,7 @@ function ContrackConnect({ contrack }: { contrack: NonNullable
- {(connect.error ?? disconnect.error) && (
+ {(connect.error ?? disconnect.error ?? options.error) && (
{(connect.error ?? disconnect.error)!.message}
)}
diff --git a/docs/admin-guide/contacts.md b/docs/admin-guide/contacts.md
index c8cac90..0b05125 100644
--- a/docs/admin-guide/contacts.md
+++ b/docs/admin-guide/contacts.md
@@ -65,6 +65,16 @@ notes and the rest happen in Contrack.
Names refresh from Contrack every 5 minutes (at once after SelfPhoneNG adds a contact). If
Contrack is down, the last names it gave keep working.
+### Calls on the timeline
+
+Once connected, each person's calls with their Contrack contacts go on that contact's timeline in
+Contrack ("Incoming call", "Missed call, voicemail left", …, with the time on the phone and whether
+it was recorded), a minute or so after the call. In **My settings → Contacts** they can turn this
+off, or add each recorded call's summary and action items (when call summaries are on in
+**Settings → AI**); the first action item becomes a Contrack follow-up, due the next day. Calls
+with numbers that aren't in their Contrack aren't added; **Add to contacts** first. Only calls
+from the moment they connected are logged.
+
### Privacy
SelfPhoneNG only talks to Contrack on this server or the local network, and keeps the tokens
diff --git a/docs/spec/02-data-model.md b/docs/spec/02-data-model.md
index d0949d0..68bbbc3 100644
--- a/docs/spec/02-data-model.md
+++ b/docs/spec/02-data-model.md
@@ -77,7 +77,10 @@ create table users (
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_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 (
@@ -450,6 +453,15 @@ create table voicemail_messages ( -- index of files Asterisk
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,
diff --git a/docs/spec/04-backend-api.md b/docs/spec/04-backend-api.md
index 321aacc..0f144e0 100644
--- a/docs/spec/04-backend-api.md
+++ b/docs/spec/04-backend-api.md
@@ -458,6 +458,7 @@ Provisioning and inbound hooks (outside `/api`, no session):
| `trunkWatch` | every 60 s | if a trunk is unregistered for more than 5 min → email admins once per outage |
| `diskWatch` | hourly | if free disk < 10% → email admins once a day |
| `contactsWarm` | every 5 min, Contrack mode | refresh the company's and each connected person's Contrack book, so a ringing phone's caller-name lookup (11 §6.1) finds them |
+| `contrackLog` | every 60 s, Contrack mode | calls ended over 1 min ago (and within 2 days) of people logging calls: one `call` interaction on the matching contact's Contrack timeline (11 §4.4) |
| `callReports` | every 60 s | each user whose call report period (05 §4) ended over 5 min ago and isn't reported yet: email their calls of that period (by hang-up time) if there were any; `extensions.call_report_sent_at` = the period's end. Changing the schedule sets it to now, so the first report is the next period's |
Jobs must never overlap with themselves (use a running flag).
diff --git a/docs/spec/11-contacts.md b/docs/spec/11-contacts.md
index 5499688..e592e8c 100644
--- a/docs/spec/11-contacts.md
+++ b/docs/spec/11-contacts.md
@@ -245,6 +245,26 @@ New per-user options (My settings → Contacts, shown when connected):
- Unknown numbers are not added to Contrack automatically; "Add to contacts" does it, and calls
from then on are logged.
+As built (C5, `contacts/timeline.ts`):
+
+- Options on `users` (`contrack_log_calls`, `contrack_log_summaries`, `contrack_log_since`),
+ `PATCH /api/me/contrack { logCalls?, logSummaries? }`, shown in `GET /api/me` `contrack` with
+ `summariesAvailable`. Calls count from `contrack_log_since`: set when the token is first
+ connected, and when logging is turned back on, so connecting doesn't fill the timeline with the
+ last 2 days. Migration 0011 sets it to the migration time for people connected before.
+- Calls between colleagues (`internal`) aren't logged. Every person on the call (caller, rang,
+ answered) gets it on their own timeline, as in My calls, with their own contact.
+- Titles: "Incoming call", "Incoming call, answered by Sue", "Missed call", "Missed call, voicemail
+ left", "Outgoing call", "Outgoing call, busy" / "no answer". Content: `From … to … · 3:12 ·
+ recorded
`, then the summary's sentences and an `` of its action items, all escaped.
+ `source: "selfphoneng"`. Contrack requires a due date on a follow-up: the day after the call,
+ in the company time zone.
+- `contrack_call_log` also has `error`: a refused write (4xx other than 401/429: a read-only
+ token, a deleted contact, a bad request) is noted once and not retried. Contrack down (5xx,
+ unreachable), or a token it refuses (401): tried again on the next run.
+- Proven against Contrack 2.0.2 (`contrack-live.test.ts`): the entry, its follow-up, and the
+ escaped content arrive. Contrack answers `duration` as a string ("4.0").
+
### 4.5 Not in Contrack mode
- Personal contacts are not on desk-phone phonebooks at first (company book only). Phase C6.
@@ -271,7 +291,7 @@ New per-user options (My settings → Contacts, shown when connected):
| C2 | Company book on desk phones (07) | a Yealink shows company contacts in its directory. Already built in M10-T2 (`loadPhonebook`, the builtin company book); C3 adds the Contrack company account to it |
| C3 | Contrack: decision 0003, `deploy/contrack`, Caddy/nginx blocks, Settings → Contacts, company token, per-user tokens, contrack provider + cache, sharing, setup wizard step | with the owner's Contrack, a call from a Contrack contact shows their name |
| C4 | Caller name on desk phones while ringing (owner, 2026-10-23: its own task after C3): the dialplan asks the api (AGI or `func_curl`) and sets `CALLERID(name)` | a desk phone rings with the contact's name, from either kind of book. Done, see §6.1 |
-| C5 | Calls on the Contrack timeline (§4.4), summaries opt-in | an answered, recorded call shows up on the contact's Contrack timeline with its summary |
+| C5 | Calls on the Contrack timeline (§4.4), summaries opt-in | an answered, recorded call shows up on the contact's Contrack timeline with its summary. Done, see §4.4 as built |
| C6 | Copy builtin contacts to Contrack; personal and shared contacts in desk phone directories | optional |
### 6.1 As built: C4
diff --git a/packages/shared/src/me.ts b/packages/shared/src/me.ts
index 502c823..b310ed5 100644
--- a/packages/shared/src/me.ts
+++ b/packages/shared/src/me.ts
@@ -56,5 +56,21 @@ export type MeDto = {
callReportWeekday: number;
callReportSummaries: boolean;
/** Contacts in Contrack mode (11 §4.3): my token, never shown again; null in builtin mode. */
- contrack: { connected: boolean; tokenPrefix: string | null; publicUrl: string | null } | null;
+ contrack: {
+ connected: boolean;
+ tokenPrefix: string | null;
+ publicUrl: string | null;
+ /** 11 §4.4: my calls on my contacts' Contrack timelines, and with their summaries. */
+ logCalls: boolean;
+ logSummaries: boolean;
+ /** Call summaries are on (Settings → AI), so logSummaries can do something. */
+ summariesAvailable: boolean;
+ } | null;
};
+
+/** PATCH /api/me/contrack. */
+export const ContrackOptionsSchema = z.object({
+ logCalls: z.boolean().optional(),
+ logSummaries: z.boolean().optional(),
+});
+export type ContrackOptions = z.input;
--
2.34.1
From 604e40adf1145d39768dccf14369a85b960f0ef4 Mon Sep 17 00:00:00 2001
From: iamdoubz
Date: Fri, 23 Oct 2026 16:07:19 -0500
Subject: [PATCH 6/6] contacts: owners' contacts on their desk phones, copy
builtin contacts to Contrack (C6)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Spec 11 §6.2: a phone's directory is the extensions, its owner's own contacts (accepted shares
included), then the company's, in both modes. In Contrack mode, contacts from before Contrack
are offered for copying (contrack.toCopy, POST …/copy-to-contrack: one Contrack bulk import,
skipping what's already there; the builtin rows stay). Tested against a fake and a real
Contrack 2.0.2.
---
apps/api/src/contacts/book.ts | 30 ++++++++
apps/api/src/contacts/contrack.ts | 27 +++++++
apps/api/src/provisioning/context.ts | 14 +++-
apps/api/src/provisioning/templates.test.ts | 1 +
apps/api/src/routes/contacts.ts | 25 +++++++
apps/api/src/routes/provisioning.ts | 2 +-
apps/api/test/contrack-live.test.ts | 32 +++++++-
apps/api/test/contrack.test.ts | 81 ++++++++++++++++++++-
apps/api/test/devices.test.ts | 26 +++++++
apps/web/src/api/client.ts | 2 +
apps/web/src/pages/user/ContactsPage.tsx | 26 +++++++
docs/admin-guide/contacts.md | 11 ++-
docs/spec/04-backend-api.md | 2 +-
docs/spec/07-provisioning.md | 2 +-
docs/spec/11-contacts.md | 17 ++++-
packages/shared/src/contacts.ts | 11 ++-
16 files changed, 294 insertions(+), 15 deletions(-)
diff --git a/apps/api/src/contacts/book.ts b/apps/api/src/contacts/book.ts
index 15cff3a..f764580 100644
--- a/apps/api/src/contacts/book.ts
+++ b/apps/api/src/contacts/book.ts
@@ -6,6 +6,7 @@ import { asc, eq, isNull, or, sql } from 'drizzle-orm';
import {
normalizeNumber,
type ContactBookDto,
+ type ContactCopyResult,
type ContactData,
type ContactDto,
type ContactImportResult,
@@ -17,6 +18,7 @@ import {
ContrackError,
contrackBook,
contrackCreate,
+ contrackBulk,
contrackSettings,
needToken,
type Contrack,
@@ -101,10 +103,38 @@ export async function readBooks(db: Db, userId: string | null): Promise {
+ const rows = (await db.select().from(contacts).where(inBook(owner))).map(toDto);
+ return rows.filter((r) => !have.some((h) => isDuplicate(h, r)));
+}
+
+/**
+ * "Copy my contacts to Contrack" (11 §5, C6): the builtin book's contacts that aren't there yet,
+ * in one Contrack import. The builtin ones stay, for switching back.
+ */
+export async function copyToContrack(db: Db, owner: Owner): Promise {
+ const c = await contrackSettings(db);
+ if (!c)
+ throw new HttpError(409, 'contrack', 'Contacts are not in Contrack (Settings → Contacts)');
+ const b = await contrackBook(db, c, owner);
+ if (!b) asHttp(needToken(owner));
+ if (b!.error) throw new HttpError(502, 'contrack', b!.error);
+ const all = (await db.select().from(contacts).where(inBook(owner))).length;
+ const fresh = await notInContrack(db, owner, b!.contacts);
+ if (!fresh.length) return { added: 0, duplicates: all, failed: 0 };
+ const r = await contrackBulk(db, c, owner, fresh).catch(asHttp);
+ return { added: r.count, duplicates: all - fresh.length, failed: r.failed };
+}
+
/** One contact added by hand (or from a call): a row, or a Contrack contact. */
export async function createContact(db: Db, owner: Owner, d: ContactData): Promise {
const clean = { ...d, numbers: cleanNumbers(d) };
diff --git a/apps/api/src/contacts/contrack.ts b/apps/api/src/contacts/contrack.ts
index f1aff35..9ec6e98 100644
--- a/apps/api/src/contacts/contrack.ts
+++ b/apps/api/src/contacts/contrack.ts
@@ -2,6 +2,7 @@
// Contrack (11 §4): a self-hosted contact manager beside the PBX. We call a handful of its REST
// routes with a person's (or the company account's) personal token, and keep each book in memory
// for 5 minutes, so name lookups don't wait on it.
+import { randomUUID } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { normalizeNumber, type ContactData, type ContactDto } from '@selfphoneng/shared';
import { AiError, assertLocalUrl } from '../ai/client.ts';
@@ -36,6 +37,7 @@ async function ctk(
r = await fetch(url, {
...init,
headers: {
+ ...(init.headers as Record | undefined),
Authorization: `Bearer ${token}`,
...(init.body ? { 'Content-Type': 'application/json' } : {}),
},
@@ -204,6 +206,31 @@ export async function contrackCreate(
return toDto(c, row, owner === null ? 'company' : 'mine');
}
+/** Many contacts into a Contrack book in one import (11 §5, C6); 5,000 at most. */
+export async function contrackBulk(
+ db: Db,
+ c: Contrack,
+ owner: string | null,
+ list: ContactData[],
+): Promise<{ count: number; failed: number }> {
+ const token = await tokenFor(db, c, owner);
+ if (!token) throw needToken(owner);
+ const r = await ctk<{ count: number; failed: number }>(c.url, token, 'api/contacts/bulk', {
+ method: 'POST',
+ headers: { 'X-Import-Id': randomUUID() },
+ body: JSON.stringify(
+ list.slice(0, 5000).map((d) => ({
+ name: d.name,
+ ...(d.company ? { company: d.company } : {}),
+ ...(d.email ? { emails: [{ email: d.email }] } : {}),
+ phones: d.numbers.map((n) => ({ phone: n.number, ...(n.label ? { label: n.label } : {}) })),
+ })),
+ ),
+ });
+ forgetBook(owner);
+ return { count: r.count, failed: typeof r.failed === 'number' ? r.failed : 0 };
+}
+
/** A timeline entry on a contact (11 §4.4); answers its id. */
export async function contrackInteraction(
c: Contrack,
diff --git a/apps/api/src/provisioning/context.ts b/apps/api/src/provisioning/context.ts
index 8f28aaa..09e6168 100644
--- a/apps/api/src/provisioning/context.ts
+++ b/apps/api/src/provisioning/context.ts
@@ -27,6 +27,8 @@ export type ProvisionContext = {
provPassword: string;
/** The phonebook with this phone's credentials in it (phones fetch it like the config). */
phonebookUrl: string;
+ /** Whose phone: their own contacts go in its directory (11 §6 C6). */
+ ownerUserId: string | null;
ntpServer: string;
/** What's dialable (dialplan.ts), so phones can send complete numbers at once. */
dialRules: DialRule[];
@@ -80,6 +82,7 @@ export async function loadContext(
provUser: d.mac,
provPassword,
phonebookUrl: `https://${d.mac}:${provPassword}@${config.PUBLIC_HOSTNAME}/prov/phonebook/${d.mac}.xml`,
+ ownerUserId: e.userId,
ntpServer: NTP_SERVER,
dialRules: await loadDialRules(db),
},
@@ -88,14 +91,17 @@ export async function loadContext(
export type PhonebookEntry = { name: string; numbers: { label: string; number: string }[] };
-/** The company phonebook (07 §2): every extension, then the company contacts, by name (from
- * Contrack's company account in Contrack mode, 11 §4.3). */
-export async function loadPhonebook(db: Db): Promise {
+/** A phone's directory (07 §2): every extension, then its owner's own contacts (11 §6 C6), then
+ * the company's, each by name. Contrack's books in Contrack mode (11 §4.3). */
+export async function loadPhonebook(
+ db: Db,
+ ownerUserId: string | null = null,
+): Promise {
const exts = await db
.select({ name: extensions.name, number: extensions.number })
.from(extensions)
.orderBy(asc(extensions.name));
- const company = (await readBooks(db, null)).items;
+ const company = (await readBooks(db, ownerUserId)).items; // mine first, then the company's
return [
...exts.map((x) => ({ name: x.name, numbers: [{ label: 'Extension', number: x.number }] })),
...company.map((c) => ({
diff --git a/apps/api/src/provisioning/templates.test.ts b/apps/api/src/provisioning/templates.test.ts
index ec58cc9..8224b4f 100644
--- a/apps/api/src/provisioning/templates.test.ts
+++ b/apps/api/src/provisioning/templates.test.ts
@@ -31,6 +31,7 @@ const ctx = (over: Partial = {}): ProvisionContext => ({
provPassword: 'Pr0vPassw0rdXyZ12345',
phonebookUrl:
'https://001565abcdef:Pr0vPassw0rdXyZ12345@pbx.example.com/prov/phonebook/001565abcdef.xml',
+ ownerUserId: null,
ntpServer: 'pool.ntp.org',
dialRules: dialRules(['NXXNXXXXXX', '1NXXNXXXXXX'], [3]),
...over,
diff --git a/apps/api/src/routes/contacts.ts b/apps/api/src/routes/contacts.ts
index 28613c4..01f821b 100644
--- a/apps/api/src/routes/contacts.ts
+++ b/apps/api/src/routes/contacts.ts
@@ -9,6 +9,7 @@ import {
ContactShareDecisionSchema,
ContactShareSchema,
type ContactBookDto,
+ type ContactCopyResult,
type ContactData,
type ContactDto,
type ContactImportResult,
@@ -21,6 +22,7 @@ import {
assertBuiltin,
cleanNumbers,
contactNames,
+ copyToContrack,
createContact,
readBooks,
toDto,
@@ -188,6 +190,29 @@ export async function contactRoutes(app: FastifyInstance) {
.send(phoneSafeName(names.get(q.data.number) ?? ''));
});
+ /** Copy the builtin book into Contrack (11 §5, C6): mine, or (admin) the company's. */
+ app.post(
+ '/api/me/contacts/copy-to-contrack',
+ { preHandler: requireAuth },
+ async (req): Promise => copyToContrack(db, req.user!.id),
+ );
+ app.post(
+ '/api/contacts/copy-to-contrack',
+ { preHandler: requireAdmin },
+ async (req): Promise => {
+ const r = await copyToContrack(db, null);
+ if (r.added)
+ await audit(db, {
+ actorUserId: req.user!.id,
+ actorIp: req.ip,
+ action: 'create',
+ entityType: 'contact',
+ summary: `Copied ${r.added} company contacts to Contrack`,
+ });
+ return r;
+ },
+ );
+
/** Settings → Contacts → Test (11 §4.3): the form's address, and its token or the saved one. */
app.post('/api/contacts/contrack/test', { preHandler: requireAdmin }, async (req) => {
const b = z
diff --git a/apps/api/src/routes/provisioning.ts b/apps/api/src/routes/provisioning.ts
index a11dfff..17529db 100644
--- a/apps/api/src/routes/provisioning.ts
+++ b/apps/api/src/routes/provisioning.ts
@@ -66,7 +66,7 @@ export async function provisioningRoutes(app: FastifyInstance) {
/** The config, or (phonebook) the directory in the phone's vendor's format. */
async function render(c: ProvisionContext, phonebook = false): Promise {
if (!phonebook) return c.vendor === 'yealink' ? yealink(c) : grandstream(c);
- const entries = await loadPhonebook(db);
+ const entries = await loadPhonebook(db, c.ownerUserId);
return c.vendor === 'yealink' ? yealinkPhonebook(entries) : grandstreamPhonebook(entries);
}
diff --git a/apps/api/test/contrack-live.test.ts b/apps/api/test/contrack-live.test.ts
index 94acbed..0cdebaf 100644
--- a/apps/api/test/contrack-live.test.ts
+++ b/apps/api/test/contrack-live.test.ts
@@ -11,7 +11,14 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { eq } from 'drizzle-orm';
import { contactNames } from '../src/contacts/book.ts';
import { logCallsToContrack } from '../src/contacts/timeline.ts';
-import { callExtensions, calls, extensions, recordings, users } from '../src/db/schema.ts';
+import {
+ callExtensions,
+ calls,
+ contacts,
+ extensions,
+ recordings,
+ users,
+} from '../src/db/schema.ts';
import { setSetting } from '../src/services/settings.ts';
import { loadPhonebook } from '../src/provisioning/context.ts';
import { hasTestDb } from './db.ts';
@@ -188,6 +195,7 @@ describe.skipIf(!hasTestDb || !LIVE)('contacts in a real Contrack', () => {
publicUrl: 'https://contacts.example.com',
connected: true,
error: null,
+ toCopy: 0,
});
expect(book.items).toEqual([
expect.objectContaining({
@@ -307,6 +315,26 @@ describe.skipIf(!hasTestDb || !LIVE)('contacts in a real Contrack', () => {
expect(content).not.toContain('');
});
+ it('contacts from before Contrack copy into the account in one import (C6)', async () => {
+ await t.db.insert(contacts).values([
+ {
+ name: 'Old Friend',
+ ownerUserId: danId,
+ numbers: [{ label: 'home', number: '13125550202' }],
+ },
+ { name: 'Jane Doe', ownerUserId: danId, numbers: [{ label: '', number: '13125551234' }] },
+ ]);
+ expect((await dan('GET', '/api/me/contacts')).json().contrack.toCopy).toBe(1);
+ const r = await dan('POST', '/api/me/contacts/copy-to-contrack');
+ expect(r.json(), r.body).toEqual({ added: 1, duplicates: 1, failed: 0 });
+ const rows = (await ctk('/api/contacts?view=slim', { token: tok.dan })).body as unknown as {
+ name: string;
+ phones: { phone: string }[];
+ }[];
+ expect(rows.find((x) => x.name === 'Old Friend')?.phones).toEqual([{ phone: '13125550202' }]);
+ expect((await dan('GET', '/api/me/contacts')).json().contrack.toCopy).toBe(0);
+ });
+
it('a share is accepted into the recipient’s own Contrack account', async () => {
const jane = (await dan('GET', '/api/me/contacts'))
.json()
@@ -328,6 +356,6 @@ describe.skipIf(!hasTestDb || !LIVE)('contacts in a real Contrack', () => {
const dans = (await ctk('/api/contacts?view=slim', { token: tok.dan })).body as unknown as {
name: string;
}[];
- expect(dans.map((c) => c.name).sort()).toEqual(['Bob Smith', 'Jane Doe']);
+ expect(dans.map((c) => c.name).sort()).toEqual(['Bob Smith', 'Jane Doe', 'Old Friend']);
});
});
diff --git a/apps/api/test/contrack.test.ts b/apps/api/test/contrack.test.ts
index a749e3e..4936911 100644
--- a/apps/api/test/contrack.test.ts
+++ b/apps/api/test/contrack.test.ts
@@ -11,6 +11,7 @@ import { logCallsToContrack } from '../src/contacts/timeline.ts';
import {
callExtensions,
calls,
+ contacts,
contrackCallLog,
extensions,
recordings,
@@ -67,6 +68,30 @@ function fakeContrack() {
const token = (req.headers.authorization ?? '').replace(/^Bearer /, '');
const book = books[token];
if (!book) return send(401, { error: { code: 'UNAUTHORIZED', message: 'Unauthorized' } });
+ if (req.method === 'POST' && req.url === '/api/contacts/bulk') {
+ if (!/^[0-9a-f-]{36}$/.test(String(req.headers['x-import-id'])))
+ return send(400, { error: { code: 'VALIDATION_ERROR', message: 'X-Import-Id' } });
+ let raw = '';
+ req.on('data', (c) => (raw += c));
+ req.on('end', () => {
+ const rows = JSON.parse(raw) as { name: string; phones: { phone: string }[] }[];
+ for (const r of rows)
+ book.push({
+ id: `bulk-${++n}`,
+ name: r.name,
+ company: null,
+ emails: [],
+ phones: r.phones,
+ });
+ send(201, {
+ success: true,
+ count: rows.length,
+ failed: 0,
+ importId: req.headers['x-import-id'],
+ });
+ });
+ return;
+ }
const into = /^\/api\/contacts\/([^/]+)\/interactions$/.exec(req.url ?? '');
if (req.method === 'POST' && into) {
if (state.readOnly.has(token))
@@ -217,6 +242,7 @@ describe.skipIf(!hasTestDb)('contacts in Contrack', () => {
publicUrl: 'https://contacts.example.com',
connected: true,
error: null,
+ toCopy: 0,
});
expect(book.items).toEqual([
{
@@ -436,10 +462,63 @@ describe.skipIf(!hasTestDb)('contacts in Contrack', () => {
expect(jane).toHaveLength(8);
});
+ it('contacts from before Contrack are copied once, the builtin ones kept (C6)', async () => {
+ await t.db.insert(contacts).values([
+ {
+ name: 'Jane Doe',
+ ownerUserId: danId,
+ numbers: [{ label: 'mobile', number: '13125551234' }],
+ }, // in Contrack
+ {
+ name: 'Old Friend',
+ ownerUserId: danId,
+ numbers: [{ label: 'home', number: '13125550202' }],
+ },
+ { name: 'Plumber', numbers: [{ label: '', number: '13125557777' }] }, // in the company account
+ { name: 'Locksmith', numbers: [{ label: 'work', number: '13125550303' }] },
+ ]);
+ expect((await dan('GET', '/api/me/contacts')).json().contrack.toCopy).toBe(1);
+ expect((await dan('POST', '/api/me/contacts/copy-to-contrack')).json()).toEqual({
+ added: 1,
+ duplicates: 1,
+ failed: 0,
+ });
+ expect(fake.books.ctk_dan_0001!.at(-1)).toMatchObject({
+ name: 'Old Friend',
+ phones: [{ phone: '13125550202', label: 'home' }],
+ });
+ expect((await dan('GET', '/api/me/contacts')).json().contrack.toCopy).toBe(0);
+ expect((await dan('POST', '/api/me/contacts/copy-to-contrack')).json()).toEqual({
+ added: 0,
+ duplicates: 2,
+ failed: 0,
+ });
+ // The company book, by an admin only.
+ expect((await dan('POST', '/api/contacts/copy-to-contrack')).statusCode).toBe(403);
+ expect((await admin('GET', '/api/contacts')).json().contrack.toCopy).toBe(1);
+ expect((await admin('POST', '/api/contacts/copy-to-contrack')).json()).toEqual({
+ added: 1,
+ duplicates: 1,
+ failed: 0,
+ });
+ expect(fake.books.ctk_company!.map((c) => c.name)).toEqual(['Plumber', 'Locksmith']);
+ // Dan's desk phone: his Contrack contacts, then the company's.
+ const names = (await loadPhonebook(t.db, danId)).map((e) => e.name);
+ expect(names.indexOf('Old Friend')).toBeGreaterThan(-1);
+ expect(names.indexOf('Old Friend')).toBeLessThan(names.indexOf('Locksmith'));
+ expect((await loadPhonebook(t.db, null)).map((e) => e.name)).not.toContain('Old Friend');
+ });
+
it('back to builtin: the table again, tokens kept for later', async () => {
await admin('PATCH', '/api/settings', { 'contacts.provider': 'builtin' });
const book = (await dan('GET', '/api/me/contacts')).json();
- expect(book).toEqual({ items: [], provider: 'builtin', contrack: null });
+ expect([book.provider, book.contrack]).toEqual(['builtin', null]);
+ expect(book.items.map((c: { name: string }) => c.name)).toEqual([
+ 'Jane Doe',
+ 'Old Friend',
+ 'Locksmith',
+ 'Plumber',
+ ]);
expect((await dan('GET', '/api/me')).json().contrack).toBeNull();
});
});
diff --git a/apps/api/test/devices.test.ts b/apps/api/test/devices.test.ts
index a139bcd..1914fb0 100644
--- a/apps/api/test/devices.test.ts
+++ b/apps/api/test/devices.test.ts
@@ -245,6 +245,32 @@ describe.skipIf(!hasTestDb)('/api/devices and /prov', () => {
await setSetting(t.db, 'general.timezone', 'America/Chicago');
});
+ it("11 §6 C6: a phone's directory has its owner's own contacts, after the extensions", async () => {
+ const owner = await t.addUser('dan@example.com', 'dan-password-123');
+ const other = await t.addUser('sue@example.com', 'sue-password-123');
+ await req('PATCH', `/api/extensions/${extId}`, { userId: owner.id });
+ await t.db.insert(contacts).values([
+ { name: 'Mom', ownerUserId: owner.id, numbers: [{ label: 'mobile', number: '13125550101' }] },
+ {
+ name: 'Sue Secret',
+ ownerUserId: other.id,
+ numbers: [{ label: '', number: '13125550102' }],
+ },
+ ]);
+ const book = (
+ await prov(`phonebook/${device.mac}.xml`, '192.168.1.60', device.provisioningPassword)
+ ).body;
+ expect(book).toContain('13125550101 ');
+ expect(book).not.toContain('Sue Secret');
+ expect(book.indexOf('Ann Lee')).toBeLessThan(book.indexOf('Mom'));
+ expect(book.indexOf('Mom')).toBeLessThan(book.indexOf('Acme')); // mine before the company's
+ await req('PATCH', `/api/extensions/${extId}`, { userId: null });
+ const unowned = (
+ await prov(`phonebook/${device.mac}.xml`, '192.168.1.60', device.provisioningPassword)
+ ).body;
+ expect(unowned).not.toContain('Mom');
+ });
+
it('M10-T3: Linphone QR (admin and me), the token is the credential, audited', async () => {
const r = await req('GET', `/api/extensions/${extId}/softphone-qr`);
expect(r.statusCode, r.body).toBe(200);
diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts
index 916755f..cfb90c8 100644
--- a/apps/web/src/api/client.ts
+++ b/apps/web/src/api/client.ts
@@ -23,6 +23,7 @@ import type {
CallDto,
CallFilter,
ContactBookDto,
+ ContactCopyResult,
ContrackOptions,
ContactDto,
ContactImportResult,
@@ -299,6 +300,7 @@ export type ContactBase = '/api/me/contacts' | '/api/contacts';
export const contactsApi = {
list: (base: ContactBase) => api('GET', base),
+ copyToContrack: (base: ContactBase) => api('POST', `${base}/copy-to-contrack`),
testContrack: (url: string, companyToken?: string) =>
api<{ ok: true; detail: string } | { ok: false; error: string }>(
'POST',
diff --git a/apps/web/src/pages/user/ContactsPage.tsx b/apps/web/src/pages/user/ContactsPage.tsx
index 00537f3..119a101 100644
--- a/apps/web/src/pages/user/ContactsPage.tsx
+++ b/apps/web/src/pages/user/ContactsPage.tsx
@@ -159,6 +159,7 @@ function ContactBook({ base, title }: { base: ContactBase; title: string }) {
: 'The company Contrack account has no token yet: Settings → Contacts.')}
)}
+ {ctk?.connected && ctk.toCopy > 0 && }
{list.data && shown.length === 0 && (
{q ? 'No contact matches that.' : 'No contacts yet.'}
@@ -261,6 +262,31 @@ function ContactBook({ base, title }: { base: ContactBase; title: string }) {
);
}
+/** Contacts from before Contrack (11 §5, C6): copied once, kept here for switching back. */
+function CopyToContrack({ base, count }: { base: ContactBase; count: number }) {
+ const qc = useQueryClient();
+ const copy = useMutation({
+ mutationFn: () => contactsApi.copyToContrack(base),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['contacts'] }),
+ });
+ return (
+
+
+ {count} contact{count === 1 ? '' : 's'} from before Contrack{' '}
+ {count === 1 ? "isn't" : "aren't"} in {base === COMPANY ? "the company's" : 'your'} Contrack
+ yet.
+
+
copy.mutate()}>
+ Copy {count === 1 ? 'it' : 'them'} to Contrack
+
+ {copy.error &&
{copy.error.message} }
+
+ );
+}
+
const LABELS = ['mobile', 'work', 'home', 'main', 'fax', 'other'];
function ContactDrawer(props: {
diff --git a/docs/admin-guide/contacts.md b/docs/admin-guide/contacts.md
index 0b05125..4a67939 100644
--- a/docs/admin-guide/contacts.md
+++ b/docs/admin-guide/contacts.md
@@ -1,7 +1,8 @@
# Contacts
-Contacts put names on numbers: in call history, voicemail, the browser phone while it rings, the
-call report email, and the directory on desk phones (the company contacts). Pick where they live
+Contacts put names on numbers: in call history, voicemail, the browser phone and desk phones
+while they ring, the call report email, and the directory on desk phones (each phone has its
+owner's contacts and the company's). Pick where they live
in the setup wizard or in **Settings → Contacts**:
- **Simple**: a phonebook inside SelfPhoneNG. Nothing else to run.
@@ -89,6 +90,12 @@ Administration → AI**.
SelfPhoneNG's backup doesn't include Contrack. Contrack makes its own snapshots in its
`contrack-data` volume (its **Settings → Backups**); back up that volume too.
+### Moving to Contrack with contacts already in SelfPhoneNG
+
+Switching from Simple keeps everyone's contacts. Each person (and an admin, for the company
+contacts) sees **Copy them to Contrack** on their Contacts page until they're copied; contacts
+Contrack already has are skipped.
+
### Going back to Simple
**Settings → Contacts → Simple**. The builtin contacts are still there, and the Contrack tokens
diff --git a/docs/spec/04-backend-api.md b/docs/spec/04-backend-api.md
index 0f144e0..86f7c70 100644
--- a/docs/spec/04-backend-api.md
+++ b/docs/spec/04-backend-api.md
@@ -377,7 +377,7 @@ Auth & self:
| GET | `/api/me/greetings/:kind/audio` | user | the wav |
| PUT/DELETE | `/api/me/greetings/:kind` | user | PUT body is the raw audio (a MediaRecorder Blob or a picked File, any `Content-Type`, max 20 MB), not multipart: no extra dependency. ffmpeg converts it (§6) |
| GET | `/api/me/voicemail/:id/audio?download=1` | user | same, as an attachment |
-| GET/POST/PATCH/DELETE | `/api/me/contacts[/:id]` | user | my contacts; GET lists the company's too (11 §3). `POST /import`, `POST /share`, `GET /shares`, `POST /shares/accept\|deny` (11 §2.1, §3.1) |
+| GET/POST/PATCH/DELETE | `/api/me/contacts[/:id]` | user | my contacts; GET lists the company's too (11 §3). `POST /import`, `POST /share`, `GET /shares`, `POST /shares/accept\|deny` (11 §2.1, §3.1), `POST /copy-to-contrack` (11 §6.2) |
| POST | `/api/me/queues/:id/pause`, `/unpause` | user | log out of / in to a queue I'm an agent of (Home, 05 §4), same as `*45` |
| POST | `/api/me/click-to-call` | user | `{number}` → AMI `Originate Channel: PJSIP/, Context: sp-from-ext, Exten: , Priority: 1, CallerID: "" <>, Async: true`. The user's phone rings first, then the call goes out |
diff --git a/docs/spec/07-provisioning.md b/docs/spec/07-provisioning.md
index faba310..fb6313c 100644
--- a/docs/spec/07-provisioning.md
+++ b/docs/spec/07-provisioning.md
@@ -37,7 +37,7 @@ type ProvisionContext = {
| `GET /prov/.cfg` | Yealink (also Polycom master, see §3.3) | MAC lowercase. Decide by `devices.vendor` |
| `GET /prov/y0000000000.cfg` | Yealink common file | Return an empty valid file `#!version:1.0.0.1\n` |
| `GET /prov/cfg.xml` | Grandstream | |
-| `GET /prov/phonebook/.xml` | both | company phonebook + extensions, vendor-specific XML |
+| `GET /prov/phonebook/.xml` | both | extensions + the phone owner's contacts + company phonebook (11 §6.2), vendor-specific XML |
| `GET /prov/linphone/.xml` | Linphone (§5) | token auth instead of Basic |
Rules:
diff --git a/docs/spec/11-contacts.md b/docs/spec/11-contacts.md
index e592e8c..87b0522 100644
--- a/docs/spec/11-contacts.md
+++ b/docs/spec/11-contacts.md
@@ -267,7 +267,6 @@ As built (C5, `contacts/timeline.ts`):
### 4.5 Not in Contrack mode
-- Personal contacts are not on desk-phone phonebooks at first (company book only). Phase C6.
- Editing a Contrack contact from SelfPhoneNG.
- A contact in several books is shown once per book; lookup takes the first in §1's order.
@@ -292,7 +291,7 @@ As built (C5, `contacts/timeline.ts`):
| C3 | Contrack: decision 0003, `deploy/contrack`, Caddy/nginx blocks, Settings → Contacts, company token, per-user tokens, contrack provider + cache, sharing, setup wizard step | with the owner's Contrack, a call from a Contrack contact shows their name |
| C4 | Caller name on desk phones while ringing (owner, 2026-10-23: its own task after C3): the dialplan asks the api (AGI or `func_curl`) and sets `CALLERID(name)` | a desk phone rings with the contact's name, from either kind of book. Done, see §6.1 |
| C5 | Calls on the Contrack timeline (§4.4), summaries opt-in | an answered, recorded call shows up on the contact's Contrack timeline with its summary. Done, see §4.4 as built |
-| C6 | Copy builtin contacts to Contrack; personal and shared contacts in desk phone directories | optional |
+| C6 | Copy builtin contacts to Contrack; personal and shared contacts in desk phone directories | optional. Done, see §6.2 |
### 6.1 As built: C4
@@ -315,6 +314,20 @@ As built (C5, `contacts/timeline.ts`):
contact name not reaching 102 after a no-answer forward, and the phone still ringing (with the
provider's name) when the api hangs.
+### 6.2 As built: C6
+
+- A phone's directory (`/prov/phonebook/.xml`, 07) is the extensions, then **its owner's own
+ contacts** (accepted shares are among them), then the company's, each by name, from the
+ builtin books or Contrack. A phone whose extension has no user gets the extensions and the
+ company only.
+- Contrack mode: `GET /api/me/contacts` (and `/api/contacts`) says in `contrack.toCopy` how many
+ builtin contacts that book's Contrack account doesn't have yet (same name and number), and the
+ page offers **Copy them to Contrack**: `POST /api/me/contacts/copy-to-contrack` (admin:
+ `/api/contacts/copy-to-contrack`, audited) sends them in one `POST /api/contacts/bulk` (with an
+ `X-Import-Id`). The builtin rows stay, for switching back; once copied the offer is gone.
+ Contrack's own duplicate check then runs on the import as usual.
+- Proven against Contrack 2.0.2 (`contrack-live.test.ts`).
+
Tests: builtin lookup/search/import against the test DB; contrack provider against a fake HTTP
server built from the pinned `openapi.json`; one e2e script against a real Contrack container.
diff --git a/packages/shared/src/contacts.ts b/packages/shared/src/contacts.ts
index bcf36a9..f05fb99 100644
--- a/packages/shared/src/contacts.ts
+++ b/packages/shared/src/contacts.ts
@@ -53,9 +53,18 @@ export type ContactBookDto = {
items: ContactDto[];
provider: 'builtin' | 'contrack';
/** Contrack mode: the address people open, and whether this book's token works. */
- contrack: { publicUrl: string | null; connected: boolean; error: string | null } | null;
+ contrack: {
+ publicUrl: string | null;
+ connected: boolean;
+ error: string | null;
+ /** Contacts still only in the builtin book (from before Contrack): "Copy them" (11 §5). */
+ toCopy: number;
+ } | null;
};
+/** POST …/contacts/copy-to-contrack (11 §5, C6). */
+export type ContactCopyResult = { added: number; duplicates: number; failed: number };
+
/** POST …/contacts/import: the files' text, read in the browser. */
export const ContactImportSchema = z.object({
files: z
--
2.34.1