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/AGENTS.md b/AGENTS.md index b8f0fab..58d0544 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,7 @@ write the software around it: database, config generator, API, web UI. | `docs/spec/07-provisioning.md` | Desk phone auto-provisioning (Yealink, Grandstream, Polycom) and mobile apps | Working on provisioning | | `docs/spec/08-security-ops.md` | Security, backups, monitoring, legal requirements | Before any release, and when touching auth or networking | | `docs/spec/09-roadmap.md` | Milestones, ordered tasks, acceptance criteria | Picking your next task | +| `docs/spec/11-contacts.md` | Contacts: the builtin phonebook or Contrack, lookup, the Contrack timeline | Working on contacts or caller names | ## Hard rules diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 42e8262..ee254f6 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -39,6 +39,7 @@ import { liveCallRoutes } from './routes/livecalls.ts'; import { integrationRoutes } from './routes/integrations.ts'; import { reportRoutes } from './routes/reports.ts'; import { aboutRoutes } from './routes/about.ts'; +import { contactRoutes } from './routes/contacts.ts'; declare module 'fastify' { interface FastifyInstance { @@ -146,6 +147,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public')) app.register(settingsRoutes); app.register(liveRoutes); app.register(meRoutes); + app.register(contactRoutes); app.register(soundRoutes); app.register(ringGroupRoutes); app.register(queueRoutes); diff --git a/apps/api/src/contacts/book.ts b/apps/api/src/contacts/book.ts new file mode 100644 index 0000000..f764580 --- /dev/null +++ b/apps/api/src/contacts/book.ts @@ -0,0 +1,236 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// 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 ContactCopyResult, + type ContactData, + type ContactDto, + type ContactImportResult, +} from '@selfphoneng/shared'; +import type { Db } from '../db/client.ts'; +import { contacts } from '../db/schema.ts'; +import { HttpError, badRequest } from '../errors.ts'; +import { + ContrackError, + contrackBook, + contrackCreate, + contrackBulk, + contrackSettings, + needToken, + type Contrack, +} from './contrack.ts'; + +/** null = the company book. */ +export type Owner = string | null; +const inBook = (owner: Owner) => + owner ? eq(contacts.ownerUserId, owner) : isNull(contacts.ownerUserId); + +type Row = typeof contacts.$inferSelect; +export const toDto = (r: Row): ContactDto => ({ + id: r.id, + book: r.ownerUserId ? 'mine' : 'company', + name: r.name, + company: r.company, + email: r.email, + numbers: r.numbers as ContactData['numbers'], + notes: r.notes, + 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'] = []; + for (const n of c.numbers) + try { + const number = normalizeNumber(n.number); + if (!out.some((o) => o.number === number)) out.push({ label: n.label, number }); + } catch { + if (!bad) throw badRequest(`"${n.number}" isn't a phone number`); + bad(n.number); + } + return out; +} + +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 }; +} + +/** + * 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, + toCopy: + own.book && !own.book.error + ? (await notInContrack(db, userId, own.book.contacts)).length + : 0, + }, + }; +} + +/** This book's builtin contacts that its Contrack account doesn't have yet. */ +async function notInContrack(db: Db, owner: Owner, have: ContactDto[]): Promise { + const rows = (await db.select().from(contacts).where(inBook(owner))).map(toDto); + return rows.filter((r) => !have.some((h) => isDuplicate(h, r))); +} + +/** + * "Copy my contacts to Contrack" (11 §5, C6): the builtin book's contacts that aren't there yet, + * in one Contrack import. The builtin ones stay, for switching back. + */ +export async function copyToContrack(db: Db, owner: Owner): Promise { + const c = await contrackSettings(db); + if (!c) + throw new HttpError(409, 'contrack', 'Contacts are not in Contrack (Settings → Contacts)'); + const b = await contrackBook(db, c, owner); + if (!b) asHttp(needToken(owner)); + if (b!.error) throw new HttpError(502, 'contrack', b!.error); + const all = (await db.select().from(contacts).where(inBook(owner))).length; + const fresh = await notInContrack(db, owner, b!.contacts); + if (!fresh.length) return { added: 0, duplicates: all, failed: 0 }; + const r = await contrackBulk(db, c, owner, fresh).catch(asHttp); + return { added: r.count, duplicates: all - fresh.length, failed: r.failed }; +} + +/** One contact added by hand (or from a call): a row, or a Contrack contact. */ +export async function createContact(db: Db, owner: Owner, d: ContactData): Promise { + const clean = { ...d, numbers: cleanNumbers(d) }; + 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. */ +const sameKey = (c: ContactData) => c.name.trim().toLowerCase(); +const isDuplicate = (a: ContactData, b: ContactData) => + sameKey(a) === sameKey(b) && + ((!a.numbers.length && !b.numbers.length) || + a.numbers.some((n) => b.numbers.some((m) => m.number === n.number))); + +/** + * Adds contacts to a book, skipping ones it already has (11 §3.1): an import or accepted shares. + * With `commit` false it only counts. Bad numbers are dropped and reported. + */ +export async function addToBook( + db: Db, + owner: Owner, + list: ContactData[], + commit: boolean, + errors: string[] = [], +): Promise { + 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 d of list) { + const numbers = cleanNumbers(d, (n) => + errors.push(`${d.name}: "${n}" isn't a phone number, left out`), + ); + const clean = { ...d, numbers }; + if ([...have, ...fresh].some((h) => isDuplicate(h, clean))) duplicates++; + else fresh.push(clean); + } + 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((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). userId null: the company book only. + */ +export async function contactNames( + db: Db, + userId: string | null, + numbers: (string | null)[], + opts: { wait?: boolean } = {}, +): Promise> { + const want = [...new Set(numbers.filter((n): n is string => !!n))]; + const out = new Map(); + if (!want.length) return out; + const c = await contrackSettings(db); + if (c) { + const books = [ + userId ? await contrackBook(db, c, userId, opts) : null, + await contrackBook(db, c, null, opts), + ]; + for (const b of books) + for (const ct of b?.contacts ?? []) + 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 + from ${contacts} c, jsonb_array_elements(c.numbers) n + where ${userId ? sql`(c.owner_user_id = ${userId} or c.owner_user_id is null)` : sql`c.owner_user_id is null`} + and n->>'number' in ${want}`); + for (const r of rows.sort((a, b) => Number(b.mine) - Number(a.mine))) + if (!out.has(r.number)) out.set(r.number, r.name); + return out; +} diff --git a/apps/api/src/contacts/callername.ts b/apps/api/src/contacts/callername.ts new file mode 100644 index 0000000..6234aed --- /dev/null +++ b/apps/api/src/contacts/callername.ts @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Caller names on desk phones while they ring (11 §6 C4): before ringing a user's phones, the +// dialplan asks GET /api/internal/caller-name (func_curl, 2 s at most) for that user's name for +// the caller, and the phones show it. +import { createHmac, timingSafeEqual } from 'node:crypto'; + +/** The dialplan's key for the route, derived from APP_SECRET (stable across restarts). */ +export const callerNameToken = (appSecret: string) => + createHmac('sha256', appSecret).update('selfphoneng caller-name').digest('hex'); + +export const sameToken = (a: string, b: string) => + a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b)); + +/** + * What a phone can show and the dialplan can carry: letters, digits, spaces and . ' & @ -, at + * most 40 characters. Nothing that Dial's options (, ^ ( ) |) or a SIP header (" < > \ ;) could + * read as syntax. + */ +export const phoneSafeName = (name: string) => + name + .normalize('NFC') + .replace(/[^\p{L}\p{M}\p{N} .'&@-]+/gu, ' ') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 40) + .trim(); diff --git a/apps/api/src/contacts/contrack.ts b/apps/api/src/contacts/contrack.ts new file mode 100644 index 0000000..9ec6e98 --- /dev/null +++ b/apps/api/src/contacts/contrack.ts @@ -0,0 +1,265 @@ +// 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 { randomUUID } from 'node:crypto'; +import { eq } from 'drizzle-orm'; +import { normalizeNumber, type ContactData, type ContactDto } from '@selfphoneng/shared'; +import { AiError, assertLocalUrl } from '../ai/client.ts'; +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: { + ...(init.headers as Record | undefined), + 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 ?? ''); + +const inflight = new Map>(); + +/** + * A Contrack book, from the cache when it's under 5 minutes old. Null: no token. A failing + * Contrack keeps the last good list and says why. `wait: false` (a phone ringing, 11 §6 C4) + * never waits on Contrack: it answers what the cache has and refreshes in the background. + */ +export async function contrackBook( + db: Db, + c: Contrack, + owner: string | null, + { now = Date.now(), wait = true }: { now?: number; wait?: boolean } = {}, +): Promise { + const token = await tokenFor(db, c, owner); + if (!token) return null; + const key = owner ?? ''; + const hit = cache.get(key); + const usable = hit && hit.token === token ? hit : undefined; + if (usable && now - usable.at < TTL) return usable; + if (!inflight.has(key)) + inflight.set( + key, + fetchBook(c, owner, token, usable, now).finally(() => inflight.delete(key)), + ); + if (!wait) return usable ?? null; + return inflight.get(key)!; +} + +async function fetchBook( + c: Contrack, + owner: string | null, + token: string, + hit: Book | undefined, + now: number, +): Promise { + const key = owner ?? ''; + const book = owner === null ? 'company' : 'mine'; + try { + const rows = await ctk(c.url, token, 'api/contacts?view=slim'); + 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?.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'); +} + +/** Many contacts into a Contrack book in one import (11 §5, C6); 5,000 at most. */ +export async function contrackBulk( + db: Db, + c: Contrack, + owner: string | null, + list: ContactData[], +): Promise<{ count: number; failed: number }> { + const token = await tokenFor(db, c, owner); + if (!token) throw needToken(owner); + const r = await ctk<{ count: number; failed: number }>(c.url, token, 'api/contacts/bulk', { + method: 'POST', + headers: { 'X-Import-Id': randomUUID() }, + body: JSON.stringify( + list.slice(0, 5000).map((d) => ({ + name: d.name, + ...(d.company ? { company: d.company } : {}), + ...(d.email ? { emails: [{ email: d.email }] } : {}), + phones: d.numbers.map((n) => ({ phone: n.number, ...(n.label ? { label: n.label } : {}) })), + })), + ), + }); + forgetBook(owner); + return { count: r.count, failed: typeof r.failed === 'number' ? r.failed : 0 }; +} + +/** A timeline entry on a contact (11 §4.4); answers its id. */ +export async function contrackInteraction( + c: Contrack, + token: string, + contactId: string, + body: Record, +): Promise { + const r = await ctk<{ id: string }>( + c.url, + token, + `api/contacts/${encodeURIComponent(contactId)}/interactions`, + { method: 'POST', body: JSON.stringify(body) }, + ); + return r.id; +} + +/** Settings → Contacts → Test, and connecting a token: reachable, and the token reads contacts. */ +export async function contrackCheck(url: string, token: string | null): Promise { + try { + 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/contacts/csv.ts b/apps/api/src/contacts/csv.ts new file mode 100644 index 0000000..48c0d02 --- /dev/null +++ b/apps/api/src/contacts/csv.ts @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// CSV to contacts (04 §7.4): name,company,email,label1,number1,label2,number2,… with a header row +// (any order, any case; notes too). +import type { ContactData } from '@selfphoneng/shared'; + +/** RFC 4180 rows (blank ones too): quoted cells may hold commas, quotes ("") and line breaks. */ +export function csvRows(input: string): string[][] { + const rows: string[][] = []; + let row: string[] = []; + let cell = ''; + let quoted = false; + const s = input.replace(/^\uFEFF/, ''); + for (let i = 0; i < s.length; i++) { + const c = s[i]!; + if (quoted) { + if (c === '"' && s[i + 1] === '"') { + cell += '"'; + i++; + } else if (c === '"') quoted = false; + else cell += c; + } else if (c === '"') quoted = true; + else if (c === ',') { + row.push(cell); + cell = ''; + } else if (c === '\n' || c === '\r') { + if (c === '\r' && s[i + 1] === '\n') i++; + row.push(cell); + rows.push(row); + row = []; + cell = ''; + } else cell += c; + } + if (cell || row.length) rows.push([...row, cell]); + return rows; +} + +export function parseCsv(input: string): { contacts: ContactData[]; errors: string[] } { + const [header, ...rows] = csvRows(input); + if (!header) return { contacts: [], errors: [] }; + const col = new Map(header.map((h, i) => [h.trim().toLowerCase(), i])); + if (!col.has('name')) + return { contacts: [], errors: ['The first row must name the columns, with a "name" column'] }; + const contacts: ContactData[] = []; + const errors: string[] = []; + rows.forEach((r, i) => { + if (!r.some((c) => c.trim())) return; + const get = (k: string) => { + const at = col.get(k); + return at === undefined ? '' : (r[at] ?? '').trim(); + }; + const numbers: ContactData['numbers'] = []; + for (let n = 1; col.has(`number${n}`); n++) + if (get(`number${n}`)) numbers.push({ label: get(`label${n}`), number: get(`number${n}`) }); + const name = get('name'); + if (!name) { + errors.push(`Row ${i + 2}: no name, skipped`); + return; + } + contacts.push({ + name, + company: get('company') || null, + email: get('email') || null, + numbers, + notes: get('notes') || null, + }); + }); + return { contacts, errors }; +} diff --git a/apps/api/src/contacts/timeline.test.ts b/apps/api/src/contacts/timeline.test.ts new file mode 100644 index 0000000..fd3504c --- /dev/null +++ b/apps/api/src/contacts/timeline.test.ts @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// C5: what a call looks like on a Contrack timeline (11 §4.4). +import { describe, expect, it } from 'vitest'; +import { splitSummary, timelineEntry, type TimelineCall } from './timeline.ts'; + +const call = (c: Partial): TimelineCall => ({ + out: false, + status: 'answered', + fromNumber: '13125551234', + toNumber: '13125550000', + talkSeconds: 192, + answeredBy: null, + recorded: false, + ...c, +}); + +describe('timelineEntry', () => { + it('titles calls from where the person sat', () => { + const t = (c: Partial) => timelineEntry(call(c), null).title; + expect(t({})).toBe('Incoming call'); + expect(t({ answeredBy: 'Sue Smith' })).toBe('Incoming call, answered by Sue Smith'); + expect(t({ status: 'missed', talkSeconds: 0 })).toBe('Missed call'); + expect(t({ status: 'voicemail', talkSeconds: 0 })).toBe('Missed call, voicemail left'); + expect(t({ out: true })).toBe('Outgoing call'); + expect(t({ out: true, status: 'busy', talkSeconds: 0 })).toBe('Outgoing call, busy'); + expect(t({ out: true, status: 'failed', talkSeconds: 0 })).toBe('Outgoing call, no answer'); + }); + + it('writes the facts, the summary and its action items as escaped HTML', () => { + const e = timelineEntry( + call({ recorded: true }), + 'Acme a quote & a call back.\nAction items:\n- Dan sends the quote\n- Sue books a visit', + ); + expect(e).toEqual({ + title: 'Incoming call', + content: + '

From (312) 555-1234 to (312) 555-0000 · 3:12 · recorded

' + + '

Acme <wants> a quote & a call back.

' + + '

Action items:

  • Dan sends the quote
  • Sue books a visit
', + duration: 4, + firstItem: 'Dan sends the quote', + }); + const missed = timelineEntry(call({ status: 'missed', talkSeconds: 0 }), null); + expect([missed.content, missed.duration, missed.firstItem]).toEqual([ + '

From (312) 555-1234 to (312) 555-0000

', + null, + null, + ]); + expect(timelineEntry(call({ out: true }), null).content).toBe( + '

To (312) 555-0000 · 3:12

', + ); + }); + + it('reads "Action items: none" as none', () => { + expect(splitSummary('Wrong number.\nAction items: none')).toEqual({ + text: ['Wrong number.'], + items: [], + }); + expect(splitSummary('Just chatted.')).toEqual({ text: ['Just chatted.'], items: [] }); + expect(splitSummary('X.\nAction items:\n- none')).toEqual({ text: ['X.'], items: [] }); + }); +}); diff --git a/apps/api/src/contacts/timeline.ts b/apps/api/src/contacts/timeline.ts new file mode 100644 index 0000000..649e063 --- /dev/null +++ b/apps/api/src/contacts/timeline.ts @@ -0,0 +1,218 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Calls on the Contrack timeline (11 §4.4): each call of a connected person with one of their +// Contrack contacts becomes a "call" interaction on that contact, with the recorded call's +// summary if they asked for it. Unknown numbers are not added to Contrack. +import { and, asc, eq, gt, isNotNull, lt, ne, sql } from 'drizzle-orm'; +import { alias } from 'drizzle-orm/pg-core'; +import { formatNumber } from '@selfphoneng/shared'; +import { decrypt } from '../crypto/secrets.ts'; +import type { Db } from '../db/client.ts'; +import { + callExtensions, + calls, + contrackCallLog, + extensions, + recordings, + users, +} from '../db/schema.ts'; +import { todayIn } from '../generator/load.ts'; +import { getSetting } from '../services/settings.ts'; +import { ContrackError, contrackBook, contrackInteraction, contrackSettings } from './contrack.ts'; + +/** A recorded call waits this long for its summary before it's logged without one. */ +const SUMMARY_WAIT_MS = 30 * 60_000; + +const esc = (s: string) => + s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +const num = (n: string | null) => (n ? (n.length > 6 ? formatNumber(n) : n) : 'an unknown number'); +const mmss = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; + +export type TimelineCall = { + out: boolean; + status: string; + fromNumber: string | null; + toNumber: string | null; + talkSeconds: number; + /** Someone else answered (a ring group or queue colleague). */ + answeredBy: string | null; + recorded: boolean; +}; + +/** "Action items:" lines of a summary (10 §3), and the text before them. */ +export function splitSummary(summary: string): { text: string[]; items: string[] } { + const lines = summary + .split('\n') + .map((l) => l.trim()) + .filter(Boolean); + const at = lines.findIndex((l) => /^action items:/i.test(l)); + if (at < 0) return { text: lines, items: [] }; + return { + text: lines.slice(0, at), + items: lines + .slice(at + 1) + .filter((l) => /^[-*•]\s+/.test(l)) + .map((l) => l.replace(/^[-*•]\s+/, '')) + .filter((l) => !/^none\.?$/i.test(l)), + }; +} + +/** The interaction for one call: title, HTML content, minutes, the first action item. */ +export function timelineEntry(c: TimelineCall, summary: string | null) { + const missed = ['missed', 'voicemail', 'abandoned'].includes(c.status); + const title = c.out + ? c.status === 'answered' + ? 'Outgoing call' + : `Outgoing call, ${c.status === 'busy' ? 'busy' : 'no answer'}` + : c.status === 'voicemail' + ? 'Missed call, voicemail left' + : missed || c.status !== 'answered' + ? 'Missed call' + : c.answeredBy + ? `Incoming call, answered by ${c.answeredBy}` + : 'Incoming call'; + const facts = [ + c.out ? `To ${num(c.toNumber)}` : `From ${num(c.fromNumber)} to ${num(c.toNumber)}`, + ...(c.talkSeconds ? [mmss(c.talkSeconds)] : []), + ...(c.recorded ? ['recorded'] : []), + ]; + const parts = [`

${esc(facts.join(' · '))}

`]; + const s = summary ? splitSummary(summary) : null; + if (s?.text.length) parts.push(...s.text.map((l) => `

${esc(l)}

`)); + if (s?.items.length) + parts.push(`

Action items:

    ${s.items.map((i) => `
  • ${esc(i)}
  • `).join('')}
`); + return { + title, + content: parts.join(''), + duration: c.talkSeconds ? Math.ceil(c.talkSeconds / 60) : null, + firstItem: s?.items[0] ?? null, + }; +} + +const answerer = alias(extensions, 'answerer'); + +/** + * One run of the contrackLog job: calls that ended over a minute ago (and within 2 days), of + * people who connected Contrack and log calls, not looked at yet. Contrack down: tried again on + * the next run. + */ +export async function logCallsToContrack(d: { + db: Db; + log: { error(o: object, m: string): void }; + now?: Date; +}): Promise { + const c = await contrackSettings(d.db); + if (!c) return 0; + const now = d.now ?? new Date(); + const tz = await getSetting(d.db, 'general.timezone'); + const summariesOn = + (await getSetting(d.db, 'ai.callTranscripts')) && (await getSetting(d.db, 'ai.callSummaries')); + const rows = await d.db + .select({ + call: calls, + role: callExtensions.role, + userId: users.id, + withSummary: users.contrackLogSummaries, + tokenEnc: users.contrackTokenEnc, + extId: extensions.id, + answeredById: answerer.id, + answeredBy: answerer.name, + summary: recordings.summary, + aiDoneAt: recordings.aiDoneAt, + }) + .from(calls) + .innerJoin(callExtensions, eq(callExtensions.callId, calls.id)) + .innerJoin(extensions, eq(extensions.id, callExtensions.extensionId)) + .innerJoin(users, eq(users.id, extensions.userId)) + .leftJoin(answerer, eq(answerer.id, calls.answeredByExtensionId)) + .leftJoin(recordings, eq(recordings.id, calls.recordingId)) + .where( + and( + isNotNull(users.contrackTokenEnc), + eq(users.contrackLogCalls, true), + eq(users.isActive, true), + ne(calls.direction, 'internal'), + gt(calls.endedAt, users.contrackLogSince), + lt(calls.endedAt, new Date(now.getTime() - 60_000)), + gt(calls.endedAt, new Date(now.getTime() - 2 * 24 * 3600_000)), + sql`not exists (select 1 from ${contrackCallLog} l where l.call_id = ${calls.id} and l.user_id = ${users.id})`, + ), + ) + .orderBy(asc(calls.endedAt)) + .limit(500); + + // One entry per call and person (a person can be in a call as caller, rang and answered). + const todo = new Map(); + for (const r of rows) { + const k = `${r.call.id}|${r.userId}`; + const out = r.role === 'caller' || todo.get(k)?.out === true; + todo.set(k, { ...r, out }); + } + const skipUser = new Set(); // Contrack down or token refused: try again next run + let logged = 0; + for (const r of todo.values()) { + if (skipUser.has(r.userId)) continue; + const record = (interactionId: string | null, error: string | null = null) => + d.db + .insert(contrackCallLog) + .values({ callId: r.call.id, userId: r.userId, interactionId, error }) + .onConflictDoNothing(); + const book = await contrackBook(d.db, c, r.userId); + if (!book || book.error) { + skipUser.add(r.userId); + continue; + } + const other = r.out ? r.call.toNumber : r.call.fromNumber; + const contact = other + ? book.contacts.find((x) => x.numbers.some((n) => n.number === other)) + : undefined; + if (!contact) { + await record(null); + continue; + } + const wantSummary = r.withSummary && summariesOn && r.call.recordingId !== null; + if (wantSummary && !r.aiDoneAt && now.getTime() - r.call.endedAt.getTime() < SUMMARY_WAIT_MS) + continue; // the transcript job hasn't summarized it yet + const e = timelineEntry( + { + out: r.out, + status: r.call.status, + fromNumber: r.call.fromNumber, + toNumber: r.call.toNumber, + talkSeconds: r.call.talkSeconds, + answeredBy: r.answeredById && r.answeredById !== r.extId ? r.answeredBy : null, + recorded: r.call.recordingId !== null, + }, + wantSummary ? r.summary : null, + ); + try { + const id = await contrackInteraction(c, decrypt(r.tokenEnc!), contact.id, { + type: 'call', + title: e.title, + content: e.content, + date: r.call.startedAt.toISOString(), + duration: e.duration, + source: 'selfphoneng', + ...(e.firstItem + ? { + actionItem: { + title: e.firstItem.slice(0, 200), + dueAt: todayIn(tz, new Date(r.call.startedAt.getTime() + 24 * 3600_000)), + }, + } + : {}), + }); + await record(id); + logged++; + } catch (err) { + const status = err instanceof ContrackError ? err.status : undefined; + // Refused (bad request, a read-only token, a deleted contact): noted, not retried. + if (status && status >= 400 && status < 500 && status !== 401 && status !== 429) + await record(null, (err as Error).message); + else { + skipUser.add(r.userId); + d.log.error({ err, user: r.userId }, 'contrack: call not logged yet'); + } + } + } + return logged; +} diff --git a/apps/api/src/contacts/vcard.test.ts b/apps/api/src/contacts/vcard.test.ts new file mode 100644 index 0000000..0902c18 --- /dev/null +++ b/apps/api/src/contacts/vcard.test.ts @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// C1: vCard and CSV imports read what phones and address books export (11 §3.1). +import { describe, expect, it } from 'vitest'; +import { parseCsv } from './csv.ts'; +import { parseVcards } from './vcard.ts'; + +const IPHONE = [ + 'BEGIN:VCARD', + 'VERSION:3.0', + 'PRODID:-//Apple Inc.//iPhone OS 17.0//EN', + 'N:Doe;Jane;;;', + 'FN:Jane Doe', + 'ORG:Acme Corp;', + 'EMAIL;type=INTERNET;type=HOME;type=pref:jane@example.com', + 'TEL;type=CELL;type=VOICE;type=pref:(312) 555-1234', + 'item1.TEL:+1 312 555 0000', + 'item1.X-ABLabel:_$!
!$_', + 'NOTE:Met at the fair\\, 2026\\nCall after 5', + 'PHOTO;ENCODING=b;TYPE=JPEG:/9j/4AAQSkZJRgABAQAAAQABAAD', + ' /2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJ', + 'END:VCARD', +].join('\r\n'); + +const GOOGLE = `BEGIN:VCARD +VERSION:3.0 +FN:Bob Smith +N:Smith;Bob;;; +TEL;TYPE=WORK:+13125559999 +TEL;TYPE=HOME:3125558888 +EMAIL;TYPE=INTERNET:bob@example.com +ORG:Widgets Inc. +item1.URL:http\\://example.com +END:VCARD +BEGIN:VCARD +VERSION:3.0 +N:;;;; +TEL;TYPE=CELL:3125550101 +END:VCARD +`; + +const V21 = `BEGIN:VCARD +VERSION:2.1 +N;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:M=C3=BCller;J=C3=BCrgen;;Dr.; +TEL;CELL:+49301234567 +TEL;WORK;FAX:+49301234500 +NOTE;ENCODING=QUOTED-PRINTABLE:Line one=0D=0ALine tw= +o +END:VCARD`; + +const V40 = `BEGIN:VCARD +VERSION:4.0 +FN:Carol Lee +TEL;VALUE=uri;TYPE="voice,cell":tel:+1-312-555-7777 +END:VCARD +BEGIN:VCARD +VERSION:4.0 +NOTE:nothing to call +END:VCARD`; + +describe('parseVcards', () => { + it('reads an iPhone export: names, company, labels, note; skips the photo', () => { + expect(parseVcards(IPHONE)).toEqual({ + contacts: [ + { + name: 'Jane Doe', + company: 'Acme Corp', + email: 'jane@example.com', + numbers: [ + { label: 'mobile', number: '(312) 555-1234' }, + { label: 'main', number: '+1 312 555 0000' }, + ], + notes: 'Met at the fair, 2026\nCall after 5', + }, + ], + errors: [], + }); + }); + + it('reads a Google export, and names a card with only a number after it', () => { + const { contacts } = parseVcards(GOOGLE); + expect(contacts.map((c) => [c.name, c.company, c.numbers])).toEqual([ + [ + 'Bob Smith', + 'Widgets Inc.', + [ + { label: 'work', number: '+13125559999' }, + { label: 'home', number: '3125558888' }, + ], + ], + ['3125550101', null, [{ label: 'mobile', number: '3125550101' }]], + ]); + }); + + it('reads vCard 2.1: quoted-printable UTF-8, bare types, soft line breaks', () => { + const [c] = parseVcards(V21).contacts; + expect(c).toMatchObject({ + name: 'Dr. Jürgen Müller', + numbers: [ + { label: 'mobile', number: '+49301234567' }, + { label: 'fax', number: '+49301234500' }, + ], + notes: 'Line one\r\nLine two', + }); + }); + + it('reads vCard 4.0 tel: URIs, and reports a card with nothing to call', () => { + expect(parseVcards(V40)).toEqual({ + contacts: [ + { + name: 'Carol Lee', + company: null, + email: null, + numbers: [{ label: 'mobile', number: '+1-312-555-7777' }], + notes: null, + }, + ], + errors: ['Card 2: no name or number, skipped'], + }); + }); +}); + +describe('parseCsv', () => { + it('reads the 04 §7.4 columns in any order, with quotes and line breaks', () => { + const csv = + 'Number1,Name,label1,company,notes,number2,label2\r\n' + + '312-555-1234,"Doe, Jane",mobile,Acme,"two\nlines",,\r\n' + + ',,,,,,\r\n' + + '3125550000,,work,,,,\r\n' + + '+44 20 7946 0958,"Bob ""B"" Smith",,,,101,desk\r\n'; + expect(parseCsv(csv)).toEqual({ + contacts: [ + { + name: 'Doe, Jane', + company: 'Acme', + email: null, + numbers: [{ label: 'mobile', number: '312-555-1234' }], + notes: 'two\nlines', + }, + { + name: 'Bob "B" Smith', + company: null, + email: null, + numbers: [ + { label: '', number: '+44 20 7946 0958' }, + { label: 'desk', number: '101' }, + ], + notes: null, + }, + ], + errors: ['Row 4: no name, skipped'], + }); + }); + + it('needs a header with a name column', () => { + expect(parseCsv('Jane,3125551234\n').errors[0]).toMatch(/"name" column/); + }); +}); diff --git a/apps/api/src/contacts/vcard.ts b/apps/api/src/contacts/vcard.ts new file mode 100644 index 0000000..46fded3 --- /dev/null +++ b/apps/api/src/contacts/vcard.ts @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// vCard 2.1, 3.0 and 4.0 to contacts (11 §3.1): the name, company, first email, every phone and +// the note. Everything else (photos, addresses, birthdays) is ignored. +import type { ContactData } from '@selfphoneng/shared'; + +type Prop = { group: string; name: string; params: Map; value: string }; + +/** "ENCODING=QUOTED-PRINTABLE;CHARSET=utf-8;TYPE=CELL,VOICE;WORK" (2.1 allows bare types). */ +function parseParams(parts: string[]): Map { + const out = new Map(); + for (const p of parts) { + const eq = p.indexOf('='); + const key = eq < 0 ? 'TYPE' : p.slice(0, eq).toUpperCase(); + const raw = eq < 0 ? p : p.slice(eq + 1); + const vals = raw.replace(/"/g, '').split(',').filter(Boolean); + // 2.1's bare QUOTED-PRINTABLE is an encoding, not a type. + const k = eq < 0 && /^(QUOTED-PRINTABLE|BASE64|8BIT)$/i.test(raw) ? 'ENCODING' : key; + out.set(k, [...(out.get(k) ?? []), ...vals.map((v) => v.toUpperCase())]); + } + return out; +} + +/** "[group.]NAME[;params]:value", with ':' allowed inside quoted params. */ +function parseLine(line: string): Prop | null { + let quoted = false; + let colon = -1; + for (let i = 0; i < line.length; i++) { + if (line[i] === '"') quoted = !quoted; + else if (line[i] === ':' && !quoted) { + colon = i; + break; + } + } + if (colon < 0) return null; + const [head = '', ...params] = line.slice(0, colon).split(';'); + const dot = head.lastIndexOf('.'); + return { + group: dot < 0 ? '' : head.slice(0, dot).toUpperCase(), + name: head.slice(dot + 1).toUpperCase(), + params: parseParams(params), + value: line.slice(colon + 1), + }; +} + +function decodeQp(v: string, charset: string): string { + const bytes: number[] = []; + for (let i = 0; i < v.length; i++) { + const hex = v.slice(i + 1, i + 3); + if (v[i] === '=' && /^[0-9A-F]{2}$/i.test(hex)) { + bytes.push(parseInt(hex, 16)); + i += 2; + } else bytes.push(v.charCodeAt(i) & 0xff); + } + try { + return new TextDecoder(charset).decode(new Uint8Array(bytes)); + } catch { + return new TextDecoder('utf-8').decode(new Uint8Array(bytes)); + } +} + +/** Splits on unescaped `sep`, then unescapes \n \, \; \\. */ +function components(v: string, sep: ';' | ','): string[] { + const out = ['']; + for (let i = 0; i < v.length; i++) { + const c = v[i]!; + if (c === '\\' && i + 1 < v.length) { + const n = v[++i]!; + out[out.length - 1] += n === 'n' || n === 'N' ? '\n' : n; + } else if (c === sep) out.push(''); + else out[out.length - 1] += c; + } + return out.map((s) => s.trim()); +} +const text = (v: string) => components(v, ';').join(';').trim(); + +/** cell → mobile, work, home, fax, else other; an Apple X-ABLabel wins. */ +function telLabel(types: string[], abLabel: string | undefined): string { + if (abLabel) { + const l = abLabel.replace(/^_\$!<|>!\$_$/g, '').trim(); + if (l) return l.toLowerCase() === 'cell' ? 'mobile' : l.toLowerCase(); + } + if (types.includes('FAX')) return 'fax'; + if (types.some((t) => t === 'CELL' || t === 'MOBILE' || t === 'IPHONE')) return 'mobile'; + if (types.includes('WORK')) return 'work'; + if (types.includes('HOME')) return 'home'; + if (types.includes('MAIN')) return 'main'; + return 'other'; +} + +/** Every card in the text, in order. A card with no name, company or number is an error. */ +export function parseVcards(input: string): { contacts: ContactData[]; errors: string[] } { + // Unfold: a line starting with a space or tab continues the one before (and 2.1 + // quoted-printable continues a line that ends with "="). + const lines: string[] = []; + for (const l of input.replace(/^\uFEFF/, '').split(/\r\n|\r|\n/)) { + const prev = lines.at(-1); + if (prev?.endsWith('=') && /QUOTED-PRINTABLE/i.test(prev.split(':')[0]!)) + lines[lines.length - 1] = prev.slice(0, -1) + l.replace(/^[ \t]/, ''); + else if (prev !== undefined && /^[ \t]/.test(l)) lines[lines.length - 1] = prev + l.slice(1); + else lines.push(l); + } + + const contacts: ContactData[] = []; + const errors: string[] = []; + let card: Prop[] | null = null; + for (const line of lines) { + const p = parseLine(line); + if (!p) continue; + if (p.name === 'BEGIN' && p.value.toUpperCase() === 'VCARD') card = []; + else if (p.name === 'END' && p.value.toUpperCase() === 'VCARD' && card) { + const c = toContact(card); + if (c) contacts.push(c); + else errors.push(`Card ${contacts.length + errors.length + 1}: no name or number, skipped`); + card = null; + } else if (card) { + const enc = p.params.get('ENCODING') ?? []; + if (enc.includes('QUOTED-PRINTABLE')) + p.value = decodeQp(p.value, p.params.get('CHARSET')?.[0]?.toLowerCase() ?? 'utf-8'); + card.push(p); + } + } + return { contacts, errors }; +} + +function toContact(props: Prop[]): ContactData | null { + const first = (n: string) => props.find((p) => p.name === n)?.value; + const fn = first('FN') ? text(first('FN')!) : ''; + const n = first('N'); + const [family = '', given = '', middle = '', prefix = '', suffix = ''] = n + ? components(n, ';') + : []; + const fromN = [prefix, given, middle, family, suffix].filter(Boolean).join(' '); + const org = first('ORG') ? components(first('ORG')!, ';')[0] || null : null; + const email = first('EMAIL') ? text(first('EMAIL')!) || null : null; + const abLabels = new Map( + props.filter((p) => p.name === 'X-ABLABEL' && p.group).map((p) => [p.group, text(p.value)]), + ); + const numbers = props + .filter((p) => p.name === 'TEL') + .map((p) => ({ + label: telLabel(p.params.get('TYPE') ?? [], p.group ? abLabels.get(p.group) : undefined), + number: text(p.value).replace(/^tel:/i, '').split(';')[0]!.trim(), + })) + .filter((t) => t.number); + const note = first('NOTE') ? text(first('NOTE')!) || null : null; + const name = fn || fromN || org || numbers[0]?.number; + if (!name) return null; + return { name, company: org, email, numbers, notes: note }; +} diff --git a/apps/api/src/db/migrations/0009_contact_shares.sql b/apps/api/src/db/migrations/0009_contact_shares.sql new file mode 100644 index 0000000..017129d --- /dev/null +++ b/apps/api/src/db/migrations/0009_contact_shares.sql @@ -0,0 +1,16 @@ +CREATE TABLE "contact_shares" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "from_user_id" uuid, + "to_user_id" uuid NOT NULL, + "source_id" text NOT NULL, + "contact" jsonb NOT NULL, + "status" text DEFAULT 'pending' NOT NULL, + "decided_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "contact_shares_status_check" CHECK ("contact_shares"."status" in ('pending', 'accepted', 'denied')) +); +--> statement-breakpoint +ALTER TABLE "contact_shares" ADD CONSTRAINT "contact_shares_from_user_id_users_id_fk" FOREIGN KEY ("from_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "contact_shares" ADD CONSTRAINT "contact_shares_to_user_id_users_id_fk" FOREIGN KEY ("to_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "contact_shares_to_user_id_index" ON "contact_shares" USING btree ("to_user_id") WHERE "contact_shares"."status" = 'pending'; \ No newline at end of file diff --git a/apps/api/src/db/migrations/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/0011_contrack_call_log.sql b/apps/api/src/db/migrations/0011_contrack_call_log.sql new file mode 100644 index 0000000..6b2e340 --- /dev/null +++ b/apps/api/src/db/migrations/0011_contrack_call_log.sql @@ -0,0 +1,16 @@ +CREATE TABLE "contrack_call_log" ( + "call_id" uuid NOT NULL, + "user_id" uuid NOT NULL, + "interaction_id" text, + "error" text, + "logged_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "contrack_call_log_call_id_user_id_pk" PRIMARY KEY("call_id","user_id") +); +--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "contrack_log_calls" boolean DEFAULT true NOT NULL;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "contrack_log_summaries" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "users" ADD COLUMN "contrack_log_since" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "contrack_call_log" ADD CONSTRAINT "contrack_call_log_call_id_calls_id_fk" FOREIGN KEY ("call_id") REFERENCES "public"."calls"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "contrack_call_log" ADD CONSTRAINT "contrack_call_log_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +-- People already connected (C3) start logging now, not with the last 2 days of calls. +UPDATE "users" SET "contrack_log_since" = now() WHERE "contrack_token_enc" IS NOT NULL; \ No newline at end of file diff --git a/apps/api/src/db/migrations/meta/0009_snapshot.json b/apps/api/src/db/migrations/meta/0009_snapshot.json new file mode 100644 index 0000000..a3b21a3 --- /dev/null +++ b/apps/api/src/db/migrations/meta/0009_snapshot.json @@ -0,0 +1,4248 @@ +{ + "id": "aae66860-cb88-440a-b88a-c5575ebcd24a", + "prevId": "75adba1b-ae0f-488f-8a5e-a573226446f4", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.announcements": { + "name": "announcements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sound_id": { + "name": "sound_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "destination": { + "name": "destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "announcements_sound_id_sound_files_id_fk": { + "name": "announcements_sound_id_sound_files_id_fk", + "tableFrom": "announcements", + "tableTo": "sound_files", + "columnsFrom": [ + "sound_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_tokens": { + "name": "api_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_tokens_created_by_users_id_fk": { + "name": "api_tokens_created_by_users_id_fk", + "tableFrom": "api_tokens", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_tokens_tokenHash_unique": { + "name": "api_tokens_tokenHash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_ip": { + "name": "actor_ip", + "type": "inet", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "diff": { + "name": "diff", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "audit_log_actor_user_id_users_id_fk": { + "name": "audit_log_actor_user_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blocked_numbers": { + "name": "blocked_numbers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "blocked_numbers_number_unique": { + "name": "blocked_numbers_number_unique", + "nullsNotDistinct": false, + "columns": [ + "number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.call_extensions": { + "name": "call_extensions", + "schema": "", + "columns": { + "call_id": { + "name": "call_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "extension_id": { + "name": "extension_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "call_extensions_call_id_calls_id_fk": { + "name": "call_extensions_call_id_calls_id_fk", + "tableFrom": "call_extensions", + "tableTo": "calls", + "columnsFrom": [ + "call_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "call_extensions_extension_id_extensions_id_fk": { + "name": "call_extensions_extension_id_extensions_id_fk", + "tableFrom": "call_extensions", + "tableTo": "extensions", + "columnsFrom": [ + "extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "call_extensions_call_id_extension_id_role_pk": { + "name": "call_extensions_call_id_extension_id_role_pk", + "columns": [ + "call_id", + "extension_id", + "role" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "call_extensions_role_check": { + "name": "call_extensions_role_check", + "value": "\"call_extensions\".\"role\" in ('caller', 'rang', 'answered')" + } + }, + "isRLSEnabled": false + }, + "public.calls": { + "name": "calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "linkedid": { + "name": "linkedid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "call_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "call_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "answered_at": { + "name": "answered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "from_number": { + "name": "from_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "to_number": { + "name": "to_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "did_id": { + "name": "did_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "answered_by_extension_id": { + "name": "answered_by_extension_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "talk_seconds": { + "name": "talk_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "recording_id": { + "name": "recording_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "calls_started_at_index": { + "name": "calls_started_at_index", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "calls_did_id_dids_id_fk": { + "name": "calls_did_id_dids_id_fk", + "tableFrom": "calls", + "tableTo": "dids", + "columnsFrom": [ + "did_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "calls_answered_by_extension_id_extensions_id_fk": { + "name": "calls_answered_by_extension_id_extensions_id_fk", + "tableFrom": "calls", + "tableTo": "extensions", + "columnsFrom": [ + "answered_by_extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "calls_recording_id_recordings_id_fk": { + "name": "calls_recording_id_recordings_id_fk", + "tableFrom": "calls", + "tableTo": "recordings", + "columnsFrom": [ + "recording_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "calls_linkedid_unique": { + "name": "calls_linkedid_unique", + "nullsNotDistinct": false, + "columns": [ + "linkedid" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cdr": { + "name": "cdr", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "uniqueid": { + "name": "uniqueid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "linkedid": { + "name": "linkedid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_at": { + "name": "start_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "answer_at": { + "name": "answer_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "end_at": { + "name": "end_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "billsec": { + "name": "billsec", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "src": { + "name": "src", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dst": { + "name": "dst", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dcontext": { + "name": "dcontext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "clid": { + "name": "clid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dstchannel": { + "name": "dstchannel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastapp": { + "name": "lastapp", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastdata": { + "name": "lastdata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disposition": { + "name": "disposition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accountcode": { + "name": "accountcode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userfield": { + "name": "userfield", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sp_did": { + "name": "sp_did", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sp_direction": { + "name": "sp_direction", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sp_recording": { + "name": "sp_recording", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw": { + "name": "raw", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "cdr_linkedid_index": { + "name": "cdr_linkedid_index", + "columns": [ + { + "expression": "linkedid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conferences": { + "name": "conferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pin_enc": { + "name": "pin_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "admin_pin_enc": { + "name": "admin_pin_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_members": { + "name": "max_members", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 20 + }, + "record": { + "name": "record", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "conferences_number_unique": { + "name": "conferences_number_unique", + "nullsNotDistinct": false, + "columns": [ + "number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contact_shares": { + "name": "contact_shares", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "from_user_id": { + "name": "from_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "to_user_id": { + "name": "to_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact": { + "name": "contact", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "contact_shares_to_user_id_index": { + "name": "contact_shares_to_user_id_index", + "columns": [ + { + "expression": "to_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"contact_shares\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "contact_shares_from_user_id_users_id_fk": { + "name": "contact_shares_from_user_id_users_id_fk", + "tableFrom": "contact_shares", + "tableTo": "users", + "columnsFrom": [ + "from_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "contact_shares_to_user_id_users_id_fk": { + "name": "contact_shares_to_user_id_users_id_fk", + "tableFrom": "contact_shares", + "tableTo": "users", + "columnsFrom": [ + "to_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "contact_shares_status_check": { + "name": "contact_shares_status_check", + "value": "\"contact_shares\".\"status\" in ('pending', 'accepted', 'denied')" + } + }, + "isRLSEnabled": false + }, + "public.contacts": { + "name": "contacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company": { + "name": "company", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "numbers": { + "name": "numbers", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "contacts_owner_user_id_users_id_fk": { + "name": "contacts_owner_user_id_users_id_fk", + "tableFrom": "contacts", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.devices": { + "name": "devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "extension_id": { + "name": "extension_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "mac": { + "name": "mac", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vendor": { + "name": "vendor", + "type": "phone_vendor", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provisioning_password_enc": { + "name": "provisioning_password_enc", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_provisioned_at": { + "name": "last_provisioned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_provisioned_ip": { + "name": "last_provisioned_ip", + "type": "inet", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "devices_extension_id_extensions_id_fk": { + "name": "devices_extension_id_extensions_id_fk", + "tableFrom": "devices", + "tableTo": "extensions", + "columnsFrom": [ + "extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "devices_mac_unique": { + "name": "devices_mac_unique", + "nullsNotDistinct": false, + "columns": [ + "mac" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.did_sms_users": { + "name": "did_sms_users", + "schema": "", + "columns": { + "did_id": { + "name": "did_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "did_sms_users_did_id_dids_id_fk": { + "name": "did_sms_users_did_id_dids_id_fk", + "tableFrom": "did_sms_users", + "tableTo": "dids", + "columnsFrom": [ + "did_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "did_sms_users_user_id_users_id_fk": { + "name": "did_sms_users_user_id_users_id_fk", + "tableFrom": "did_sms_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "did_sms_users_did_id_user_id_pk": { + "name": "did_sms_users_did_id_user_id_pk", + "columns": [ + "did_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dids": { + "name": "dids", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trunk_id": { + "name": "trunk_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination": { + "name": "destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "caller_id_name_prefix": { + "name": "caller_id_name_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "record_inbound": { + "name": "record_inbound", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sms_enabled": { + "name": "sms_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "e911_address": { + "name": "e911_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "dids_trunk_id_trunks_id_fk": { + "name": "dids_trunk_id_trunks_id_fk", + "tableFrom": "dids", + "tableTo": "trunks", + "columnsFrom": [ + "trunk_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "dids_number_unique": { + "name": "dids_number_unique", + "nullsNotDistinct": false, + "columns": [ + "number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.emergency_events": { + "name": "emergency_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "extension_number": { + "name": "extension_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dialed": { + "name": "dialed", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "caller_id_used": { + "name": "caller_id_used", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notified": { + "name": "notified", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.extensions": { + "name": "extensions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sip_password_enc": { + "name": "sip_password_enc", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webrtc_enabled": { + "name": "webrtc_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "max_contacts": { + "name": "max_contacts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "ring_seconds": { + "name": "ring_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 25 + }, + "voicemail_enabled": { + "name": "voicemail_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "voicemail_pin_enc": { + "name": "voicemail_pin_enc", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "voicemail_email": { + "name": "voicemail_email", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "voicemail_attach_audio": { + "name": "voicemail_attach_audio", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "voicemail_delete_after_email": { + "name": "voicemail_delete_after_email", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "outbound_class": { + "name": "outbound_class", + "type": "outbound_class", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'national'" + }, + "outbound_caller_id_did_id": { + "name": "outbound_caller_id_did_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "emergency_caller_id_did_id": { + "name": "emergency_caller_id_did_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "emergency_location": { + "name": "emergency_location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "record_mode": { + "name": "record_mode", + "type": "record_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'never'" + }, + "pickup_group": { + "name": "pickup_group", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "moh_class": { + "name": "moh_class", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "blf_numbers": { + "name": "blf_numbers", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "call_report": { + "name": "call_report", + "type": "call_report", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'off'" + }, + "call_report_time": { + "name": "call_report_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'08:00'" + }, + "call_report_weekday": { + "name": "call_report_weekday", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "call_report_summaries": { + "name": "call_report_summaries", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "call_report_sent_at": { + "name": "call_report_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "extensions_user_id_users_id_fk": { + "name": "extensions_user_id_users_id_fk", + "tableFrom": "extensions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "extensions_outbound_caller_id_did_id_dids_id_fk": { + "name": "extensions_outbound_caller_id_did_id_dids_id_fk", + "tableFrom": "extensions", + "tableTo": "dids", + "columnsFrom": [ + "outbound_caller_id_did_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "extensions_emergency_caller_id_did_id_dids_id_fk": { + "name": "extensions_emergency_caller_id_did_id_dids_id_fk", + "tableFrom": "extensions", + "tableTo": "dids", + "columnsFrom": [ + "emergency_caller_id_did_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "extensions_number_unique": { + "name": "extensions_number_unique", + "nullsNotDistinct": false, + "columns": [ + "number" + ] + }, + "extensions_userId_unique": { + "name": "extensions_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.generator_runs": { + "name": "generator_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "files_changed": { + "name": "files_changed", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "commands": { + "name": "commands", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "ok": { + "name": "ok", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.holidays": { + "name": "holidays", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "recurring_yearly": { + "name": "recurring_yearly", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ivr_options": { + "name": "ivr_options", + "schema": "", + "columns": { + "ivr_id": { + "name": "ivr_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "digit": { + "name": "digit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "destination": { + "name": "destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ivr_options_ivr_id_ivrs_id_fk": { + "name": "ivr_options_ivr_id_ivrs_id_fk", + "tableFrom": "ivr_options", + "tableTo": "ivrs", + "columnsFrom": [ + "ivr_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ivr_options_ivr_id_digit_pk": { + "name": "ivr_options_ivr_id_digit_pk", + "columns": [ + "ivr_id", + "digit" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ivr_options_digit_check": { + "name": "ivr_options_digit_check", + "value": "\"ivr_options\".\"digit\" ~ '^[0-9*#]$'" + } + }, + "isRLSEnabled": false + }, + "public.ivrs": { + "name": "ivrs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "greeting_sound_id": { + "name": "greeting_sound_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invalid_sound_id": { + "name": "invalid_sound_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "timeout_seconds": { + "name": "timeout_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "max_retries": { + "name": "max_retries", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "allow_direct_dial": { + "name": "allow_direct_dial", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timeout_destination": { + "name": "timeout_destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "invalid_destination": { + "name": "invalid_destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ivrs_greeting_sound_id_sound_files_id_fk": { + "name": "ivrs_greeting_sound_id_sound_files_id_fk", + "tableFrom": "ivrs", + "tableTo": "sound_files", + "columnsFrom": [ + "greeting_sound_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "ivrs_invalid_sound_id_sound_files_id_fk": { + "name": "ivrs_invalid_sound_id_sound_files_id_fk", + "tableFrom": "ivrs", + "tableTo": "sound_files", + "columnsFrom": [ + "invalid_sound_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ivrs_number_unique": { + "name": "ivrs_number_unique", + "nullsNotDistinct": false, + "columns": [ + "number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.moh_classes": { + "name": "moh_classes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shuffle": { + "name": "shuffle", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "moh_classes_name_unique": { + "name": "moh_classes_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbound_routes": { + "name": "outbound_routes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "patterns": { + "name": "patterns", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "required_class": { + "name": "required_class", + "type": "outbound_class", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trunk_ids": { + "name": "trunk_ids", + "type": "uuid[]", + "primaryKey": false, + "notNull": true + }, + "caller_id_did_id": { + "name": "caller_id_did_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "outbound_routes_caller_id_did_id_dids_id_fk": { + "name": "outbound_routes_caller_id_did_id_dids_id_fk", + "tableFrom": "outbound_routes", + "tableTo": "dids", + "columnsFrom": [ + "caller_id_did_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paging_group_members": { + "name": "paging_group_members", + "schema": "", + "columns": { + "paging_group_id": { + "name": "paging_group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "extension_id": { + "name": "extension_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "paging_group_members_paging_group_id_paging_groups_id_fk": { + "name": "paging_group_members_paging_group_id_paging_groups_id_fk", + "tableFrom": "paging_group_members", + "tableTo": "paging_groups", + "columnsFrom": [ + "paging_group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "paging_group_members_extension_id_extensions_id_fk": { + "name": "paging_group_members_extension_id_extensions_id_fk", + "tableFrom": "paging_group_members", + "tableTo": "extensions", + "columnsFrom": [ + "extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "paging_group_members_paging_group_id_extension_id_pk": { + "name": "paging_group_members_paging_group_id_extension_id_pk", + "columns": [ + "paging_group_id", + "extension_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paging_groups": { + "name": "paging_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duplex": { + "name": "duplex", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "paging_groups_number_unique": { + "name": "paging_groups_number_unique", + "nullsNotDistinct": false, + "columns": [ + "number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.queue_calls": { + "name": "queue_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "queue_id": { + "name": "queue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "uniqueid": { + "name": "uniqueid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "linkedid": { + "name": "linkedid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "outcome": { + "name": "outcome", + "type": "queue_outcome", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "wait_seconds": { + "name": "wait_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "agent_extension_id": { + "name": "agent_extension_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "queue_calls_queue_id_joined_at_index": { + "name": "queue_calls_queue_id_joined_at_index", + "columns": [ + { + "expression": "queue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "joined_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "queue_calls_uniqueid_index": { + "name": "queue_calls_uniqueid_index", + "columns": [ + { + "expression": "uniqueid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "queue_calls_queue_id_queues_id_fk": { + "name": "queue_calls_queue_id_queues_id_fk", + "tableFrom": "queue_calls", + "tableTo": "queues", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "queue_calls_agent_extension_id_extensions_id_fk": { + "name": "queue_calls_agent_extension_id_extensions_id_fk", + "tableFrom": "queue_calls", + "tableTo": "extensions", + "columnsFrom": [ + "agent_extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.queue_member_state": { + "name": "queue_member_state", + "schema": "", + "columns": { + "queue_id": { + "name": "queue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "extension_id": { + "name": "extension_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "paused": { + "name": "paused", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "queue_member_state_queue_id_queues_id_fk": { + "name": "queue_member_state_queue_id_queues_id_fk", + "tableFrom": "queue_member_state", + "tableTo": "queues", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "queue_member_state_extension_id_extensions_id_fk": { + "name": "queue_member_state_extension_id_extensions_id_fk", + "tableFrom": "queue_member_state", + "tableTo": "extensions", + "columnsFrom": [ + "extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "queue_member_state_queue_id_extension_id_pk": { + "name": "queue_member_state_queue_id_extension_id_pk", + "columns": [ + "queue_id", + "extension_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.queue_members": { + "name": "queue_members", + "schema": "", + "columns": { + "queue_id": { + "name": "queue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "extension_id": { + "name": "extension_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "penalty": { + "name": "penalty", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "queue_members_queue_id_queues_id_fk": { + "name": "queue_members_queue_id_queues_id_fk", + "tableFrom": "queue_members", + "tableTo": "queues", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "queue_members_extension_id_extensions_id_fk": { + "name": "queue_members_extension_id_extensions_id_fk", + "tableFrom": "queue_members", + "tableTo": "extensions", + "columnsFrom": [ + "extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "queue_members_queue_id_extension_id_pk": { + "name": "queue_members_queue_id_extension_id_pk", + "columns": [ + "queue_id", + "extension_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.queues": { + "name": "queues", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "strategy": { + "name": "strategy", + "type": "queue_strategy", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'rrmemory'" + }, + "member_ring_seconds": { + "name": "member_ring_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15 + }, + "retry_seconds": { + "name": "retry_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "wrapup_seconds": { + "name": "wrapup_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "max_wait_seconds": { + "name": "max_wait_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 300 + }, + "max_callers": { + "name": "max_callers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "moh_class": { + "name": "moh_class", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "announce_position": { + "name": "announce_position", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "announce_every_seconds": { + "name": "announce_every_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 60 + }, + "join_when_no_agents": { + "name": "join_when_no_agents", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "record": { + "name": "record", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sla_seconds": { + "name": "sla_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 20 + }, + "caller_id_name_prefix": { + "name": "caller_id_name_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timeout_destination": { + "name": "timeout_destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "queues_number_unique": { + "name": "queues_number_unique", + "nullsNotDistinct": false, + "columns": [ + "number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recordings": { + "name": "recordings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "linkedid": { + "name": "linkedid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "transcript": { + "name": "transcript", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_done_at": { + "name": "ai_done_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ai_error": { + "name": "ai_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "recordings_filePath_unique": { + "name": "recordings_filePath_unique", + "nullsNotDistinct": false, + "columns": [ + "file_path" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ring_group_members": { + "name": "ring_group_members", + "schema": "", + "columns": { + "ring_group_id": { + "name": "ring_group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "extension_id": { + "name": "extension_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ring_group_members_ring_group_id_ring_groups_id_fk": { + "name": "ring_group_members_ring_group_id_ring_groups_id_fk", + "tableFrom": "ring_group_members", + "tableTo": "ring_groups", + "columnsFrom": [ + "ring_group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ring_group_members_extension_id_extensions_id_fk": { + "name": "ring_group_members_extension_id_extensions_id_fk", + "tableFrom": "ring_group_members", + "tableTo": "extensions", + "columnsFrom": [ + "extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ring_group_members_ring_group_id_extension_id_pk": { + "name": "ring_group_members_ring_group_id_extension_id_pk", + "columns": [ + "ring_group_id", + "extension_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ring_groups": { + "name": "ring_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "strategy": { + "name": "strategy", + "type": "ring_strategy", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ringall'" + }, + "ring_seconds": { + "name": "ring_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 25 + }, + "caller_id_name_prefix": { + "name": "caller_id_name_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "music_instead_of_ring": { + "name": "music_instead_of_ring", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "no_answer_destination": { + "name": "no_answer_destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ring_groups_number_unique": { + "name": "ring_groups_number_unique", + "nullsNotDistinct": false, + "columns": [ + "number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "inet", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_id_index": { + "name": "sessions_user_id_index", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sms_messages": { + "name": "sms_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "did_id": { + "name": "did_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "remote_number": { + "name": "remote_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "media": { + "name": "media", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sent_by_user_id": { + "name": "sent_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sms_messages_did_id_remote_number_created_at_index": { + "name": "sms_messages_did_id_remote_number_created_at_index", + "columns": [ + { + "expression": "did_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "remote_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sms_messages_did_id_dids_id_fk": { + "name": "sms_messages_did_id_dids_id_fk", + "tableFrom": "sms_messages", + "tableTo": "dids", + "columnsFrom": [ + "did_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sms_messages_sent_by_user_id_users_id_fk": { + "name": "sms_messages_sent_by_user_id_users_id_fk", + "tableFrom": "sms_messages", + "tableTo": "users", + "columnsFrom": [ + "sent_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sms_messages_direction_check": { + "name": "sms_messages_direction_check", + "value": "\"sms_messages\".\"direction\" in ('in', 'out')" + } + }, + "isRLSEnabled": false + }, + "public.sound_files": { + "name": "sound_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "moh_class_id": { + "name": "moh_class_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tts_text": { + "name": "tts_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_filename": { + "name": "original_filename", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "asterisk_name": { + "name": "asterisk_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sound_files_moh_class_id_moh_classes_id_fk": { + "name": "sound_files_moh_class_id_moh_classes_id_fk", + "tableFrom": "sound_files", + "tableTo": "moh_classes", + "columnsFrom": [ + "moh_class_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sound_files_asteriskName_unique": { + "name": "sound_files_asteriskName_unique", + "nullsNotDistinct": false, + "columns": [ + "asterisk_name" + ] + } + }, + "policies": {}, + "checkConstraints": { + "sound_files_kind_check": { + "name": "sound_files_kind_check", + "value": "\"sound_files\".\"kind\" in ('prompt', 'moh')" + }, + "sound_files_source_check": { + "name": "sound_files_source_check", + "value": "\"sound_files\".\"source\" in ('upload', 'tts', 'phone')" + } + }, + "isRLSEnabled": false + }, + "public.system_events": { + "name": "system_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "system_events_created_at_index": { + "name": "system_events_created_at_index", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "system_events_severity_check": { + "name": "system_events_severity_check", + "value": "\"system_events\".\"severity\" in ('info', 'warning', 'error')" + } + }, + "isRLSEnabled": false + }, + "public.time_conditions": { + "name": "time_conditions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "open_ranges": { + "name": "open_ranges", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "use_holidays": { + "name": "use_holidays", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "override_digit": { + "name": "override_digit", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "override_state": { + "name": "override_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "open_destination": { + "name": "open_destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "closed_destination": { + "name": "closed_destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "holiday_destination": { + "name": "holiday_destination", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "time_conditions_overrideDigit_unique": { + "name": "time_conditions_overrideDigit_unique", + "nullsNotDistinct": false, + "columns": [ + "override_digit" + ] + } + }, + "policies": {}, + "checkConstraints": { + "time_conditions_override_digit_check": { + "name": "time_conditions_override_digit_check", + "value": "\"time_conditions\".\"override_digit\" between 1 and 9" + }, + "time_conditions_override_state_check": { + "name": "time_conditions_override_state_check", + "value": "\"time_conditions\".\"override_state\" in ('open', 'closed')" + } + }, + "isRLSEnabled": false + }, + "public.trunks": { + "name": "trunks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "trunk_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'voipms'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "server_host": { + "name": "server_host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_port": { + "name": "server_port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5060 + }, + "transport": { + "name": "transport", + "type": "sip_transport", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'udp'" + }, + "auth_mode": { + "name": "auth_mode", + "type": "trunk_auth_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'registration'" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password_enc": { + "name": "password_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from_user": { + "name": "from_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from_domain": { + "name": "from_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "codecs": { + "name": "codecs", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{ulaw}'" + }, + "srtp": { + "name": "srtp", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "max_channels": { + "name": "max_channels", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "voipms_api_username": { + "name": "voipms_api_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "voipms_api_password_enc": { + "name": "voipms_api_password_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "trunks_name_unique": { + "name": "trunks_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_runtime_state": { + "name": "user_runtime_state", + "schema": "", + "columns": { + "extension_id": { + "name": "extension_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "dnd": { + "name": "dnd", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cfu": { + "name": "cfu", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cfb": { + "name": "cfb", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cfna": { + "name": "cfna", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_runtime_state_extension_id_extensions_id_fk": { + "name": "user_runtime_state_extension_id_extensions_id_fk", + "tableFrom": "user_runtime_state", + "tableTo": "extensions", + "columnsFrom": [ + "extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "citext", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "totp_secret_enc": { + "name": "totp_secret_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "totp_recovery_hashes": { + "name": "totp_recovery_hashes", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "totp_last_step": { + "name": "totp_last_step", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "must_change_password": { + "name": "must_change_password", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.voicemail_messages": { + "name": "voicemail_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "extension_id": { + "name": "extension_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "msg_id": { + "name": "msg_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "caller_number": { + "name": "caller_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "caller_name": { + "name": "caller_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_s": { + "name": "duration_s", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emailed_at": { + "name": "emailed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "transcript": { + "name": "transcript", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "voicemail_messages_extension_id_extensions_id_fk": { + "name": "voicemail_messages_extension_id_extensions_id_fk", + "tableFrom": "voicemail_messages", + "tableTo": "extensions", + "columnsFrom": [ + "extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "voicemail_messages_msgId_unique": { + "name": "voicemail_messages_msgId_unique", + "nullsNotDistinct": false, + "columns": [ + "msg_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_deliveries": { + "name": "webhook_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "webhook_id": { + "name": "webhook_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "response_code": { + "name": "response_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "webhook_deliveries_webhook_id_webhooks_id_fk": { + "name": "webhook_deliveries_webhook_id_webhooks_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhooks", + "columnsFrom": [ + "webhook_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhooks": { + "name": "webhooks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_enc": { + "name": "secret_enc", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "events": { + "name": "events", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.call_direction": { + "name": "call_direction", + "schema": "public", + "values": [ + "inbound", + "outbound", + "internal" + ] + }, + "public.call_report": { + "name": "call_report", + "schema": "public", + "values": [ + "off", + "hourly", + "daily", + "weekly" + ] + }, + "public.call_status": { + "name": "call_status", + "schema": "public", + "values": [ + "answered", + "missed", + "voicemail", + "abandoned", + "busy", + "failed" + ] + }, + "public.outbound_class": { + "name": "outbound_class", + "schema": "public", + "values": [ + "internal", + "local", + "national", + "international" + ] + }, + "public.phone_vendor": { + "name": "phone_vendor", + "schema": "public", + "values": [ + "yealink", + "grandstream", + "polycom", + "generic" + ] + }, + "public.queue_outcome": { + "name": "queue_outcome", + "schema": "public", + "values": [ + "answered", + "abandoned", + "timed_out" + ] + }, + "public.queue_strategy": { + "name": "queue_strategy", + "schema": "public", + "values": [ + "ringall", + "leastrecent", + "fewestcalls", + "random", + "rrmemory", + "linear" + ] + }, + "public.record_mode": { + "name": "record_mode", + "schema": "public", + "values": [ + "never", + "inbound", + "outbound", + "all" + ] + }, + "public.ring_strategy": { + "name": "ring_strategy", + "schema": "public", + "values": [ + "ringall", + "hunt" + ] + }, + "public.sip_transport": { + "name": "sip_transport", + "schema": "public", + "values": [ + "udp", + "tcp", + "tls" + ] + }, + "public.trunk_auth_mode": { + "name": "trunk_auth_mode", + "schema": "public", + "values": [ + "registration", + "ip" + ] + }, + "public.trunk_provider": { + "name": "trunk_provider", + "schema": "public", + "values": [ + "voipms", + "generic" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "admin", + "user" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/src/db/migrations/meta/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/0011_snapshot.json b/apps/api/src/db/migrations/meta/0011_snapshot.json new file mode 100644 index 0000000..9f77d39 --- /dev/null +++ b/apps/api/src/db/migrations/meta/0011_snapshot.json @@ -0,0 +1,4353 @@ +{ + "id": "e22c65f0-c8ee-42c8-aa4f-667a4831350d", + "prevId": "f785afac-008e-455e-a203-59d8c1c46102", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.announcements": { + "name": "announcements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sound_id": { + "name": "sound_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "destination": { + "name": "destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "announcements_sound_id_sound_files_id_fk": { + "name": "announcements_sound_id_sound_files_id_fk", + "tableFrom": "announcements", + "tableTo": "sound_files", + "columnsFrom": [ + "sound_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_tokens": { + "name": "api_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_tokens_created_by_users_id_fk": { + "name": "api_tokens_created_by_users_id_fk", + "tableFrom": "api_tokens", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_tokens_tokenHash_unique": { + "name": "api_tokens_tokenHash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_ip": { + "name": "actor_ip", + "type": "inet", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "diff": { + "name": "diff", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "audit_log_actor_user_id_users_id_fk": { + "name": "audit_log_actor_user_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blocked_numbers": { + "name": "blocked_numbers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "blocked_numbers_number_unique": { + "name": "blocked_numbers_number_unique", + "nullsNotDistinct": false, + "columns": [ + "number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.call_extensions": { + "name": "call_extensions", + "schema": "", + "columns": { + "call_id": { + "name": "call_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "extension_id": { + "name": "extension_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "call_extensions_call_id_calls_id_fk": { + "name": "call_extensions_call_id_calls_id_fk", + "tableFrom": "call_extensions", + "tableTo": "calls", + "columnsFrom": [ + "call_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "call_extensions_extension_id_extensions_id_fk": { + "name": "call_extensions_extension_id_extensions_id_fk", + "tableFrom": "call_extensions", + "tableTo": "extensions", + "columnsFrom": [ + "extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "call_extensions_call_id_extension_id_role_pk": { + "name": "call_extensions_call_id_extension_id_role_pk", + "columns": [ + "call_id", + "extension_id", + "role" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "call_extensions_role_check": { + "name": "call_extensions_role_check", + "value": "\"call_extensions\".\"role\" in ('caller', 'rang', 'answered')" + } + }, + "isRLSEnabled": false + }, + "public.calls": { + "name": "calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "linkedid": { + "name": "linkedid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "call_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "call_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "answered_at": { + "name": "answered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "from_number": { + "name": "from_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "to_number": { + "name": "to_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "did_id": { + "name": "did_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "answered_by_extension_id": { + "name": "answered_by_extension_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "talk_seconds": { + "name": "talk_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "recording_id": { + "name": "recording_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "calls_started_at_index": { + "name": "calls_started_at_index", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "calls_did_id_dids_id_fk": { + "name": "calls_did_id_dids_id_fk", + "tableFrom": "calls", + "tableTo": "dids", + "columnsFrom": [ + "did_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "calls_answered_by_extension_id_extensions_id_fk": { + "name": "calls_answered_by_extension_id_extensions_id_fk", + "tableFrom": "calls", + "tableTo": "extensions", + "columnsFrom": [ + "answered_by_extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "calls_recording_id_recordings_id_fk": { + "name": "calls_recording_id_recordings_id_fk", + "tableFrom": "calls", + "tableTo": "recordings", + "columnsFrom": [ + "recording_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "calls_linkedid_unique": { + "name": "calls_linkedid_unique", + "nullsNotDistinct": false, + "columns": [ + "linkedid" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cdr": { + "name": "cdr", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "uniqueid": { + "name": "uniqueid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "linkedid": { + "name": "linkedid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_at": { + "name": "start_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "answer_at": { + "name": "answer_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "end_at": { + "name": "end_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "billsec": { + "name": "billsec", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "src": { + "name": "src", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dst": { + "name": "dst", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dcontext": { + "name": "dcontext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "clid": { + "name": "clid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dstchannel": { + "name": "dstchannel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastapp": { + "name": "lastapp", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastdata": { + "name": "lastdata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disposition": { + "name": "disposition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accountcode": { + "name": "accountcode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userfield": { + "name": "userfield", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sp_did": { + "name": "sp_did", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sp_direction": { + "name": "sp_direction", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sp_recording": { + "name": "sp_recording", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw": { + "name": "raw", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "cdr_linkedid_index": { + "name": "cdr_linkedid_index", + "columns": [ + { + "expression": "linkedid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conferences": { + "name": "conferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pin_enc": { + "name": "pin_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "admin_pin_enc": { + "name": "admin_pin_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_members": { + "name": "max_members", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 20 + }, + "record": { + "name": "record", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "conferences_number_unique": { + "name": "conferences_number_unique", + "nullsNotDistinct": false, + "columns": [ + "number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contact_shares": { + "name": "contact_shares", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "from_user_id": { + "name": "from_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "to_user_id": { + "name": "to_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact": { + "name": "contact", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "contact_shares_to_user_id_index": { + "name": "contact_shares_to_user_id_index", + "columns": [ + { + "expression": "to_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"contact_shares\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "contact_shares_from_user_id_users_id_fk": { + "name": "contact_shares_from_user_id_users_id_fk", + "tableFrom": "contact_shares", + "tableTo": "users", + "columnsFrom": [ + "from_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "contact_shares_to_user_id_users_id_fk": { + "name": "contact_shares_to_user_id_users_id_fk", + "tableFrom": "contact_shares", + "tableTo": "users", + "columnsFrom": [ + "to_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "contact_shares_status_check": { + "name": "contact_shares_status_check", + "value": "\"contact_shares\".\"status\" in ('pending', 'accepted', 'denied')" + } + }, + "isRLSEnabled": false + }, + "public.contacts": { + "name": "contacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company": { + "name": "company", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "numbers": { + "name": "numbers", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "contacts_owner_user_id_users_id_fk": { + "name": "contacts_owner_user_id_users_id_fk", + "tableFrom": "contacts", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contrack_call_log": { + "name": "contrack_call_log", + "schema": "", + "columns": { + "call_id": { + "name": "call_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "interaction_id": { + "name": "interaction_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logged_at": { + "name": "logged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "contrack_call_log_call_id_calls_id_fk": { + "name": "contrack_call_log_call_id_calls_id_fk", + "tableFrom": "contrack_call_log", + "tableTo": "calls", + "columnsFrom": [ + "call_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "contrack_call_log_user_id_users_id_fk": { + "name": "contrack_call_log_user_id_users_id_fk", + "tableFrom": "contrack_call_log", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "contrack_call_log_call_id_user_id_pk": { + "name": "contrack_call_log_call_id_user_id_pk", + "columns": [ + "call_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.devices": { + "name": "devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "extension_id": { + "name": "extension_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "mac": { + "name": "mac", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vendor": { + "name": "vendor", + "type": "phone_vendor", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provisioning_password_enc": { + "name": "provisioning_password_enc", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_provisioned_at": { + "name": "last_provisioned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_provisioned_ip": { + "name": "last_provisioned_ip", + "type": "inet", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "devices_extension_id_extensions_id_fk": { + "name": "devices_extension_id_extensions_id_fk", + "tableFrom": "devices", + "tableTo": "extensions", + "columnsFrom": [ + "extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "devices_mac_unique": { + "name": "devices_mac_unique", + "nullsNotDistinct": false, + "columns": [ + "mac" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.did_sms_users": { + "name": "did_sms_users", + "schema": "", + "columns": { + "did_id": { + "name": "did_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "did_sms_users_did_id_dids_id_fk": { + "name": "did_sms_users_did_id_dids_id_fk", + "tableFrom": "did_sms_users", + "tableTo": "dids", + "columnsFrom": [ + "did_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "did_sms_users_user_id_users_id_fk": { + "name": "did_sms_users_user_id_users_id_fk", + "tableFrom": "did_sms_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "did_sms_users_did_id_user_id_pk": { + "name": "did_sms_users_did_id_user_id_pk", + "columns": [ + "did_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dids": { + "name": "dids", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trunk_id": { + "name": "trunk_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination": { + "name": "destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "caller_id_name_prefix": { + "name": "caller_id_name_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "record_inbound": { + "name": "record_inbound", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sms_enabled": { + "name": "sms_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "e911_address": { + "name": "e911_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "dids_trunk_id_trunks_id_fk": { + "name": "dids_trunk_id_trunks_id_fk", + "tableFrom": "dids", + "tableTo": "trunks", + "columnsFrom": [ + "trunk_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "dids_number_unique": { + "name": "dids_number_unique", + "nullsNotDistinct": false, + "columns": [ + "number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.emergency_events": { + "name": "emergency_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "extension_number": { + "name": "extension_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dialed": { + "name": "dialed", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "caller_id_used": { + "name": "caller_id_used", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notified": { + "name": "notified", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.extensions": { + "name": "extensions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sip_password_enc": { + "name": "sip_password_enc", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webrtc_enabled": { + "name": "webrtc_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "max_contacts": { + "name": "max_contacts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "ring_seconds": { + "name": "ring_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 25 + }, + "voicemail_enabled": { + "name": "voicemail_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "voicemail_pin_enc": { + "name": "voicemail_pin_enc", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "voicemail_email": { + "name": "voicemail_email", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "voicemail_attach_audio": { + "name": "voicemail_attach_audio", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "voicemail_delete_after_email": { + "name": "voicemail_delete_after_email", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "outbound_class": { + "name": "outbound_class", + "type": "outbound_class", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'national'" + }, + "outbound_caller_id_did_id": { + "name": "outbound_caller_id_did_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "emergency_caller_id_did_id": { + "name": "emergency_caller_id_did_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "emergency_location": { + "name": "emergency_location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "record_mode": { + "name": "record_mode", + "type": "record_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'never'" + }, + "pickup_group": { + "name": "pickup_group", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "moh_class": { + "name": "moh_class", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "blf_numbers": { + "name": "blf_numbers", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "call_report": { + "name": "call_report", + "type": "call_report", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'off'" + }, + "call_report_time": { + "name": "call_report_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'08:00'" + }, + "call_report_weekday": { + "name": "call_report_weekday", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "call_report_summaries": { + "name": "call_report_summaries", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "call_report_sent_at": { + "name": "call_report_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "extensions_user_id_users_id_fk": { + "name": "extensions_user_id_users_id_fk", + "tableFrom": "extensions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "extensions_outbound_caller_id_did_id_dids_id_fk": { + "name": "extensions_outbound_caller_id_did_id_dids_id_fk", + "tableFrom": "extensions", + "tableTo": "dids", + "columnsFrom": [ + "outbound_caller_id_did_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "extensions_emergency_caller_id_did_id_dids_id_fk": { + "name": "extensions_emergency_caller_id_did_id_dids_id_fk", + "tableFrom": "extensions", + "tableTo": "dids", + "columnsFrom": [ + "emergency_caller_id_did_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "extensions_number_unique": { + "name": "extensions_number_unique", + "nullsNotDistinct": false, + "columns": [ + "number" + ] + }, + "extensions_userId_unique": { + "name": "extensions_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.generator_runs": { + "name": "generator_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "files_changed": { + "name": "files_changed", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "commands": { + "name": "commands", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "ok": { + "name": "ok", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.holidays": { + "name": "holidays", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "recurring_yearly": { + "name": "recurring_yearly", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ivr_options": { + "name": "ivr_options", + "schema": "", + "columns": { + "ivr_id": { + "name": "ivr_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "digit": { + "name": "digit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "destination": { + "name": "destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ivr_options_ivr_id_ivrs_id_fk": { + "name": "ivr_options_ivr_id_ivrs_id_fk", + "tableFrom": "ivr_options", + "tableTo": "ivrs", + "columnsFrom": [ + "ivr_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ivr_options_ivr_id_digit_pk": { + "name": "ivr_options_ivr_id_digit_pk", + "columns": [ + "ivr_id", + "digit" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ivr_options_digit_check": { + "name": "ivr_options_digit_check", + "value": "\"ivr_options\".\"digit\" ~ '^[0-9*#]$'" + } + }, + "isRLSEnabled": false + }, + "public.ivrs": { + "name": "ivrs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "greeting_sound_id": { + "name": "greeting_sound_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invalid_sound_id": { + "name": "invalid_sound_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "timeout_seconds": { + "name": "timeout_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "max_retries": { + "name": "max_retries", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "allow_direct_dial": { + "name": "allow_direct_dial", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timeout_destination": { + "name": "timeout_destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "invalid_destination": { + "name": "invalid_destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ivrs_greeting_sound_id_sound_files_id_fk": { + "name": "ivrs_greeting_sound_id_sound_files_id_fk", + "tableFrom": "ivrs", + "tableTo": "sound_files", + "columnsFrom": [ + "greeting_sound_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "ivrs_invalid_sound_id_sound_files_id_fk": { + "name": "ivrs_invalid_sound_id_sound_files_id_fk", + "tableFrom": "ivrs", + "tableTo": "sound_files", + "columnsFrom": [ + "invalid_sound_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ivrs_number_unique": { + "name": "ivrs_number_unique", + "nullsNotDistinct": false, + "columns": [ + "number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.moh_classes": { + "name": "moh_classes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shuffle": { + "name": "shuffle", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "moh_classes_name_unique": { + "name": "moh_classes_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbound_routes": { + "name": "outbound_routes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "patterns": { + "name": "patterns", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "required_class": { + "name": "required_class", + "type": "outbound_class", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trunk_ids": { + "name": "trunk_ids", + "type": "uuid[]", + "primaryKey": false, + "notNull": true + }, + "caller_id_did_id": { + "name": "caller_id_did_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "outbound_routes_caller_id_did_id_dids_id_fk": { + "name": "outbound_routes_caller_id_did_id_dids_id_fk", + "tableFrom": "outbound_routes", + "tableTo": "dids", + "columnsFrom": [ + "caller_id_did_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paging_group_members": { + "name": "paging_group_members", + "schema": "", + "columns": { + "paging_group_id": { + "name": "paging_group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "extension_id": { + "name": "extension_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "paging_group_members_paging_group_id_paging_groups_id_fk": { + "name": "paging_group_members_paging_group_id_paging_groups_id_fk", + "tableFrom": "paging_group_members", + "tableTo": "paging_groups", + "columnsFrom": [ + "paging_group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "paging_group_members_extension_id_extensions_id_fk": { + "name": "paging_group_members_extension_id_extensions_id_fk", + "tableFrom": "paging_group_members", + "tableTo": "extensions", + "columnsFrom": [ + "extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "paging_group_members_paging_group_id_extension_id_pk": { + "name": "paging_group_members_paging_group_id_extension_id_pk", + "columns": [ + "paging_group_id", + "extension_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paging_groups": { + "name": "paging_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duplex": { + "name": "duplex", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "paging_groups_number_unique": { + "name": "paging_groups_number_unique", + "nullsNotDistinct": false, + "columns": [ + "number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.queue_calls": { + "name": "queue_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "queue_id": { + "name": "queue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "uniqueid": { + "name": "uniqueid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "linkedid": { + "name": "linkedid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "outcome": { + "name": "outcome", + "type": "queue_outcome", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "wait_seconds": { + "name": "wait_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "agent_extension_id": { + "name": "agent_extension_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "queue_calls_queue_id_joined_at_index": { + "name": "queue_calls_queue_id_joined_at_index", + "columns": [ + { + "expression": "queue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "joined_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "queue_calls_uniqueid_index": { + "name": "queue_calls_uniqueid_index", + "columns": [ + { + "expression": "uniqueid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "queue_calls_queue_id_queues_id_fk": { + "name": "queue_calls_queue_id_queues_id_fk", + "tableFrom": "queue_calls", + "tableTo": "queues", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "queue_calls_agent_extension_id_extensions_id_fk": { + "name": "queue_calls_agent_extension_id_extensions_id_fk", + "tableFrom": "queue_calls", + "tableTo": "extensions", + "columnsFrom": [ + "agent_extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.queue_member_state": { + "name": "queue_member_state", + "schema": "", + "columns": { + "queue_id": { + "name": "queue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "extension_id": { + "name": "extension_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "paused": { + "name": "paused", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "queue_member_state_queue_id_queues_id_fk": { + "name": "queue_member_state_queue_id_queues_id_fk", + "tableFrom": "queue_member_state", + "tableTo": "queues", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "queue_member_state_extension_id_extensions_id_fk": { + "name": "queue_member_state_extension_id_extensions_id_fk", + "tableFrom": "queue_member_state", + "tableTo": "extensions", + "columnsFrom": [ + "extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "queue_member_state_queue_id_extension_id_pk": { + "name": "queue_member_state_queue_id_extension_id_pk", + "columns": [ + "queue_id", + "extension_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.queue_members": { + "name": "queue_members", + "schema": "", + "columns": { + "queue_id": { + "name": "queue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "extension_id": { + "name": "extension_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "penalty": { + "name": "penalty", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "queue_members_queue_id_queues_id_fk": { + "name": "queue_members_queue_id_queues_id_fk", + "tableFrom": "queue_members", + "tableTo": "queues", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "queue_members_extension_id_extensions_id_fk": { + "name": "queue_members_extension_id_extensions_id_fk", + "tableFrom": "queue_members", + "tableTo": "extensions", + "columnsFrom": [ + "extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "queue_members_queue_id_extension_id_pk": { + "name": "queue_members_queue_id_extension_id_pk", + "columns": [ + "queue_id", + "extension_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.queues": { + "name": "queues", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "strategy": { + "name": "strategy", + "type": "queue_strategy", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'rrmemory'" + }, + "member_ring_seconds": { + "name": "member_ring_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15 + }, + "retry_seconds": { + "name": "retry_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "wrapup_seconds": { + "name": "wrapup_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "max_wait_seconds": { + "name": "max_wait_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 300 + }, + "max_callers": { + "name": "max_callers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "moh_class": { + "name": "moh_class", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "announce_position": { + "name": "announce_position", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "announce_every_seconds": { + "name": "announce_every_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 60 + }, + "join_when_no_agents": { + "name": "join_when_no_agents", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "record": { + "name": "record", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sla_seconds": { + "name": "sla_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 20 + }, + "caller_id_name_prefix": { + "name": "caller_id_name_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timeout_destination": { + "name": "timeout_destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "queues_number_unique": { + "name": "queues_number_unique", + "nullsNotDistinct": false, + "columns": [ + "number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recordings": { + "name": "recordings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "linkedid": { + "name": "linkedid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "transcript": { + "name": "transcript", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_done_at": { + "name": "ai_done_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ai_error": { + "name": "ai_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "recordings_filePath_unique": { + "name": "recordings_filePath_unique", + "nullsNotDistinct": false, + "columns": [ + "file_path" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ring_group_members": { + "name": "ring_group_members", + "schema": "", + "columns": { + "ring_group_id": { + "name": "ring_group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "extension_id": { + "name": "extension_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ring_group_members_ring_group_id_ring_groups_id_fk": { + "name": "ring_group_members_ring_group_id_ring_groups_id_fk", + "tableFrom": "ring_group_members", + "tableTo": "ring_groups", + "columnsFrom": [ + "ring_group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ring_group_members_extension_id_extensions_id_fk": { + "name": "ring_group_members_extension_id_extensions_id_fk", + "tableFrom": "ring_group_members", + "tableTo": "extensions", + "columnsFrom": [ + "extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ring_group_members_ring_group_id_extension_id_pk": { + "name": "ring_group_members_ring_group_id_extension_id_pk", + "columns": [ + "ring_group_id", + "extension_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ring_groups": { + "name": "ring_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "strategy": { + "name": "strategy", + "type": "ring_strategy", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ringall'" + }, + "ring_seconds": { + "name": "ring_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 25 + }, + "caller_id_name_prefix": { + "name": "caller_id_name_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "music_instead_of_ring": { + "name": "music_instead_of_ring", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "no_answer_destination": { + "name": "no_answer_destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ring_groups_number_unique": { + "name": "ring_groups_number_unique", + "nullsNotDistinct": false, + "columns": [ + "number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "inet", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_id_index": { + "name": "sessions_user_id_index", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sms_messages": { + "name": "sms_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "did_id": { + "name": "did_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "remote_number": { + "name": "remote_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "media": { + "name": "media", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sent_by_user_id": { + "name": "sent_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sms_messages_did_id_remote_number_created_at_index": { + "name": "sms_messages_did_id_remote_number_created_at_index", + "columns": [ + { + "expression": "did_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "remote_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sms_messages_did_id_dids_id_fk": { + "name": "sms_messages_did_id_dids_id_fk", + "tableFrom": "sms_messages", + "tableTo": "dids", + "columnsFrom": [ + "did_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sms_messages_sent_by_user_id_users_id_fk": { + "name": "sms_messages_sent_by_user_id_users_id_fk", + "tableFrom": "sms_messages", + "tableTo": "users", + "columnsFrom": [ + "sent_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sms_messages_direction_check": { + "name": "sms_messages_direction_check", + "value": "\"sms_messages\".\"direction\" in ('in', 'out')" + } + }, + "isRLSEnabled": false + }, + "public.sound_files": { + "name": "sound_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "moh_class_id": { + "name": "moh_class_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tts_text": { + "name": "tts_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_filename": { + "name": "original_filename", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "asterisk_name": { + "name": "asterisk_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sound_files_moh_class_id_moh_classes_id_fk": { + "name": "sound_files_moh_class_id_moh_classes_id_fk", + "tableFrom": "sound_files", + "tableTo": "moh_classes", + "columnsFrom": [ + "moh_class_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sound_files_asteriskName_unique": { + "name": "sound_files_asteriskName_unique", + "nullsNotDistinct": false, + "columns": [ + "asterisk_name" + ] + } + }, + "policies": {}, + "checkConstraints": { + "sound_files_kind_check": { + "name": "sound_files_kind_check", + "value": "\"sound_files\".\"kind\" in ('prompt', 'moh')" + }, + "sound_files_source_check": { + "name": "sound_files_source_check", + "value": "\"sound_files\".\"source\" in ('upload', 'tts', 'phone')" + } + }, + "isRLSEnabled": false + }, + "public.system_events": { + "name": "system_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "system_events_created_at_index": { + "name": "system_events_created_at_index", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "system_events_severity_check": { + "name": "system_events_severity_check", + "value": "\"system_events\".\"severity\" in ('info', 'warning', 'error')" + } + }, + "isRLSEnabled": false + }, + "public.time_conditions": { + "name": "time_conditions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "open_ranges": { + "name": "open_ranges", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "use_holidays": { + "name": "use_holidays", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "override_digit": { + "name": "override_digit", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "override_state": { + "name": "override_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "open_destination": { + "name": "open_destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "closed_destination": { + "name": "closed_destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "holiday_destination": { + "name": "holiday_destination", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "time_conditions_overrideDigit_unique": { + "name": "time_conditions_overrideDigit_unique", + "nullsNotDistinct": false, + "columns": [ + "override_digit" + ] + } + }, + "policies": {}, + "checkConstraints": { + "time_conditions_override_digit_check": { + "name": "time_conditions_override_digit_check", + "value": "\"time_conditions\".\"override_digit\" between 1 and 9" + }, + "time_conditions_override_state_check": { + "name": "time_conditions_override_state_check", + "value": "\"time_conditions\".\"override_state\" in ('open', 'closed')" + } + }, + "isRLSEnabled": false + }, + "public.trunks": { + "name": "trunks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "trunk_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'voipms'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "server_host": { + "name": "server_host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_port": { + "name": "server_port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5060 + }, + "transport": { + "name": "transport", + "type": "sip_transport", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'udp'" + }, + "auth_mode": { + "name": "auth_mode", + "type": "trunk_auth_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'registration'" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password_enc": { + "name": "password_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from_user": { + "name": "from_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from_domain": { + "name": "from_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "codecs": { + "name": "codecs", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{ulaw}'" + }, + "srtp": { + "name": "srtp", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "max_channels": { + "name": "max_channels", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "voipms_api_username": { + "name": "voipms_api_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "voipms_api_password_enc": { + "name": "voipms_api_password_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "trunks_name_unique": { + "name": "trunks_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_runtime_state": { + "name": "user_runtime_state", + "schema": "", + "columns": { + "extension_id": { + "name": "extension_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "dnd": { + "name": "dnd", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cfu": { + "name": "cfu", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cfb": { + "name": "cfb", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cfna": { + "name": "cfna", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_runtime_state_extension_id_extensions_id_fk": { + "name": "user_runtime_state_extension_id_extensions_id_fk", + "tableFrom": "user_runtime_state", + "tableTo": "extensions", + "columnsFrom": [ + "extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "citext", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "totp_secret_enc": { + "name": "totp_secret_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "totp_recovery_hashes": { + "name": "totp_recovery_hashes", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "totp_last_step": { + "name": "totp_last_step", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "must_change_password": { + "name": "must_change_password", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "contrack_token_enc": { + "name": "contrack_token_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contrack_log_calls": { + "name": "contrack_log_calls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "contrack_log_summaries": { + "name": "contrack_log_summaries", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "contrack_log_since": { + "name": "contrack_log_since", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.voicemail_messages": { + "name": "voicemail_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "extension_id": { + "name": "extension_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "msg_id": { + "name": "msg_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "caller_number": { + "name": "caller_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "caller_name": { + "name": "caller_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_s": { + "name": "duration_s", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emailed_at": { + "name": "emailed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "transcript": { + "name": "transcript", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "voicemail_messages_extension_id_extensions_id_fk": { + "name": "voicemail_messages_extension_id_extensions_id_fk", + "tableFrom": "voicemail_messages", + "tableTo": "extensions", + "columnsFrom": [ + "extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "voicemail_messages_msgId_unique": { + "name": "voicemail_messages_msgId_unique", + "nullsNotDistinct": false, + "columns": [ + "msg_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_deliveries": { + "name": "webhook_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "webhook_id": { + "name": "webhook_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "response_code": { + "name": "response_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "webhook_deliveries_webhook_id_webhooks_id_fk": { + "name": "webhook_deliveries_webhook_id_webhooks_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhooks", + "columnsFrom": [ + "webhook_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhooks": { + "name": "webhooks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_enc": { + "name": "secret_enc", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "events": { + "name": "events", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.call_direction": { + "name": "call_direction", + "schema": "public", + "values": [ + "inbound", + "outbound", + "internal" + ] + }, + "public.call_report": { + "name": "call_report", + "schema": "public", + "values": [ + "off", + "hourly", + "daily", + "weekly" + ] + }, + "public.call_status": { + "name": "call_status", + "schema": "public", + "values": [ + "answered", + "missed", + "voicemail", + "abandoned", + "busy", + "failed" + ] + }, + "public.outbound_class": { + "name": "outbound_class", + "schema": "public", + "values": [ + "internal", + "local", + "national", + "international" + ] + }, + "public.phone_vendor": { + "name": "phone_vendor", + "schema": "public", + "values": [ + "yealink", + "grandstream", + "polycom", + "generic" + ] + }, + "public.queue_outcome": { + "name": "queue_outcome", + "schema": "public", + "values": [ + "answered", + "abandoned", + "timed_out" + ] + }, + "public.queue_strategy": { + "name": "queue_strategy", + "schema": "public", + "values": [ + "ringall", + "leastrecent", + "fewestcalls", + "random", + "rrmemory", + "linear" + ] + }, + "public.record_mode": { + "name": "record_mode", + "schema": "public", + "values": [ + "never", + "inbound", + "outbound", + "all" + ] + }, + "public.ring_strategy": { + "name": "ring_strategy", + "schema": "public", + "values": [ + "ringall", + "hunt" + ] + }, + "public.sip_transport": { + "name": "sip_transport", + "schema": "public", + "values": [ + "udp", + "tcp", + "tls" + ] + }, + "public.trunk_auth_mode": { + "name": "trunk_auth_mode", + "schema": "public", + "values": [ + "registration", + "ip" + ] + }, + "public.trunk_provider": { + "name": "trunk_provider", + "schema": "public", + "values": [ + "voipms", + "generic" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "admin", + "user" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/src/db/migrations/meta/_journal.json b/apps/api/src/db/migrations/meta/_journal.json index f9e9a2e..2997d7f 100644 --- a/apps/api/src/db/migrations/meta/_journal.json +++ b/apps/api/src/db/migrations/meta/_journal.json @@ -64,6 +64,27 @@ "when": 1792781321577, "tag": "0008_call_reports", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1792784242837, + "tag": "0009_contact_shares", + "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1792785318384, + "tag": "0010_contrack_token", + "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1792788393381, + "tag": "0011_contrack_call_log", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index b920ef8..a3d9433 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -63,6 +63,13 @@ 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(), + /** 11 §4.4: my calls on my Contrack contacts' timelines, from contrackLogSince on. */ + contrackLogCalls: boolean().notNull().default(true), + /** …with each recorded call's summary (10 §3). */ + contrackLogSummaries: boolean().notNull().default(false), + contrackLogSince: tstz(), ...timestamps, }); @@ -620,6 +627,49 @@ export const contacts = pgTable('contacts', { ...timestamps, }); +/** A call put on (or checked for) a person's Contrack timeline (11 §4.4): never twice. */ +export const contrackCallLog = pgTable( + 'contrack_call_log', + { + callId: uuid() + .notNull() + .references(() => calls.id, { onDelete: 'cascade' }), + userId: uuid() + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + /** Contrack's interaction id; null: no contact has that number, or it couldn't be written. */ + interactionId: text(), + error: text(), + loggedAt: tstz().notNull().defaultNow(), + }, + (t) => [primaryKey({ columns: [t.callId, t.userId] })], +); + +/** A contact offered to a colleague, waiting for Accept or Deny (11 §2.1). */ +export const contactShares = pgTable( + 'contact_shares', + { + id: id(), + fromUserId: uuid().references(() => users.id, { onDelete: 'set null' }), + toUserId: uuid() + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + /** The shared contact's id in the sender's book: offering it again replaces a pending offer. */ + sourceId: text().notNull(), + /** A copy taken when shared: { name, company, email, numbers, notes }. */ + contact: jsonb().notNull(), + status: text().notNull().default('pending'), + decidedAt: tstz(), + ...timestamps, + }, + (t) => [ + index() + .on(t.toUserId) + .where(sql`${t.status} = 'pending'`), + check('contact_shares_status_check', sql`${t.status} in ('pending', 'accepted', 'denied')`), + ], +); + export const smsMessages = pgTable( 'sms_messages', { diff --git a/apps/api/src/generator/__golden__/conferences/expected/extensions_globals_selfphoneng.conf b/apps/api/src/generator/__golden__/conferences/expected/extensions_globals_selfphoneng.conf index e10b558..3cddc14 100644 --- a/apps/api/src/generator/__golden__/conferences/expected/extensions_globals_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/conferences/expected/extensions_globals_selfphoneng.conf @@ -3,4 +3,6 @@ SP_DEFAULT_CID= SP_DEFAULT_E911_CID= SP_REC_ANNOUNCE= SP_REC_SIDES= +SP_CNAM_URL= +SP_CNAM_TOKEN= SP_CLASS_101=2 diff --git a/apps/api/src/generator/__golden__/conferences/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/conferences/expected/extensions_selfphoneng.conf index 34d9ed7..4e455b4 100644 --- a/apps/api/src/generator/__golden__/conferences/expected/extensions_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/conferences/expected/extensions_selfphoneng.conf @@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})}) ; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds. ; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web). +; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each +; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member +; never sees someone else's contact name. [sp-sub-dial-user] exten => s,1,Set(DS=) same => n,Gosub(sp-sub-contacts,s,1(${ARG1})) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web)) same => n,GotoIf($["${DS}" = ""]?unavail) - same => n,Dial(${DS},${ARG2},tx) + same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain) + same => n,Gosub(sp-sub-cnam,s,1(${ARG1})) + same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain) + same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL}) + same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1)) + same => n,Set(_SP_CNAM=) + same => n,Return() + same => n(plain),Dial(${DS},${ARG2},tx) same => n,Return() same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL) same => n,Return() +; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s). +; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back. +[sp-sub-cnam] +exten => s,1,Set(CURLOPT(conntimeout)=1) + same => n,Set(CURLOPT(httptimeout)=2) + same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})}) + +; ─── Pre-dial handler on each ringing leg: the name its phone shows. +[sp-predial-cnam] +exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM}) + same => n,Return() + ; ─── Add AOR ARG1's reachable contacts to DS as PJSIP//. Not PJSIP_DIAL_CONTACTS: iOS ; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list. ; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts diff --git a/apps/api/src/generator/__golden__/empty/expected/extensions_globals_selfphoneng.conf b/apps/api/src/generator/__golden__/empty/expected/extensions_globals_selfphoneng.conf index e5b4f80..9319c87 100644 --- a/apps/api/src/generator/__golden__/empty/expected/extensions_globals_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/empty/expected/extensions_globals_selfphoneng.conf @@ -3,3 +3,5 @@ SP_DEFAULT_CID= SP_DEFAULT_E911_CID= SP_REC_ANNOUNCE= SP_REC_SIDES= +SP_CNAM_URL= +SP_CNAM_TOKEN= diff --git a/apps/api/src/generator/__golden__/empty/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/empty/expected/extensions_selfphoneng.conf index 8ba2d1e..99289e6 100644 --- a/apps/api/src/generator/__golden__/empty/expected/extensions_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/empty/expected/extensions_selfphoneng.conf @@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})}) ; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds. ; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web). +; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each +; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member +; never sees someone else's contact name. [sp-sub-dial-user] exten => s,1,Set(DS=) same => n,Gosub(sp-sub-contacts,s,1(${ARG1})) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web)) same => n,GotoIf($["${DS}" = ""]?unavail) - same => n,Dial(${DS},${ARG2},tx) + same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain) + same => n,Gosub(sp-sub-cnam,s,1(${ARG1})) + same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain) + same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL}) + same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1)) + same => n,Set(_SP_CNAM=) + same => n,Return() + same => n(plain),Dial(${DS},${ARG2},tx) same => n,Return() same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL) same => n,Return() +; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s). +; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back. +[sp-sub-cnam] +exten => s,1,Set(CURLOPT(conntimeout)=1) + same => n,Set(CURLOPT(httptimeout)=2) + same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})}) + +; ─── Pre-dial handler on each ringing leg: the name its phone shows. +[sp-predial-cnam] +exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM}) + same => n,Return() + ; ─── Add AOR ARG1's reachable contacts to DS as PJSIP//. Not PJSIP_DIAL_CONTACTS: iOS ; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list. ; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts diff --git a/apps/api/src/generator/__golden__/holidays/expected/extensions_globals_selfphoneng.conf b/apps/api/src/generator/__golden__/holidays/expected/extensions_globals_selfphoneng.conf index 4da4068..0d975b4 100644 --- a/apps/api/src/generator/__golden__/holidays/expected/extensions_globals_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/holidays/expected/extensions_globals_selfphoneng.conf @@ -3,5 +3,7 @@ SP_DEFAULT_CID= SP_DEFAULT_E911_CID= SP_REC_ANNOUNCE= SP_REC_SIDES= +SP_CNAM_URL= +SP_CNAM_TOKEN= SP_CLASS_101=2 SP_TCADMIN_101=1 diff --git a/apps/api/src/generator/__golden__/holidays/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/holidays/expected/extensions_selfphoneng.conf index 5291b40..af3d13a 100644 --- a/apps/api/src/generator/__golden__/holidays/expected/extensions_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/holidays/expected/extensions_selfphoneng.conf @@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})}) ; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds. ; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web). +; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each +; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member +; never sees someone else's contact name. [sp-sub-dial-user] exten => s,1,Set(DS=) same => n,Gosub(sp-sub-contacts,s,1(${ARG1})) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web)) same => n,GotoIf($["${DS}" = ""]?unavail) - same => n,Dial(${DS},${ARG2},tx) + same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain) + same => n,Gosub(sp-sub-cnam,s,1(${ARG1})) + same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain) + same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL}) + same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1)) + same => n,Set(_SP_CNAM=) + same => n,Return() + same => n(plain),Dial(${DS},${ARG2},tx) same => n,Return() same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL) same => n,Return() +; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s). +; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back. +[sp-sub-cnam] +exten => s,1,Set(CURLOPT(conntimeout)=1) + same => n,Set(CURLOPT(httptimeout)=2) + same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})}) + +; ─── Pre-dial handler on each ringing leg: the name its phone shows. +[sp-predial-cnam] +exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM}) + same => n,Return() + ; ─── Add AOR ARG1's reachable contacts to DS as PJSIP//. Not PJSIP_DIAL_CONTACTS: iOS ; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list. ; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts diff --git a/apps/api/src/generator/__golden__/hostile-names/expected/extensions_globals_selfphoneng.conf b/apps/api/src/generator/__golden__/hostile-names/expected/extensions_globals_selfphoneng.conf index 52ddf49..7735298 100644 --- a/apps/api/src/generator/__golden__/hostile-names/expected/extensions_globals_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/hostile-names/expected/extensions_globals_selfphoneng.conf @@ -3,6 +3,8 @@ SP_DEFAULT_CID= SP_DEFAULT_E911_CID= SP_REC_ANNOUNCE= SP_REC_SIDES= +SP_CNAM_URL= +SP_CNAM_TOKEN= SP_CLASS_103=2 SP_CLASS_104=2 SP_CLASS_105=2 diff --git a/apps/api/src/generator/__golden__/hostile-names/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/hostile-names/expected/extensions_selfphoneng.conf index 691b31b..89fc562 100644 --- a/apps/api/src/generator/__golden__/hostile-names/expected/extensions_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/hostile-names/expected/extensions_selfphoneng.conf @@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})}) ; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds. ; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web). +; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each +; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member +; never sees someone else's contact name. [sp-sub-dial-user] exten => s,1,Set(DS=) same => n,Gosub(sp-sub-contacts,s,1(${ARG1})) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web)) same => n,GotoIf($["${DS}" = ""]?unavail) - same => n,Dial(${DS},${ARG2},tx) + same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain) + same => n,Gosub(sp-sub-cnam,s,1(${ARG1})) + same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain) + same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL}) + same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1)) + same => n,Set(_SP_CNAM=) + same => n,Return() + same => n(plain),Dial(${DS},${ARG2},tx) same => n,Return() same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL) same => n,Return() +; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s). +; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back. +[sp-sub-cnam] +exten => s,1,Set(CURLOPT(conntimeout)=1) + same => n,Set(CURLOPT(httptimeout)=2) + same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})}) + +; ─── Pre-dial handler on each ringing leg: the name its phone shows. +[sp-predial-cnam] +exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM}) + same => n,Return() + ; ─── Add AOR ARG1's reachable contacts to DS as PJSIP//. Not PJSIP_DIAL_CONTACTS: iOS ; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list. ; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts diff --git a/apps/api/src/generator/__golden__/ivr-business-hours/expected/extensions_globals_selfphoneng.conf b/apps/api/src/generator/__golden__/ivr-business-hours/expected/extensions_globals_selfphoneng.conf index b6c991a..9f1e342 100644 --- a/apps/api/src/generator/__golden__/ivr-business-hours/expected/extensions_globals_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/ivr-business-hours/expected/extensions_globals_selfphoneng.conf @@ -3,6 +3,8 @@ SP_DEFAULT_CID= SP_DEFAULT_E911_CID= SP_REC_ANNOUNCE= SP_REC_SIDES= +SP_CNAM_URL= +SP_CNAM_TOKEN= SP_CLASS_101=2 SP_CLASS_102=2 SP_CLASS_103=2 diff --git a/apps/api/src/generator/__golden__/ivr-business-hours/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/ivr-business-hours/expected/extensions_selfphoneng.conf index 6dd6221..73c048e 100644 --- a/apps/api/src/generator/__golden__/ivr-business-hours/expected/extensions_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/ivr-business-hours/expected/extensions_selfphoneng.conf @@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})}) ; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds. ; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web). +; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each +; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member +; never sees someone else's contact name. [sp-sub-dial-user] exten => s,1,Set(DS=) same => n,Gosub(sp-sub-contacts,s,1(${ARG1})) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web)) same => n,GotoIf($["${DS}" = ""]?unavail) - same => n,Dial(${DS},${ARG2},tx) + same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain) + same => n,Gosub(sp-sub-cnam,s,1(${ARG1})) + same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain) + same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL}) + same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1)) + same => n,Set(_SP_CNAM=) + same => n,Return() + same => n(plain),Dial(${DS},${ARG2},tx) same => n,Return() same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL) same => n,Return() +; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s). +; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back. +[sp-sub-cnam] +exten => s,1,Set(CURLOPT(conntimeout)=1) + same => n,Set(CURLOPT(httptimeout)=2) + same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})}) + +; ─── Pre-dial handler on each ringing leg: the name its phone shows. +[sp-predial-cnam] +exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM}) + same => n,Return() + ; ─── Add AOR ARG1's reachable contacts to DS as PJSIP//. Not PJSIP_DIAL_CONTACTS: iOS ; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list. ; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts diff --git a/apps/api/src/generator/__golden__/ivr/expected/extensions_globals_selfphoneng.conf b/apps/api/src/generator/__golden__/ivr/expected/extensions_globals_selfphoneng.conf index ca1762b..06ede72 100644 --- a/apps/api/src/generator/__golden__/ivr/expected/extensions_globals_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/ivr/expected/extensions_globals_selfphoneng.conf @@ -3,5 +3,7 @@ SP_DEFAULT_CID= SP_DEFAULT_E911_CID= SP_REC_ANNOUNCE= SP_REC_SIDES= +SP_CNAM_URL= +SP_CNAM_TOKEN= SP_CLASS_101=2 SP_CLASS_102=2 diff --git a/apps/api/src/generator/__golden__/ivr/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/ivr/expected/extensions_selfphoneng.conf index 420f28f..f2b3179 100644 --- a/apps/api/src/generator/__golden__/ivr/expected/extensions_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/ivr/expected/extensions_selfphoneng.conf @@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})}) ; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds. ; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web). +; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each +; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member +; never sees someone else's contact name. [sp-sub-dial-user] exten => s,1,Set(DS=) same => n,Gosub(sp-sub-contacts,s,1(${ARG1})) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web)) same => n,GotoIf($["${DS}" = ""]?unavail) - same => n,Dial(${DS},${ARG2},tx) + same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain) + same => n,Gosub(sp-sub-cnam,s,1(${ARG1})) + same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain) + same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL}) + same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1)) + same => n,Set(_SP_CNAM=) + same => n,Return() + same => n(plain),Dial(${DS},${ARG2},tx) same => n,Return() same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL) same => n,Return() +; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s). +; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back. +[sp-sub-cnam] +exten => s,1,Set(CURLOPT(conntimeout)=1) + same => n,Set(CURLOPT(httptimeout)=2) + same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})}) + +; ─── Pre-dial handler on each ringing leg: the name its phone shows. +[sp-predial-cnam] +exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM}) + same => n,Return() + ; ─── Add AOR ARG1's reachable contacts to DS as PJSIP//. Not PJSIP_DIAL_CONTACTS: iOS ; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list. ; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts diff --git a/apps/api/src/generator/__golden__/one-user/expected/extensions_globals_selfphoneng.conf b/apps/api/src/generator/__golden__/one-user/expected/extensions_globals_selfphoneng.conf index 3385f31..330edda 100644 --- a/apps/api/src/generator/__golden__/one-user/expected/extensions_globals_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/one-user/expected/extensions_globals_selfphoneng.conf @@ -3,5 +3,7 @@ SP_DEFAULT_CID=13125551234 SP_DEFAULT_E911_CID=13125551234 SP_REC_ANNOUNCE= SP_REC_SIDES= +SP_CNAM_URL= +SP_CNAM_TOKEN= SP_CLASS_101=2 SP_TCADMIN_101=1 diff --git a/apps/api/src/generator/__golden__/one-user/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/one-user/expected/extensions_selfphoneng.conf index 935e840..7760f81 100644 --- a/apps/api/src/generator/__golden__/one-user/expected/extensions_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/one-user/expected/extensions_selfphoneng.conf @@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})}) ; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds. ; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web). +; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each +; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member +; never sees someone else's contact name. [sp-sub-dial-user] exten => s,1,Set(DS=) same => n,Gosub(sp-sub-contacts,s,1(${ARG1})) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web)) same => n,GotoIf($["${DS}" = ""]?unavail) - same => n,Dial(${DS},${ARG2},tx) + same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain) + same => n,Gosub(sp-sub-cnam,s,1(${ARG1})) + same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain) + same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL}) + same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1)) + same => n,Set(_SP_CNAM=) + same => n,Return() + same => n(plain),Dial(${DS},${ARG2},tx) same => n,Return() same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL) same => n,Return() +; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s). +; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back. +[sp-sub-cnam] +exten => s,1,Set(CURLOPT(conntimeout)=1) + same => n,Set(CURLOPT(httptimeout)=2) + same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})}) + +; ─── Pre-dial handler on each ringing leg: the name its phone shows. +[sp-predial-cnam] +exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM}) + same => n,Return() + ; ─── Add AOR ARG1's reachable contacts to DS as PJSIP//. Not PJSIP_DIAL_CONTACTS: iOS ; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list. ; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts diff --git a/apps/api/src/generator/__golden__/paging/expected/extensions_globals_selfphoneng.conf b/apps/api/src/generator/__golden__/paging/expected/extensions_globals_selfphoneng.conf index e2ed0d6..2a02788 100644 --- a/apps/api/src/generator/__golden__/paging/expected/extensions_globals_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/paging/expected/extensions_globals_selfphoneng.conf @@ -3,6 +3,8 @@ SP_DEFAULT_CID= SP_DEFAULT_E911_CID= SP_REC_ANNOUNCE= SP_REC_SIDES= +SP_CNAM_URL= +SP_CNAM_TOKEN= SP_CLASS_99=2 SP_CLASS_101=2 SP_CLASS_102=2 diff --git a/apps/api/src/generator/__golden__/paging/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/paging/expected/extensions_selfphoneng.conf index a1ef75a..8cd7f72 100644 --- a/apps/api/src/generator/__golden__/paging/expected/extensions_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/paging/expected/extensions_selfphoneng.conf @@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})}) ; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds. ; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web). +; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each +; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member +; never sees someone else's contact name. [sp-sub-dial-user] exten => s,1,Set(DS=) same => n,Gosub(sp-sub-contacts,s,1(${ARG1})) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web)) same => n,GotoIf($["${DS}" = ""]?unavail) - same => n,Dial(${DS},${ARG2},tx) + same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain) + same => n,Gosub(sp-sub-cnam,s,1(${ARG1})) + same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain) + same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL}) + same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1)) + same => n,Set(_SP_CNAM=) + same => n,Return() + same => n(plain),Dial(${DS},${ARG2},tx) same => n,Return() same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL) same => n,Return() +; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s). +; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back. +[sp-sub-cnam] +exten => s,1,Set(CURLOPT(conntimeout)=1) + same => n,Set(CURLOPT(httptimeout)=2) + same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})}) + +; ─── Pre-dial handler on each ringing leg: the name its phone shows. +[sp-predial-cnam] +exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM}) + same => n,Return() + ; ─── Add AOR ARG1's reachable contacts to DS as PJSIP//. Not PJSIP_DIAL_CONTACTS: iOS ; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list. ; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts diff --git a/apps/api/src/generator/__golden__/phone-acl/expected/extensions_globals_selfphoneng.conf b/apps/api/src/generator/__golden__/phone-acl/expected/extensions_globals_selfphoneng.conf index e10b558..3cddc14 100644 --- a/apps/api/src/generator/__golden__/phone-acl/expected/extensions_globals_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/phone-acl/expected/extensions_globals_selfphoneng.conf @@ -3,4 +3,6 @@ SP_DEFAULT_CID= SP_DEFAULT_E911_CID= SP_REC_ANNOUNCE= SP_REC_SIDES= +SP_CNAM_URL= +SP_CNAM_TOKEN= SP_CLASS_101=2 diff --git a/apps/api/src/generator/__golden__/phone-acl/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/phone-acl/expected/extensions_selfphoneng.conf index ca71e11..3b30f19 100644 --- a/apps/api/src/generator/__golden__/phone-acl/expected/extensions_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/phone-acl/expected/extensions_selfphoneng.conf @@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})}) ; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds. ; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web). +; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each +; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member +; never sees someone else's contact name. [sp-sub-dial-user] exten => s,1,Set(DS=) same => n,Gosub(sp-sub-contacts,s,1(${ARG1})) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web)) same => n,GotoIf($["${DS}" = ""]?unavail) - same => n,Dial(${DS},${ARG2},tx) + same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain) + same => n,Gosub(sp-sub-cnam,s,1(${ARG1})) + same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain) + same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL}) + same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1)) + same => n,Set(_SP_CNAM=) + same => n,Return() + same => n(plain),Dial(${DS},${ARG2},tx) same => n,Return() same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL) same => n,Return() +; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s). +; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back. +[sp-sub-cnam] +exten => s,1,Set(CURLOPT(conntimeout)=1) + same => n,Set(CURLOPT(httptimeout)=2) + same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})}) + +; ─── Pre-dial handler on each ringing leg: the name its phone shows. +[sp-predial-cnam] +exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM}) + same => n,Return() + ; ─── Add AOR ARG1's reachable contacts to DS as PJSIP//. Not PJSIP_DIAL_CONTACTS: iOS ; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list. ; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts diff --git a/apps/api/src/generator/__golden__/queue/expected/extensions_globals_selfphoneng.conf b/apps/api/src/generator/__golden__/queue/expected/extensions_globals_selfphoneng.conf index 736348e..63b7ac4 100644 --- a/apps/api/src/generator/__golden__/queue/expected/extensions_globals_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/queue/expected/extensions_globals_selfphoneng.conf @@ -3,6 +3,8 @@ SP_DEFAULT_CID= SP_DEFAULT_E911_CID= SP_REC_ANNOUNCE= SP_REC_SIDES= +SP_CNAM_URL= +SP_CNAM_TOKEN= SP_CLASS_101=2 SP_CLASS_102=2 SP_CLASS_103=2 diff --git a/apps/api/src/generator/__golden__/queue/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/queue/expected/extensions_selfphoneng.conf index 006b7ad..15bd3c2 100644 --- a/apps/api/src/generator/__golden__/queue/expected/extensions_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/queue/expected/extensions_selfphoneng.conf @@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})}) ; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds. ; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web). +; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each +; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member +; never sees someone else's contact name. [sp-sub-dial-user] exten => s,1,Set(DS=) same => n,Gosub(sp-sub-contacts,s,1(${ARG1})) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web)) same => n,GotoIf($["${DS}" = ""]?unavail) - same => n,Dial(${DS},${ARG2},tx) + same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain) + same => n,Gosub(sp-sub-cnam,s,1(${ARG1})) + same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain) + same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL}) + same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1)) + same => n,Set(_SP_CNAM=) + same => n,Return() + same => n(plain),Dial(${DS},${ARG2},tx) same => n,Return() same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL) same => n,Return() +; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s). +; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back. +[sp-sub-cnam] +exten => s,1,Set(CURLOPT(conntimeout)=1) + same => n,Set(CURLOPT(httptimeout)=2) + same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})}) + +; ─── Pre-dial handler on each ringing leg: the name its phone shows. +[sp-predial-cnam] +exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM}) + same => n,Return() + ; ─── Add AOR ARG1's reachable contacts to DS as PJSIP//. Not PJSIP_DIAL_CONTACTS: iOS ; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list. ; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts @@ -260,6 +282,7 @@ exten => 551,1,Goto(sp-queue-44444444-4444-4444-8444-444444444551,s,1) exten => s,1,Set(__SP_TARGET=550) same => n,Answer() same => n,Set(CALLERID(name)=Support: ${CALLERID(name)}) + same => n,Set(__SP_CNAM_PREFIX=Support: ${SP_CNAM_PREFIX}) same => n,Gosub(sp-sub-record,s,1) same => n,Queue(550,t,,,300) same => n,GotoIf($["${QUEUESTATUS}" = "TIMEOUT" | "${QUEUESTATUS}" = "FULL" | "${QUEUESTATUS}" = "JOINEMPTY" | "${QUEUESTATUS}" = "LEAVEEMPTY" | "${QUEUESTATUS}" = "JOINUNAVAIL" | "${QUEUESTATUS}" = "LEAVEUNAVAIL"]?fail) diff --git a/apps/api/src/generator/__golden__/ringgroups/expected/extensions_globals_selfphoneng.conf b/apps/api/src/generator/__golden__/ringgroups/expected/extensions_globals_selfphoneng.conf index 736348e..63b7ac4 100644 --- a/apps/api/src/generator/__golden__/ringgroups/expected/extensions_globals_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/ringgroups/expected/extensions_globals_selfphoneng.conf @@ -3,6 +3,8 @@ SP_DEFAULT_CID= SP_DEFAULT_E911_CID= SP_REC_ANNOUNCE= SP_REC_SIDES= +SP_CNAM_URL= +SP_CNAM_TOKEN= SP_CLASS_101=2 SP_CLASS_102=2 SP_CLASS_103=2 diff --git a/apps/api/src/generator/__golden__/ringgroups/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/ringgroups/expected/extensions_selfphoneng.conf index 1fda7b8..1dbe3f2 100644 --- a/apps/api/src/generator/__golden__/ringgroups/expected/extensions_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/ringgroups/expected/extensions_selfphoneng.conf @@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})}) ; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds. ; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web). +; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each +; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member +; never sees someone else's contact name. [sp-sub-dial-user] exten => s,1,Set(DS=) same => n,Gosub(sp-sub-contacts,s,1(${ARG1})) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web)) same => n,GotoIf($["${DS}" = ""]?unavail) - same => n,Dial(${DS},${ARG2},tx) + same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain) + same => n,Gosub(sp-sub-cnam,s,1(${ARG1})) + same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain) + same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL}) + same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1)) + same => n,Set(_SP_CNAM=) + same => n,Return() + same => n(plain),Dial(${DS},${ARG2},tx) same => n,Return() same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL) same => n,Return() +; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s). +; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back. +[sp-sub-cnam] +exten => s,1,Set(CURLOPT(conntimeout)=1) + same => n,Set(CURLOPT(httptimeout)=2) + same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})}) + +; ─── Pre-dial handler on each ringing leg: the name its phone shows. +[sp-predial-cnam] +exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM}) + same => n,Return() + ; ─── Add AOR ARG1's reachable contacts to DS as PJSIP//. Not PJSIP_DIAL_CONTACTS: iOS ; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list. ; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts @@ -260,6 +282,7 @@ exten => 502,1,Goto(sp-rg-22222222-2222-4222-8222-222222222502,s,1) [sp-rg-22222222-2222-4222-8222-222222222500] exten => s,1,Set(__SP_TARGET=500) same => n,Set(CALLERID(name)=Sales: ${CALLERID(name)}) + same => n,Set(__SP_CNAM_PREFIX=Sales: ${SP_CNAM_PREFIX}) same => n,Dial(Local/101@sp-rg-member&Local/102@sp-rg-member&Local/103@sp-rg-member,25,tm) same => n,GotoIf($["${DIALSTATUS}" = "ANSWER"]?done) same => n,Goto(sp-vm-direct,101,1) diff --git a/apps/api/src/generator/__golden__/short-numbers/expected/extensions_globals_selfphoneng.conf b/apps/api/src/generator/__golden__/short-numbers/expected/extensions_globals_selfphoneng.conf index ebc3a7b..ad8734a 100644 --- a/apps/api/src/generator/__golden__/short-numbers/expected/extensions_globals_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/short-numbers/expected/extensions_globals_selfphoneng.conf @@ -3,5 +3,7 @@ SP_DEFAULT_CID= SP_DEFAULT_E911_CID= SP_REC_ANNOUNCE= SP_REC_SIDES= +SP_CNAM_URL= +SP_CNAM_TOKEN= SP_CLASS_1=2 SP_CLASS_12=2 diff --git a/apps/api/src/generator/__golden__/short-numbers/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/short-numbers/expected/extensions_selfphoneng.conf index 5c4430a..6e7c01f 100644 --- a/apps/api/src/generator/__golden__/short-numbers/expected/extensions_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/short-numbers/expected/extensions_selfphoneng.conf @@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})}) ; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds. ; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web). +; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each +; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member +; never sees someone else's contact name. [sp-sub-dial-user] exten => s,1,Set(DS=) same => n,Gosub(sp-sub-contacts,s,1(${ARG1})) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web)) same => n,GotoIf($["${DS}" = ""]?unavail) - same => n,Dial(${DS},${ARG2},tx) + same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain) + same => n,Gosub(sp-sub-cnam,s,1(${ARG1})) + same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain) + same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL}) + same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1)) + same => n,Set(_SP_CNAM=) + same => n,Return() + same => n(plain),Dial(${DS},${ARG2},tx) same => n,Return() same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL) same => n,Return() +; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s). +; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back. +[sp-sub-cnam] +exten => s,1,Set(CURLOPT(conntimeout)=1) + same => n,Set(CURLOPT(httptimeout)=2) + same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})}) + +; ─── Pre-dial handler on each ringing leg: the name its phone shows. +[sp-predial-cnam] +exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM}) + same => n,Return() + ; ─── Add AOR ARG1's reachable contacts to DS as PJSIP//. Not PJSIP_DIAL_CONTACTS: iOS ; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list. ; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts diff --git a/apps/api/src/generator/__golden__/trunk-variants/expected/extensions_globals_selfphoneng.conf b/apps/api/src/generator/__golden__/trunk-variants/expected/extensions_globals_selfphoneng.conf index e5b4f80..9319c87 100644 --- a/apps/api/src/generator/__golden__/trunk-variants/expected/extensions_globals_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/trunk-variants/expected/extensions_globals_selfphoneng.conf @@ -3,3 +3,5 @@ SP_DEFAULT_CID= SP_DEFAULT_E911_CID= SP_REC_ANNOUNCE= SP_REC_SIDES= +SP_CNAM_URL= +SP_CNAM_TOKEN= diff --git a/apps/api/src/generator/__golden__/trunk-variants/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/trunk-variants/expected/extensions_selfphoneng.conf index 3929f5f..34200b7 100644 --- a/apps/api/src/generator/__golden__/trunk-variants/expected/extensions_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/trunk-variants/expected/extensions_selfphoneng.conf @@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})}) ; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds. ; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web). +; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each +; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member +; never sees someone else's contact name. [sp-sub-dial-user] exten => s,1,Set(DS=) same => n,Gosub(sp-sub-contacts,s,1(${ARG1})) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web)) same => n,GotoIf($["${DS}" = ""]?unavail) - same => n,Dial(${DS},${ARG2},tx) + same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain) + same => n,Gosub(sp-sub-cnam,s,1(${ARG1})) + same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain) + same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL}) + same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1)) + same => n,Set(_SP_CNAM=) + same => n,Return() + same => n(plain),Dial(${DS},${ARG2},tx) same => n,Return() same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL) same => n,Return() +; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s). +; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back. +[sp-sub-cnam] +exten => s,1,Set(CURLOPT(conntimeout)=1) + same => n,Set(CURLOPT(httptimeout)=2) + same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})}) + +; ─── Pre-dial handler on each ringing leg: the name its phone shows. +[sp-predial-cnam] +exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM}) + same => n,Return() + ; ─── Add AOR ARG1's reachable contacts to DS as PJSIP//. Not PJSIP_DIAL_CONTACTS: iOS ; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list. ; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts diff --git a/apps/api/src/generator/__golden__/voipms-basic/expected/extensions_globals_selfphoneng.conf b/apps/api/src/generator/__golden__/voipms-basic/expected/extensions_globals_selfphoneng.conf index dc8f4e0..90730d7 100644 --- a/apps/api/src/generator/__golden__/voipms-basic/expected/extensions_globals_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/voipms-basic/expected/extensions_globals_selfphoneng.conf @@ -3,4 +3,6 @@ SP_DEFAULT_CID=13125551234 SP_DEFAULT_E911_CID=13125551234 SP_REC_ANNOUNCE=custom/44444444-4444-4444-8444-444444444448 SP_REC_SIDES= +SP_CNAM_URL=http://127.0.0.1:8080/api/internal/caller-name +SP_CNAM_TOKEN=0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f SP_CLASS_101=2 diff --git a/apps/api/src/generator/__golden__/voipms-basic/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/voipms-basic/expected/extensions_selfphoneng.conf index 5303c42..c39ef34 100644 --- a/apps/api/src/generator/__golden__/voipms-basic/expected/extensions_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/voipms-basic/expected/extensions_selfphoneng.conf @@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})}) ; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds. ; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web). +; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each +; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member +; never sees someone else's contact name. [sp-sub-dial-user] exten => s,1,Set(DS=) same => n,Gosub(sp-sub-contacts,s,1(${ARG1})) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web)) same => n,GotoIf($["${DS}" = ""]?unavail) - same => n,Dial(${DS},${ARG2},tx) + same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain) + same => n,Gosub(sp-sub-cnam,s,1(${ARG1})) + same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain) + same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL}) + same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1)) + same => n,Set(_SP_CNAM=) + same => n,Return() + same => n(plain),Dial(${DS},${ARG2},tx) same => n,Return() same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL) same => n,Return() +; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s). +; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back. +[sp-sub-cnam] +exten => s,1,Set(CURLOPT(conntimeout)=1) + same => n,Set(CURLOPT(httptimeout)=2) + same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})}) + +; ─── Pre-dial handler on each ringing leg: the name its phone shows. +[sp-predial-cnam] +exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM}) + same => n,Return() + ; ─── Add AOR ARG1's reachable contacts to DS as PJSIP//. Not PJSIP_DIAL_CONTACTS: iOS ; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list. ; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts @@ -284,6 +306,7 @@ exten => 13125551234,1,Set(__SP_DID=13125551234) exten => 13125559876,1,Set(__SP_DID=13125559876) same => n,Set(CDR(SP_DID)=13125559876) same => n,Set(CALLERID(name)=SALES: ${CALLERID(name)}) + same => n,Set(__SP_CNAM_PREFIX=SALES: ${SP_CNAM_PREFIX}) same => n,Goto(sp-ext-forward,13125550000,1) ; anything else exten => _[0-9a-z].,1,Hangup() diff --git a/apps/api/src/generator/__golden__/voipms-basic/snapshot.json b/apps/api/src/generator/__golden__/voipms-basic/snapshot.json index 9951770..04a05de 100644 --- a/apps/api/src/generator/__golden__/voipms-basic/snapshot.json +++ b/apps/api/src/generator/__golden__/voipms-basic/snapshot.json @@ -117,5 +117,9 @@ "trunkIds": ["7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37"] } ], - "recordingAnnouncement": "custom/44444444-4444-4444-8444-444444444448" + "recordingAnnouncement": "custom/44444444-4444-4444-8444-444444444448", + "callerNames": { + "url": "http://127.0.0.1:8080/api/internal/caller-name", + "token": "0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f" + } } diff --git a/apps/api/src/generator/__golden__/webrtc-disabled/expected/extensions_globals_selfphoneng.conf b/apps/api/src/generator/__golden__/webrtc-disabled/expected/extensions_globals_selfphoneng.conf index c402163..fd06a99 100644 --- a/apps/api/src/generator/__golden__/webrtc-disabled/expected/extensions_globals_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/webrtc-disabled/expected/extensions_globals_selfphoneng.conf @@ -3,5 +3,7 @@ SP_DEFAULT_CID= SP_DEFAULT_E911_CID= SP_REC_ANNOUNCE= SP_REC_SIDES= +SP_CNAM_URL= +SP_CNAM_TOKEN= SP_CLASS_99=0 SP_CLASS_102=1 diff --git a/apps/api/src/generator/__golden__/webrtc-disabled/expected/extensions_selfphoneng.conf b/apps/api/src/generator/__golden__/webrtc-disabled/expected/extensions_selfphoneng.conf index 1aa689a..746bbcd 100644 --- a/apps/api/src/generator/__golden__/webrtc-disabled/expected/extensions_selfphoneng.conf +++ b/apps/api/src/generator/__golden__/webrtc-disabled/expected/extensions_selfphoneng.conf @@ -9,16 +9,38 @@ exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})}) ; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds. ; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web). +; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each +; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member +; never sees someone else's contact name. [sp-sub-dial-user] exten => s,1,Set(DS=) same => n,Gosub(sp-sub-contacts,s,1(${ARG1})) same => n,Gosub(sp-sub-contacts,s,1(${ARG1}-web)) same => n,GotoIf($["${DS}" = ""]?unavail) - same => n,Dial(${DS},${ARG2},tx) + same => n,GotoIf($["${SP_DIRECTION}" != "inbound" | "${SP_CNAM_URL}" = ""]?plain) + same => n,Gosub(sp-sub-cnam,s,1(${ARG1})) + same => n,GotoIf($["${GOSUB_RETVAL}" = ""]?plain) + same => n,Set(_SP_CNAM=${SP_CNAM_PREFIX}${GOSUB_RETVAL}) + same => n,Dial(${DS},${ARG2},txb(sp-predial-cnam^s^1)) + same => n,Set(_SP_CNAM=) + same => n,Return() + same => n(plain),Dial(${DS},${ARG2},tx) same => n,Return() same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL) same => n,Return() +; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s). +; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back. +[sp-sub-cnam] +exten => s,1,Set(CURLOPT(conntimeout)=1) + same => n,Set(CURLOPT(httptimeout)=2) + same => n,Return(${CURL(${SP_CNAM_URL}?ext=${FILTER(0-9,${ARG1})}&number=${FILTER(0-9,${CALLERID(num)})}&token=${SP_CNAM_TOKEN})}) + +; ─── Pre-dial handler on each ringing leg: the name its phone shows. +[sp-predial-cnam] +exten => s,1,Set(CONNECTEDLINE(name,i)=${SP_CNAM}) + same => n,Return() + ; ─── Add AOR ARG1's reachable contacts to DS as PJSIP//. Not PJSIP_DIAL_CONTACTS: iOS ; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list. ; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts diff --git a/apps/api/src/generator/dialplan/inbound.ts b/apps/api/src/generator/dialplan/inbound.ts index 1473979..50c7651 100644 --- a/apps/api/src/generator/dialplan/inbound.ts +++ b/apps/api/src/generator/dialplan/inbound.ts @@ -95,7 +95,13 @@ export function renderDids(snap: Snapshot): string { `; DID ${num}${d.label ? ` "${s.name(d.label)}"` : ''}`, `exten => ${num},1,Set(__SP_DID=${num})`, ` same => n,Set(CDR(SP_DID)=${num})`, - ...(prefix ? [` same => n,Set(CALLERID(name)=${prefix} \${CALLERID(name)})`] : []), + ...(prefix + ? [ + ` same => n,Set(CALLERID(name)=${prefix} \${CALLERID(name)})`, + // …and before a contact's name on ringing phones (sp-sub-dial-user, 11 §6 C4). + ` same => n,Set(__SP_CNAM_PREFIX=${prefix} \${SP_CNAM_PREFIX})`, + ] + : []), // Recorded number: the caller hears the notice (if set) before the recording starts. ...(d.recordInbound && snap.recordingAnnouncement ? [' same => n,Answer()', ` same => n,Playback(${s.sound(snap.recordingAnnouncement)})`] diff --git a/apps/api/src/generator/dialplan/local.ts b/apps/api/src/generator/dialplan/local.ts index 61bc22b..a7537fd 100644 --- a/apps/api/src/generator/dialplan/local.ts +++ b/apps/api/src/generator/dialplan/local.ts @@ -75,6 +75,9 @@ export function renderGlobals(snap: Snapshot): string { `SP_REC_ANNOUNCE=${snap.recordingAnnouncement ? s.sound(snap.recordingAnnouncement) : ''}`, // Spec 10 §3: also record each side on its own, for the transcript job. `SP_REC_SIDES=${st['ai.callTranscripts'] && st['ai.stt'] ? '1' : ''}`, + // 11 §6 C4: where sp-sub-cnam asks for a caller's name in the ringing user's contacts. + `SP_CNAM_URL=${snap.callerNames ? s.localUrl(snap.callerNames.url) : ''}`, + `SP_CNAM_TOKEN=${snap.callerNames ? s.secret(snap.callerNames.token) : ''}`, ]; const exts = [...snap.extensions].sort(byNumber); for (const e of exts) lines.push(`SP_CLASS_${s.digits(e.number)}=${CLASS_RANK[e.outboundClass]}`); diff --git a/apps/api/src/generator/dialplan/queues.ts b/apps/api/src/generator/dialplan/queues.ts index b4c168c..a9fb0de 100644 --- a/apps/api/src/generator/dialplan/queues.ts +++ b/apps/api/src/generator/dialplan/queues.ts @@ -18,7 +18,13 @@ export function renderQueueDialplan(snap: Snapshot): string { `[sp-queue-${s.ident(q.id)}]`, `exten => s,1,Set(__SP_TARGET=${num})`, ' same => n,Answer()', - ...(prefix ? [` same => n,Set(CALLERID(name)=${prefix} \${CALLERID(name)})`] : []), + ...(prefix + ? [ + ` same => n,Set(CALLERID(name)=${prefix} \${CALLERID(name)})`, + // …and before a contact's name on ringing phones (sp-sub-dial-user, 11 §6 C4). + ` same => n,Set(__SP_CNAM_PREFIX=${prefix} \${SP_CNAM_PREFIX})`, + ] + : []), ...(q.record ? [' same => n,Gosub(sp-sub-record,s,1)'] : []), ` same => n,Queue(${num},t,,,${s.digits(String(q.maxWaitSeconds))})`, ` same => n,GotoIf($[${FAILED.map((f) => `"\${QUEUESTATUS}" = "${f}"`).join(' | ')}]?fail)`, diff --git a/apps/api/src/generator/dialplan/ringgroups.ts b/apps/api/src/generator/dialplan/ringgroups.ts index aa9dbb7..efe1cc7 100644 --- a/apps/api/src/generator/dialplan/ringgroups.ts +++ b/apps/api/src/generator/dialplan/ringgroups.ts @@ -22,7 +22,13 @@ export function renderRingGroups(snap: Snapshot): string { `; ─── Ring group ${num} "${s.name(g.name)}" (${g.strategy})`, `[sp-rg-${s.ident(g.id)}]`, `exten => s,1,Set(__SP_TARGET=${num})`, - ...(prefix ? [` same => n,Set(CALLERID(name)=${prefix} \${CALLERID(name)})`] : []), + ...(prefix + ? [ + ` same => n,Set(CALLERID(name)=${prefix} \${CALLERID(name)})`, + // …and before a contact's name on ringing phones (sp-sub-dial-user, 11 §6 C4). + ` same => n,Set(__SP_CNAM_PREFIX=${prefix} \${SP_CNAM_PREFIX})`, + ] + : []), ...(g.members.length === 0 ? [] : g.strategy === 'ringall' diff --git a/apps/api/src/generator/dialplan/static.ts b/apps/api/src/generator/dialplan/static.ts index e983361..c5f7d87 100644 --- a/apps/api/src/generator/dialplan/static.ts +++ b/apps/api/src/generator/dialplan/static.ts @@ -12,16 +12,38 @@ exten => s,1,Set(N=\${FILTER(0123456789+,\${ARG1})}) ; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds. ; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web). +; A call from outside shows the caller's name from this user's contacts (11 §6 C4): set on each +; ringing leg only (sp-predial-cnam), never on the call itself, so a forward or the next member +; never sees someone else's contact name. [sp-sub-dial-user] exten => s,1,Set(DS=) same => n,Gosub(sp-sub-contacts,s,1(\${ARG1})) same => n,Gosub(sp-sub-contacts,s,1(\${ARG1}-web)) same => n,GotoIf($["\${DS}" = ""]?unavail) - same => n,Dial(\${DS},\${ARG2},tx) + same => n,GotoIf($["\${SP_DIRECTION}" != "inbound" | "\${SP_CNAM_URL}" = ""]?plain) + same => n,Gosub(sp-sub-cnam,s,1(\${ARG1})) + same => n,GotoIf($["\${GOSUB_RETVAL}" = ""]?plain) + same => n,Set(_SP_CNAM=\${SP_CNAM_PREFIX}\${GOSUB_RETVAL}) + same => n,Dial(\${DS},\${ARG2},txb(sp-predial-cnam^s^1)) + same => n,Set(_SP_CNAM=) + same => n,Return() + same => n(plain),Dial(\${DS},\${ARG2},tx) same => n,Return() same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL) same => n,Return() +; ─── The caller's name in user ARG1's contacts, or "" (none, or the api didn't answer in 2 s). +; The api only answers this machine with the key, and only letters, digits and . ' & @ - come back. +[sp-sub-cnam] +exten => s,1,Set(CURLOPT(conntimeout)=1) + same => n,Set(CURLOPT(httptimeout)=2) + same => n,Return(\${CURL(\${SP_CNAM_URL}?ext=\${FILTER(0-9,\${ARG1})}&number=\${FILTER(0-9,\${CALLERID(num)})}&token=\${SP_CNAM_TOKEN})}) + +; ─── Pre-dial handler on each ringing leg: the name its phone shows. +[sp-predial-cnam] +exten => s,1,Set(CONNECTEDLINE(name,i)=\${SP_CNAM}) + same => n,Return() + ; ─── Add AOR ARG1's reachable contacts to DS as PJSIP//. Not PJSIP_DIAL_CONTACTS: iOS ; Linphone's push params (pn-prid=…:voip&…:remote) hold '&', which splits Dial's device list. ; pn-* only matter to a push server, so they're dropped; FILTER keeps whatever else a phone puts diff --git a/apps/api/src/generator/golden-case.ts b/apps/api/src/generator/golden-case.ts index d64f8ed..4f31003 100644 --- a/apps/api/src/generator/golden-case.ts +++ b/apps/api/src/generator/golden-case.ts @@ -26,6 +26,7 @@ export function loadCase(name: string): Snapshot { holidays?: Snapshot['holidays']; announcements?: Snapshot['announcements']; mohClasses?: { name: string; shuffle?: boolean; files: string[] }[]; + callerNames?: Snapshot['callerNames']; }; const settings = Object.fromEntries( (Object.keys(SettingsSchemas) as SettingKey[]) @@ -34,6 +35,7 @@ export function loadCase(name: string): Snapshot { ) as Snapshot['settings']; return { today: raw.today ?? '2026-10-17', + ...(raw.callerNames ? { callerNames: raw.callerNames } : {}), settings, dids: (raw.dids ?? []).map((d) => ({ trunkId: '7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37', diff --git a/apps/api/src/generator/index.ts b/apps/api/src/generator/index.ts index 38fbd64..52fd65a 100644 --- a/apps/api/src/generator/index.ts +++ b/apps/api/src/generator/index.ts @@ -103,6 +103,8 @@ export type GeneratorDeps = { debounceMs?: number; /** RTP_PORT_START/END (config.ts). */ rtpPorts?: { start: number; end: number }; + /** The caller-name lookup (11 §6 C4). */ + callerNames?: { url: string; token: string }; }; const sha256 = (s: string) => createHash('sha256').update(s).digest('hex'); @@ -167,7 +169,11 @@ export function createGenerator(deps: GeneratorDeps) { restartRequired: false, }; try { - const snap = { ...(await loadSnapshot(db)), rtpPorts: deps.rtpPorts }; + const snap = { + ...(await loadSnapshot(db)), + rtpPorts: deps.rtpPorts, + callerNames: deps.callerNames, + }; const files = render(snap); await mkdir(join(dir, '.prev'), { recursive: true }); const current = new Map(); diff --git a/apps/api/src/generator/load.ts b/apps/api/src/generator/load.ts index 8dc4e2d..cac8831 100644 --- a/apps/api/src/generator/load.ts +++ b/apps/api/src/generator/load.ts @@ -205,6 +205,8 @@ export type Snapshot = { recordingAnnouncement: string | null; /** Call audio ports from the environment (RTP_PORT_START/END), not the database. */ rtpPorts?: { start: number; end: number }; + /** Caller names on ringing phones (11 §6 C4): the api's lookup URL and its key. */ + callerNames?: { url: string; token: string }; }; export const DEFAULT_RTP_PORTS = { start: 10000, end: 20000 }; diff --git a/apps/api/src/generator/sanitize.ts b/apps/api/src/generator/sanitize.ts index b9dde33..fd5c31e 100644 --- a/apps/api/src/generator/sanitize.ts +++ b/apps/api/src/generator/sanitize.ts @@ -29,6 +29,12 @@ export const sound = check(/^[a-z0-9/_-]+$/, 'sound', 100); /** Context-name parts (uuids), MOH class names. */ export const ident = check(/^[a-z0-9-]+$/, 'ident', 64); /** Generated SIP passwords. */ +/** The api on this machine, e.g. http://127.0.0.1:8080/api/internal/caller-name. */ +export const localUrl = check( + /^http:\/\/(?:127\.0\.0\.1|localhost):[0-9]{1,5}\/[a-z0-9/_-]+$/, + 'local url', + 100, +); export const secret = check(/^[A-Za-z0-9]+$/, 'secret', 64); /** Trunk usernames (voip.ms sub-accounts like 123456_pbx). */ export const sipUser = check(/^[A-Za-z0-9_.-]+$/, 'sip user', 64); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 12064d5..b142129 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -21,6 +21,7 @@ import { watchPinChanges } from './services/vmpin.ts'; import { startJobs } from './jobs.ts'; import { watchWebhooks } from './services/webhooks.ts'; import { watchEvents } from './services/events.ts'; +import { callerNameToken } from './contacts/callername.ts'; let config; try { @@ -47,6 +48,10 @@ const generator = createGenerator({ ami, dir: resolve(config.ASTERISK_CONFIG_DIR), rtpPorts: { start: config.RTP_PORT_START, end: config.RTP_PORT_END }, + callerNames: { + url: `http://127.0.0.1:${config.API_PORT}/api/internal/caller-name`, + token: callerNameToken(config.APP_SECRET), + }, log: app.log, onApplied: (r) => { live.emit('message', { diff --git a/apps/api/src/jobs.ts b/apps/api/src/jobs.ts index 7d7d756..998cc3a 100644 --- a/apps/api/src/jobs.ts +++ b/apps/api/src/jobs.ts @@ -3,7 +3,7 @@ // ponytail: setInterval instead of croner: every schedule in 04 §8 is a whole number of minutes. import { statfs } from 'node:fs/promises'; import { resolve } from 'node:path'; -import { and, eq, isNull, lt, sql } from 'drizzle-orm'; +import { and, eq, isNotNull, isNull, lt, sql } from 'drizzle-orm'; import { z } from 'zod'; import type { Config } from './config.ts'; import type { Db } from './db/client.ts'; @@ -19,11 +19,14 @@ import { import type { Generator } from './generator/index.ts'; import { todayIn } from './generator/load.ts'; import type { LiveState } from './live/state.ts'; +import { cleanupContactShares } from './routes/contacts.ts'; import { cleanupRecordings } from './routes/recordings.ts'; import { transcribeCalls } from './ai/calls.ts'; import { audit } from './services/audit.ts'; import { backupDue, runBackup } from './services/backup.ts'; import { sendCallReports } from './services/callreport.ts'; +import { contrackBook, contrackSettings } from './contacts/contrack.ts'; +import { logCallsToContrack } from './contacts/timeline.ts'; import { logEvent } from './services/events.ts'; import { deliverDue } from './services/webhooks.ts'; import type { SendMail } from './services/mail.ts'; @@ -108,6 +111,7 @@ export async function retentionCleanup(d: JobDeps) { await d.db .delete(systemEvents) .where(lt(systemEvents.createdAt, sql`now() - interval '90 days'`)); + await cleanupContactShares(d.db); if (recordings || voicemail) d.log.info({ recordings, voicemail }, 'old messages deleted'); } @@ -238,11 +242,35 @@ export function createJobs(d: JobDeps) { ); }; + /** Contrack books warmed every 5 minutes, so a ringing phone's lookup finds them (11 §6 C4). */ + const contactsWarm = async () => { + const c = await contrackSettings(d.db); + if (!c) return; + const owners = await d.db + .select({ id: users.id }) + .from(users) + .where(and(eq(users.isActive, true), isNotNull(users.contrackTokenEnc))); + for (const owner of [null, ...owners.map((o) => o.id)]) await contrackBook(d.db, c, owner); + }; + const callReports = async () => { await sendCallReports({ db: d.db, send: d.send, log: d.log, now: now() }); }; - return { daily, backup, publicIpWatch, trunkWatch, diskWatch, callReports }; + const contrackLog = async () => { + await logCallsToContrack({ db: d.db, log: d.log, now: now() }); + }; + + return { + daily, + backup, + publicIpWatch, + trunkWatch, + diskWatch, + callReports, + contactsWarm, + contrackLog, + }; } /** Starts the jobs; returns a stop function. */ @@ -265,6 +293,8 @@ export function startJobs(d: JobDeps): () => void { if (minute % 5 === 0) run('publicIpWatch', j.publicIpWatch); if (minute % 60 === 0) run('diskWatch', j.diskWatch); run('callReports', j.callReports); + if (minute % 5 === 0) run('contactsWarm', j.contactsWarm); + run('contrackLog', j.contrackLog); run('callTranscripts', async () => { await transcribeCalls({ db: d.db, dir: resolve(d.config.RECORDINGS_DIR), log: d.log }); }); diff --git a/apps/api/src/provisioning/context.ts b/apps/api/src/provisioning/context.ts index 31fa07d..09e6168 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'; @@ -26,6 +27,8 @@ export type ProvisionContext = { provPassword: string; /** The phonebook with this phone's credentials in it (phones fetch it like the config). */ phonebookUrl: string; + /** Whose phone: their own contacts go in its directory (11 §6 C6). */ + ownerUserId: string | null; ntpServer: string; /** What's dialable (dialplan.ts), so phones can send complete numbers at once. */ dialRules: DialRule[]; @@ -79,6 +82,7 @@ export async function loadContext( provUser: d.mac, provPassword, phonebookUrl: `https://${d.mac}:${provPassword}@${config.PUBLIC_HOSTNAME}/prov/phonebook/${d.mac}.xml`, + ownerUserId: e.userId, ntpServer: NTP_SERVER, dialRules: await loadDialRules(db), }, @@ -87,17 +91,17 @@ export async function loadContext( export type PhonebookEntry = { name: string; numbers: { label: string; number: string }[] }; -/** The company phonebook (07 §2): every extension, then the company contacts, by name. */ -export async function loadPhonebook(db: Db): Promise { +/** A phone's directory (07 §2): every extension, then its owner's own contacts (11 §6 C6), then + * the company's, each by name. Contrack's books in Contrack mode (11 §4.3). */ +export async function loadPhonebook( + db: Db, + ownerUserId: string | null = null, +): Promise { const exts = await db .select({ name: extensions.name, number: extensions.number }) .from(extensions) .orderBy(asc(extensions.name)); - const company = await db - .select({ name: contacts.name, numbers: contacts.numbers }) - .from(contacts) - .where(isNull(contacts.ownerUserId)) - .orderBy(asc(contacts.name)); + const company = (await readBooks(db, ownerUserId)).items; // mine first, then the company's return [ ...exts.map((x) => ({ name: x.name, numbers: [{ label: 'Extension', number: x.number }] })), ...company.map((c) => ({ diff --git a/apps/api/src/provisioning/templates.test.ts b/apps/api/src/provisioning/templates.test.ts index ec58cc9..8224b4f 100644 --- a/apps/api/src/provisioning/templates.test.ts +++ b/apps/api/src/provisioning/templates.test.ts @@ -31,6 +31,7 @@ const ctx = (over: Partial = {}): ProvisionContext => ({ provPassword: 'Pr0vPassw0rdXyZ12345', phonebookUrl: 'https://001565abcdef:Pr0vPassw0rdXyZ12345@pbx.example.com/prov/phonebook/001565abcdef.xml', + ownerUserId: null, ntpServer: 'pool.ntp.org', dialRules: dialRules(['NXXNXXXXXX', '1NXXNXXXXXX'], [3]), ...over, diff --git a/apps/api/src/routes/contacts.ts b/apps/api/src/routes/contacts.ts new file mode 100644 index 0000000..01f821b --- /dev/null +++ b/apps/api/src/routes/contacts.ts @@ -0,0 +1,352 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// /api/me/contacts (my book, shares) and /api/contacts (the company book, admin): spec 11. +import { and, desc, eq, inArray, isNull, lt, ne, sql } from 'drizzle-orm'; +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { + ContactImportSchema, + ContactInputSchema, + ContactShareDecisionSchema, + ContactShareSchema, + type ContactBookDto, + type ContactCopyResult, + type ContactData, + type ContactDto, + type ContactImportResult, + type ContactShareDto, + type ListResponse, +} from '@selfphoneng/shared'; +import { requireAdmin, requireAuth } from '../auth/guards.ts'; +import { + addToBook, + assertBuiltin, + cleanNumbers, + contactNames, + copyToContrack, + createContact, + readBooks, + toDto, + type Owner, +} from '../contacts/book.ts'; +import { parseCsv } from '../contacts/csv.ts'; +import { parseVcards } from '../contacts/vcard.ts'; +import { contactShares, contacts, extensions, users } from '../db/schema.ts'; +import { badRequest, notFound } from '../errors.ts'; +import { audit } from '../services/audit.ts'; +import { getSetting } from '../services/settings.ts'; +import { decrypt } from '../crypto/secrets.ts'; +import { contrackCheck } from '../contacts/contrack.ts'; +import { callerNameToken, phoneSafeName, sameToken } from '../contacts/callername.ts'; + +const IdParams = z.object({ id: z.uuid() }); +const CommitQuery = z.object({ commit: z.enum(['0', '1']).default('0') }); +/** 5,000 contacts or 5 MB of files per import (11 §3.1). */ +const MAX_IMPORT = 5000; +const IMPORT_BODY = 6 * 1024 * 1024; + +/** Every contact in the files, and what couldn't be read. */ +function readFiles(files: { name: string; text: string }[]) { + const list: ContactData[] = []; + const errors: string[] = []; + for (const f of files) { + const vcf = /\.vcf$/i.test(f.name) || /^\s*BEGIN:VCARD/i.test(f.text); + const r = vcf ? parseVcards(f.text) : parseCsv(f.text); + list.push(...r.contacts); + errors.push(...r.errors.map((e) => `${f.name}: ${e}`)); + } + if (list.length > MAX_IMPORT) throw badRequest(`At most ${MAX_IMPORT} contacts per import`); + return { list, errors }; +} + +export async function contactRoutes(app: FastifyInstance) { + const { db } = app; + + // ───── the routes both books have; `owner` says whose ───── + const book = ( + prefix: string, + guard: typeof requireAuth, + ownerOf: (u: { id: string }) => Owner, + ) => { + const mine = (u: { id: string }, id: string) => + db + .select() + .from(contacts) + .where( + and( + eq(contacts.id, id), + ownerOf(u) ? eq(contacts.ownerUserId, ownerOf(u)!) : isNull(contacts.ownerUserId), + ), + ) + .then(([r]) => { + if (!r) throw notFound('Contact not found'); + return r; + }); + const company = (u: { id: string }) => ownerOf(u) === null; + const log = ( + req: { user: { id: string } | null; ip: string }, + action: 'create' | 'update' | 'delete', + id: string | undefined, + summary: string, + ) => + company(req.user!) + ? audit(db, { + actorUserId: req.user!.id, + actorIp: req.ip, + action, + entityType: 'contact', + entityId: id, + summary, + }) + : undefined; + + app.post(prefix, { preHandler: guard }, async (req, reply) => { + const c = ContactInputSchema.parse(req.body); + const 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 + .update(contacts) + .set({ ...c, numbers: cleanNumbers(c), updatedAt: new Date() }) + .where(eq(contacts.id, id)) + .returning(); + await log(req, 'update', id, `Changed the company contact ${c.name}`); + return toDto(row!); + }); + + app.delete(`${prefix}/:id`, { preHandler: guard }, async (req) => { + const { id } = IdParams.parse(req.params); + 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}`); + return { ok: true }; + }); + + app.post( + `${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); + const r = await addToBook(db, ownerOf(req.user!), list, commit, errors); + if (commit && r.added) + await log(req, 'create', undefined, `Imported ${r.added} company contacts`); + return r; + }, + ); + }; + + // ───── my book ───── + /** Mine and the company's: the page filters, and the browser looks up names from it. */ + app.get('/api/me/contacts', { preHandler: requireAuth }, async (req): Promise => + readBooks(db, req.user!.id), + ); + book('/api/me/contacts', requireAuth, (u) => u.id); + + // ───── the company book ───── + app.get('/api/contacts', { preHandler: requireAdmin }, async (): Promise => + readBooks(db, null), + ); + book('/api/contacts', requireAdmin, () => null); + + /** + * The dialplan's caller-name lookup (11 §6 C4): only Asterisk on this machine (no proxy in + * between) with the token. Never waits on Contrack. Not logged: the token is in the query. + */ + app.get('/api/internal/caller-name', { logLevel: 'silent' }, async (req, reply) => { + const q = z + .object({ + ext: z.string().regex(/^\d{1,10}$/), + number: z.string().regex(/^\d{3,20}$/), + token: z.string().max(100), + }) + .safeParse(req.query); + const local = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(req.socket.remoteAddress ?? ''); + if ( + !local || + req.headers['x-forwarded-for'] || + !q.success || + !sameToken(q.data.token, callerNameToken(app.config.APP_SECRET)) + ) + return reply.code(404).send(''); + const [e] = await db + .select({ userId: extensions.userId }) + .from(extensions) + .where(eq(extensions.number, q.data.ext)); + const names = await contactNames(db, e?.userId ?? null, [q.data.number], { wait: false }).catch( + () => new Map(), + ); + return reply + .type('text/plain; charset=utf-8') + .send(phoneSafeName(names.get(q.data.number) ?? '')); + }); + + /** Copy the builtin book into Contrack (11 §5, C6): mine, or (admin) the company's. */ + app.post( + '/api/me/contacts/copy-to-contrack', + { preHandler: requireAuth }, + async (req): Promise => copyToContrack(db, req.user!.id), + ); + app.post( + '/api/contacts/copy-to-contrack', + { preHandler: requireAdmin }, + async (req): Promise => { + const r = await copyToContrack(db, null); + if (r.added) + await audit(db, { + actorUserId: req.user!.id, + actorIp: req.ip, + action: 'create', + entityType: 'contact', + summary: `Copied ${r.added} company contacts to Contrack`, + }); + return r; + }, + ); + + /** Settings → Contacts → Test (11 §4.3): the form's address, and its token or the saved one. */ + app.post('/api/contacts/contrack/test', { preHandler: requireAdmin }, async (req) => { + const b = z + .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 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 }) + .from(users) + .where(and(eq(users.isActive, true), ne(users.id, me))) + : await db + .select({ id: users.id }) + .from(extensions) + .innerJoin(users, eq(users.id, extensions.userId)) + .where( + and( + inArray(extensions.number, s.extensions), + eq(users.isActive, true), + ne(users.id, me), + ), + ); + if (!to.length) throw badRequest('Nobody to share with'); + await db.transaction(async (tx) => { + // The same contact offered again replaces the pending offer. + await tx.delete(contactShares).where( + and( + eq(contactShares.status, 'pending'), + inArray( + contactShares.toUserId, + to.map((u) => u.id), + ), + inArray( + contactShares.sourceId, + picked.map((c) => c.id), + ), + ), + ); + await tx.insert(contactShares).values( + to.flatMap((u) => + picked.map(({ id, name, company, email, numbers, notes }) => ({ + fromUserId: me, + toUserId: u.id, + sourceId: id, + contact: { name, company, email, numbers, notes }, + })), + ), + ); + }); + return { offers: picked.length * to.length, people: to.length }; + }); + + app.get( + '/api/me/contacts/shares', + { preHandler: requireAuth }, + async (req): Promise> => { + const rows = await db + .select({ s: contactShares, fromName: users.displayName }) + .from(contactShares) + .leftJoin(users, eq(users.id, contactShares.fromUserId)) + .where(and(eq(contactShares.toUserId, req.user!.id), eq(contactShares.status, 'pending'))) + .orderBy(desc(contactShares.createdAt)); + return { + items: rows.map(({ s, fromName }) => ({ + id: s.id, + from: s.fromUserId && fromName ? { userId: s.fromUserId, name: fromName } : null, + contact: s.contact as ContactData, + createdAt: s.createdAt.toISOString(), + })), + nextCursor: null, + }; + }, + ); + + for (const decision of ['accept', 'deny'] as const) + app.post(`/api/me/contacts/shares/${decision}`, { preHandler: requireAuth }, async (req) => { + const { ids } = ContactShareDecisionSchema.parse(req.body); + const me = req.user!.id; + 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), + ), + ), + ); + return r ? { added: r.added, duplicates: r.duplicates } : { denied: offers.length }; + }); +} + +/** Decided offers go after 30 days (11 §2.1); retentionCleanup calls it. */ +export const cleanupContactShares = (db: FastifyInstance['db']) => + db + .delete(contactShares) + .where( + and( + ne(contactShares.status, 'pending'), + lt(contactShares.decidedAt, sql`now() - interval '30 days'`), + ), + ); diff --git a/apps/api/src/routes/me.ts b/apps/api/src/routes/me.ts index 7ebd926..02e35c1 100644 --- a/apps/api/src/routes/me.ts +++ b/apps/api/src/routes/me.ts @@ -3,10 +3,11 @@ import { existsSync } from 'node:fs'; import { mkdir, rm } from 'node:fs/promises'; import { join } from 'node:path'; -import { and, desc, eq, isNull } from 'drizzle-orm'; +import { and, desc, eq, isNull, sql } from 'drizzle-orm'; import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { + ContrackOptionsSchema, GREETINGS, MeUpdateSchema, type ListResponse, @@ -27,8 +28,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 +124,84 @@ 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; + const [u] = await db + .select({ logCalls: users.contrackLogCalls, logSummaries: users.contrackLogSummaries }) + .from(users) + .where(eq(users.id, userId)); + return { + connected: !!book && !book.error, + tokenPrefix: token ? `${token.slice(0, 12)}…` : null, + publicUrl: c.publicUrl, + logCalls: u?.logCalls ?? true, + logSummaries: u?.logSummaries ?? false, + summariesAvailable: + (await getSetting(db, 'ai.callTranscripts')) && (await getSetting(db, 'ai.callSummaries')), + }; + } + + /** 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); + }); + // Calls are logged from now on, not the ones before (11 §4.4). + await db + .update(users) + .set({ + contrackTokenEnc: encrypt(token), + contrackLogSince: sql`coalesce(${users.contrackLogSince}, now())`, + }) + .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); + }); + + /** Log my calls in Contrack, with or without their summaries (11 §4.4). */ + app.patch('/api/me/contrack', async (req) => { + const o = ContrackOptionsSchema.parse(req.body); + const [u] = await db + .select({ logCalls: users.contrackLogCalls }) + .from(users) + .where(eq(users.id, req.user!.id)); + await db + .update(users) + .set({ + ...(o.logCalls !== undefined ? { contrackLogCalls: o.logCalls } : {}), + ...(o.logSummaries !== undefined ? { contrackLogSummaries: o.logSummaries } : {}), + // Turned back on: from now, not the calls while it was off. + ...(o.logCalls && !u?.logCalls ? { contrackLogSince: new Date() } : {}), + }) + .where(eq(users.id, req.user!.id)); + return myState(req.user!.id); + }); + + app.delete('/api/me/contrack', async (req) => { + await db.update(users).set({ contrackTokenEnc: null }).where(eq(users.id, req.user!.id)); + forgetBook(req.user!.id); + 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/provisioning.ts b/apps/api/src/routes/provisioning.ts index a11dfff..17529db 100644 --- a/apps/api/src/routes/provisioning.ts +++ b/apps/api/src/routes/provisioning.ts @@ -66,7 +66,7 @@ export async function provisioningRoutes(app: FastifyInstance) { /** The config, or (phonebook) the directory in the phone's vendor's format. */ async function render(c: ProvisionContext, phonebook = false): Promise { if (!phonebook) return c.vendor === 'yealink' ? yealink(c) : grandstream(c); - const entries = await loadPhonebook(db); + const entries = await loadPhonebook(db, c.ownerUserId); return c.vendor === 'yealink' ? yealinkPhonebook(entries) : grandstreamPhonebook(entries); } diff --git a/apps/api/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/src/services/callreport.ts b/apps/api/src/services/callreport.ts index d06e2b3..5d8ef85 100644 --- a/apps/api/src/services/callreport.ts +++ b/apps/api/src/services/callreport.ts @@ -4,6 +4,7 @@ import { and, asc, eq, gte, inArray, lt, ne, sql } from 'drizzle-orm'; import { alias } from 'drizzle-orm/pg-core'; import { formatNumber, type CallReport } from '@selfphoneng/shared'; +import { contactNames } from '../contacts/book.ts'; import type { Db } from '../db/client.ts'; import { callExtensions, calls, extensions, recordings, users } from '../db/schema.ts'; import type { SendMail } from './mail.ts'; @@ -56,6 +57,8 @@ export type ReportCall = { status: string; fromNumber: string | null; fromName: string | null; + /** The other side's name in my contacts or the company's (11 §1). */ + contactName: string | null; toNumber: string | null; talkSeconds: number; answeredBy: string | null; @@ -80,9 +83,10 @@ export function reportText( `${list.length} call${list.length === 1 ? '' : 's'} from ${when.format(period.from)} to ${when.format(period.to)}:`, ]; for (const c of list) { + const name = c.contactName ?? (c.out ? null : c.fromName); const who = c.out - ? `Outgoing to ${num(c.toNumber)}` - : `Incoming from ${num(c.fromNumber)}${c.fromName ? ` (${c.fromName})` : ''} to ${num(c.toNumber)}`; + ? `Outgoing to ${num(c.toNumber)}${name ? ` (${name})` : ''}` + : `Incoming from ${num(c.fromNumber)}${name ? ` (${name})` : ''} to ${num(c.toNumber)}`; const facts = [ c.status === 'answered' && c.answeredBy ? `answered by ${c.answeredBy}` : c.status, duration(c.talkSeconds), @@ -158,12 +162,18 @@ export async function sendCallReports(d: { .set({ callReportSentAt: period.to }) .where(eq(extensions.id, e.id)); if (!rows.length) continue; + const names = await contactNames( + d.db, + e.userId, + rows.map((r) => (r.out ? r.c.toNumber : r.c.fromNumber)), + ); const list = rows.map((r): ReportCall => ({ startedAt: r.c.startedAt, out: r.out, status: r.c.status, fromNumber: r.c.fromNumber, fromName: r.c.fromName, + contactName: names.get((r.out ? r.c.toNumber : r.c.fromNumber) ?? '') ?? null, toNumber: r.c.toNumber, talkSeconds: r.c.talkSeconds, answeredBy: r.answeredById && r.answeredById !== e.id ? r.answeredBy : null, diff --git a/apps/api/test/asterisk/cnam-test.ts b/apps/api/test/asterisk/cnam-test.ts new file mode 100644 index 0000000..af2600d --- /dev/null +++ b/apps/api/test/asterisk/cnam-test.ts @@ -0,0 +1,415 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// 11 §6 C4 against real Asterisk: a call from outside rings 101's phone with the caller's name +// from 101's contacts. A second Asterisk plays the provider, a baresip phone is 101, and a tiny +// stand-in for the api's /api/internal/caller-name runs in the PBX's network namespace (so it is +// 127.0.0.1 to Asterisk, as in production). What the phone sees is read from the INVITE the PBX +// sends it (pjsip logger). +// Checks: a contact's name; no contact keeps the provider's name; a ring group's "Sales:" prefix +// stays in front; the api not answering still rings with the provider's name; 101's contact name +// does not follow a forward to 102. +// Usage: node --conditions=source apps/api/test/asterisk/cnam-test.ts +import { spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { SettingsSchemas, type SettingKey } from '@selfphoneng/shared'; +import { AmiClient } from '../../src/ami/client.ts'; +import { GENERATED_FILES, render } from '../../src/generator/index.ts'; +import type { Snapshot } from '../../src/generator/load.ts'; +import { providerPjsip } from './provider.ts'; + +const IMAGE = process.env.ASTERISK_IMAGE ?? 'selfphoneng-asterisk'; +const NET = 'sp-cnam'; +const PBX = 'sp-cnam-pbx'; +const PROVIDER = 'sp-cnam-provider'; +const PHONES = 'sp-cnam-phones'; +const API = 'sp-cnam-api'; +const AMI_PORT = 15041; +const AMI_SECRET = 'cnam-test-secret'; +const TRUNK = '7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37'; +const SUB = { user: '123456_pbx', pass: 'ProviderSecret42' }; +const TOKEN = 'ab'.repeat(32); +const EXT = { + '101': '00000000-0000-4000-8000-000000000101', + '102': '00000000-0000-4000-8000-000000000102', +}; +const PASS = { '101': 'CnamTest101xxxxxxxxxxxxx', '102': 'CnamTest102xxxxxxxxxxxxx' }; +const DID_USER = '13125551234'; // rings 101 +const DID_GROUP = '13125551235'; // "Sales:" ring group 600 → 101 +const KNOWN = '3125559999'; // Jane Doe in 101's contacts (10 digits, as voip.ms sends) +const UNKNOWN = '3125550001'; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +const docker = (...args: string[]) => { + const r = spawnSync('docker', args, { encoding: 'utf8' }); + return { ok: r.status === 0, out: `${r.stdout ?? ''}${r.stderr ?? ''}` }; +}; +const log = (...a: unknown[]) => console.log('[cnam-test]', ...a); + +function snapshot(): Snapshot { + const settings = Object.fromEntries( + (Object.keys(SettingsSchemas) as SettingKey[]) + .filter((k) => k !== 'sms.webhookToken') + .map((k) => [k, SettingsSchemas[k].parse(undefined)]), + ) as Snapshot['settings']; + const ext = (number: '101' | '102', name: string) => ({ + id: EXT[number], + number, + name, + sipPassword: PASS[number], + webrtcEnabled: false, + maxContacts: 5, + ringSeconds: 6, // the forward check waits for 101's no-answer + voicemailEnabled: false, + voicemailPin: '4826', + outboundClass: 'national' as const, + outboundCallerId: null, + emergencyCallerId: null, + recordMode: 'never' as const, + pickupGroup: null, + language: 'en', + mohClass: 'default', + isAdmin: false, + }); + return { + today: '2026-10-23', + settings, + callerNames: { url: 'http://127.0.0.1:8080/api/internal/caller-name', token: TOKEN }, + dids: [ + { + id: '11111111-1111-4111-8111-111111111111', + number: DID_USER, + trunkId: TRUNK, + label: null, + destination: { type: 'user', id: EXT['101'] }, + callerIdNamePrefix: null, + recordInbound: false, + }, + { + id: '11111111-1111-4111-8111-111111111112', + number: DID_GROUP, + trunkId: TRUNK, + label: null, + destination: { type: 'ring_group', id: '22222222-2222-4222-8222-222222222222' }, + callerIdNamePrefix: null, + recordInbound: false, + }, + ], + extensions: [ext('101', 'Alice'), ext('102', 'Bob')], + trunks: [ + { + id: TRUNK, + name: 'voip.ms', + enabled: true, + serverHost: PROVIDER, + serverPort: 5060, + transport: 'udp', + authMode: 'registration', + username: SUB.user, + password: SUB.pass, + fromUser: null, + fromDomain: null, + codecs: ['ulaw'], + srtp: false, + maxChannels: null, + }, + ], + ringGroups: [ + { + id: '22222222-2222-4222-8222-222222222222', + number: '600', + name: 'Sales', + strategy: 'ringall', + ringSeconds: 20, + callerIdNamePrefix: 'Sales:', + musicInsteadOfRing: false, + members: ['101'], + noAnswerDestination: { type: 'hangup' }, + }, + ], + queues: [], + conferences: [], + pagingGroups: [], + mohClasses: [], + ivrs: [], + timeConditions: [], + holidays: [], + announcements: [], + recordingAnnouncement: null, + routes: [], + }; +} + +/** The provider: "Mobile" calls one of our DIDs; ${CALLER} picks who calls. */ +const providerDialplan = `[provider-out] +exten => _X.,1,Set(CALLERID(all)="Mobile" <\${CALLER}>) + same => n,Dial(PJSIP/\${EXTEN}@${SUB.user},30) + +[provider-wait] +exten => s,1,Answer() + same => n,Wait(30) + +[provider-in] +exten => _X.,1,Answer() + same => n,Wait(30) +`; + +/** baresip phones as 101 and 102: they ring, nobody answers (the INVITE is what we read). */ +function phoneFiles(dir: string) { + for (const n of ['101', '102'] as const) { + const d = join(dir, `phone-${n}`); + mkdirSync(d, { recursive: true }); + writeFileSync( + join(d, 'config'), + [ + 'poll_method epoll', + `sip_listen 0.0.0.0:${n === '101' ? 5070 : 5072}`, + 'module_path /usr/lib/baresip/modules', + 'module g711.so', + 'module ausine.so', + 'module aufile.so', + 'module menu.so', + 'module account.so', + 'audio_source ausine,440', + 'audio_player aufile,/dev/null', + 'audio_alert aufile,/dev/null', + '', + ].join('\n'), + ); + writeFileSync( + join(d, 'accounts'), + `;auth_pass=${PASS[n]};answermode=manual;audio_codecs=PCMU;regint=60\n`, + ); + } +} + +/** The api's caller-name route, faked: Jane Doe for KNOWN in 101's book, with the right key. */ +const FAKE_API = ` +const http = require('http'); +http.createServer((req, res) => { + const u = new URL(req.url, 'http://x'); + const ok = u.pathname === '/api/internal/caller-name' && u.searchParams.get('token') === '${TOKEN}'; + const name = ok && u.searchParams.get('ext') === '101' && u.searchParams.get('number') === '1${KNOWN}' ? 'Jane Doe' : ''; + console.log('lookup ' + u.searchParams.get('ext') + ' ' + u.searchParams.get('number') + ' ' + (ok ? 'key-ok' : 'key-bad') + ' -> ' + JSON.stringify(name)); + if (process.env.SLOW) return setTimeout(() => res.end(name), 5000); + res.end(name); +}).listen(8080, '127.0.0.1'); +`; + +async function waitFor(what: string, fn: () => Promise, seconds = 60) { + for (let i = 0; i < seconds; i++) { + if (await fn()) return; + await sleep(1000); + } + throw new Error(`timed out waiting for ${what}`); +} + +async function main() { + const dir = mkdtempSync(join(tmpdir(), 'sp-cnam-')); + const pbxConf = join(dir, 'pbx'); + const provConf = join(dir, 'provider'); + mkdirSync(pbxConf); + mkdirSync(provConf); + const files = render(snapshot()); + for (const f of GENERATED_FILES) { + writeFileSync(join(pbxConf, f), files[f]); + writeFileSync( + join(provConf, f), + f === 'pjsip_selfphoneng.conf' + ? providerPjsip(SUB) + : f === 'extensions_selfphoneng.conf' + ? providerDialplan + : '', + ); + } + phoneFiles(dir); + writeFileSync( + join(dir, 'manager.conf.template'), + '[general]\nenabled = yes\nport = 5038\nbindaddr = 0.0.0.0\n\n[selfphoneng]\nsecret = ${AMI_SECRET}\npermit = 0.0.0.0/0.0.0.0\nread = all\nwrite = all\n', + ); + const problems: string[] = []; + const ami = new AmiClient({ + host: '127.0.0.1', + port: AMI_PORT, + username: 'selfphoneng', + secret: AMI_SECRET, + logger: { info() {}, warn() {}, error() {}, debug() {} }, + backoffMs: [500], + }); + const asterisk = (name: string, conf: string, extra: string[]) => + docker( + 'run', + '-d', + '--rm', + '--name', + name, + '--network', + NET, + ...extra, + '-e', + `AMI_SECRET=${AMI_SECRET}`, + '-v', + `${conf}:/etc/asterisk/selfphoneng`, + IMAGE, + ); + const booted = (name: string) => + waitFor( + `${name} to boot`, + async () => docker('exec', name, 'asterisk', '-rx', 'core waitfullybooted').ok, + ); + const startApi = (slow: boolean) => { + docker('rm', '-f', API); + const r = docker( + 'run', + '-d', + '--rm', + '--name', + API, + '--network', + `container:${PBX}`, + ...(slow ? ['-e', 'SLOW=1'] : []), + 'node:22-alpine', + 'node', + '-e', + FAKE_API, + ); + if (!r.ok) throw new Error(`fake api: ${r.out}`); + }; + + /** The provider calls `did` as `caller`; returns the From display name of the INVITE to `ext`. */ + async function ring(did: string, caller: string, ext: '101' | '102'): Promise { + // channel originate can't set a variable: the provider's dialplan reads a global. + docker('exec', PROVIDER, 'asterisk', '-rx', `dialplan set global CALLER ${caller}`); + const since = new Date().toISOString(); + await ami.command('pjsip set logger on'); + const o = docker( + 'exec', + PROVIDER, + 'asterisk', + '-rx', + `channel originate Local/${did.slice(1)}@provider-out extension s@provider-wait`, + ); + if (!o.ok) throw new Error(`originate: ${o.out}`); + let from: string | null = null; + await waitFor( + `the INVITE to ${ext}`, + async () => { + const logs = docker('logs', '--since', since, PBX).out; + const invites = logs + .split(/<--- Transmitting SIP request/) + .slice(1) + .filter((b) => new RegExp(`^INVITE sip:${ext}[-@]`, 'm').test(b)); + const m = invites.map((b) => /^From: (?:"([^"]*)" )? ami.once('connected', r)); + await waitFor('the trunk to register', async () => + (await ami.actionList({ Action: 'PJSIPShowRegistrationsOutbound' })).some( + (m) => m.Status === 'Registered', + ), + ); + + log('starting baresip phones 101 and 102 (installs baresip on first run)…'); + const phones = docker( + 'run', + '-d', + '--rm', + '--name', + PHONES, + '--network', + NET, + '-v', + `${dir}:/phones`, + 'debian:trixie-slim', + 'sh', + '-c', + 'apt-get update -qq >/dev/null && apt-get install -y -qq --no-install-recommends baresip-core >/dev/null 2>&1 && ' + + '(baresip -f /phones/phone-101 > /phones/phone-101/log 2>&1 &) && (baresip -f /phones/phone-102 > /phones/phone-102/log 2>&1 &) && sleep 900', + ); + if (!phones.ok) throw new Error(`phones: ${phones.out}`); + await waitFor( + '101 and 102 to register', + async () => { + const c = await ami.command('pjsip show contacts'); + return /101\/sip:.*Avail/.test(c) && /102\/sip:.*Avail/.test(c); + }, + 240, + ); + + const expect = (what: string, got: string | null, want: string) => { + log(`${what}: the phone shows ${JSON.stringify(got)}`); + if (got !== want) + problems.push( + `${what}: phone showed ${JSON.stringify(got)}, expected ${JSON.stringify(want)}`, + ); + }; + expect('a contact calls 101', await ring(DID_USER, KNOWN, '101'), 'Jane Doe'); + expect('a stranger calls 101', await ring(DID_USER, UNKNOWN, '101'), 'Mobile'); + expect( + 'a contact calls the Sales group', + await ring(DID_GROUP, KNOWN, '101'), + 'Sales: Jane Doe', + ); + + // 101 doesn't answer and forwards to 102 (AstDB SP/CFNA/101, as My settings writes it): 101's + // phone showed Jane Doe, and 102 must not see 101's private contact name. + await ami.command('database put SP CFNA/101 102'); + expect('101 unanswered, forwarded to 102', await ring(DID_USER, KNOWN, '102'), 'Mobile'); + await ami.command('database del SP CFNA/101'); + + const lookups = docker('logs', API).out; + if (!/lookup 101 13125559999 key-ok -> "Jane Doe"/.test(lookups)) + problems.push(`no keyed lookup for 101:\n${lookups}`); + if (/key-bad/.test(lookups)) problems.push(`a lookup without the key:\n${lookups}`); + + // The api too slow (5 s, the dialplan waits 2): the phone still rings, with the provider's name. + startApi(true); + await sleep(1000); + const t0 = Date.now(); + expect('the api does not answer in time', await ring(DID_USER, KNOWN, '101'), 'Mobile'); + log(`rang after ${Math.round((Date.now() - t0) / 100) / 10} s with the api hanging`); + } finally { + ami.stop(); + if (process.env.KEEP) log(`KEEP: containers left running, config in ${dir}`); + else { + docker('rm', '-f', PBX, PROVIDER, PHONES, API); + docker('network', 'rm', NET); + rmSync(dir, { recursive: true, force: true }); + } + } + if (problems.length) { + console.error(`[cnam-test] FAILED:\n- ${problems.join('\n- ')}`); + process.exit(1); + } + log('PASS'); +} + +main().catch((e: unknown) => { + console.error('[cnam-test]', e); + process.exit(1); +}); diff --git a/apps/api/test/contacts.test.ts b/apps/api/test/contacts.test.ts new file mode 100644 index 0000000..5d2201e --- /dev/null +++ b/apps/api/test/contacts.test.ts @@ -0,0 +1,228 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// C1: my contacts, the company's, imports and shares (spec 11). +import type { LightMyRequestResponse } from 'fastify'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { contactNames } from '../src/contacts/book.ts'; +import { callerNameToken, phoneSafeName } from '../src/contacts/callername.ts'; +import { hasTestDb } from './db.ts'; +import { ADMIN, H, createTestApp } from './app.ts'; + +type Req = (method: string, url: string, payload?: unknown) => Promise; + +describe.skipIf(!hasTestDb)('contacts', () => { + let t: Awaited>; + let admin: Req; + let dan: Req; + let sue: Req; + let danId: string; + const vcf = (name: string, tel: string) => + `BEGIN:VCARD\r\nVERSION:3.0\r\nFN:${name}\r\nTEL;TYPE=CELL:${tel}\r\nEND:VCARD\r\n`; + + beforeAll(async () => { + t = await createTestApp(); + await t.clearMustChange(ADMIN.email); + const as = + (cookie: string): Req => + (method, url, payload) => + t.app.inject({ + method: method as 'GET', + url, + payload: payload as object, + headers: { ...H, cookie }, + }); + admin = as(await t.login(ADMIN.email, ADMIN.password)); + danId = (await t.addUser('dan@example.com', 'dan-password-123')).id; + const sueId = (await t.addUser('sue@example.com', 'sue-password-123')).id; + await admin('POST', '/api/extensions', { number: '101', name: 'Dan Doe', userId: danId }); + await admin('POST', '/api/extensions', { number: '102', name: 'Sue Smith', userId: sueId }); + dan = as(await t.login('dan@example.com', 'dan-password-123')); + sue = as(await t.login('sue@example.com', 'sue-password-123')); + }); + afterAll(() => t?.close()); + + it('my book and the company book: numbers normalized, users only edit their own', async () => { + const mine = await dan('POST', '/api/me/contacts', { + name: 'Jane Doe', + company: 'Acme', + email: '', + numbers: [{ label: 'mobile', number: '(312) 555-1234' }], + }); + expect(mine.statusCode, mine.body).toBe(201); + expect(mine.json()).toMatchObject({ + book: 'mine', + email: null, + numbers: [{ label: 'mobile', number: '13125551234' }], + }); + expect( + ( + await dan('POST', '/api/me/contacts', { name: 'X', numbers: [{ number: 'call me' }] }) + ).json().error.message, + ).toMatch(/isn't a phone number/); + + const co = await admin('POST', '/api/contacts', { + name: 'Pizza Place', + numbers: [{ label: 'main', number: '312-555-0000' }], + }); + expect(co.statusCode, co.body).toBe(201); + expect((await dan('GET', '/api/contacts')).statusCode).toBe(403); + // Dan sees the company contact but can't change it. + const list = (await dan('GET', '/api/me/contacts')).json().items; + expect(list.map((c: { name: string; book: string }) => [c.name, c.book])).toEqual([ + ['Jane Doe', 'mine'], + ['Pizza Place', 'company'], + ]); + expect((await dan('DELETE', `/api/me/contacts/${co.json().id}`)).statusCode).toBe(404); + expect( + (await sue('PATCH', `/api/me/contacts/${mine.json().id}`, { name: 'Mine now' })).statusCode, + ).toBe(404); + expect((await sue('GET', '/api/me/contacts')).json().items).toHaveLength(1); // company only + }); + + it('import: preview first, then save; what is already there is skipped', async () => { + const files = [ + { + name: 'phone.vcf', + text: + vcf('Jane Doe', '+1 312 555 1234') + // already in Dan's book + vcf('Bob Smith', '312.555.9999') + + 'BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Nobody Home\r\nEMAIL:n@example.com\r\nEND:VCARD\r\n', + }, + { name: 'more.csv', text: 'name,number1\nBob Smith,3125559999\nCarl,12ab\n' }, + ]; + const preview = await dan('POST', '/api/me/contacts/import', { files }); + expect(preview.json()).toEqual({ + committed: false, + added: 3, + duplicates: 2, + noNumber: 2, + errors: ['Carl: "12ab" isn\'t a phone number, left out'], + }); + expect((await dan('GET', '/api/me/contacts')).json().items).toHaveLength(2); + const saved = await dan('POST', '/api/me/contacts/import?commit=1', { files }); + expect(saved.json()).toMatchObject({ committed: true, added: 3, duplicates: 2 }); + expect((await dan('POST', '/api/me/contacts/import?commit=1', { files })).json()).toMatchObject( + { + added: 0, + duplicates: 5, + }, + ); + // An admin imports into the company book. + const co = await admin('POST', '/api/contacts/import?commit=1', { + files: [{ name: 'co.vcf', text: vcf('Plumber', '3125557777') }], + }); + expect(co.json()).toMatchObject({ added: 1 }); + expect( + (await sue('GET', '/api/me/contacts')).json().items.map((c: { name: string }) => c.name), + ).toEqual(['Pizza Place', 'Plumber']); + }); + + it('sharing offers a copy; nothing lands until Accept, Deny adds nothing', async () => { + const book = (await dan('GET', '/api/me/contacts')).json().items as { + id: string; + name: string; + book: string; + }[]; + const jane = book.find((c) => c.name === 'Jane Doe')!; + const bob = book.find((c) => c.name === 'Bob Smith')!; + expect( + ( + await dan('POST', '/api/me/contacts/share', { + contactIds: [book.find((c) => c.book === 'company')!.id], + everyone: true, + }) + ).statusCode, + ).toBe(404); // not Dan's to share + const r = await dan('POST', '/api/me/contacts/share', { + contactIds: [jane.id, bob.id], + extensions: ['102'], + }); + expect(r.json()).toEqual({ offers: 2, people: 1 }); + // Shared again: still one offer per contact. + await dan('POST', '/api/me/contacts/share', { contactIds: [jane.id, bob.id], everyone: true }); + + const before = (await sue('GET', '/api/me/contacts')).json().items.length; + const offers = (await sue('GET', '/api/me/contacts/shares')).json().items as { + id: string; + from: { name: string }; + contact: { name: string }; + }[]; + expect(offers.map((o) => [o.from.name, o.contact.name]).sort()).toEqual([ + ['dan@example.com', 'Bob Smith'], + ['dan@example.com', 'Jane Doe'], + ]); + expect((await sue('GET', '/api/me/contacts')).json().items).toHaveLength(before); + + const janeOffer = offers.find((o) => o.contact.name === 'Jane Doe')!; + const bobOffer = offers.find((o) => o.contact.name === 'Bob Smith')!; + expect( + (await sue('POST', '/api/me/contacts/shares/deny', { ids: [bobOffer.id] })).json(), + ).toEqual({ denied: 1 }); + expect( + (await sue('POST', '/api/me/contacts/shares/accept', { ids: [janeOffer.id] })).json(), + ).toEqual({ + added: 1, + duplicates: 0, + }); + // Decided offers can't be decided again, or by someone else. + expect( + (await sue('POST', '/api/me/contacts/shares/accept', { ids: [bobOffer.id] })).json(), + ).toEqual({ + added: 0, + duplicates: 0, + }); + const names = (await sue('GET', '/api/me/contacts')) + .json() + .items.map((c: { name: string }) => c.name); + expect(names).toContain('Jane Doe'); + expect(names).not.toContain('Bob Smith'); + expect((await sue('GET', '/api/me/contacts/shares')).json().items).toEqual([]); + // The copy is Sue's own: Dan editing his doesn't change it. + await dan('PATCH', `/api/me/contacts/${jane.id}`, { name: 'Jane D.', numbers: [] }); + expect( + (await sue('GET', '/api/me/contacts')).json().items.map((c: { name: string }) => c.name), + ).toContain('Jane Doe'); + }); + + it('looks up names: my book first, then the company', async () => { + await admin('POST', '/api/contacts', { + name: 'Front desk', + numbers: [{ number: '3125551234' }], + }); + await dan('POST', '/api/me/contacts', { name: 'My Jane', numbers: [{ number: '3125551234' }] }); + const names = await contactNames(t.db, danId, [ + '13125551234', + '13125550000', + '19995550000', + null, + ]); + expect(Object.fromEntries(names)).toEqual({ + '13125551234': 'My Jane', + '13125550000': 'Pizza Place', + }); + const company = await contactNames(t.db, null, ['13125551234']); + expect(company.get('13125551234')).toBe('Front desk'); + }); + + it('the dialplan lookup (C4): only with the key, never through the proxy, phone-safe names', async () => { + const token = callerNameToken(t.app.config.APP_SECRET); + const look = (q: string, headers: Record = {}) => + t.app.inject({ method: 'GET', url: `/api/internal/caller-name?${q}`, headers }); + const mine = await look(`ext=101&number=13125551234&token=${token}`); + expect([mine.statusCode, mine.body]).toEqual([200, 'My Jane']); + expect((await look(`ext=102&number=13125551234&token=${token}`)).body).toBe('Jane Doe'); // Sue's own copy, from the accepted share + expect((await look(`ext=999&number=13125551234&token=${token}`)).body).toBe('Front desk'); // no user + expect((await look(`ext=101&number=19995550000&token=${token}`)).body).toBe(''); + expect((await look('ext=101&number=13125551234&token=wrong')).statusCode).toBe(404); + expect( + ( + await look(`ext=101&number=13125551234&token=${token}`, { + 'x-forwarded-for': '203.0.113.9', + }) + ).statusCode, + ).toBe(404); + expect(phoneSafeName(String.raw`Jane "J" , Inc.; (x)^|\ Ünïcode-Smith`)).toBe( + 'Jane J Doe Inc. x Ünïcode-Smith', + ); + expect(phoneSafeName('A'.repeat(60))).toHaveLength(40); + }); +}); diff --git a/apps/api/test/contrack-live.test.ts b/apps/api/test/contrack-live.test.ts new file mode 100644 index 0000000..0cdebaf --- /dev/null +++ b/apps/api/test/contrack-live.test.ts @@ -0,0 +1,361 @@ +// 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 { eq } from 'drizzle-orm'; +import { contactNames } from '../src/contacts/book.ts'; +import { logCallsToContrack } from '../src/contacts/timeline.ts'; +import { + callExtensions, + calls, + contacts, + extensions, + recordings, + users, +} from '../src/db/schema.ts'; +import { setSetting } from '../src/services/settings.ts'; +import { loadPhonebook } from '../src/provisioning/context.ts'; +import { hasTestDb } from './db.ts'; +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, + toCopy: 0, + }); + 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 call with Jane lands on her Contrack timeline, with the summary and a follow-up (C5)', async () => { + const at = (minAgo: number) => new Date(Date.now() - minAgo * 60_000); + await t.db + .update(users) + .set({ contrackLogSince: at(60) }) + .where(eq(users.id, danId)); + await setSetting(t.db, 'ai.callTranscripts', true); + await setSetting(t.db, 'ai.callSummaries', true); + expect((await dan('PATCH', '/api/me/contrack', { logSummaries: true })).statusCode).toBe(200); + const [ext] = await t.db.select().from(extensions).where(eq(extensions.number, '101')); + const [rec] = await t.db + .insert(recordings) + .values({ + linkedid: 'live-r1', + filePath: 'live-r1.wav', + startedAt: at(20), + summary: + 'Jane asked for a & delivery dates.\nAction items:\n- Dan sends the quote by Friday', + aiDoneAt: at(15), + }) + .returning(); + const [c] = await t.db + .insert(calls) + .values({ + linkedid: 'live-c1', + direction: 'inbound', + status: 'answered', + startedAt: at(20), + endedAt: at(17), + fromNumber: '13125551234', + toNumber: '13125550000', + talkSeconds: 185, + answeredByExtensionId: ext!.id, + recordingId: rec!.id, + }) + .returning(); + await t.db + .insert(callExtensions) + .values({ callId: c!.id, extensionId: ext!.id, role: 'answered' }); + expect(await logCallsToContrack({ db: t.db, log: t.app.log })).toBe(1); + expect(await logCallsToContrack({ db: t.db, log: t.app.log })).toBe(0); + + const jane = ( + (await ctk('/api/contacts?view=slim', { token: tok.dan })).body as unknown as { + id: string; + name: string; + }[] + ).find((x) => x.name === 'Jane Doe')!; + const timeline = (await ctk(`/api/contacts/${jane.id}/timeline`, { token: tok.dan })) + .body as unknown as Record[]; + expect(timeline).toHaveLength(1); + expect(timeline[0]).toMatchObject({ + type: 'call', + title: 'Incoming call', + date: c!.startedAt.toISOString(), + duration: expect.anything(), // Contrack answers it as "4.0" + actionItems: [ + expect.objectContaining({ title: 'Dan sends the quote by Friday', completedAt: null }), + ], + }); + expect(Number(timeline[0]!.duration)).toBe(4); + const content = String(timeline[0]!.content); + expect(content).toContain('From (312) 555-1234 to (312) 555-0000 · 3:05 · recorded'); + expect(content).toContain('Jane asked for a <quote> & delivery dates.'); + expect(content).toContain('
  • Dan sends the quote by Friday
  • '); + expect(content).not.toContain(''); + }); + + it('contacts from before Contrack copy into the account in one import (C6)', async () => { + await t.db.insert(contacts).values([ + { + name: 'Old Friend', + ownerUserId: danId, + numbers: [{ label: 'home', number: '13125550202' }], + }, + { name: 'Jane Doe', ownerUserId: danId, numbers: [{ label: '', number: '13125551234' }] }, + ]); + expect((await dan('GET', '/api/me/contacts')).json().contrack.toCopy).toBe(1); + const r = await dan('POST', '/api/me/contacts/copy-to-contrack'); + expect(r.json(), r.body).toEqual({ added: 1, duplicates: 1, failed: 0 }); + const rows = (await ctk('/api/contacts?view=slim', { token: tok.dan })).body as unknown as { + name: string; + phones: { phone: string }[]; + }[]; + expect(rows.find((x) => x.name === 'Old Friend')?.phones).toEqual([{ phone: '13125550202' }]); + expect((await dan('GET', '/api/me/contacts')).json().contrack.toCopy).toBe(0); + }); + + it('a share is accepted into the recipient’s own Contrack account', async () => { + const jane = (await dan('GET', '/api/me/contacts')) + .json() + .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', 'Old Friend']); + }); +}); diff --git a/apps/api/test/contrack.test.ts b/apps/api/test/contrack.test.ts new file mode 100644 index 0000000..4936911 --- /dev/null +++ b/apps/api/test/contrack.test.ts @@ -0,0 +1,524 @@ +// 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 { eq } from 'drizzle-orm'; +import { contactNames } from '../src/contacts/book.ts'; +import { forgetBook } from '../src/contacts/contrack.ts'; +import { logCallsToContrack } from '../src/contacts/timeline.ts'; +import { + callExtensions, + calls, + contacts, + contrackCallLog, + extensions, + recordings, + users, +} from '../src/db/schema.ts'; +import { setSetting } from '../src/services/settings.ts'; +import { loadPhonebook } from '../src/provisioning/context.ts'; +import { hasTestDb } from './db.ts'; +import { ADMIN, H, createTestApp } from './app.ts'; + +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; + /** Timeline entries by contact id; `down` makes every API call fail with 503. */ + const timeline: Record[]> = {}; + const state = { down: false, readOnly: new Set() }; + const server: Server = createServer((req, res) => { + const send = (status: number, body: unknown) => { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(body)); + }; + if (req.url === '/healthz') return send(200, { ok: true }); + if (state.down) return send(503, { error: { code: 'DB_BUSY', message: 'Busy' } }); + const token = (req.headers.authorization ?? '').replace(/^Bearer /, ''); + const book = books[token]; + if (!book) return send(401, { error: { code: 'UNAUTHORIZED', message: 'Unauthorized' } }); + if (req.method === 'POST' && req.url === '/api/contacts/bulk') { + if (!/^[0-9a-f-]{36}$/.test(String(req.headers['x-import-id']))) + return send(400, { error: { code: 'VALIDATION_ERROR', message: 'X-Import-Id' } }); + let raw = ''; + req.on('data', (c) => (raw += c)); + req.on('end', () => { + const rows = JSON.parse(raw) as { name: string; phones: { phone: string }[] }[]; + for (const r of rows) + book.push({ + id: `bulk-${++n}`, + name: r.name, + company: null, + emails: [], + phones: r.phones, + }); + send(201, { + success: true, + count: rows.length, + failed: 0, + importId: req.headers['x-import-id'], + }); + }); + return; + } + const into = /^\/api\/contacts\/([^/]+)\/interactions$/.exec(req.url ?? ''); + if (req.method === 'POST' && into) { + if (state.readOnly.has(token)) + return send(403, { error: { code: 'TOKEN_READ_ONLY', message: 'Read-only token' } }); + if (!book.some((c) => c.id === into[1])) + return send(404, { error: { code: 'NOT_FOUND', message: 'Contact not found' } }); + let raw = ''; + req.on('data', (c) => (raw += c)); + req.on('end', () => { + const id = `int-${++n}`; + (timeline[into[1]!] ??= []).push({ id, ...(JSON.parse(raw) as object) }); + send(201, { id }); + }); + return; + } + if (req.method === 'GET' && req.url === '/api/contacts?view=slim') return send(200, book); + if (req.method === 'POST' && req.url === '/api/contacts') { + let raw = ''; + 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, timeline, state }; +} + +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', + logCalls: true, + logSummaries: false, + summariesAvailable: false, + }); + 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, + toCopy: 0, + }); + 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("calls go on the contact's timeline once, the summary when asked for (C5)", async () => { + const now = Date.now(); + const at = (minAgo: number) => new Date(now - minAgo * 60_000); + // Dan connected a moment ago: his calls from an hour back count for this test. + await t.db + .update(users) + .set({ contrackLogSince: at(60) }) + .where(eq(users.id, danId)); + await setSetting(t.db, 'ai.callTranscripts', true); + await setSetting(t.db, 'ai.callSummaries', true); + const [ext] = await t.db.select().from(extensions).where(eq(extensions.number, '101')); + const [done, pending] = await t.db + .insert(recordings) + .values([ + { + linkedid: 'r1', + filePath: 'r1.wav', + startedAt: at(30), + summary: 'Acme wants a quote.\nAction items:\n- Dan sends the quote', + aiDoneAt: at(25), + }, + { linkedid: 'r2', filePath: 'r2.wav', startedAt: at(10) }, + ]) + .returning(); + const call = (linkedid: string, minAgo: number, v: Partial) => ({ + linkedid, + direction: 'inbound' as const, + status: 'answered' as const, + startedAt: at(minAgo + 3), + endedAt: at(minAgo), + fromNumber: '13125551234', // Jane Doe + toNumber: '13125550000', + talkSeconds: 125, + ...v, + }); + const rows = await t.db + .insert(calls) + .values([ + call('k1', 30, { recordingId: done!.id }), // answered, recorded, summarized + call('k2', 20, { status: 'missed', talkSeconds: 0 }), + call('k3', 15, { direction: 'outbound', fromNumber: '101', toNumber: '13125551234' }), + call('k4', 12, { fromNumber: '13125550101' }), // not a contact + call('k5', 10, { recordingId: pending!.id }), // not summarized yet + call('k6', 90, {}), // before Dan connected + call('k7', 0.2, {}), // still being written (under a minute ago) + ]) + .returning(); + const role = (i: number) => (rows[i]!.direction === 'outbound' ? 'caller' : 'answered'); + await t.db + .insert(callExtensions) + .values(rows.map((r, i) => ({ callId: r.id, extensionId: ext!.id, role: role(i) }))); + const run = (min = 0) => + logCallsToContrack({ db: t.db, log: t.app.log, now: new Date(now + min * 60_000) }); + + // Summaries off (the default): no summary, and nothing waits for one. + expect(await run()).toBe(4); // k1, k2, k3, k5 (k4 a stranger, k6 too early, k7 too fresh) + const jane = fake.timeline['jane-doe-00000001']!; + expect(jane.map((e) => e.title)).toEqual([ + 'Incoming call', + 'Missed call', + 'Outgoing call', + 'Incoming call', + ]); + expect(jane[0]).toMatchObject({ + type: 'call', + source: 'selfphoneng', + date: rows[0]!.startedAt.toISOString(), + duration: 3, + content: '

    From (312) 555-1234 to (312) 555-0000 · 2:05 · recorded

    ', + }); + expect(jane[0]).not.toHaveProperty('actionItem'); + expect(await run()).toBe(0); // once + + // With summaries: the next recorded call waits for its summary, then carries it. + await dan('PATCH', '/api/me/contrack', { logSummaries: true }); + const [k8] = await t.db + .insert(calls) + .values(call('k8', 5, { recordingId: done!.id })) + .returning(); + const [k9] = await t.db + .insert(calls) + .values(call('k9', 4, { recordingId: pending!.id })) + .returning(); + await t.db + .insert(callExtensions) + .values([k8!, k9!].map((r) => ({ callId: r.id, extensionId: ext!.id, role: 'answered' }))); + expect(await run()).toBe(1); // k8; k9 waits + expect(jane.at(-1)).toMatchObject({ + content: + '

    From (312) 555-1234 to (312) 555-0000 · 2:05 · recorded

    Acme wants a quote.

    Action items:

    • Dan sends the quote
    ', + actionItem: { + title: 'Dan sends the quote', + dueAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/), + }, + }); + // 40 minutes on: k9 without a summary (it waited 30), and k7 is old enough now. + expect(await run(40)).toBe(2); + expect(jane.find((e) => e.date === k9!.startedAt.toISOString())!.content).toBe( + '

    From (312) 555-1234 to (312) 555-0000 · 2:05 · recorded

    ', + ); + expect(jane).toHaveLength(7); + + // Contrack down: nothing is lost, the call goes in on a later run. + const [k10] = await t.db + .insert(calls) + .values(call('k10', 3, {})) + .returning(); + await t.db + .insert(callExtensions) + .values({ callId: k10!.id, extensionId: ext!.id, role: 'answered' }); + forgetBook(danId); + fake.state.down = true; + expect(await run()).toBe(0); + fake.state.down = false; + forgetBook(danId); + expect(await run()).toBe(1); + // A read-only token: noted once, not retried every minute. + fake.state.readOnly.add('ctk_dan_0001'); + const [k11] = await t.db + .insert(calls) + .values(call('k11', 2, {})) + .returning(); + await t.db + .insert(callExtensions) + .values({ callId: k11!.id, extensionId: ext!.id, role: 'answered' }); + expect(await run()).toBe(0); + const [noted] = await t.db + .select() + .from(contrackCallLog) + .where(eq(contrackCallLog.callId, k11!.id)); + expect(noted).toMatchObject({ interactionId: null, error: 'Contrack: Read-only token' }); + fake.state.readOnly.clear(); + expect(await run()).toBe(0); + expect(jane).toHaveLength(8); + }); + + it('contacts from before Contrack are copied once, the builtin ones kept (C6)', async () => { + await t.db.insert(contacts).values([ + { + name: 'Jane Doe', + ownerUserId: danId, + numbers: [{ label: 'mobile', number: '13125551234' }], + }, // in Contrack + { + name: 'Old Friend', + ownerUserId: danId, + numbers: [{ label: 'home', number: '13125550202' }], + }, + { name: 'Plumber', numbers: [{ label: '', number: '13125557777' }] }, // in the company account + { name: 'Locksmith', numbers: [{ label: 'work', number: '13125550303' }] }, + ]); + expect((await dan('GET', '/api/me/contacts')).json().contrack.toCopy).toBe(1); + expect((await dan('POST', '/api/me/contacts/copy-to-contrack')).json()).toEqual({ + added: 1, + duplicates: 1, + failed: 0, + }); + expect(fake.books.ctk_dan_0001!.at(-1)).toMatchObject({ + name: 'Old Friend', + phones: [{ phone: '13125550202', label: 'home' }], + }); + expect((await dan('GET', '/api/me/contacts')).json().contrack.toCopy).toBe(0); + expect((await dan('POST', '/api/me/contacts/copy-to-contrack')).json()).toEqual({ + added: 0, + duplicates: 2, + failed: 0, + }); + // The company book, by an admin only. + expect((await dan('POST', '/api/contacts/copy-to-contrack')).statusCode).toBe(403); + expect((await admin('GET', '/api/contacts')).json().contrack.toCopy).toBe(1); + expect((await admin('POST', '/api/contacts/copy-to-contrack')).json()).toEqual({ + added: 1, + duplicates: 1, + failed: 0, + }); + expect(fake.books.ctk_company!.map((c) => c.name)).toEqual(['Plumber', 'Locksmith']); + // Dan's desk phone: his Contrack contacts, then the company's. + const names = (await loadPhonebook(t.db, danId)).map((e) => e.name); + expect(names.indexOf('Old Friend')).toBeGreaterThan(-1); + expect(names.indexOf('Old Friend')).toBeLessThan(names.indexOf('Locksmith')); + expect((await loadPhonebook(t.db, null)).map((e) => e.name)).not.toContain('Old Friend'); + }); + + it('back to builtin: the table again, tokens kept for later', async () => { + await admin('PATCH', '/api/settings', { 'contacts.provider': 'builtin' }); + const book = (await dan('GET', '/api/me/contacts')).json(); + expect([book.provider, book.contrack]).toEqual(['builtin', null]); + expect(book.items.map((c: { name: string }) => c.name)).toEqual([ + 'Jane Doe', + 'Old Friend', + 'Locksmith', + 'Plumber', + ]); + expect((await dan('GET', '/api/me')).json().contrack).toBeNull(); + }); +}); diff --git a/apps/api/test/devices.test.ts b/apps/api/test/devices.test.ts index a139bcd..1914fb0 100644 --- a/apps/api/test/devices.test.ts +++ b/apps/api/test/devices.test.ts @@ -245,6 +245,32 @@ describe.skipIf(!hasTestDb)('/api/devices and /prov', () => { await setSetting(t.db, 'general.timezone', 'America/Chicago'); }); + it("11 §6 C6: a phone's directory has its owner's own contacts, after the extensions", async () => { + const owner = await t.addUser('dan@example.com', 'dan-password-123'); + const other = await t.addUser('sue@example.com', 'sue-password-123'); + await req('PATCH', `/api/extensions/${extId}`, { userId: owner.id }); + await t.db.insert(contacts).values([ + { name: 'Mom', ownerUserId: owner.id, numbers: [{ label: 'mobile', number: '13125550101' }] }, + { + name: 'Sue Secret', + ownerUserId: other.id, + numbers: [{ label: '', number: '13125550102' }], + }, + ]); + const book = ( + await prov(`phonebook/${device.mac}.xml`, '192.168.1.60', device.provisioningPassword) + ).body; + expect(book).toContain('13125550101'); + expect(book).not.toContain('Sue Secret'); + expect(book.indexOf('Ann Lee')).toBeLessThan(book.indexOf('Mom')); + expect(book.indexOf('Mom')).toBeLessThan(book.indexOf('Acme')); // mine before the company's + await req('PATCH', `/api/extensions/${extId}`, { userId: null }); + const unowned = ( + await prov(`phonebook/${device.mac}.xml`, '192.168.1.60', device.provisioningPassword) + ).body; + expect(unowned).not.toContain('Mom'); + }); + it('M10-T3: Linphone QR (admin and me), the token is the credential, audited', async () => { const r = await req('GET', `/api/extensions/${extId}/softphone-qr`); expect(r.statusCode, r.body).toBe(200); diff --git a/apps/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 49a7257..3e512df 100644 --- a/apps/api/test/schema.test.ts +++ b/apps/api/test/schema.test.ts @@ -23,6 +23,10 @@ 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|', + contrack_log_calls: 'boolean|NO|true', + contrack_log_summaries: 'boolean|NO|false', + contrack_log_since: 'timestamp with time zone|YES|', ...ts, }, extensions: { @@ -111,11 +115,11 @@ describe.skipIf(!hasTestDb)('schema matches spec 02', () => { beforeAll(async () => ({ db, drop } = await createTestDb())); afterAll(() => drop?.()); - it('creates all 41 tables', async () => { + it('creates all 43 tables', async () => { const r = await db.execute( sql`select count(*)::int as n from information_schema.tables where table_schema = 'public'`, ); - expect(r.rows[0]).toEqual({ n: 41 }); + expect(r.rows[0]).toEqual({ n: 43 }); }); it.each(Object.keys(EXPECTED))('%s columns', async (table) => { diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 3ec1084..cfb90c8 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -22,6 +22,14 @@ import type { CallDetailDto, CallDto, CallFilter, + ContactBookDto, + ContactCopyResult, + ContrackOptions, + ContactDto, + ContactImportResult, + ContactInput, + ContactShare, + ContactShareDto, MyCallDto, RecordingDto, AuthUser, @@ -265,6 +273,9 @@ 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'), + contrackOptions: (o: ContrackOptions) => api('PATCH', '/api/me/contrack', o), webrtc: () => api('GET', '/api/me/webrtc'), /** Log in to (unpause) or out of (pause) a queue I'm an agent of. */ setQueuePaused: (queueId: string, paused: boolean) => @@ -284,6 +295,36 @@ export const meApi = { deleteGreeting: (kind: GreetingKind) => api<{ ok: true }>('DELETE', `/api/me/greetings/${kind}`), }; +/** My book is /api/me/contacts (it lists the company's too); the company's is /api/contacts. */ +export type ContactBase = '/api/me/contacts' | '/api/contacts'; + +export const contactsApi = { + list: (base: ContactBase) => api('GET', base), + copyToContrack: (base: ContactBase) => api('POST', `${base}/copy-to-contrack`), + 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), + remove: (base: ContactBase, id: string) => api<{ ok: true }>('DELETE', `${base}/${id}`), + import: (base: ContactBase, files: { name: string; text: string }[], commit: boolean) => + api('POST', `${base}/import?commit=${commit ? 1 : 0}`, { files }), + share: (s: ContactShare) => + api<{ offers: number; people: number }>('POST', '/api/me/contacts/share', s), + shares: () => + api>('GET', '/api/me/contacts/shares').then((r) => r.items), + decide: (decision: 'accept' | 'deny', ids: string[]) => + api<{ added?: number; duplicates?: number; denied?: number }>( + 'POST', + `/api/me/contacts/shares/${decision}`, + { ids }, + ), +}; + export const soundsApi = { list: () => api>('GET', '/api/sounds').then((r) => r.items), audioUrl: (id: string) => `/api/sounds/${id}/audio`, diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 7207dac..3476200 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -27,6 +27,7 @@ import { OfficeHoursPage } from './pages/admin/OfficeHoursPage.tsx'; import { BlockedPage } from './pages/admin/BlockedPage.tsx'; import { CallsPage } from './pages/admin/CallsPage.tsx'; import { RecordingsPage } from './pages/admin/RecordingsPage.tsx'; +import { CompanyContactsPage } from './pages/user/ContactsPage.tsx'; import { ReportsPage } from './pages/admin/ReportsPage.tsx'; import { SystemPage } from './pages/admin/SystemPage.tsx'; import { MePage } from './pages/user/MePage.tsx'; @@ -75,6 +76,8 @@ const router = createBrowserRouter([ ) : p.path === 'blocked' ? ( + ) : p.path === 'contacts' ? ( + ) : p.path === 'calls' ? ( ) : p.path === 'recordings' ? ( diff --git a/apps/web/src/pages/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' ? ( + + ) : ( + + )}