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' ? (
+
+ ) : (
+
+ )}
)}
{list.error && {list.error.message}}
+ {ctk && !ctk.connected && (
+
+ {ctk.error ??
+ (base === MINE
+ ? 'Your contacts are in Contrack: connect your account in Settings → Contacts to see and add them.'
+ : 'The company Contrack account has no token yet: Settings → Contacts.')}
+
+ )}
{list.data && shown.length === 0 && (
{q ? 'No contact matches that.' : 'No contacts yet.'}
@@ -181,6 +204,16 @@ function ContactBook({ base, title }: { base: ContactBase; title: string }) {
))}
+ {c.url && (
+
+ Open in Contrack
+
+ )}
{mine(c) && (