diff --git a/.gitignore b/.gitignore index 8abbb9b..339527b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ data/ coverage/ .pnpm-store/ *.pid +# The mobile app is its own repo (13), checked out here beside the PBX. +mobile/ diff --git a/.prettierignore b/.prettierignore index 5322f64..5b8e3cd 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,3 +6,4 @@ dist/ AGENTS.md CLAUDE.md apps/api/src/db/migrations/ +mobile/ diff --git a/AGENTS.md b/AGENTS.md index 210f7e2..cb9779b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,7 @@ write the software around it: database, config generator, API, web UI. | `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 | | `docs/spec/12-sso.md` | Single sign-on: OpenID Connect (Keycloak and others) and LDAP/LDAPS | Working on sign-in, users or roles | +| `docs/spec/13-mobile.md` | The Android/iPhone app (`mobile` repo): pairing, device tokens, its tasks | Working on the mobile app or its API | ## Hard rules diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 496e119..4879bcf 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -23,6 +23,7 @@ import { didRoutes } from './routes/dids.ts'; import { outboundRouteRoutes } from './routes/outbound.ts'; import { liveRoutes } from './live/hub.ts'; import { meRoutes } from './routes/me.ts'; +import { mobileRoutes } from './routes/mobile.ts'; import { soundRoutes } from './routes/sounds.ts'; import { ringGroupRoutes } from './routes/ringgroups.ts'; import { queueRoutes } from './routes/queues.ts'; @@ -159,6 +160,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public')) app.register(settingsRoutes); app.register(liveRoutes); app.register(meRoutes); + app.register(mobileRoutes); app.register(contactRoutes); app.register(soundRoutes); app.register(ringGroupRoutes); diff --git a/apps/api/src/auth/devices.ts b/apps/api/src/auth/devices.ts new file mode 100644 index 0000000..764a954 --- /dev/null +++ b/apps/api/src/auth/devices.ts @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Paired phones (13 §2): a one-time pairing code from the web, traded by the app for a device token +// `Authorization: Bearer spm_<40 base62>`, stored as sha256, acting as the phone's user. +import { randomInt } from 'node:crypto'; +import { and, eq, gt, isNull } from 'drizzle-orm'; +import type { Db } from '../db/client.ts'; +import { mobileDevices, users } from '../db/schema.ts'; +import { generateToken, hashToken } from './tokens.ts'; + +export const DEVICE_TOKEN_RE = /^spm_[A-Za-z0-9]{40}$/; +/** No 0/O/1/I: people type it from a screen. */ +export const PAIR_CODE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; +export const PAIR_CODE_TTL_MS = 10 * 60_000; + +export const generateDeviceToken = () => `spm_${generateToken().slice(3)}`; + +export function generatePairCode(): string { + let s = ''; + for (let i = 0; i < 12; i++) s += PAIR_CODE_CHARS[randomInt(PAIR_CODE_CHARS.length)]; + return s; +} + +/** The device's (active) user, or null for an unknown, removed or malformed token. */ +export async function getDeviceUser(db: Db, token: string) { + if (!DEVICE_TOKEN_RE.test(token)) return null; + const [row] = await db + .select({ id: mobileDevices.id, user: users }) + .from(mobileDevices) + .innerJoin(users, eq(users.id, mobileDevices.userId)) + .where(and(eq(mobileDevices.tokenHash, hashToken(token)), eq(users.isActive, true))); + if (!row) return null; + await db + .update(mobileDevices) + .set({ lastUsedAt: new Date() }) + .where(eq(mobileDevices.id, row.id)); + return { user: row.user, deviceId: row.id }; +} + +/** + * Trades a pairing code for a device token, once. Null for a code that's unknown, used, expired, + * or whose user was disabled since. + */ +export async function pairDevice( + db: Db, + code: string, + device: { name: string; platform: 'android' | 'ios' }, +) { + const [row] = await db + .select({ id: mobileDevices.id, user: users }) + .from(mobileDevices) + .innerJoin(users, eq(users.id, mobileDevices.userId)) + .where( + and( + eq(mobileDevices.pairCodeHash, hashToken(code)), + gt(mobileDevices.pairExpiresAt, new Date()), + eq(users.isActive, true), + ), + ); + if (!row) return null; + const token = generateDeviceToken(); + // The code is cleared in the same statement: two apps racing with one code can't both win. + const [won] = await db + .update(mobileDevices) + .set({ + ...device, + tokenPrefix: token.slice(0, 8), + tokenHash: hashToken(token), + pairCodeHash: null, + pairExpiresAt: null, + }) + .where(and(eq(mobileDevices.id, row.id), isNull(mobileDevices.tokenHash))) + .returning({ id: mobileDevices.id }); + return won ? { token, deviceId: won.id, user: row.user } : null; +} diff --git a/apps/api/src/auth/guards.ts b/apps/api/src/auth/guards.ts index 925aa73..f3c6279 100644 --- a/apps/api/src/auth/guards.ts +++ b/apps/api/src/auth/guards.ts @@ -5,6 +5,7 @@ import type { FastifyReply, FastifyRequest } from 'fastify'; import type { users } from '../db/schema.ts'; import { forbidden, unauthorized } from '../errors.ts'; import { COOKIE, getSessionUser, mustChange, setSessionCookie } from './sessions.ts'; +import { getDeviceUser } from './devices.ts'; import { getTokenUser } from './tokens.ts'; export type CurrentUser = typeof users.$inferSelect; @@ -15,22 +16,28 @@ declare module 'fastify' { sessionId: string | null; /** Set when the request came with an API token instead of a session. */ apiTokenId?: string; + /** Set when the request came from a paired phone (13 §2). */ + mobileDeviceId?: string; } } /** - * Sets req.user from an API token (Authorization: Bearer, 04 §2.3) or else the session cookie. - * Enforces must_change_password outside /api/auth/*. + * Sets req.user from a device token (`spm_`, 13 §2) or API token (Authorization: Bearer, 04 §2.3), + * or else the session cookie. Enforces must_change_password outside /api/auth/*. */ export async function requireAuth(req: FastifyRequest, reply: FastifyReply): Promise { const auth = req.headers.authorization; if (auth?.startsWith('Bearer ')) { - const t = await getTokenUser(req.server.db, auth.slice(7).trim()); - if (!t) throw unauthorized(); - req.user = t.user; + const raw = auth.slice(7).trim(); + const d = raw.startsWith('spm_') ? await getDeviceUser(req.server.db, raw) : null; + const t = d ? null : await getTokenUser(req.server.db, raw); + if (!d && !t) throw unauthorized(); + // A phone is never an admin (13 §2): a lost one mustn't open the admin panel or recordings. + req.user = d ? { ...d.user, role: 'user' } : t!.user; req.sessionId = null; - req.apiTokenId = t.tokenId; - if (t.user.mustChangePassword) + if (d) req.mobileDeviceId = d.deviceId; + else req.apiTokenId = t!.tokenId; + if (req.user.mustChangePassword) throw forbidden('Change your password first', 'password_change_required'); return; } diff --git a/apps/api/src/auth/sessions.ts b/apps/api/src/auth/sessions.ts index be71fe0..9a25968 100644 --- a/apps/api/src/auth/sessions.ts +++ b/apps/api/src/auth/sessions.ts @@ -5,7 +5,7 @@ import { and, eq, gt, ne } from 'drizzle-orm'; import type { FastifyReply } from 'fastify'; import type { Db } from '../db/client.ts'; import { encrypt } from '../crypto/secrets.ts'; -import { sessions, users } from '../db/schema.ts'; +import { mobileDevices, sessions, users } from '../db/schema.ts'; export const COOKIE = 'sp_session'; const DAY = 86_400_000; @@ -83,8 +83,12 @@ export async function deleteSession(db: Db, token: string) { await db.delete(sessions).where(eq(sessions.id, sha256(token))); } -/** Logs a user out everywhere except (optionally) the current session. */ +/** + * Logs a user out everywhere except (optionally) the current session, paired phones included (13 §2: + * a password change, a reset, or disabling them). + */ export async function deleteUserSessions(db: Db, userId: string, keepSessionId?: string) { + await db.delete(mobileDevices).where(eq(mobileDevices.userId, userId)); await db .delete(sessions) .where( diff --git a/apps/api/src/db/migrations/0013_mobile_devices.sql b/apps/api/src/db/migrations/0013_mobile_devices.sql new file mode 100644 index 0000000..6ca17c0 --- /dev/null +++ b/apps/api/src/db/migrations/0013_mobile_devices.sql @@ -0,0 +1,19 @@ +CREATE TABLE "mobile_devices" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "name" text, + "platform" text, + "token_prefix" text, + "token_hash" text, + "pair_code_hash" text, + "pair_expires_at" timestamp with time zone, + "last_used_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 "mobile_devices_tokenHash_unique" UNIQUE("token_hash"), + CONSTRAINT "mobile_devices_pairCodeHash_unique" UNIQUE("pair_code_hash"), + CONSTRAINT "mobile_devices_platform_check" CHECK ("mobile_devices"."platform" in ('android', 'ios')) +); +--> statement-breakpoint +ALTER TABLE "mobile_devices" ADD CONSTRAINT "mobile_devices_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "mobile_devices_user_id_index" ON "mobile_devices" USING btree ("user_id"); \ No newline at end of file diff --git a/apps/api/src/db/migrations/meta/0013_snapshot.json b/apps/api/src/db/migrations/meta/0013_snapshot.json new file mode 100644 index 0000000..8eaaa0f --- /dev/null +++ b/apps/api/src/db/migrations/meta/0013_snapshot.json @@ -0,0 +1,4619 @@ +{ + "id": "ca681357-1ce8-48b8-9d7b-b60c3d457344", + "prevId": "92199db9-7d77-4ea4-8cc5-fe79a0d702e5", + "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.mobile_devices": { + "name": "mobile_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pair_code_hash": { + "name": "pair_code_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pair_expires_at": { + "name": "pair_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_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": { + "mobile_devices_user_id_index": { + "name": "mobile_devices_user_id_index", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mobile_devices_user_id_users_id_fk": { + "name": "mobile_devices_user_id_users_id_fk", + "tableFrom": "mobile_devices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mobile_devices_tokenHash_unique": { + "name": "mobile_devices_tokenHash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + }, + "mobile_devices_pairCodeHash_unique": { + "name": "mobile_devices_pairCodeHash_unique", + "nullsNotDistinct": false, + "columns": [ + "pair_code_hash" + ] + } + }, + "policies": {}, + "checkConstraints": { + "mobile_devices_platform_check": { + "name": "mobile_devices_platform_check", + "value": "\"mobile_devices\".\"platform\" in ('android', 'ios')" + } + }, + "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 + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'password'" + }, + "idp_refresh_enc": { + "name": "idp_refresh_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idp_id_token_enc": { + "name": "idp_id_token_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idp_checked_at": { + "name": "idp_checked_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": { + "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": { + "sessions_method_check": { + "name": "sessions_method_check", + "value": "\"sessions\".\"method\" in ('password', 'ldap', 'oidc')" + } + }, + "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_identities": { + "name": "user_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "identity_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "groups": { + "name": "groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_identities_user_id_users_id_fk": { + "name": "user_identities_user_id_users_id_fk", + "tableFrom": "user_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_identities_kind_subject_unique": { + "name": "user_identities_kind_subject_unique", + "nullsNotDistinct": false, + "columns": [ + "kind", + "subject" + ] + }, + "user_identities_userId_kind_unique": { + "name": "user_identities_userId_kind_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "kind" + ] + } + }, + "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": false + }, + "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.identity_kind": { + "name": "identity_kind", + "schema": "public", + "values": [ + "oidc", + "ldap" + ] + }, + "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 2e12f72..cf9c337 100644 --- a/apps/api/src/db/migrations/meta/_journal.json +++ b/apps/api/src/db/migrations/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1792951905226, "tag": "0012_sso", "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1793052019332, + "tag": "0013_mobile_devices", + "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 6849a51..c1cd7e9 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -134,6 +134,29 @@ export const apiTokens = pgTable('api_tokens', { ...timestamps, }); +/** Paired phones (13 §3): a row waits with a pairing code, then holds the device token. */ +export const mobileDevices = pgTable( + 'mobile_devices', + { + id: id(), + userId: uuid() + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + name: text(), + platform: text(), + tokenPrefix: text(), + tokenHash: text().unique(), + pairCodeHash: text().unique(), + pairExpiresAt: tstz(), + lastUsedAt: tstz(), + ...timestamps, + }, + (t) => [ + index().on(t.userId), + check('mobile_devices_platform_check', sql`${t.platform} in ('android', 'ios')`), + ], +); + // ───────────── extensions ───────────── export const outboundClass = pgEnum('outbound_class', [ 'internal', diff --git a/apps/api/src/routes/mobile.ts b/apps/api/src/routes/mobile.ts new file mode 100644 index 0000000..5f6b9cc --- /dev/null +++ b/apps/api/src/routes/mobile.ts @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Pairing the mobile app (13 §2, §4): a one-time code from My settings, traded by the app for a +// device token; the person's paired phones, and removing one. +import { and, eq, isNotNull, isNull } from 'drizzle-orm'; +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import QRCode from 'qrcode'; +import { z } from 'zod'; +import { + MobilePairSchema, + type ListResponse, + type MobileDeviceDto, + type MobilePairCode, + type MobilePairResult, +} from '@selfphoneng/shared'; +import { PAIR_CODE_TTL_MS, generatePairCode, pairDevice } from '../auth/devices.ts'; +import { SLOW, requireAuth } from '../auth/guards.ts'; +import { hashToken } from '../auth/tokens.ts'; +import { mobileDevices } from '../db/schema.ts'; +import { forbidden, HttpError, notFound } from '../errors.ts'; +import { audit } from '../services/audit.ts'; + +const IdParams = z.object({ id: z.uuid() }); + +export async function mobileRoutes(app: FastifyInstance) { + const db = app.db; + const act = (req: FastifyRequest) => ({ actorUserId: req.user!.id, actorIp: req.ip }); + + app.post( + '/api/me/mobile-devices/pair', + { preHandler: requireAuth, config: SLOW }, + async (req): Promise => { + // Only a person at the web page: no token (a stolen phone can't make itself a spare). + if (!req.sessionId) throw forbidden('Pair a phone from the web page'); + const userId = req.user!.id; + await db + .delete(mobileDevices) + .where(and(eq(mobileDevices.userId, userId), isNull(mobileDevices.tokenHash))); + const code = generatePairCode(); + const expiresAt = new Date(Date.now() + PAIR_CODE_TTL_MS); + const [d] = await db + .insert(mobileDevices) + .values({ userId, pairCodeHash: hashToken(code), pairExpiresAt: expiresAt }) + .returning({ id: mobileDevices.id }); + await audit(db, { + ...act(req), + action: 'create', + entityType: 'mobile_device', + entityId: d!.id, + summary: 'Made a code to pair a phone', + }); + const server = `https://${app.config.PUBLIC_HOSTNAME}`; + const link = `selfphoneng://pair?${new URLSearchParams({ server, code })}`; + return { + code, + server, + link, + qr: await QRCode.toDataURL(link, { margin: 2, width: 280 }), + expiresAt: expiresAt.toISOString(), + }; + }, + ); + + /** Public: the code is the credential. The only time the device token is shown. */ + app.post('/api/mobile/pair', { config: SLOW }, async (req): Promise => { + const { code, ...device } = MobilePairSchema.parse(req.body); + const paired = await pairDevice(db, code, device); + if (!paired) + throw new HttpError( + 404, + 'pair_code_invalid', + 'That code is wrong or expired. Make a new one.', + ); + await audit(db, { + actorUserId: paired.user.id, + actorIp: req.ip, + action: 'create', + entityType: 'mobile_device', + entityId: paired.deviceId, + summary: `Paired ${device.platform} phone ${device.name}`, + }); + return { + token: paired.token, + user: { name: paired.user.displayName, email: paired.user.email }, + }; + }); + + app.get( + '/api/me/mobile-devices', + { preHandler: requireAuth }, + async (req): Promise> => { + const rows = await db + .select() + .from(mobileDevices) + .where(and(eq(mobileDevices.userId, req.user!.id), isNotNull(mobileDevices.tokenHash))) + .orderBy(mobileDevices.createdAt); + return { + items: rows.map((d) => ({ + id: d.id, + name: d.name!, + platform: d.platform as MobileDeviceDto['platform'], + tokenPrefix: d.tokenPrefix!, + createdAt: d.createdAt.toISOString(), + lastUsedAt: d.lastUsedAt?.toISOString() ?? null, + current: d.id === req.mobileDeviceId, + })), + nextCursor: null, + }; + }, + ); + + /** Removes one of my phones; the app's Sign out removes itself. */ + app.delete('/api/me/mobile-devices/:id', { preHandler: requireAuth }, async (req) => { + const { id } = IdParams.parse(req.params); + const [d] = await db + .delete(mobileDevices) + .where(and(eq(mobileDevices.id, id), eq(mobileDevices.userId, req.user!.id))) + .returning(); + if (!d) throw notFound('Phone not found'); + await audit(db, { + ...act(req), + action: 'delete', + entityType: 'mobile_device', + entityId: id, + summary: `Removed phone ${d.name ?? '(not paired)'}`, + }); + return { ok: true }; + }); +} diff --git a/apps/api/test/mobile.test.ts b/apps/api/test/mobile.test.ts new file mode 100644 index 0000000..3e3e843 --- /dev/null +++ b/apps/api/test/mobile.test.ts @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// MB1: pairing the mobile app and its device tokens (13 §2, §4). +import { eq } from 'drizzle-orm'; +import type { LightMyRequestResponse } from 'fastify'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { auditLog, mobileDevices, users } from '../src/db/schema.ts'; +import { hasTestDb } from './db.ts'; +import { ADMIN, H, createTestApp } from './app.ts'; + +describe.skipIf(!hasTestDb)('mobile app pairing', () => { + let t: Awaited>; + type Req = (method: string, url: string, payload?: unknown) => Promise; + let admin: Req; + let dan: Req; + let danId: string; + const withHeaders = + (headers: Record): Req => + (method, url, payload) => + t.app.inject({ method: method as 'GET', url, payload: payload as object, headers }); + const bearer = (token: string) => withHeaders({ authorization: `Bearer ${token}` }); + // The app sends X-Requested-With on its one call without a token (CSRF hook, 04 §2.2). + const anon = withHeaders(H); + const pair = (code: string, name = 'Pixel 9') => + anon('POST', '/api/mobile/pair', { code, name, platform: 'android' }); + + beforeAll(async () => { + t = await createTestApp(); + await t.clearMustChange(ADMIN.email); + admin = withHeaders({ ...H, cookie: await t.login(ADMIN.email, ADMIN.password) }); + danId = (await t.addUser('dan@example.com', 'dan-password-123')).id; + dan = withHeaders({ ...H, cookie: await t.login('dan@example.com', 'dan-password-123') }); + // GET /api/me needs an extension. + for (const [number, userId] of [ + ['101', danId], + ['102', (await t.db.select().from(users).where(eq(users.email, ADMIN.email)))[0]!.id], + ]) + await admin('POST', '/api/extensions', { number, name: `Ext ${number}`, userId }); + }); + afterAll(() => t?.close()); + + let token: string; + let deviceId: string; + + it('pairs a phone once with a code from the web page', async () => { + const r = await dan('POST', '/api/me/mobile-devices/pair'); + expect(r.statusCode).toBe(200); + const c = r.json(); + expect(c.code).toMatch(/^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{12}$/); + expect(c.server).toBe('https://pbx.test'); + expect(c.link).toBe( + `selfphoneng://pair?server=${encodeURIComponent('https://pbx.test')}&code=${c.code}`, + ); + expect(c.qr).toMatch(/^data:image\/png;base64,/); + expect(new Date(c.expiresAt).getTime() - Date.now()).toBeGreaterThan(9 * 60_000); + + // Typed by hand: lower case and spaces are fine. + const typed = `${c.code.slice(0, 4)} ${c.code.slice(4)}`.toLowerCase(); + const p = await pair(typed); + expect(p.statusCode).toBe(200); + expect(p.json().user).toEqual({ name: expect.any(String), email: 'dan@example.com' }); + token = p.json().token; + expect(token).toMatch(/^spm_[A-Za-z0-9]{40}$/); + + expect((await pair(c.code)).json().error.code).toBe('pair_code_invalid'); // used + const rows = await t.db.select().from(mobileDevices); + expect(rows).toHaveLength(1); + expect(rows[0]!.tokenHash).not.toContain(token.slice(4)); // stored hashed + expect(rows[0]!.pairCodeHash).toBeNull(); + deviceId = rows[0]!.id; + }); + + it('the device token acts as its user', async () => { + const me = bearer(token); + expect((await me('GET', '/api/me')).statusCode).toBe(200); + // No X-Requested-With needed with a Bearer token. + expect((await me('PATCH', '/api/me', { dnd: true })).statusCode).toBe(200); + const list = (await me('GET', '/api/me/mobile-devices')).json().items; + expect(list).toEqual([ + { + id: deviceId, + name: 'Pixel 9', + platform: 'android', + tokenPrefix: token.slice(0, 8), + createdAt: expect.any(String), + lastUsedAt: expect.any(String), + current: true, + }, + ]); + expect((await dan('GET', '/api/me/mobile-devices')).json().items[0].current).toBe(false); + // A phone can't make itself a spare. + expect((await me('POST', '/api/me/mobile-devices/pair')).statusCode).toBe(403); + expect((await bearer(`spm_${'x'.repeat(40)}`)('GET', '/api/me')).statusCode).toBe(401); + }); + + it("an admin's phone is not an admin", async () => { + const code = (await admin('POST', '/api/me/mobile-devices/pair')).json().code; + const phone = bearer((await pair(code, 'iPhone')).json().token); + expect((await phone('GET', '/api/me')).statusCode).toBe(200); + expect((await phone('GET', '/api/users')).statusCode).toBe(403); + expect((await phone('GET', '/api/api-tokens')).statusCode).toBe(403); + expect((await admin('GET', '/api/users')).statusCode).toBe(200); + }); + + it('refuses expired codes, bad input, and replaces an unused code', async () => { + const first = (await dan('POST', '/api/me/mobile-devices/pair')).json().code; + const second = (await dan('POST', '/api/me/mobile-devices/pair')).json().code; + expect((await pair(first)).statusCode).toBe(404); // replaced + await t.db + .update(mobileDevices) + .set({ pairExpiresAt: new Date(Date.now() - 1000) }) + .where(eq(mobileDevices.userId, danId)); + expect((await pair(second)).json().error.code).toBe('pair_code_invalid'); + const bad = await anon('POST', '/api/mobile/pair', { + code: second, + name: 'x', + platform: 'windows', + }); + expect(bad.statusCode).toBe(400); + }); + + it('a disabled user loses their phones and can’t pair one', async () => { + const code = (await dan('POST', '/api/me/mobile-devices/pair')).json().code; + await t.db.update(users).set({ isActive: false }).where(eq(users.id, danId)); + expect((await bearer(token)('GET', '/api/me')).statusCode).toBe(401); + expect((await pair(code)).statusCode).toBe(404); + await t.db.update(users).set({ isActive: true }).where(eq(users.id, danId)); + expect((await bearer(token)('GET', '/api/me')).statusCode).toBe(200); + }); + + it('removing a device ends its token; only your own', async () => { + const adminPhone = (await t.db.select().from(mobileDevices)).find((d) => d.name === 'iPhone')!; + expect( + (await bearer(token)('DELETE', `/api/me/mobile-devices/${adminPhone.id}`)).statusCode, + ).toBe(404); + expect((await bearer(token)('DELETE', `/api/me/mobile-devices/${deviceId}`)).statusCode).toBe( + 200, + ); + expect((await bearer(token)('GET', '/api/me')).statusCode).toBe(401); + const actions = (await t.db.select().from(auditLog)) + .filter((a) => a.entityType === 'mobile_device') + .map((a) => a.action); + expect(actions).toContain('create'); + expect(actions).toContain('delete'); + }); + + it('changing the password removes every paired phone', async () => { + const code = (await dan('POST', '/api/me/mobile-devices/pair')).json().code; + const phone = bearer((await pair(code)).json().token); + expect((await phone('GET', '/api/me')).statusCode).toBe(200); + const r = await dan('POST', '/api/auth/change-password', { + currentPassword: 'dan-password-123', + newPassword: 'a-new-dan-password-456', + }); + expect(r.statusCode).toBe(200); + expect((await phone('GET', '/api/me')).statusCode).toBe(401); + expect((await dan('GET', '/api/me/mobile-devices')).json().items).toEqual([]); + }); +}); diff --git a/apps/api/test/schema.test.ts b/apps/api/test/schema.test.ts index f64ff4c..3081e8c 100644 --- a/apps/api/test/schema.test.ts +++ b/apps/api/test/schema.test.ts @@ -115,11 +115,11 @@ describe.skipIf(!hasTestDb)('schema matches spec 02', () => { beforeAll(async () => ({ db, drop } = await createTestDb())); afterAll(() => drop?.()); - it('creates all 44 tables', async () => { + it('creates all 45 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: 44 }); + expect(r.rows[0]).toEqual({ n: 45 }); }); 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 4885981..d9aa709 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -36,6 +36,8 @@ import type { ContactShare, ContactShareDto, MyCallDto, + MobileDeviceDto, + MobilePairCode, RecordingDto, AuthUser, DidCreate, @@ -303,6 +305,10 @@ export const meApi = { disconnectContrack: () => api('DELETE', '/api/me/contrack'), contrackOptions: (o: ContrackOptions) => api('PATCH', '/api/me/contrack', o), webrtc: () => api('GET', '/api/me/webrtc'), + pairPhone: () => api('POST', '/api/me/mobile-devices/pair'), + phones: () => + api>('GET', '/api/me/mobile-devices').then((r) => r.items), + removePhone: (id: string) => api<{ ok: true }>('DELETE', `/api/me/mobile-devices/${id}`), /** Log in to (unpause) or out of (pause) a queue I'm an agent of. */ setQueuePaused: (queueId: string, paused: boolean) => api<{ ok: true }>('POST', `/api/me/queues/${queueId}/${paused ? 'pause' : 'unpause'}`), diff --git a/apps/web/src/pages/user/MeSettingsPage.tsx b/apps/web/src/pages/user/MeSettingsPage.tsx index d9c8a34..aa3f3d9 100644 --- a/apps/web/src/pages/user/MeSettingsPage.tsx +++ b/apps/web/src/pages/user/MeSettingsPage.tsx @@ -1,9 +1,9 @@ // SPDX-License-Identifier: AGPL-3.0-or-later // My settings (05 §4): call forwarding, do not disturb, voicemail PIN and email options, the call -// report email, my Contrack account (11 §4.3). +// report email, my Contrack account (11 §4.3), pairing the mobile app (13 §2). // Softphone devices and notifications live in the softphone panel. "Set up a phone app" (07 §5). import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { formatNumber, type CallReport, type MeDto, type MeUpdate } from '@selfphoneng/shared'; import { meApi } from '../../api/client.ts'; import { PhoneAppSetup } from '../../components/PhoneAppSetup.tsx'; @@ -70,6 +70,7 @@ function SettingsForms({ me }: { me: MeDto }) { + ); @@ -411,3 +412,88 @@ function ContrackConnect({ contrack }: { contrack: NonNullable ); } + +/** Pairing our own app (13 §2): a one-time QR code, and the phones paired so far. */ +function MobileApp() { + const qc = useQueryClient(); + const pair = useMutation({ mutationFn: meApi.pairPhone }); + // While a code is up, look for the phone that uses it, then put the code away. + const phones = useQuery({ + queryKey: ['me', 'phones'], + queryFn: meApi.phones, + refetchInterval: pair.data ? 3000 : false, + }); + const [before, setBefore] = useState(0); + const paired = pair.data && (phones.data?.length ?? 0) > before; + useEffect(() => { + if (paired) pair.reset(); + }, [paired, pair]); + const remove = useMutation({ + mutationFn: meApi.removePhone, + onSuccess: () => qc.invalidateQueries({ queryKey: ['me', 'phones'] }), + }); + return ( +
+

+ Mobile app +

+

+ The SelfPhoneNG app for Android and iPhone signs in by pairing: tap{' '} + Pair with a server in the app and point the camera at the code below. It rings only + while the app is open. +

+ {phones.data && phones.data.length > 0 && ( +
    + {phones.data.map((d) => ( +
  • + + {d.name}{' '} + + ({d.platform === 'ios' ? 'iPhone' : 'Android'}, paired{' '} + {new Date(d.createdAt).toLocaleDateString()} + {d.lastUsedAt && `, last used ${new Date(d.lastUsedAt).toLocaleString()}`}) + + + +
  • + ))} +
+ )} + {!pair.data ? ( + + ) : ( +
+ Pairing QR code for the mobile app +

+ Or type it in the app: server {pair.data.server}, + code {pair.data.code} +

+

+ Works once, until {new Date(pair.data.expiresAt).toLocaleTimeString()}. Whoever uses it + gets into your account: don't share it. +

+
+ )} + {(pair.error ?? phones.error ?? remove.error) && ( + {(pair.error ?? phones.error ?? remove.error)!.message} + )} +
+ ); +} diff --git a/docs/decisions/0005-mobile-app.md b/docs/decisions/0005-mobile-app.md new file mode 100644 index 0000000..92ce2ae --- /dev/null +++ b/docs/decisions/0005-mobile-app.md @@ -0,0 +1,19 @@ +# 0005 — Our own mobile app: React Native (Expo) in the `mobile` repo + +Owner, 2026-10-26: build an Android and iPhone app instead of only recommending Linphone (this +reverses the "native mobile apps" non-goal in 00 §2). One codebase for both, in its own repo +(`git.dou.bet/spng/mobile`, AGPL-3.0), with this repo as a git submodule for +`@selfphoneng/shared`. **Expo (React Native)** because the web client is already React 19 + +TypeScript + react-query + zustand + zod: the types, schemas and the SIP.js call code carry over, +which Flutter or Kotlin Multiplatform would not allow. Calls use **SIP.js + react-native-webrtc** +(both MIT) against the browser softphone's existing `-web` endpoint, so Asterisk stays the only SIP +stack; Linphone SDK and PJSIP were rejected (GPL with a commercial dual license, which conflicts +with App Store terms, and their React Native wrappers are abandoned). The native call screen is +**expo-callkit-telecom** (MIT: CallKit and Jetpack Core-Telecom); react-native-callkeep is +unmaintained and uses the older ConnectionService. v1 has **no push**: the app rings only while +open, and CFNA to a mobile number is the fallback (07 §5). Push comes later as FCM (Play) and +UnifiedPush (F-Droid) on Android and a relay for Apple's VoIP push; that adds a closed-source +dependency (Play Services) to the Play build, which needs its own decision then. iOS builds without +a Mac use **ios-builder** (MobAI-App, MIT), which runs Xcode on a macOS CI runner. For now the app +is built and sideloaded only; App Store and Play listings, and the AGPL/App Store terms question, +come later. diff --git a/docs/spec/00-overview.md b/docs/spec/00-overview.md index a34a217..9835e8f 100644 --- a/docs/spec/00-overview.md +++ b/docs/spec/00-overview.md @@ -27,7 +27,7 @@ Target user: one small business or home lab, 1–100 extensions, one server. Do NOT build these in v1. Do not leave half-built hooks for them either. - Multi-tenancy (several companies on one install) -- Native mobile apps (we document open source SIP apps instead, see `07-provisioning.md`) +- ~~Native mobile apps~~: reversed 2026-10-26, our own app is `13-mobile.md` (decision 0005) - Video calls, screen sharing, team chat - Fax - CRM integrations (the REST API + webhooks are the integration point) diff --git a/docs/spec/02-data-model.md b/docs/spec/02-data-model.md index 6839d5b..a477f11 100644 --- a/docs/spec/02-data-model.md +++ b/docs/spec/02-data-model.md @@ -122,6 +122,22 @@ create table api_tokens ( revoked_at timestamptz ); +-- Paired phones (13 §2, §3): a row waits with a pairing code, then holds the device token. +create table mobile_devices ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references users(id) on delete cascade, + name text, -- "Pixel 9", from the app; null until paired + platform text check (platform in ('android', 'ios')), + token_prefix text, -- "spm_AbC1", shown on the web + token_hash text unique, -- sha256 hex; null while waiting to pair + pair_code_hash text unique, -- sha256 hex; null once paired + pair_expires_at timestamptz, + last_used_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); +create index on mobile_devices (user_id); + -- ───────────── extensions (telephony identity of a user) ───────────── create type outbound_class as enum ('internal', 'local', 'national', 'international'); create type record_mode as enum ('never', 'inbound', 'outbound', 'all'); diff --git a/docs/spec/04-backend-api.md b/docs/spec/04-backend-api.md index 4d91d9b..4b7ef1e 100644 --- a/docs/spec/04-backend-api.md +++ b/docs/spec/04-backend-api.md @@ -58,6 +58,10 @@ Bearer requests don't need `X-Requested-With` (no cookie is used, and a cross-si Authorization). A token can't list, make or revoke tokens (403): a leaked one can't make itself a spare. `lastUsedAt` is written on every request. Settings → API tokens. +### 2.3a Mobile device tokens +`Authorization: Bearer spm_<40 base62>`, made by pairing a phone and acting as its user, never as +an admin (`requireAdmin` refuses them). See `13-mobile.md` §2 and §4. + ### 2.4 Guards Fastify `preHandler` hooks in `auth/guards.ts`: - `requireAuth`: sets `req.user`. diff --git a/docs/spec/05-web-ui.md b/docs/spec/05-web-ui.md index aa7cd0c..8f3fdd5 100644 --- a/docs/spec/05-web-ui.md +++ b/docs/spec/05-web-ui.md @@ -106,7 +106,7 @@ Implementation notes (M3): | Voicemail | `/me/voicemail` | List (new bold), play (marks read), download, delete, search (transcript, caller). Greetings section: record in the browser (MediaRecorder → upload), upload, or delete for unavail/busy/name | | Contacts | `/me/contacts` | Personal + company (read-only) with search, click to call | | Messages | `/me/sms` | Threads per DID + remote number, chat bubbles, composer with 160-char segment counter, unread badges (06 §5) | -| Settings | `/me/settings` | Forwarding rules (always/busy/no answer with number fields), voicemail PIN, email options, call report email (off / hourly / daily at a time / weekly on a day at a time, company time zone; optional summaries of recorded calls, 10 §3; nothing sent for a period without calls), softphone audio devices, notification permission, change password, "Set up a phone app" (QR, 07 §5) | +| Settings | `/me/settings` | Forwarding rules (always/busy/no answer with number fields), voicemail PIN, email options, call report email (off / hourly / daily at a time / weekly on a day at a time, company time zone; optional summaries of recorded calls, 10 §3; nothing sent for a period without calls), softphone audio devices, notification permission, change password, "Set up a phone app" (QR, 07 §5), "Mobile app" (pair a phone, list and remove paired phones, 13 §2) | ### 4.1 Presence panel Right column on wide screens, a tab on mobile. Lists every extension with its diff --git a/docs/spec/07-provisioning.md b/docs/spec/07-provisioning.md index fb6313c..941be08 100644 --- a/docs/spec/07-provisioning.md +++ b/docs/spec/07-provisioning.md @@ -222,5 +222,5 @@ As built (M10-T3), `provisioning/linphone.ts`: Mobile limitation (state it plainly in the UI and admin guide): without push notifications, iOS and battery-optimized Android apps miss calls while in the -background. A push gateway is a v2 item. For v1, recommend the desk phone + browser softphone, +background. A push gateway is a v2 item (13 §6; our own app, 13, has the same limit in v1). For v1, recommend the desk phone + browser softphone, or call forwarding to a mobile number (CFNA) as the fallback. diff --git a/docs/spec/09-roadmap.md b/docs/spec/09-roadmap.md index 317ebe3..84a5d12 100644 --- a/docs/spec/09-roadmap.md +++ b/docs/spec/09-roadmap.md @@ -9,6 +9,8 @@ Legend: 📖 read · 🔨 build · ✅ done when --- ## Backlog (owner's order, 2026-10-21) +0. **Mobile app** (owner, 2026-10-26: next), tasks MB1–MB8 in [13 §5](13-mobile.md#5-order-of-work): + Android and iPhone from one Expo codebase in the `mobile` repo; user portal only, no push in v1. 0. **Single sign-on** (owner, 2026-10-25: next), tasks S1–S5 in [12 §8](12-sso.md#8-order-of-work): OpenID Connect (any provider; the owner uses Keycloak), then LDAP/LDAPS. 0. Done: **Contacts** (owner, 2026-10-23), tasks C1–C6 in [11 §6](11-contacts.md#6-order-of-work): @@ -299,7 +301,7 @@ Each one checked against a real account before it's called supported (06's verif - Piper TTS for prompts (04 §6). - Record prompts by phone. - Wallboard page for queues. -- Push gateway for mobile apps. +- Push gateway for mobile apps: now 13 §6. - Polycom provisioning (07 §3.3). - Multi-tenant (big redesign; needs a decision record). diff --git a/docs/spec/13-mobile.md b/docs/spec/13-mobile.md new file mode 100644 index 0000000..b40be28 --- /dev/null +++ b/docs/spec/13-mobile.md @@ -0,0 +1,110 @@ +# 13 — Mobile app (Android + iPhone) + +Owner, 2026-10-26: our own app for the user portal, on both phones from one codebase. Why this +stack: [decision 0005](../decisions/0005-mobile-app.md). + +## 1. What it is + +- Repo: `git.dou.bet/spng/mobile` (AGPL-3.0), checked out at `mobile/` beside this repo. It has + this repo as a git submodule for `@selfphoneng/shared` (types and zod schemas). +- Expo (React Native) + TypeScript, react-query, zustand, zod, as the web client. +- **User portal only** (owner): calls, call history, voicemail, contacts, My settings. No admin + screens; admins use the web panel, which works in a phone's browser. +- Calls: SIP.js + react-native-webrtc on the extension's `-web` endpoint (`GET /api/me/webrtc`), + the same one the browser softphone uses (`max_contacts = 3`, so a browser and a phone fit). The + extension needs the browser softphone turned on. Native call screen: expo-callkit-telecom + (CallKit on iOS, Core-Telecom on Android); calls show in the phone's own recent calls (iOS + Recents; Android `EXTRA_LOG_SELF_MANAGED_CALLS`). +- **No push in v1** (owner): the app rings only while it's open. The app and the admin guide say + so, as 07 §5 does, and suggest forwarding unanswered calls to the mobile number. +- Built and sideloaded only for now (owner): an Android APK; iOS through ios-builder (macOS CI + runner) with a free Apple ID re-sign. No store listings yet. + +## 2. Pairing a phone + +The app signs in by pairing, not with a password: the person is already signed in on the web, so +OpenID, LDAP and two-step sign-in all work without the app knowing about them. + +1. My settings → **Mobile app** → **Pair a phone** (`POST /api/me/mobile-devices/pair`, a browser + session only: 403 with any Bearer token). The api makes a one-time **pairing code**: 12 characters + from `ABCDEFGHJKLMNPQRSTUVWXYZ23456789` (no 0/O/1/I), good for 10 minutes, stored as sha256. + The answer: `{ code, server, link, qr, expiresAt }`, where `server = https://PUBLIC_HOSTNAME`, + `link = selfphoneng://pair?server=&code=` and `qr` is a PNG data URL of `link`. + The page shows the QR, and the server and code to type by hand. +2. The app scans it (or takes them typed) and calls `POST /api/mobile/pair` + `{ code, name, platform: 'android' | 'ios' }` (public, `SLOW`: 10 a minute per IP; it has no + token yet, so it sends `X-Requested-With: selfphoneng` like the web, 04 §2.2). Case and anything + but letters and digits are dropped from the code before the check. A code + that is unknown, used or expired → 404 `pair_code_invalid`; the user disabled since → the same. + Success → `{ token, user: { name, email } }`: the **device token**, shown this once. +3. The app keeps the token in the phone's secure storage (expo-secure-store) and sends it as + `Authorization: Bearer spm_<40 base62>` on every request. + +Device tokens (`auth/devices.ts`): +- `requireAuth` takes `spm_` tokens before `sp_` API tokens; the request acts as the device's user + (`req.mobileDeviceId` set). Unknown, removed, or the user disabled → 401. `lastUsedAt` is written + on every request. `must_change_password` → 403 `password_change_required` as for API tokens. +- A phone is never an admin: `requireAuth` gives the request `role: 'user'` even for an admin's + phone, so `requireAdmin` refuses it (403) and admin-only checks inside routes (recordings) don't + pass. The app has no admin screens, and a lost phone shouldn't open the admin panel. +- A device token can't make pairing codes (a stolen phone can't make itself a spare). +- They don't expire (owner, 2026-10-26). They end when the person removes the device (on the web + or Sign out in the app), and with every "sign out everywhere" (`deleteUserSessions`): the person + changing their password (their current web session stays), an admin resetting their password or + two-step sign-in, disabling them (also by the directory check), or deleting them. +- An OpenID user disabled at the provider but not here keeps their phone until an admin disables + them on Users: the provider's session check (12 §3.5) only ends web sessions (owner: fine). + +## 3. Data (`mobile_devices`, migration 0013) + +The table is in 02 (`mobile_devices`). + +A row is made by **Pair a phone** (code only) and filled by the app's pair call (token, code +cleared). Making a new code first deletes the user's rows that never paired. + +## 4. API + +| Method | Path | Notes | +|---|---|---| +| POST | `/api/me/mobile-devices/pair` | browser session only. → `{ code, server, link, qr, expiresAt }` | +| POST | `/api/mobile/pair` | public, `SLOW`. `{ code, name, platform }` → `{ token, user }` | +| GET | `/api/me/mobile-devices` | paired devices: `{ id, name, platform, tokenPrefix, createdAt, lastUsedAt, current }` | +| DELETE | `/api/me/mobile-devices/:id` | removes one of your devices (the app's Sign out removes its own) | + +Audit: `create` / `delete` on `mobile_device` (pairing code made, phone paired, device removed). + +What the app uses besides these (all exist): `GET|PATCH /api/me`, `GET /api/me/webrtc`, +`GET /api/me/calls`, `GET|PATCH|DELETE /api/me/voicemail[/:id]`, `GET /api/me/voicemail/:id/audio`, +`GET /api/me/contacts`, `GET|PUT|DELETE /api/me/greetings/:kind`. + +## 5. Order of work + +| Task | Repo | What | Done when | +|---|---|---|---| +| MB1 | this | Decision 0005; this spec; migration 0013; device tokens in the guards; §4 routes; My settings → Mobile app (QR, code, device list, Remove) | `pnpm lint && typecheck && test` pass; pairing, device token, admin refusal and removal are tested. Done, see §5.1 | +| MB2 | this | The live socket (`GET /api/live`, 04 §5.2) takes a device token as `Authorization: Bearer` (React Native's WebSocket can send headers; browsers keep the cookie) | the app gets `voicemail` and presence events | +| MB3 | mobile | Expo skeleton, the submodule, pairing by QR and by hand, Sign out, a tab bar; Android APK builds; iOS builds through ios-builder | the owner pairs an Android phone and an iPhone and sees their name | +| MB4 | mobile | Calls: SIP.js + react-native-webrtc, register while open, dial pad, in and out, hold, mute, DTMF, speaker; native call screen; the "rings only while open" notice | a call each way with the PBX, on both phones, shown on the native call screen and in the phone's recent calls | +| MB5 | mobile | Call history (`/api/me/calls`), tap to call back | matches the web's Calls page | +| MB6 | mobile | Voicemail: list, play, mark heard, delete | as the web's Voicemail page | +| MB7 | mobile | Contacts: search, tap to call | as the web's Contacts page | +| MB8 | mobile | My settings: DND, forwarding, voicemail PIN and email, greetings | as the web's My settings | + +### 5.1 As built: MB1 + +- `auth/devices.ts` (codes, tokens, `pairDevice`: the code is cleared in the same `update` that + sets the token, so one code pairs one phone), `routes/mobile.ts`, migration 0013. +- My settings → **Mobile app**: Pair a phone shows the QR and the server and code; while it's up + the page checks the list every 3 s and puts the code away when a new phone appears. Paired + phones, with Remove. +- `apps/api/test/mobile.test.ts`. Not tried with a real phone yet: that's MB3. + +## 6. Later (not in MB1–MB8) + +- Push, so the app rings in the background: FCM (Play build) and UnifiedPush (F-Droid build) on + Android, Apple VoIP push through a relay the project runs (Apple's key belongs to the app's + publisher, not to each PBX). The PBX sends the push and waits for the phone to register before + ringing it. Needs a decision record (Play Services is closed source). Replaces M12's "Push + gateway for mobile apps". +- App Store and Play listings (and AGPL vs App Store terms). +- TURN for strict mobile networks (`network.turn` already reaches `/api/me/webrtc`). diff --git a/eslint.config.js b/eslint.config.js index 59bde03..dbc46f8 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,7 +5,7 @@ import reactHooks from 'eslint-plugin-react-hooks'; import globals from 'globals'; export default tseslint.config( - { ignores: ['**/dist/', 'data/', '**/node_modules/', '.pnpm-store/'] }, + { ignores: ['**/dist/', 'data/', 'mobile/', '**/node_modules/', '.pnpm-store/'] }, js.configs.recommended, tseslint.configs.recommended, { diff --git a/packages/shared/src/me.ts b/packages/shared/src/me.ts index b310ed5..15bf52e 100644 --- a/packages/shared/src/me.ts +++ b/packages/shared/src/me.ts @@ -74,3 +74,37 @@ export const ContrackOptionsSchema = z.object({ logSummaries: z.boolean().optional(), }); export type ContrackOptions = z.input; + +/** POST /api/mobile/pair (13 §2): the app trades a pairing code for its device token. */ +export const MobilePairSchema = z.object({ + // Typed by hand from a screen: case and spaces don't matter. + code: z + .string() + .transform((s) => s.toUpperCase().replace(/[^A-Z0-9]/g, '')) + .pipe(z.string().length(12)), + name: z.string().trim().min(1).max(100), + platform: z.enum(['android', 'ios']), +}); +export type MobilePair = z.input; +export type MobilePairResult = { token: string; user: { name: string; email: string } }; + +/** POST /api/me/mobile-devices/pair: shown on the web as a QR code and as server + code. */ +export type MobilePairCode = { + code: string; + server: string; + link: string; + qr: string; + expiresAt: string; +}; + +export type MobileDeviceDto = { + id: string; + name: string; + platform: 'android' | 'ios'; + /** "spm_AbC1", to recognize it. */ + tokenPrefix: string; + createdAt: string; + lastUsedAt: string | null; + /** The device making this request. */ + current: boolean; +};