From 692dd595523aafa156c070d13e14a181f42b3881 Mon Sep 17 00:00:00 2001 From: iamdoubz Date: Fri, 23 Oct 2026 15:23:49 -0500 Subject: [PATCH] contacts: Contrack as the robust contacts (C3), picked in Settings or the setup wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec 11 §4: contacts.provider and contacts.contrack (local address only, company token write-only), each person's own token (users.contrack_token_enc, migration 0010) in My settings, books read from Contrack with a 5-minute cache, Add to contacts and accepted shares created in Contrack, the company account on desk phones. Setup wizard step 8 Contacts. deploy/contrack (2.0.2, pinned), Caddy/nginx blocks, decision 0003, admin guide Contacts. Tested against a fake and a real Contrack 2.0.2 (contrack-live.test.ts). --- .env.example | 1 + apps/api/src/contacts/book.ts | 138 +- apps/api/src/contacts/contrack.ts | 201 + .../src/db/migrations/0010_contrack_token.sql | 1 + .../src/db/migrations/meta/0010_snapshot.json | 4254 +++++++++++++++++ apps/api/src/db/migrations/meta/_journal.json | 7 + apps/api/src/db/schema.ts | 2 + apps/api/src/provisioning/context.ts | 14 +- apps/api/src/routes/contacts.ts | 119 +- apps/api/src/routes/me.ts | 52 + apps/api/src/routes/settings.ts | 29 +- apps/api/test/contrack-live.test.ts | 261 + apps/api/test/contrack.test.ts | 274 ++ apps/api/test/me-settings.test.ts | 1 + apps/api/test/schema.test.ts | 1 + apps/web/src/api/client.ts | 11 +- apps/web/src/pages/SetupPage.tsx | 24 +- apps/web/src/pages/admin/SettingsPage.tsx | 146 + apps/web/src/pages/user/ContactsPage.tsx | 41 +- apps/web/src/pages/user/MeSettingsPage.tsx | 82 +- deploy/caddy/Caddyfile | 7 + deploy/contrack/compose.yml | 32 + deploy/nginx/selfphoneng.conf.example | 16 + docker-compose.yml | 1 + docs/admin-guide/README.md | 1 + docs/admin-guide/contacts.md | 85 + docs/decisions/0003-contrack.md | 15 + docs/spec/02-data-model.md | 3 +- docs/spec/11-contacts.md | 42 +- packages/shared/src/contacts.ts | 13 +- packages/shared/src/me.ts | 2 + packages/shared/src/settings.ts | 31 +- 32 files changed, 5787 insertions(+), 120 deletions(-) create mode 100644 apps/api/src/contacts/contrack.ts create mode 100644 apps/api/src/db/migrations/0010_contrack_token.sql create mode 100644 apps/api/src/db/migrations/meta/0010_snapshot.json create mode 100644 apps/api/test/contrack-live.test.ts create mode 100644 apps/api/test/contrack.test.ts create mode 100644 deploy/contrack/compose.yml create mode 100644 docs/admin-guide/contacts.md create mode 100644 docs/decisions/0003-contrack.md diff --git a/.env.example b/.env.example index 2be5d22..5835676 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,7 @@ INITIAL_ADMIN_EMAIL=admin@example.com INITIAL_ADMIN_PASSWORD=change-me # only used if no admin exists yet # --- optional --- +CONTRACK_HOSTNAME= # Contrack contacts (spec 11 §4): its own DNS name, e.g. contacts.example.com TZ=America/Chicago API_PORT=8080 POSTGRES_PORT=5433 diff --git a/apps/api/src/contacts/book.ts b/apps/api/src/contacts/book.ts index ddb156e..84348d8 100644 --- a/apps/api/src/contacts/book.ts +++ b/apps/api/src/contacts/book.ts @@ -1,16 +1,26 @@ // 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. +// Address books (11 §2): a user's (owner = their id) and the company's (owner null), kept in the +// `contacts` table (builtin) or in Contrack (contacts.provider = 'contrack', 11 §4). Every caller +// goes through these functions and doesn't care which. import { asc, eq, isNull, or, sql } from 'drizzle-orm'; import { normalizeNumber, + type ContactBookDto, 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'; +import { HttpError, badRequest } from '../errors.ts'; +import { + ContrackError, + contrackBook, + contrackCreate, + contrackSettings, + needToken, + type Contrack, +} from './contrack.ts'; /** null = the company book. */ export type Owner = string | null; @@ -26,8 +36,22 @@ export const toDto = (r: Row): ContactDto => ({ email: r.email, numbers: r.numbers as ContactData['numbers'], notes: r.notes, + url: null, }); +/** Contrack's errors as API answers (409 for "connect first", 502 for Contrack itself). */ +const asHttp = (err: unknown): never => { + if (err instanceof ContrackError) + throw new HttpError(err.status === 409 ? 409 : 502, 'contrack', err.message); + throw err; +}; + +/** Editing, deleting and importing happen in Contrack itself in Contrack mode (11 §3.1, §4.3). */ +export async function assertBuiltin(db: Db) { + if (await contrackSettings(db)) + throw new HttpError(409, 'contrack', 'Contacts live in Contrack: change them there'); +} + /** 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'] = []; @@ -42,20 +66,55 @@ export function cleanNumbers(c: ContactData, bad?: (n: string) => void): Contact 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); +const byName = (a: ContactDto, b: ContactDto) => a.name.localeCompare(b.name); + +async function contrackList(db: Db, c: Contrack, owner: Owner) { + const b = await contrackBook(db, c, owner); + return { items: (b?.contacts ?? []).toSorted(byName), book: b }; } -export async function listBook(db: Db, owner: Owner): Promise { - return (await db.select().from(contacts).where(inBook(owner)).orderBy(asc(contacts.name))).map( - toDto, - ); +/** + * The books someone sees: my contacts, then the company's (userId), or only the company's (null, + * Admin → Contacts). `contrack` says whether that person's (or the company's) token works. + */ +export async function readBooks(db: Db, userId: string | null): Promise { + const c = await contrackSettings(db); + if (!c) { + const rows = await db + .select() + .from(contacts) + .where( + userId + ? or(eq(contacts.ownerUserId, userId), isNull(contacts.ownerUserId)) + : isNull(contacts.ownerUserId), + ) + .orderBy(sql`${contacts.ownerUserId} is null`, asc(contacts.name)); + return { items: rows.map(toDto), provider: 'builtin', contrack: null }; + } + const company = await contrackList(db, c, null); + const mine = userId ? await contrackList(db, c, userId) : null; + const own = mine ?? company; // whose token the page is about + return { + items: [...(mine?.items ?? []), ...company.items], + provider: 'contrack', + contrack: { + publicUrl: c.publicUrl, + connected: !!own.book && !own.book.error, + error: own.book?.error ?? null, + }, + }; +} + +/** One contact added by hand (or from a call): a row, or a Contrack contact. */ +export async function createContact(db: Db, owner: Owner, d: ContactData): Promise { + const clean = { ...d, numbers: cleanNumbers(d) }; + const c = await contrackSettings(db); + if (c) return contrackCreate(db, c, owner, clean).catch(asHttp); + const [row] = await db + .insert(contacts) + .values({ ...clean, ownerUserId: owner }) + .returning(); + return toDto(row!); } /** Same contact: same name (any case) and a number in common, or neither has a number. */ @@ -66,42 +125,53 @@ const isDuplicate = (a: ContactData, b: ContactData) => 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. + * Adds contacts to a book, skipping ones it already has (11 §3.1): an import or accepted shares. + * With `commit` false it only counts. Bad numbers are dropped and reported. */ export async function addToBook( - db: Pick, // a transaction too + db: Db, owner: Owner, list: ContactData[], commit: boolean, errors: string[] = [], ): Promise { - const have = (await db.select().from(contacts).where(inBook(owner))).map(toDto); + const c = await contrackSettings(db); + let have: ContactDto[]; + if (c) { + const b = await contrackBook(db, c, owner); + if (!b) asHttp(needToken(owner)); + if (b!.error) throw new HttpError(502, 'contrack', b!.error); + have = b!.contacts; + } else 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`), + for (const d of list) { + const numbers = cleanNumbers(d, (n) => + errors.push(`${d.name}: "${n}" isn't a phone number, left out`), ); - const clean = { ...c, numbers }; + const clean = { ...d, 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 }))); + if (commit && fresh.length) { + // ponytail: one Contrack request per contact; imports go to Contrack's own importer, so + // this is only accepted shares. + if (c) for (const d of fresh) await contrackCreate(db, c, owner, d).catch(asHttp); + // ponytail: one insert of up to 5,000 rows (the import cap). + else await db.insert(contacts).values(fresh.map((d) => ({ ...d, ownerUserId: owner }))); + } return { committed: commit, added: fresh.length, duplicates, - noNumber: fresh.filter((c) => !c.numbers.length).length, + noNumber: fresh.filter((d) => !d.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). + * (normalized). userId null: the company book only. */ export async function contactNames( db: Db, @@ -111,6 +181,18 @@ export async function contactNames( const want = [...new Set(numbers.filter((n): n is string => !!n))]; const out = new Map(); if (!want.length) return out; + const c = await contrackSettings(db); + if (c) { + const books = [ + userId ? await contrackBook(db, c, userId) : null, + await contrackBook(db, c, null), + ]; + for (const b of books) + for (const ct of b?.contacts ?? []) + for (const n of ct.numbers) + if (want.includes(n.number) && !out.has(n.number)) out.set(n.number, ct.name); + 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 diff --git a/apps/api/src/contacts/contrack.ts b/apps/api/src/contacts/contrack.ts new file mode 100644 index 0000000..558f895 --- /dev/null +++ b/apps/api/src/contacts/contrack.ts @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Contrack (11 §4): a self-hosted contact manager beside the PBX. We call a handful of its REST +// routes with a person's (or the company account's) personal token, and keep each book in memory +// for 5 minutes, so name lookups don't wait on it. +import { eq } from 'drizzle-orm'; +import { normalizeNumber, type ContactData, type ContactDto } from '@selfphoneng/shared'; +import { AiError, assertLocalUrl } from '../ai/client.ts'; +import { decrypt } from '../crypto/secrets.ts'; +import type { Db } from '../db/client.ts'; +import { users } from '../db/schema.ts'; +import { getSetting } from '../services/settings.ts'; + +export class ContrackError extends Error { + status: number | undefined; + constructor(message: string, status?: number) { + super(message); + this.status = status; + } +} + +/** One call to Contrack's API (base: the local address in Settings → Contacts). */ +async function ctk( + base: string, + token: string, + path: string, + init: RequestInit = {}, +): Promise { + try { + assertLocalUrl(base); + } catch (err) { + throw new ContrackError((err as AiError).message); + } + const url = new URL(path, base.endsWith('/') ? base : `${base}/`); + let r: Response; + try { + r = await fetch(url, { + ...init, + headers: { + Authorization: `Bearer ${token}`, + ...(init.body ? { 'Content-Type': 'application/json' } : {}), + }, + redirect: 'error', + signal: AbortSignal.timeout(10_000), + }); + } catch (err) { + const e = err as Error & { cause?: { code?: string } }; + throw new ContrackError( + e.name === 'TimeoutError' + ? `Contrack (${url.host}) didn't answer within 10 s` + : `Can't reach Contrack at ${url.host}${e.cause?.code ? ` (${e.cause.code})` : ''}`, + ); + } + if (r.status === 401) throw new ContrackError('Contrack refused the token', 401); + if (!r.ok) { + const body = (await r.json().catch(() => null)) as { error?: { message?: string } } | null; + throw new ContrackError(`Contrack: ${body?.error?.message ?? `HTTP ${r.status}`}`, r.status); + } + return (await r.json()) as T; +} + +type Slim = { + id: string; + name: string; + company: string | null; + emails: { email: string }[]; + phones: { phone: string }[]; +}; + +export type Contrack = { url: string; publicUrl: string | null; companyToken: string | null }; + +/** Settings → Contacts, or null in builtin mode. */ +export async function contrackSettings(db: Db): Promise { + if ((await getSetting(db, 'contacts.provider')) !== 'contrack') return null; + const c = await getSetting(db, 'contacts.contrack'); + if (!c) return null; + return { + url: c.url, + publicUrl: c.publicUrl, + companyToken: c.companyTokenEnc ? decrypt(c.companyTokenEnc) : null, + }; +} + +/** The token for a book: a user's own, or the company account's (owner null). */ +export async function tokenFor(db: Db, c: Contrack, owner: string | null): Promise { + if (owner === null) return c.companyToken; + const [u] = await db + .select({ enc: users.contrackTokenEnc }) + .from(users) + .where(eq(users.id, owner)); + return u?.enc ? decrypt(u.enc) : null; +} + +const toDto = (c: Contrack, s: Slim, book: ContactDto['book']): ContactDto => { + const numbers: ContactData['numbers'] = []; + for (const p of s.phones) + try { + const number = normalizeNumber(p.phone); + if (!numbers.some((n) => n.number === number)) numbers.push({ label: '', number }); + } catch { + // Not a number a phone can dial ("ask reception"): left out. + } + return { + id: s.id, + book, + name: s.name, + company: s.company, + email: s.emails[0]?.email ?? null, + numbers, + notes: null, + url: c.publicUrl ? `${c.publicUrl}/contact/${encodeURIComponent(s.id)}` : null, + }; +}; + +type Book = { contacts: ContactDto[]; at: number; error: string | null }; +/** By owner: a user id, or '' for the company account. Keyed by token too, so a new one refetches. */ +const cache = new Map(); +const TTL = 5 * 60_000; + +/** Forget a book (after we add to it, or a token changes). */ +export const forgetBook = (owner: string | null) => cache.delete(owner ?? ''); + +/** + * A Contrack book, from the cache when it's under 5 minutes old. Null: no token. A failing + * Contrack keeps the last good list and says why. + */ +export async function contrackBook( + db: Db, + c: Contrack, + owner: string | null, + now = Date.now(), +): Promise { + const token = await tokenFor(db, c, owner); + if (!token) return null; + const key = owner ?? ''; + const hit = cache.get(key); + if (hit && hit.token === token && now - hit.at < TTL) return hit; + const book = owner === null ? 'company' : 'mine'; + try { + const rows = await ctk(c.url, token, 'api/contacts?view=slim'); + const fresh = { contacts: rows.map((r) => toDto(c, r, book)), at: now, error: null, token }; + cache.set(key, fresh); + return fresh; + } catch (err) { + const stale = { + contacts: hit?.token === token ? hit.contacts : [], + at: now, // tried now: the next try is in 5 minutes, not on every lookup + error: (err as Error).message, + token, + }; + cache.set(key, stale); + return stale; + } +} + +/** Writing to a book whose owner hasn't connected Contrack. */ +export const needToken = (owner: string | null) => + new ContrackError( + owner + ? 'Connect your Contrack account first (My settings → Contacts)' + : 'The company Contrack account has no token (Settings → Contacts)', + 409, + ); + +/** Adds a contact to a Contrack book (11 §4.3: "Add to contacts", an accepted share). */ +export async function contrackCreate( + db: Db, + c: Contrack, + owner: string | null, + d: ContactData, +): Promise { + const token = await tokenFor(db, c, owner); + if (!token) throw needToken(owner); + const row = await ctk(c.url, token, 'api/contacts', { + method: 'POST', + body: JSON.stringify({ + name: d.name, + ...(d.company ? { company: d.company } : {}), + ...(d.email ? { emails: [{ email: d.email }] } : {}), + phones: d.numbers.map((n) => ({ phone: n.number, ...(n.label ? { label: n.label } : {}) })), + }), + }); + forgetBook(owner); + return toDto(c, row, owner === null ? 'company' : 'mine'); +} + +/** Settings → Contacts → Test, and connecting a token: reachable, and the token reads contacts. */ +export async function contrackCheck(url: string, token: string | null): Promise { + try { + assertLocalUrl(url); + } catch (err) { + throw new ContrackError((err as AiError).message); + } + const health = await fetch(new URL('healthz', url.endsWith('/') ? url : `${url}/`), { + redirect: 'error', + signal: AbortSignal.timeout(10_000), + }).catch(() => null); + if (!health?.ok) throw new ContrackError(`Can't reach Contrack at ${url}`); + if (!token) return 'Contrack answers.'; + const rows = await ctk(url, token, 'api/contacts?view=slim'); + return `Contrack answers; the token reads ${rows.length} contact${rows.length === 1 ? '' : 's'}.`; +} diff --git a/apps/api/src/db/migrations/0010_contrack_token.sql b/apps/api/src/db/migrations/0010_contrack_token.sql new file mode 100644 index 0000000..e7ab185 --- /dev/null +++ b/apps/api/src/db/migrations/0010_contrack_token.sql @@ -0,0 +1 @@ +ALTER TABLE "users" ADD COLUMN "contrack_token_enc" text; \ No newline at end of file diff --git a/apps/api/src/db/migrations/meta/0010_snapshot.json b/apps/api/src/db/migrations/meta/0010_snapshot.json new file mode 100644 index 0000000..04eabc3 --- /dev/null +++ b/apps/api/src/db/migrations/meta/0010_snapshot.json @@ -0,0 +1,4254 @@ +{ + "id": "f785afac-008e-455e-a203-59d8c1c46102", + "prevId": "aae66860-cb88-440a-b88a-c5575ebcd24a", + "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 + }, + "contrack_token_enc": { + "name": "contrack_token_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": { + "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 b49b2de..cfdc9e4 100644 --- a/apps/api/src/db/migrations/meta/_journal.json +++ b/apps/api/src/db/migrations/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1792784242837, "tag": "0009_contact_shares", "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1792785318384, + "tag": "0010_contrack_token", + "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 98ce92c..e98855e 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -63,6 +63,8 @@ export const users = pgTable('users', { totpLastStep: bigint({ mode: 'number' }), mustChangePassword: boolean().notNull().default(false), lastLoginAt: tstz(), + /** Contrack mode (11 §4.3): this person's own Contrack personal token, encrypted. */ + contrackTokenEnc: text(), ...timestamps, }); diff --git a/apps/api/src/provisioning/context.ts b/apps/api/src/provisioning/context.ts index 31fa07d..8f28aaa 100644 --- a/apps/api/src/provisioning/context.ts +++ b/apps/api/src/provisioning/context.ts @@ -1,11 +1,12 @@ // SPDX-License-Identifier: AGPL-3.0-or-later // What a vendor template gets (07 §1). Templates are pure functions of this; no I/O there. -import { asc, eq, inArray, isNull } from 'drizzle-orm'; +import { asc, eq, inArray } from 'drizzle-orm'; import { phoneTimezone, type PhoneTimezone, type PhoneVendor } from '@selfphoneng/shared'; import type { Config } from '../config.ts'; import { decrypt } from '../crypto/secrets.ts'; import type { Db } from '../db/client.ts'; -import { contacts, devices, extensions } from '../db/schema.ts'; +import { devices, extensions } from '../db/schema.ts'; +import { readBooks } from '../contacts/book.ts'; import { getSetting } from '../services/settings.ts'; import { loadDialRules, type DialRule } from './dialplan.ts'; @@ -87,17 +88,14 @@ export async function loadContext( export type PhonebookEntry = { name: string; numbers: { label: string; number: string }[] }; -/** The company phonebook (07 §2): every extension, then the company contacts, by name. */ +/** The company phonebook (07 §2): every extension, then the company contacts, by name (from + * Contrack's company account in Contrack mode, 11 §4.3). */ export async function loadPhonebook(db: Db): Promise { const exts = await db .select({ name: extensions.name, number: extensions.number }) .from(extensions) .orderBy(asc(extensions.name)); - const company = await db - .select({ name: contacts.name, numbers: contacts.numbers }) - .from(contacts) - .where(isNull(contacts.ownerUserId)) - .orderBy(asc(contacts.name)); + const company = (await readBooks(db, null)).items; return [ ...exts.map((x) => ({ name: x.name, numbers: [{ label: 'Extension', number: x.number }] })), ...company.map((c) => ({ diff --git a/apps/api/src/routes/contacts.ts b/apps/api/src/routes/contacts.ts index eecf541..c7b7532 100644 --- a/apps/api/src/routes/contacts.ts +++ b/apps/api/src/routes/contacts.ts @@ -8,6 +8,7 @@ import { ContactInputSchema, ContactShareDecisionSchema, ContactShareSchema, + type ContactBookDto, type ContactData, type ContactDto, type ContactImportResult, @@ -17,9 +18,10 @@ import { import { requireAdmin, requireAuth } from '../auth/guards.ts'; import { addToBook, + assertBuiltin, cleanNumbers, - listBook, - listBooks, + createContact, + readBooks, toDto, type Owner, } from '../contacts/book.ts'; @@ -28,6 +30,9 @@ 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'; +import { getSetting } from '../services/settings.ts'; +import { decrypt } from '../crypto/secrets.ts'; +import { contrackCheck } from '../contacts/contrack.ts'; const IdParams = z.object({ id: z.uuid() }); const CommitQuery = z.object({ commit: z.enum(['0', '1']).default('0') }); @@ -92,16 +97,14 @@ export async function contactRoutes(app: FastifyInstance) { 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!)); + const dto = await createContact(db, ownerOf(req.user!), c); + await log(req, 'create', dto.id, `Added ${c.name} to the company contacts`); + return reply.code(201).send(dto); }); app.patch(`${prefix}/:id`, { preHandler: guard }, async (req): Promise => { const { id } = IdParams.parse(req.params); + await assertBuiltin(db); await mine(req.user!, id); const c = ContactInputSchema.parse(req.body); const [row] = await db @@ -115,6 +118,7 @@ export async function contactRoutes(app: FastifyInstance) { app.delete(`${prefix}/:id`, { preHandler: guard }, async (req) => { const { id } = IdParams.parse(req.params); + await assertBuiltin(db); 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}`); @@ -125,6 +129,7 @@ export async function contactRoutes(app: FastifyInstance) { `${prefix}/import`, { preHandler: guard, bodyLimit: IMPORT_BODY }, async (req): Promise => { + await assertBuiltin(db); // Contrack has its own importer (11 §3.1) const { files } = ContactImportSchema.parse(req.body); const commit = CommitQuery.parse(req.query).commit === '1'; const { list, errors } = readFiles(files); @@ -138,36 +143,40 @@ export async function contactRoutes(app: FastifyInstance) { // ───── 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, - }), + app.get('/api/me/contacts', { preHandler: requireAuth }, async (req): Promise => + readBooks(db, req.user!.id), ); 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, - }), + app.get('/api/contacts', { preHandler: requireAdmin }, async (): Promise => + readBooks(db, null), ); book('/api/contacts', requireAdmin, () => null); + /** Settings → Contacts → Test (11 §4.3): the form's address, and its token or the saved one. */ + app.post('/api/contacts/contrack/test', { preHandler: requireAdmin }, async (req) => { + const b = z + .object({ url: z.string().trim().min(1), companyToken: z.string().trim().optional() }) + .parse(req.body); + const saved = await getSetting(db, 'contacts.contrack'); + const token = + b.companyToken || (saved?.companyTokenEnc ? decrypt(saved.companyTokenEnc) : null); + try { + return { ok: true, detail: await contrackCheck(b.url, token) }; + } catch (err) { + return { ok: false, error: (err as Error).message }; + } + }); + // ───── 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 picked = (await readBooks(db, me)).items.filter( + (c) => c.book === 'mine' && s.contactIds.includes(c.id), + ); + if (picked.length !== new Set(s.contactIds).size) throw notFound('Contact not found'); const to = s.everyone ? await db .select({ id: users.id }) @@ -202,10 +211,12 @@ export async function contactRoutes(app: FastifyInstance) { ); 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 }; - }), + picked.map(({ id, name, company, email, numbers, notes }) => ({ + fromUserId: me, + toUserId: u.id, + sourceId: id, + contact: { name, company, email, numbers, notes }, + })), ), ); }); @@ -238,27 +249,35 @@ export async function contactRoutes(app: FastifyInstance) { 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), + const mineAndPending = and( + eq(contactShares.toUserId, me), + eq(contactShares.status, 'pending'), + inArray(contactShares.id, ids), + ); + const offers = await db.select().from(contactShares).where(mineAndPending); + // Added first, then marked: Contrack being down (or not connected) leaves the offers waiting. + const r = + decision === 'accept' + ? await addToBook( + db, + me, + offers.map((o) => o.contact as ContactData), + true, + ) + : null; + await db + .update(contactShares) + .set({ status: decision === 'accept' ? 'accepted' : 'denied', decidedAt: new Date() }) + .where( + and( + mineAndPending, + inArray( + contactShares.id, + offers.map((o) => o.id), ), - ) - .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 }; - }); + return r ? { added: r.added, duplicates: r.duplicates } : { denied: offers.length }; }); } diff --git a/apps/api/src/routes/me.ts b/apps/api/src/routes/me.ts index 7ebd926..4b426b0 100644 --- a/apps/api/src/routes/me.ts +++ b/apps/api/src/routes/me.ts @@ -27,8 +27,16 @@ import { queueMembers, queues, userRuntimeState, + users, voicemailMessages, } from '../db/schema.ts'; +import { + contrackBook, + contrackCheck, + contrackSettings, + forgetBook, + tokenFor, +} from '../contacts/contrack.ts'; import { setPaused } from '../services/queues.ts'; import { badRequest, conflict, HttpError, notFound } from '../errors.ts'; import { AudioError, MAX_UPLOAD_BYTES, toPcmWav } from '../media/audio.ts'; @@ -115,9 +123,53 @@ export async function meRoutes(app: FastifyInstance) { callReportTime: e.callReportTime, callReportWeekday: e.callReportWeekday, callReportSummaries: e.callReportSummaries, + contrack: await myContrack(userId), }; } + /** Contrack mode: is my token set, and does it work (11 §4.3). Null in builtin mode. */ + async function myContrack(userId: string): Promise { + const c = await contrackSettings(db); + if (!c) return null; + const token = await tokenFor(db, c, userId); + const book = token ? await contrackBook(db, c, userId) : null; + return { + connected: !!book && !book.error, + tokenPrefix: token ? `${token.slice(0, 12)}…` : null, + publicUrl: c.publicUrl, + }; + } + + /** Connect my Contrack account: the token must read my contacts before it's kept. */ + app.put('/api/me/contrack', async (req) => { + const { token } = z.object({ token: z.string().trim().min(8).max(200) }).parse(req.body); + const c = await contrackSettings(db); + if (!c) throw conflict('Contacts are not in Contrack (Settings → Contacts)'); + await contrackCheck(c.url, token).catch((err: Error) => { + throw badRequest(err.message); + }); + await db + .update(users) + .set({ contrackTokenEnc: encrypt(token) }) + .where(eq(users.id, req.user!.id)); + forgetBook(req.user!.id); + await audit(db, { + actorUserId: req.user!.id, + actorIp: req.ip, + action: 'update', + entityType: 'user', + entityId: req.user!.id, + summary: 'Connected their Contrack account', + }); + return myState(req.user!.id); + }); + + app.delete('/api/me/contrack', async (req) => { + await db.update(users).set({ contrackTokenEnc: null }).where(eq(users.id, req.user!.id)); + forgetBook(req.user!.id); + return myState(req.user!.id); + }); + app.get('/api/me', async (req) => myState(req.user!.id)); // Queue login from Home (05 §4, 04 §7.3), the same as *45 on a phone. diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts index d11c361..1d94319 100644 --- a/apps/api/src/routes/settings.ts +++ b/apps/api/src/routes/settings.ts @@ -6,6 +6,7 @@ import { z } from 'zod'; import { AiEndpointSchema, AiTtsEndpointSchema, + ContrackInputSchema, SettingsSchemas, SmtpInputSchema, type AiTestResult, @@ -28,6 +29,7 @@ import { backupMissed } from '../services/backup.ts'; import { detectPublicIp } from '../jobs.ts'; import type { SendMail } from '../services/mail.ts'; import { AiError, assertLocalUrl, testEndpoint } from '../ai/client.ts'; +import { forgetBook } from '../contacts/contrack.ts'; declare module 'fastify' { interface FastifyInstance { @@ -45,9 +47,14 @@ const READ_ONLY = new Set([ ]); async function toDto(db: Db): Promise { - const { smtp, ...rest } = await getAllSettings(db); + const { smtp, 'contacts.contrack': ctk, ...rest } = await getAllSettings(db); return { ...rest, + 'contacts.contrack': ctk && { + url: ctk.url, + publicUrl: ctk.publicUrl, + hasCompanyToken: ctk.companyTokenEnc !== '', + }, smtp: smtp && { host: smtp.host, port: smtp.port, @@ -115,6 +122,26 @@ export async function settingsRoutes(app: FastifyInstance) { from: input.from, passwordEnc: input.password ? encrypt(input.password) : (old?.passwordEnc ?? ''), }; + } else if (key === 'contacts.contrack') { + // The company token is write-only too (11 §4.3). + const input = ContrackInputSchema.nullable().parse(raw); + const old = await getSetting(db, 'contacts.contrack'); + if (input) + try { + assertLocalUrl(input.url); + } catch (err) { + throw badRequest(`Contrack address: ${(err as Error).message}`); + } + values[key] = input && { + url: input.url.replace(/\/+$/, ''), + publicUrl: input.publicUrl, + companyTokenEnc: input.companyToken + ? encrypt(input.companyToken) + : input.clearCompanyToken + ? '' + : (old?.companyTokenEnc ?? ''), + }; + forgetBook(null); } else values[key] = SettingsSchemas[key as SettingKey].parse(raw); } await checkRefs(db, values); diff --git a/apps/api/test/contrack-live.test.ts b/apps/api/test/contrack-live.test.ts new file mode 100644 index 0000000..03c52c2 --- /dev/null +++ b/apps/api/test/contrack-live.test.ts @@ -0,0 +1,261 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// C3 against a real Contrack (decision 0003): the routes we use, as the pinned version answers +// them. Skipped unless CONTRACK_LIVE_URL is set; it needs a fresh Contrack with sign-in on and no +// account yet (it makes the accounts and tokens itself): +// +// docker run -d --rm --name contrack-live -p 127.0.0.1:3210:3210 -e AUTH_REQUIRED=true \ +// ghcr.io/arvarik/contrack:2.0.2 +// CONTRACK_LIVE_URL=http://127.0.0.1:3210 pnpm vitest run apps/api/test/contrack-live.test.ts +import type { LightMyRequestResponse } from 'fastify'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { contactNames } from '../src/contacts/book.ts'; +import { loadPhonebook } from '../src/provisioning/context.ts'; +import { hasTestDb } from './db.ts'; +import { ADMIN, H, createTestApp } from './app.ts'; + +const LIVE = process.env.CONTRACK_LIVE_URL; +type Req = (method: string, url: string, payload?: unknown) => Promise; + +/** Contrack's own API, signed in with a session cookie (accounts, tokens) or a token. */ +async function ctk( + path: string, + init: { method?: string; body?: unknown; cookie?: string; token?: string } = {}, +) { + const r = await fetch(`${LIVE}${path}`, { + method: init.method ?? 'GET', + headers: { + ...(init.body ? { 'Content-Type': 'application/json' } : {}), + ...(init.cookie ? { Cookie: init.cookie } : {}), + ...(init.token ? { Authorization: `Bearer ${init.token}` } : {}), + }, + body: init.body ? JSON.stringify(init.body) : undefined, + }); + const cookie = r.headers + .getSetCookie() + .map((c) => c.split(';')[0]) + .join('; '); + return { + status: r.status, + body: (await r.json().catch(() => null)) as Record, + cookie, + }; +} + +const PASSWORD = 'Correct-horse-battery-9'; + +/** A Contrack account's read-write personal token that never expires. */ +async function tokenOf(cookie: string, name: string) { + const t = await ctk('/api/auth/tokens', { method: 'POST', cookie, body: { name } }); + expect(t.status, JSON.stringify(t.body)).toBe(201); + return t.body.token as string; +} + +/** An admin makes a member, who signs in, sets their own password, and makes a token. */ +async function member(adminCookie: string, username: string) { + const u = await ctk('/api/admin/users', { + method: 'POST', + cookie: adminCookie, + body: { + email: `${username}@example.com`, + username, + displayName: username, + temporaryPassword: 'Temporary-pass-123', + }, + }); + expect(u.status, JSON.stringify(u.body)).toBe(201); + const login = await ctk('/api/auth/login', { + method: 'POST', + body: { identifier: username, password: 'Temporary-pass-123' }, + }); + expect(login.status, JSON.stringify(login.body)).toBe(200); + const ch = await ctk('/api/auth/change-password', { + method: 'POST', + cookie: login.cookie, + body: { currentPassword: 'Temporary-pass-123', newPassword: PASSWORD }, + }); + expect(ch.status, JSON.stringify(ch.body)).toBeLessThan(300); + const again = await ctk('/api/auth/login', { + method: 'POST', + body: { identifier: username, password: PASSWORD }, + }); + return tokenOf(again.cookie, 'SelfPhoneNG'); +} + +describe.skipIf(!hasTestDb || !LIVE)('contacts in a real Contrack', () => { + let t: Awaited>; + let admin: Req; + let dan: Req; + let sue: Req; + let danId: string; + const tok = { company: '', dan: '', sue: '' }; + + beforeAll(async () => { + // Contrack: the first account is the company's (and Contrack's admin); Dan and Sue are members. + const setup = await ctk('/api/auth/setup', { + method: 'POST', + body: { + email: 'company@example.com', + username: 'company', + password: PASSWORD, + displayName: 'Company', + }, + }); + expect(setup.status, `needs a fresh Contrack: ${JSON.stringify(setup.body)}`).toBe(201); + tok.company = await tokenOf(setup.cookie, 'SelfPhoneNG company'); + tok.dan = await member(setup.cookie, 'dan'); + tok.sue = await member(setup.cookie, 'sue'); + for (const [token, body] of [ + [tok.company, { name: 'Plumber', phones: ['+1 312 555 7777'] }], + [ + tok.dan, + { + name: 'Jane Doe', + company: 'Acme', + emails: [{ email: 'jane@acme.example' }], + phones: [{ phone: '(312) 555-1234', label: 'mobile' }, 'front desk ext 123'], + }, + ], + ] as const) { + const c = await ctk('/api/contacts', { method: 'POST', token, body }); + expect(c.status, JSON.stringify(c.body)).toBe(201); + } + + t = await createTestApp(); + await t.clearMustChange(ADMIN.email); + const as = + (cookie: string): Req => + (method, u, payload) => + t.app.inject({ + method: method as 'GET', + url: u, + 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')); + }, 60_000); + afterAll(() => t?.close()); + + it('Test, then save: Contrack answers and reads the company account', async () => { + expect( + ( + await admin('POST', '/api/contacts/contrack/test', { url: LIVE, companyToken: tok.company }) + ).json(), + ).toEqual({ + ok: true, + detail: 'Contrack answers; the token reads 1 contact.', + }); + expect( + ( + await admin('POST', '/api/contacts/contrack/test', { + url: LIVE, + companyToken: 'ctk_wrong_token', + }) + ).json(), + ).toEqual({ + ok: false, + error: 'Contrack refused the token', + }); + const r = await admin('PATCH', '/api/settings', { + 'contacts.provider': 'contrack', + 'contacts.contrack': { + url: LIVE, + publicUrl: 'https://contacts.example.com', + companyToken: tok.company, + }, + }); + expect(r.statusCode, r.body).toBe(200); + }); + + it('a person connects; their book and the company book come from Contrack', async () => { + expect((await dan('PUT', '/api/me/contrack', { token: 'ctk_wrong_token' })).statusCode).toBe( + 400, + ); + expect( + (await dan('PUT', '/api/me/contrack', { token: tok.dan })).json().contrack.connected, + ).toBe(true); + const book = (await dan('GET', '/api/me/contacts')).json(); + expect(book.contrack).toEqual({ + publicUrl: 'https://contacts.example.com', + connected: true, + error: null, + }); + expect(book.items).toEqual([ + expect.objectContaining({ + book: 'mine', + name: 'Jane Doe', + company: 'Acme', + email: 'jane@acme.example', + numbers: [{ label: '', number: '13125551234' }], + url: expect.stringMatching( + /^https:\/\/contacts\.example\.com\/contact\/jane-doe-[0-9a-f]{8}$/, + ), + }), + expect.objectContaining({ + book: 'company', + name: 'Plumber', + numbers: [{ label: '', number: '13125557777' }], + }), + ]); + const names = await contactNames(t.db, danId, ['13125551234', '13125557777']); + expect(Object.fromEntries(names)).toEqual({ + '13125551234': 'Jane Doe', + '13125557777': 'Plumber', + }); + expect((await loadPhonebook(t.db)).map((e) => e.name)).toEqual([ + 'Dan Doe', + 'Sue Smith', + 'Plumber', + ]); + }); + + it('Add to contacts lands in Contrack, with its label', async () => { + const r = await dan('POST', '/api/me/contacts', { + name: 'Bob Smith', + company: 'Widgets', + email: 'bob@widgets.example', + numbers: [{ label: 'work', number: '312-555-9999' }], + }); + expect(r.statusCode, r.body).toBe(201); + const id = r.json().id as string; + const full = await ctk(`/api/contacts/${id}`, { token: tok.dan }); + expect(full.body).toMatchObject({ + name: 'Bob Smith', + company: 'Widgets', + emails: [expect.objectContaining({ email: 'bob@widgets.example' })], + phones: [expect.objectContaining({ phone: '13125559999', label: 'work' })], + }); + expect( + (await dan('GET', '/api/me/contacts')).json().items.map((c: { name: string }) => c.name), + ).toContain('Bob Smith'); + }); + + it('a share is accepted into the recipient’s own Contrack account', async () => { + const jane = (await dan('GET', '/api/me/contacts')) + .json() + .items.find((c: { name: string }) => c.name === 'Jane Doe'); + await dan('POST', '/api/me/contacts/share', { contactIds: [jane.id], extensions: ['102'] }); + const [offer] = (await sue('GET', '/api/me/contacts/shares')).json().items; + expect( + (await sue('POST', '/api/me/contacts/shares/accept', { ids: [offer.id] })).statusCode, + ).toBe(409); + await sue('PUT', '/api/me/contrack', { token: tok.sue }); + expect( + (await sue('POST', '/api/me/contacts/shares/accept', { ids: [offer.id] })).json(), + ).toEqual({ added: 1, duplicates: 0 }); + const sues = (await ctk('/api/contacts?view=slim', { token: tok.sue })).body as unknown as { + name: string; + }[]; + expect(sues.map((c) => c.name)).toEqual(['Jane Doe']); + // Dan's account is untouched: Sue got a copy. + const dans = (await ctk('/api/contacts?view=slim', { token: tok.dan })).body as unknown as { + name: string; + }[]; + expect(dans.map((c) => c.name).sort()).toEqual(['Bob Smith', 'Jane Doe']); + }); +}); diff --git a/apps/api/test/contrack.test.ts b/apps/api/test/contrack.test.ts new file mode 100644 index 0000000..34f28ad --- /dev/null +++ b/apps/api/test/contrack.test.ts @@ -0,0 +1,274 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// C3: contacts in Contrack (11 §4), against a fake Contrack that answers the routes we use. +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import type { LightMyRequestResponse } from 'fastify'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { contactNames } from '../src/contacts/book.ts'; +import { loadPhonebook } from '../src/provisioning/context.ts'; +import { hasTestDb } from './db.ts'; +import { ADMIN, H, createTestApp } from './app.ts'; + +type Req = (method: string, url: string, payload?: unknown) => Promise; +type Slim = { + id: string; + name: string; + company: string | null; + emails: { email: string }[]; + phones: { phone: string }[]; +}; + +/** Contrack's slim rows, one private book per token (11 §4.1). */ +function fakeContrack() { + const books: Record = { + ctk_company: [ + { + id: 'plumber-1a2b3c4d', + name: 'Plumber', + company: null, + emails: [], + phones: [{ phone: '+1 312 555 7777' }], + }, + ], + ctk_dan_0001: [ + { + id: 'jane-doe-00000001', + name: 'Jane Doe', + company: 'Acme', + emails: [{ email: 'jane@acme.example' }], + phones: [{ phone: '(312) 555-1234' }, { phone: 'ask reception' }], + }, + ], + ctk_sue_0001: [], + }; + let n = 0; + const server: Server = createServer((req, res) => { + const send = (status: number, body: unknown) => { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(body)); + }; + if (req.url === '/healthz') return send(200, { ok: true }); + const book = books[(req.headers.authorization ?? '').replace(/^Bearer /, '')]; + if (!book) return send(401, { error: { code: 'UNAUTHORIZED', message: 'Unauthorized' } }); + if (req.method === 'GET' && req.url === '/api/contacts?view=slim') return send(200, book); + if (req.method === 'POST' && req.url === '/api/contacts') { + let raw = ''; + req.on('data', (c) => (raw += c)); + req.on('end', () => { + const b = JSON.parse(raw) as { + name: string; + company?: string; + emails?: { email: string }[]; + phones: { phone: string }[]; + }; + const row = { + id: `new-${++n}`, + name: b.name, + company: b.company ?? null, + emails: b.emails ?? [], + phones: b.phones, + }; + book.push(row); + send(201, row); + }); + return; + } + send(404, { error: { code: 'ROUTE_NOT_FOUND', message: 'No route' } }); + }); + return { server, books }; +} + +describe.skipIf(!hasTestDb)('contacts in Contrack', () => { + let t: Awaited>; + let admin: Req; + let dan: Req; + let sue: Req; + let danId: string; + const fake = fakeContrack(); + let url: string; + + beforeAll(async () => { + await new Promise((ok) => fake.server.listen(0, '127.0.0.1', ok)); + url = `http://127.0.0.1:${(fake.server.address() as AddressInfo).port}`; + t = await createTestApp(); + await t.clearMustChange(ADMIN.email); + const as = + (cookie: string): Req => + (method, u, payload) => + t.app.inject({ + method: method as 'GET', + url: u, + 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(async () => { + await t?.close(); + fake.server.close(); + }); + + it('Settings → Contacts: a local address only, the company token write-only', async () => { + const far = await admin('PATCH', '/api/settings', { + 'contacts.contrack': { url: 'https://contrack.example.com', companyToken: 'ctk_company' }, + }); + expect(far.statusCode).toBe(400); + expect(far.json().error.message).toMatch(/IP address/); + expect( + ( + await admin('POST', '/api/contacts/contrack/test', { url, companyToken: 'ctk_company' }) + ).json(), + ).toEqual({ + ok: true, + detail: 'Contrack answers; the token reads 1 contact.', + }); + expect( + ( + await admin('POST', '/api/contacts/contrack/test', { + url: 'http://127.0.0.1:9', + companyToken: 'x', + }) + ).json().ok, + ).toBe(false); + const r = await admin('PATCH', '/api/settings', { + 'contacts.provider': 'contrack', + 'contacts.contrack': { + url: `${url}/`, + publicUrl: 'https://contacts.example.com/', + companyToken: 'ctk_company', + }, + }); + expect(r.statusCode, r.body).toBe(200); + expect(r.json()['contacts.contrack']).toEqual({ + url, + publicUrl: 'https://contacts.example.com', + hasCompanyToken: true, + }); + expect(r.body).not.toContain('ctk_company'); + // Saving again without a token keeps it. + await admin('PATCH', '/api/settings', { + 'contacts.contrack': { url, publicUrl: 'https://contacts.example.com' }, + }); + expect((await admin('GET', '/api/settings')).json()['contacts.contrack'].hasCompanyToken).toBe( + true, + ); + }); + + it('each person connects their own token; until then they see the company book', async () => { + expect((await dan('GET', '/api/me')).json().contrack).toEqual({ + connected: false, + tokenPrefix: null, + publicUrl: 'https://contacts.example.com', + }); + const before = (await dan('GET', '/api/me/contacts')).json(); + expect(before.provider).toBe('contrack'); + expect(before.contrack.connected).toBe(false); + expect(before.items.map((c: { name: string }) => c.name)).toEqual(['Plumber']); + + const bad = await dan('PUT', '/api/me/contrack', { token: 'ctk_not_mine' }); + expect(bad.statusCode).toBe(400); + expect(bad.json().error.message).toMatch(/refused the token/); + const ok = await dan('PUT', '/api/me/contrack', { token: 'ctk_dan_0001' }); + expect(ok.json().contrack).toMatchObject({ connected: true, tokenPrefix: 'ctk_dan_0001…' }); + + const book = (await dan('GET', '/api/me/contacts')).json(); + expect(book.contrack).toEqual({ + publicUrl: 'https://contacts.example.com', + connected: true, + error: null, + }); + expect(book.items).toEqual([ + { + id: 'jane-doe-00000001', + book: 'mine', + name: 'Jane Doe', + company: 'Acme', + email: 'jane@acme.example', + numbers: [{ label: '', number: '13125551234' }], // "ask reception" isn't a number + notes: null, + url: 'https://contacts.example.com/contact/jane-doe-00000001', + }, + expect.objectContaining({ name: 'Plumber', book: 'company' }), + ]); + }); + + it('adding works, editing and importing happen in Contrack', async () => { + const r = await dan('POST', '/api/me/contacts', { + name: 'Bob', + numbers: [{ label: 'mobile', number: '312-555-9999' }], + }); + expect(r.statusCode, r.body).toBe(201); + expect(fake.books.ctk_dan_0001!.at(-1)).toMatchObject({ + name: 'Bob', + phones: [{ phone: '13125559999', label: 'mobile' }], + }); + expect( + (await dan('GET', '/api/me/contacts')).json().items.map((c: { name: string }) => c.name), + ).toContain('Bob'); + expect( + ( + await dan('PATCH', `/api/me/contacts/${'00000000-0000-4000-8000-000000000000'}`, { + name: 'x', + }) + ).statusCode, + ).toBe(409); + expect( + ( + await dan('POST', '/api/me/contacts/import', { + files: [{ name: 'a.vcf', text: 'BEGIN:VCARD\nFN:A\nEND:VCARD' }], + }) + ).statusCode, + ).toBe(409); + }); + + it('names come from my Contrack book, then the company account; desk phones get the company', async () => { + const names = await contactNames(t.db, danId, ['13125551234', '13125557777']); + expect(Object.fromEntries(names)).toEqual({ + '13125551234': 'Jane Doe', + '13125557777': 'Plumber', + }); + expect((await loadPhonebook(t.db)).map((e) => e.name)).toEqual([ + 'Dan Doe', + 'Sue Smith', + 'Plumber', + ]); + }); + + it('a share waits until the recipient has connected Contrack, then lands in their account', async () => { + const r = await dan('POST', '/api/me/contacts/share', { + contactIds: ['jane-doe-00000001'], + extensions: ['102'], + }); + expect(r.json()).toEqual({ offers: 1, people: 1 }); + const [offer] = (await sue('GET', '/api/me/contacts/shares')).json().items; + expect(offer.contact).toMatchObject({ + name: 'Jane Doe', + numbers: [{ label: '', number: '13125551234' }], + }); + + const early = await sue('POST', '/api/me/contacts/shares/accept', { ids: [offer.id] }); + expect(early.statusCode).toBe(409); + expect(early.json().error.message).toMatch(/Connect your Contrack account/); + expect((await sue('GET', '/api/me/contacts/shares')).json().items).toHaveLength(1); + + await sue('PUT', '/api/me/contrack', { token: 'ctk_sue_0001' }); + expect( + (await sue('POST', '/api/me/contacts/shares/accept', { ids: [offer.id] })).json(), + ).toEqual({ added: 1, duplicates: 0 }); + expect(fake.books.ctk_sue_0001!.map((c) => c.name)).toEqual(['Jane Doe']); + expect((await sue('GET', '/api/me/contacts/shares')).json().items).toEqual([]); + }); + + it('back to builtin: the table again, tokens kept for later', async () => { + await admin('PATCH', '/api/settings', { 'contacts.provider': 'builtin' }); + const book = (await dan('GET', '/api/me/contacts')).json(); + expect(book).toEqual({ items: [], provider: 'builtin', contrack: null }); + expect((await dan('GET', '/api/me')).json().contrack).toBeNull(); + }); +}); diff --git a/apps/api/test/me-settings.test.ts b/apps/api/test/me-settings.test.ts index b720b2a..2b67315 100644 --- a/apps/api/test/me-settings.test.ts +++ b/apps/api/test/me-settings.test.ts @@ -66,6 +66,7 @@ describe.skipIf(!hasTestDb)('/api/me settings and phone toggles', () => { callReportTime: '08:00', callReportWeekday: 1, callReportSummaries: false, + contrack: null, }); }); diff --git a/apps/api/test/schema.test.ts b/apps/api/test/schema.test.ts index e7c56c0..39a8050 100644 --- a/apps/api/test/schema.test.ts +++ b/apps/api/test/schema.test.ts @@ -23,6 +23,7 @@ const EXPECTED: Record> = { totp_last_step: 'bigint|YES|', must_change_password: 'boolean|NO|false', last_login_at: 'timestamp with time zone|YES|', + contrack_token_enc: 'text|YES|', ...ts, }, extensions: { diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 709af59..0696ad3 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -22,6 +22,7 @@ import type { CallDetailDto, CallDto, CallFilter, + ContactBookDto, ContactDto, ContactImportResult, ContactInput, @@ -270,6 +271,8 @@ export const meApi = { get: () => api('GET', '/api/me'), softphoneQr: () => api('GET', '/api/me/softphone-qr'), update: (m: MeUpdate) => api('PATCH', '/api/me', m), + connectContrack: (token: string) => api('PUT', '/api/me/contrack', { token }), + disconnectContrack: () => api('DELETE', '/api/me/contrack'), webrtc: () => api('GET', '/api/me/webrtc'), /** Log in to (unpause) or out of (pause) a queue I'm an agent of. */ setQueuePaused: (queueId: string, paused: boolean) => @@ -293,7 +296,13 @@ export const meApi = { export type ContactBase = '/api/me/contacts' | '/api/contacts'; export const contactsApi = { - list: (base: ContactBase) => api>('GET', base).then((r) => r.items), + list: (base: ContactBase) => api('GET', base), + testContrack: (url: string, companyToken?: string) => + api<{ ok: true; detail: string } | { ok: false; error: string }>( + 'POST', + '/api/contacts/contrack/test', + { url, companyToken }, + ), create: (base: ContactBase, c: ContactInput) => api('POST', base, c), update: (base: ContactBase, id: string, c: ContactInput) => api('PATCH', `${base}/${id}`, c), diff --git a/apps/web/src/pages/SetupPage.tsx b/apps/web/src/pages/SetupPage.tsx index 728a6f3..96d5121 100644 --- a/apps/web/src/pages/SetupPage.tsx +++ b/apps/web/src/pages/SetupPage.tsx @@ -19,7 +19,7 @@ import { DestinationPicker } from '../components/DestinationPicker.tsx'; import { ThemeToggle } from '../components/ThemeToggle.tsx'; import { Alert, Button, Field, inputClass } from '../components/ui.tsx'; import { subscribeLive, useLive } from '../live/store.ts'; -import { EmailTab, EmergencyTab } from './admin/SettingsPage.tsx'; +import { ContactsTab, EmailTab, EmergencyTab } from './admin/SettingsPage.tsx'; import { ImportNumbers, TrunkForm, trunkStatus } from './admin/TrunksPage.tsx'; const STEPS = [ @@ -30,6 +30,7 @@ const STEPS = [ 'Trunk', 'Phone numbers', 'Users', + 'Contacts', 'Main number', 'Emergency', 'Done', @@ -87,9 +88,10 @@ export function SetupPage() { {step === 5 && } {step === 6 && } {step === 7 && } - {step === 8 && } - {step === 9 && } - {step === 10 && finish.mutate()} busy={finish.isPending} />} + {step === 8 && } + {step === 9 && } + {step === 10 && } + {step === 11 && finish.mutate()} busy={finish.isPending} />} ); @@ -355,7 +357,7 @@ function SettingsStep({ next, skippable, }: { - tab: 'email' | 'emergency'; + tab: 'email' | 'emergency' | 'contacts'; next: () => void; skippable?: boolean; }) { @@ -369,7 +371,13 @@ function SettingsStep({ !s.data['emergency.defaultCallerIdDidId']; return ( <> - {tab === 'email' ? : } + {tab === 'email' ? ( + + ) : tab === 'contacts' ? ( + + ) : ( + + )}