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). 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 ( + <> + + {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 ( +
+

+ {title} +

+
+ setQ(e.target.value)} + /> + + + {canShare && ( + + )} +
+ {note && ( +

+ {note} +

+ )} + {list.error && {list.error.message}} + {list.data && shown.length === 0 && ( +

+ {q ? 'No contact matches that.' : 'No contacts yet.'} +

+ )} +
    + {shown.map((c) => ( +
  • + {canShare && ( + { + const next = new Set(picked); + if (e.target.checked) next.add(c.id); + else next.delete(c.id); + setPicked(next); + }} + /> + )} + + {c.name} + {c.company && · {c.company}} + {base === MINE && c.book === 'company' && ( + + Company + + )} + + + {c.numbers.map((n) => ( + + ))} + + {mine(c) && ( + + + + + )} +
  • + ))} +
+ {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 ( + + + + + } + > +
{ + e.preventDefault(); + save.mutate(); + }} + > + {(['name', 'company', 'email'] as const).map((k) => ( + + {(p) => ( + setForm({ ...form, [k]: e.target.value })} + /> + )} + + ))} +
+ Numbers + {form.numbers.map((n, i) => ( +
+ + setNumber(i, { number: e.target.value })} + /> + +
+ ))} +
+ +
+
+ + {(p) => ( +