diff --git a/apps/api/src/db/migrations/0008_call_reports.sql b/apps/api/src/db/migrations/0008_call_reports.sql new file mode 100644 index 0000000..bc3ac9e --- /dev/null +++ b/apps/api/src/db/migrations/0008_call_reports.sql @@ -0,0 +1,6 @@ +CREATE TYPE "public"."call_report" AS ENUM('off', 'hourly', 'daily', 'weekly');--> statement-breakpoint +ALTER TABLE "extensions" ADD COLUMN "call_report" "call_report" DEFAULT 'off' NOT NULL;--> statement-breakpoint +ALTER TABLE "extensions" ADD COLUMN "call_report_time" text DEFAULT '08:00' NOT NULL;--> statement-breakpoint +ALTER TABLE "extensions" ADD COLUMN "call_report_weekday" integer DEFAULT 1 NOT NULL;--> statement-breakpoint +ALTER TABLE "extensions" ADD COLUMN "call_report_summaries" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "extensions" ADD COLUMN "call_report_sent_at" timestamp with time zone; \ No newline at end of file diff --git a/apps/api/src/db/migrations/meta/0008_snapshot.json b/apps/api/src/db/migrations/meta/0008_snapshot.json new file mode 100644 index 0000000..a866248 --- /dev/null +++ b/apps/api/src/db/migrations/meta/0008_snapshot.json @@ -0,0 +1,4128 @@ +{ + "id": "75adba1b-ae0f-488f-8a5e-a573226446f4", + "prevId": "219ce637-1a2d-48e7-ab8a-7fd37913fe67", + "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.contacts": { + "name": "contacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company": { + "name": "company", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "numbers": { + "name": "numbers", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "contacts_owner_user_id_users_id_fk": { + "name": "contacts_owner_user_id_users_id_fk", + "tableFrom": "contacts", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.devices": { + "name": "devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "extension_id": { + "name": "extension_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "mac": { + "name": "mac", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vendor": { + "name": "vendor", + "type": "phone_vendor", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provisioning_password_enc": { + "name": "provisioning_password_enc", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_provisioned_at": { + "name": "last_provisioned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_provisioned_ip": { + "name": "last_provisioned_ip", + "type": "inet", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "devices_extension_id_extensions_id_fk": { + "name": "devices_extension_id_extensions_id_fk", + "tableFrom": "devices", + "tableTo": "extensions", + "columnsFrom": [ + "extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "devices_mac_unique": { + "name": "devices_mac_unique", + "nullsNotDistinct": false, + "columns": [ + "mac" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.did_sms_users": { + "name": "did_sms_users", + "schema": "", + "columns": { + "did_id": { + "name": "did_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "did_sms_users_did_id_dids_id_fk": { + "name": "did_sms_users_did_id_dids_id_fk", + "tableFrom": "did_sms_users", + "tableTo": "dids", + "columnsFrom": [ + "did_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "did_sms_users_user_id_users_id_fk": { + "name": "did_sms_users_user_id_users_id_fk", + "tableFrom": "did_sms_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "did_sms_users_did_id_user_id_pk": { + "name": "did_sms_users_did_id_user_id_pk", + "columns": [ + "did_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dids": { + "name": "dids", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trunk_id": { + "name": "trunk_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination": { + "name": "destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "caller_id_name_prefix": { + "name": "caller_id_name_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "record_inbound": { + "name": "record_inbound", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sms_enabled": { + "name": "sms_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "e911_address": { + "name": "e911_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "dids_trunk_id_trunks_id_fk": { + "name": "dids_trunk_id_trunks_id_fk", + "tableFrom": "dids", + "tableTo": "trunks", + "columnsFrom": [ + "trunk_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "dids_number_unique": { + "name": "dids_number_unique", + "nullsNotDistinct": false, + "columns": [ + "number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.emergency_events": { + "name": "emergency_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "extension_number": { + "name": "extension_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dialed": { + "name": "dialed", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "caller_id_used": { + "name": "caller_id_used", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notified": { + "name": "notified", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.extensions": { + "name": "extensions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sip_password_enc": { + "name": "sip_password_enc", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webrtc_enabled": { + "name": "webrtc_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "max_contacts": { + "name": "max_contacts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "ring_seconds": { + "name": "ring_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 25 + }, + "voicemail_enabled": { + "name": "voicemail_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "voicemail_pin_enc": { + "name": "voicemail_pin_enc", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "voicemail_email": { + "name": "voicemail_email", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "voicemail_attach_audio": { + "name": "voicemail_attach_audio", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "voicemail_delete_after_email": { + "name": "voicemail_delete_after_email", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "outbound_class": { + "name": "outbound_class", + "type": "outbound_class", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'national'" + }, + "outbound_caller_id_did_id": { + "name": "outbound_caller_id_did_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "emergency_caller_id_did_id": { + "name": "emergency_caller_id_did_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "emergency_location": { + "name": "emergency_location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "record_mode": { + "name": "record_mode", + "type": "record_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'never'" + }, + "pickup_group": { + "name": "pickup_group", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "moh_class": { + "name": "moh_class", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "blf_numbers": { + "name": "blf_numbers", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "call_report": { + "name": "call_report", + "type": "call_report", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'off'" + }, + "call_report_time": { + "name": "call_report_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'08:00'" + }, + "call_report_weekday": { + "name": "call_report_weekday", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "call_report_summaries": { + "name": "call_report_summaries", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "call_report_sent_at": { + "name": "call_report_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "extensions_user_id_users_id_fk": { + "name": "extensions_user_id_users_id_fk", + "tableFrom": "extensions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "extensions_outbound_caller_id_did_id_dids_id_fk": { + "name": "extensions_outbound_caller_id_did_id_dids_id_fk", + "tableFrom": "extensions", + "tableTo": "dids", + "columnsFrom": [ + "outbound_caller_id_did_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "extensions_emergency_caller_id_did_id_dids_id_fk": { + "name": "extensions_emergency_caller_id_did_id_dids_id_fk", + "tableFrom": "extensions", + "tableTo": "dids", + "columnsFrom": [ + "emergency_caller_id_did_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "extensions_number_unique": { + "name": "extensions_number_unique", + "nullsNotDistinct": false, + "columns": [ + "number" + ] + }, + "extensions_userId_unique": { + "name": "extensions_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.generator_runs": { + "name": "generator_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "files_changed": { + "name": "files_changed", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "commands": { + "name": "commands", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "ok": { + "name": "ok", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.holidays": { + "name": "holidays", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "recurring_yearly": { + "name": "recurring_yearly", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ivr_options": { + "name": "ivr_options", + "schema": "", + "columns": { + "ivr_id": { + "name": "ivr_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "digit": { + "name": "digit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "destination": { + "name": "destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ivr_options_ivr_id_ivrs_id_fk": { + "name": "ivr_options_ivr_id_ivrs_id_fk", + "tableFrom": "ivr_options", + "tableTo": "ivrs", + "columnsFrom": [ + "ivr_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ivr_options_ivr_id_digit_pk": { + "name": "ivr_options_ivr_id_digit_pk", + "columns": [ + "ivr_id", + "digit" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ivr_options_digit_check": { + "name": "ivr_options_digit_check", + "value": "\"ivr_options\".\"digit\" ~ '^[0-9*#]$'" + } + }, + "isRLSEnabled": false + }, + "public.ivrs": { + "name": "ivrs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "greeting_sound_id": { + "name": "greeting_sound_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invalid_sound_id": { + "name": "invalid_sound_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "timeout_seconds": { + "name": "timeout_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "max_retries": { + "name": "max_retries", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "allow_direct_dial": { + "name": "allow_direct_dial", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timeout_destination": { + "name": "timeout_destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "invalid_destination": { + "name": "invalid_destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ivrs_greeting_sound_id_sound_files_id_fk": { + "name": "ivrs_greeting_sound_id_sound_files_id_fk", + "tableFrom": "ivrs", + "tableTo": "sound_files", + "columnsFrom": [ + "greeting_sound_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "ivrs_invalid_sound_id_sound_files_id_fk": { + "name": "ivrs_invalid_sound_id_sound_files_id_fk", + "tableFrom": "ivrs", + "tableTo": "sound_files", + "columnsFrom": [ + "invalid_sound_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ivrs_number_unique": { + "name": "ivrs_number_unique", + "nullsNotDistinct": false, + "columns": [ + "number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.moh_classes": { + "name": "moh_classes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shuffle": { + "name": "shuffle", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "moh_classes_name_unique": { + "name": "moh_classes_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbound_routes": { + "name": "outbound_routes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "patterns": { + "name": "patterns", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "required_class": { + "name": "required_class", + "type": "outbound_class", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trunk_ids": { + "name": "trunk_ids", + "type": "uuid[]", + "primaryKey": false, + "notNull": true + }, + "caller_id_did_id": { + "name": "caller_id_did_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "outbound_routes_caller_id_did_id_dids_id_fk": { + "name": "outbound_routes_caller_id_did_id_dids_id_fk", + "tableFrom": "outbound_routes", + "tableTo": "dids", + "columnsFrom": [ + "caller_id_did_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paging_group_members": { + "name": "paging_group_members", + "schema": "", + "columns": { + "paging_group_id": { + "name": "paging_group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "extension_id": { + "name": "extension_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "paging_group_members_paging_group_id_paging_groups_id_fk": { + "name": "paging_group_members_paging_group_id_paging_groups_id_fk", + "tableFrom": "paging_group_members", + "tableTo": "paging_groups", + "columnsFrom": [ + "paging_group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "paging_group_members_extension_id_extensions_id_fk": { + "name": "paging_group_members_extension_id_extensions_id_fk", + "tableFrom": "paging_group_members", + "tableTo": "extensions", + "columnsFrom": [ + "extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "paging_group_members_paging_group_id_extension_id_pk": { + "name": "paging_group_members_paging_group_id_extension_id_pk", + "columns": [ + "paging_group_id", + "extension_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paging_groups": { + "name": "paging_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duplex": { + "name": "duplex", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "paging_groups_number_unique": { + "name": "paging_groups_number_unique", + "nullsNotDistinct": false, + "columns": [ + "number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.queue_calls": { + "name": "queue_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "queue_id": { + "name": "queue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "uniqueid": { + "name": "uniqueid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "linkedid": { + "name": "linkedid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "outcome": { + "name": "outcome", + "type": "queue_outcome", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "wait_seconds": { + "name": "wait_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "agent_extension_id": { + "name": "agent_extension_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "queue_calls_queue_id_joined_at_index": { + "name": "queue_calls_queue_id_joined_at_index", + "columns": [ + { + "expression": "queue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "joined_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "queue_calls_uniqueid_index": { + "name": "queue_calls_uniqueid_index", + "columns": [ + { + "expression": "uniqueid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "queue_calls_queue_id_queues_id_fk": { + "name": "queue_calls_queue_id_queues_id_fk", + "tableFrom": "queue_calls", + "tableTo": "queues", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "queue_calls_agent_extension_id_extensions_id_fk": { + "name": "queue_calls_agent_extension_id_extensions_id_fk", + "tableFrom": "queue_calls", + "tableTo": "extensions", + "columnsFrom": [ + "agent_extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.queue_member_state": { + "name": "queue_member_state", + "schema": "", + "columns": { + "queue_id": { + "name": "queue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "extension_id": { + "name": "extension_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "paused": { + "name": "paused", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "queue_member_state_queue_id_queues_id_fk": { + "name": "queue_member_state_queue_id_queues_id_fk", + "tableFrom": "queue_member_state", + "tableTo": "queues", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "queue_member_state_extension_id_extensions_id_fk": { + "name": "queue_member_state_extension_id_extensions_id_fk", + "tableFrom": "queue_member_state", + "tableTo": "extensions", + "columnsFrom": [ + "extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "queue_member_state_queue_id_extension_id_pk": { + "name": "queue_member_state_queue_id_extension_id_pk", + "columns": [ + "queue_id", + "extension_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.queue_members": { + "name": "queue_members", + "schema": "", + "columns": { + "queue_id": { + "name": "queue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "extension_id": { + "name": "extension_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "penalty": { + "name": "penalty", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "queue_members_queue_id_queues_id_fk": { + "name": "queue_members_queue_id_queues_id_fk", + "tableFrom": "queue_members", + "tableTo": "queues", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "queue_members_extension_id_extensions_id_fk": { + "name": "queue_members_extension_id_extensions_id_fk", + "tableFrom": "queue_members", + "tableTo": "extensions", + "columnsFrom": [ + "extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "queue_members_queue_id_extension_id_pk": { + "name": "queue_members_queue_id_extension_id_pk", + "columns": [ + "queue_id", + "extension_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.queues": { + "name": "queues", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "strategy": { + "name": "strategy", + "type": "queue_strategy", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'rrmemory'" + }, + "member_ring_seconds": { + "name": "member_ring_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15 + }, + "retry_seconds": { + "name": "retry_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "wrapup_seconds": { + "name": "wrapup_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "max_wait_seconds": { + "name": "max_wait_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 300 + }, + "max_callers": { + "name": "max_callers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "moh_class": { + "name": "moh_class", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "announce_position": { + "name": "announce_position", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "announce_every_seconds": { + "name": "announce_every_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 60 + }, + "join_when_no_agents": { + "name": "join_when_no_agents", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "record": { + "name": "record", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sla_seconds": { + "name": "sla_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 20 + }, + "caller_id_name_prefix": { + "name": "caller_id_name_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timeout_destination": { + "name": "timeout_destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "queues_number_unique": { + "name": "queues_number_unique", + "nullsNotDistinct": false, + "columns": [ + "number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recordings": { + "name": "recordings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "linkedid": { + "name": "linkedid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "transcript": { + "name": "transcript", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_done_at": { + "name": "ai_done_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ai_error": { + "name": "ai_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "recordings_filePath_unique": { + "name": "recordings_filePath_unique", + "nullsNotDistinct": false, + "columns": [ + "file_path" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ring_group_members": { + "name": "ring_group_members", + "schema": "", + "columns": { + "ring_group_id": { + "name": "ring_group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "extension_id": { + "name": "extension_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ring_group_members_ring_group_id_ring_groups_id_fk": { + "name": "ring_group_members_ring_group_id_ring_groups_id_fk", + "tableFrom": "ring_group_members", + "tableTo": "ring_groups", + "columnsFrom": [ + "ring_group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ring_group_members_extension_id_extensions_id_fk": { + "name": "ring_group_members_extension_id_extensions_id_fk", + "tableFrom": "ring_group_members", + "tableTo": "extensions", + "columnsFrom": [ + "extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ring_group_members_ring_group_id_extension_id_pk": { + "name": "ring_group_members_ring_group_id_extension_id_pk", + "columns": [ + "ring_group_id", + "extension_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ring_groups": { + "name": "ring_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "strategy": { + "name": "strategy", + "type": "ring_strategy", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ringall'" + }, + "ring_seconds": { + "name": "ring_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 25 + }, + "caller_id_name_prefix": { + "name": "caller_id_name_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "music_instead_of_ring": { + "name": "music_instead_of_ring", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "no_answer_destination": { + "name": "no_answer_destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ring_groups_number_unique": { + "name": "ring_groups_number_unique", + "nullsNotDistinct": false, + "columns": [ + "number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "inet", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_id_index": { + "name": "sessions_user_id_index", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sms_messages": { + "name": "sms_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "did_id": { + "name": "did_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "remote_number": { + "name": "remote_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "media": { + "name": "media", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sent_by_user_id": { + "name": "sent_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sms_messages_did_id_remote_number_created_at_index": { + "name": "sms_messages_did_id_remote_number_created_at_index", + "columns": [ + { + "expression": "did_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "remote_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sms_messages_did_id_dids_id_fk": { + "name": "sms_messages_did_id_dids_id_fk", + "tableFrom": "sms_messages", + "tableTo": "dids", + "columnsFrom": [ + "did_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sms_messages_sent_by_user_id_users_id_fk": { + "name": "sms_messages_sent_by_user_id_users_id_fk", + "tableFrom": "sms_messages", + "tableTo": "users", + "columnsFrom": [ + "sent_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sms_messages_direction_check": { + "name": "sms_messages_direction_check", + "value": "\"sms_messages\".\"direction\" in ('in', 'out')" + } + }, + "isRLSEnabled": false + }, + "public.sound_files": { + "name": "sound_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "moh_class_id": { + "name": "moh_class_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tts_text": { + "name": "tts_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_filename": { + "name": "original_filename", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "asterisk_name": { + "name": "asterisk_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sound_files_moh_class_id_moh_classes_id_fk": { + "name": "sound_files_moh_class_id_moh_classes_id_fk", + "tableFrom": "sound_files", + "tableTo": "moh_classes", + "columnsFrom": [ + "moh_class_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sound_files_asteriskName_unique": { + "name": "sound_files_asteriskName_unique", + "nullsNotDistinct": false, + "columns": [ + "asterisk_name" + ] + } + }, + "policies": {}, + "checkConstraints": { + "sound_files_kind_check": { + "name": "sound_files_kind_check", + "value": "\"sound_files\".\"kind\" in ('prompt', 'moh')" + }, + "sound_files_source_check": { + "name": "sound_files_source_check", + "value": "\"sound_files\".\"source\" in ('upload', 'tts', 'phone')" + } + }, + "isRLSEnabled": false + }, + "public.system_events": { + "name": "system_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "system_events_created_at_index": { + "name": "system_events_created_at_index", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "system_events_severity_check": { + "name": "system_events_severity_check", + "value": "\"system_events\".\"severity\" in ('info', 'warning', 'error')" + } + }, + "isRLSEnabled": false + }, + "public.time_conditions": { + "name": "time_conditions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "open_ranges": { + "name": "open_ranges", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "use_holidays": { + "name": "use_holidays", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "override_digit": { + "name": "override_digit", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "override_state": { + "name": "override_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "open_destination": { + "name": "open_destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "closed_destination": { + "name": "closed_destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "holiday_destination": { + "name": "holiday_destination", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "time_conditions_overrideDigit_unique": { + "name": "time_conditions_overrideDigit_unique", + "nullsNotDistinct": false, + "columns": [ + "override_digit" + ] + } + }, + "policies": {}, + "checkConstraints": { + "time_conditions_override_digit_check": { + "name": "time_conditions_override_digit_check", + "value": "\"time_conditions\".\"override_digit\" between 1 and 9" + }, + "time_conditions_override_state_check": { + "name": "time_conditions_override_state_check", + "value": "\"time_conditions\".\"override_state\" in ('open', 'closed')" + } + }, + "isRLSEnabled": false + }, + "public.trunks": { + "name": "trunks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "trunk_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'voipms'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "server_host": { + "name": "server_host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_port": { + "name": "server_port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5060 + }, + "transport": { + "name": "transport", + "type": "sip_transport", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'udp'" + }, + "auth_mode": { + "name": "auth_mode", + "type": "trunk_auth_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'registration'" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password_enc": { + "name": "password_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from_user": { + "name": "from_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from_domain": { + "name": "from_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "codecs": { + "name": "codecs", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{ulaw}'" + }, + "srtp": { + "name": "srtp", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "max_channels": { + "name": "max_channels", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "voipms_api_username": { + "name": "voipms_api_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "voipms_api_password_enc": { + "name": "voipms_api_password_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "trunks_name_unique": { + "name": "trunks_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_runtime_state": { + "name": "user_runtime_state", + "schema": "", + "columns": { + "extension_id": { + "name": "extension_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "dnd": { + "name": "dnd", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cfu": { + "name": "cfu", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cfb": { + "name": "cfb", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cfna": { + "name": "cfna", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_runtime_state_extension_id_extensions_id_fk": { + "name": "user_runtime_state_extension_id_extensions_id_fk", + "tableFrom": "user_runtime_state", + "tableTo": "extensions", + "columnsFrom": [ + "extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "citext", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "totp_secret_enc": { + "name": "totp_secret_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "totp_recovery_hashes": { + "name": "totp_recovery_hashes", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "totp_last_step": { + "name": "totp_last_step", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "must_change_password": { + "name": "must_change_password", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.voicemail_messages": { + "name": "voicemail_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "extension_id": { + "name": "extension_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "msg_id": { + "name": "msg_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "caller_number": { + "name": "caller_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "caller_name": { + "name": "caller_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_s": { + "name": "duration_s", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emailed_at": { + "name": "emailed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "transcript": { + "name": "transcript", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "voicemail_messages_extension_id_extensions_id_fk": { + "name": "voicemail_messages_extension_id_extensions_id_fk", + "tableFrom": "voicemail_messages", + "tableTo": "extensions", + "columnsFrom": [ + "extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "voicemail_messages_msgId_unique": { + "name": "voicemail_messages_msgId_unique", + "nullsNotDistinct": false, + "columns": [ + "msg_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_deliveries": { + "name": "webhook_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "webhook_id": { + "name": "webhook_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "response_code": { + "name": "response_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "webhook_deliveries_webhook_id_webhooks_id_fk": { + "name": "webhook_deliveries_webhook_id_webhooks_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhooks", + "columnsFrom": [ + "webhook_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhooks": { + "name": "webhooks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_enc": { + "name": "secret_enc", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "events": { + "name": "events", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.call_direction": { + "name": "call_direction", + "schema": "public", + "values": [ + "inbound", + "outbound", + "internal" + ] + }, + "public.call_report": { + "name": "call_report", + "schema": "public", + "values": [ + "off", + "hourly", + "daily", + "weekly" + ] + }, + "public.call_status": { + "name": "call_status", + "schema": "public", + "values": [ + "answered", + "missed", + "voicemail", + "abandoned", + "busy", + "failed" + ] + }, + "public.outbound_class": { + "name": "outbound_class", + "schema": "public", + "values": [ + "internal", + "local", + "national", + "international" + ] + }, + "public.phone_vendor": { + "name": "phone_vendor", + "schema": "public", + "values": [ + "yealink", + "grandstream", + "polycom", + "generic" + ] + }, + "public.queue_outcome": { + "name": "queue_outcome", + "schema": "public", + "values": [ + "answered", + "abandoned", + "timed_out" + ] + }, + "public.queue_strategy": { + "name": "queue_strategy", + "schema": "public", + "values": [ + "ringall", + "leastrecent", + "fewestcalls", + "random", + "rrmemory", + "linear" + ] + }, + "public.record_mode": { + "name": "record_mode", + "schema": "public", + "values": [ + "never", + "inbound", + "outbound", + "all" + ] + }, + "public.ring_strategy": { + "name": "ring_strategy", + "schema": "public", + "values": [ + "ringall", + "hunt" + ] + }, + "public.sip_transport": { + "name": "sip_transport", + "schema": "public", + "values": [ + "udp", + "tcp", + "tls" + ] + }, + "public.trunk_auth_mode": { + "name": "trunk_auth_mode", + "schema": "public", + "values": [ + "registration", + "ip" + ] + }, + "public.trunk_provider": { + "name": "trunk_provider", + "schema": "public", + "values": [ + "voipms", + "generic" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "admin", + "user" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/src/db/migrations/meta/_journal.json b/apps/api/src/db/migrations/meta/_journal.json index c1dee40..f9e9a2e 100644 --- a/apps/api/src/db/migrations/meta/_journal.json +++ b/apps/api/src/db/migrations/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1792626762332, "tag": "0007_recording_transcripts", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1792781321577, + "tag": "0008_call_reports", + "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 6962940..b920ef8 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -102,6 +102,7 @@ export const outboundClass = pgEnum('outbound_class', [ 'international', ]); export const recordMode = pgEnum('record_mode', ['never', 'inbound', 'outbound', 'all']); +export const callReport = pgEnum('call_report', ['off', 'hourly', 'daily', 'weekly']); export const extensions = pgTable('extensions', { id: id(), @@ -128,6 +129,13 @@ export const extensions = pgTable('extensions', { language: text().notNull().default('en'), mohClass: text().notNull().default('default'), blfNumbers: emptyTextArray(), + /** The call report email (05 §4): how often, at what local time (HH:MM) and weekday (0 = Sunday). */ + callReport: callReport().notNull().default('off'), + callReportTime: text().notNull().default('08:00'), + callReportWeekday: integer().notNull().default(1), + callReportSummaries: boolean().notNull().default(false), + /** The end of the last period reported on (or when the schedule last changed). */ + callReportSentAt: tstz(), ...timestamps, }); diff --git a/apps/api/src/jobs.ts b/apps/api/src/jobs.ts index 8beb94e..7d7d756 100644 --- a/apps/api/src/jobs.ts +++ b/apps/api/src/jobs.ts @@ -23,6 +23,7 @@ import { cleanupRecordings } from './routes/recordings.ts'; import { transcribeCalls } from './ai/calls.ts'; import { audit } from './services/audit.ts'; import { backupDue, runBackup } from './services/backup.ts'; +import { sendCallReports } from './services/callreport.ts'; import { logEvent } from './services/events.ts'; import { deliverDue } from './services/webhooks.ts'; import type { SendMail } from './services/mail.ts'; @@ -237,7 +238,11 @@ export function createJobs(d: JobDeps) { ); }; - return { daily, backup, publicIpWatch, trunkWatch, diskWatch }; + const callReports = async () => { + await sendCallReports({ db: d.db, send: d.send, log: d.log, now: now() }); + }; + + return { daily, backup, publicIpWatch, trunkWatch, diskWatch, callReports }; } /** Starts the jobs; returns a stop function. */ @@ -259,6 +264,7 @@ export function startJobs(d: JobDeps): () => void { run('trunkWatch', j.trunkWatch); if (minute % 5 === 0) run('publicIpWatch', j.publicIpWatch); if (minute % 60 === 0) run('diskWatch', j.diskWatch); + run('callReports', j.callReports); run('callTranscripts', async () => { await transcribeCalls({ db: d.db, dir: resolve(d.config.RECORDINGS_DIR), log: d.log }); }); diff --git a/apps/api/src/routes/me.ts b/apps/api/src/routes/me.ts index 9b6bd1b..7ebd926 100644 --- a/apps/api/src/routes/me.ts +++ b/apps/api/src/routes/me.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -// /api/me/voicemail and /api/me/greetings (04 §5.6, §7.3): the signed-in user's own mailbox. +// /api/me (settings, call report), /api/me/voicemail and /api/me/greetings (04 §5.6, §7.3). import { existsSync } from 'node:fs'; import { mkdir, rm } from 'node:fs/promises'; import { join } from 'node:path'; @@ -111,6 +111,10 @@ export async function meRoutes(app: FastifyInstance) { voicemailEmail: e.voicemailEmail, voicemailAttachAudio: e.voicemailAttachAudio, voicemailDeleteAfterEmail: e.voicemailDeleteAfterEmail, + callReport: e.callReport, + callReportTime: e.callReportTime, + callReportWeekday: e.callReportWeekday, + callReportSummaries: e.callReportSummaries, }; } @@ -144,6 +148,11 @@ export async function meRoutes(app: FastifyInstance) { if (body.voicemailPin && isTrivialPin(body.voicemailPin, ext.number)) throw badRequest('That PIN is too easy to guess (repeated, sequential or your extension)'); const { dnd: _d, cfu: _u, cfb: _b, cfna: _n, voicemailPin, ...vm } = body; + // A new call report schedule starts with its next period, not one that just ended. + const rescheduled = + body.callReport !== undefined || + body.callReportTime !== undefined || + body.callReportWeekday !== undefined; await db.transaction(async (tx) => { if (Object.keys(runtime).length) await tx @@ -153,7 +162,11 @@ export async function meRoutes(app: FastifyInstance) { if (Object.keys(vm).length || voicemailPin) await tx .update(extensions) - .set({ ...vm, ...(voicemailPin ? { voicemailPinEnc: encrypt(voicemailPin) } : {}) }) + .set({ + ...vm, + ...(voicemailPin ? { voicemailPinEnc: encrypt(voicemailPin) } : {}), + ...(rescheduled ? { callReportSentAt: new Date() } : {}), + }) .where(eq(extensions.id, ext.id)); }); // Offline Asterisk catches up from PostgreSQL on its next connect. diff --git a/apps/api/src/services/callreport.ts b/apps/api/src/services/callreport.ts new file mode 100644 index 0000000..d06e2b3 --- /dev/null +++ b/apps/api/src/services/callreport.ts @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// The call report email (05 §4): each user's calls of the last hour, day or week, with each +// recorded call's summary if they asked for it. No calls, no email. +import { and, asc, eq, gte, inArray, lt, ne, sql } from 'drizzle-orm'; +import { alias } from 'drizzle-orm/pg-core'; +import { formatNumber, type CallReport } from '@selfphoneng/shared'; +import type { Db } from '../db/client.ts'; +import { callExtensions, calls, extensions, recordings, users } from '../db/schema.ts'; +import type { SendMail } from './mail.ts'; +import { getSetting } from './settings.ts'; + +/** Calls are written a few seconds after hang-up and summarized a minute or two after. */ +const LAG_MS = 5 * 60_000; + +/** + * The last whole period that ended by `now`, in the time zone `tz`: e.g. daily at 08:00 is + * yesterday 08:00 to today 08:00. Local-time arithmetic in PostgreSQL, so DST days are 23 or 25 h. + */ +export async function lastPeriod( + db: Db, + tz: string, + s: { callReport: Exclude; callReportTime: string; callReportWeekday: number }, + now: Date, +): Promise<{ from: Date; to: Date }> { + const r = await db.execute<{ from: Date; to: Date }>(sql` + with l as (select (${now.toISOString()}::timestamptz at time zone ${tz}::text) as t), + s as ( + select t, case ${s.callReport}::text + when 'hourly' then date_trunc('hour', t) + when 'daily' then t::date + ${s.callReportTime}::time + else t::date - ((extract(dow from t)::int - ${s.callReportWeekday}::int + 7) % 7) + + ${s.callReportTime}::time + end as at, + case ${s.callReport}::text + when 'hourly' then interval '1 hour' + when 'daily' then interval '1 day' + else interval '7 days' + end as p + from l), + e as (select case when at > t then at - p else at end as at, p from s) + select (at - p) at time zone ${tz}::text as "from", at at time zone ${tz}::text as "to" from e`); + const row = r.rows[0]!; + return { from: new Date(row.from), to: new Date(row.to) }; +} + +const duration = (s: number) => + s >= 3600 + ? `${Math.floor(s / 3600)}:${String(Math.floor(s / 60) % 60).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}` + : `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; +/** "102" stays as is; outside numbers get their usual format. */ +const num = (n: string | null) => (n ? (n.length > 6 ? formatNumber(n) : n) : 'unknown'); + +export type ReportCall = { + startedAt: Date; + out: boolean; + status: string; + fromNumber: string | null; + fromName: string | null; + toNumber: string | null; + talkSeconds: number; + answeredBy: string | null; + recorded: boolean; + summary: string | null; + summaryPending: boolean; +}; + +/** The email's text: one block per call, oldest first. */ +export function reportText( + tz: string, + period: { from: Date; to: Date }, + list: ReportCall[], + withSummaries: boolean, +): string { + const when = new Intl.DateTimeFormat('en-US', { + timeZone: tz, + dateStyle: 'medium', + timeStyle: 'short', + }); + const lines = [ + `${list.length} call${list.length === 1 ? '' : 's'} from ${when.format(period.from)} to ${when.format(period.to)}:`, + ]; + for (const c of list) { + const who = c.out + ? `Outgoing to ${num(c.toNumber)}` + : `Incoming from ${num(c.fromNumber)}${c.fromName ? ` (${c.fromName})` : ''} to ${num(c.toNumber)}`; + const facts = [ + c.status === 'answered' && c.answeredBy ? `answered by ${c.answeredBy}` : c.status, + duration(c.talkSeconds), + ...(c.recorded ? ['recorded'] : []), + ]; + lines.push('', `${when.format(c.startedAt)} ${who}`, ` ${facts.join(' · ')}`); + if (withSummaries && c.summary) + lines.push( + ...c.summary + .trim() + .split('\n') + .map((l) => ` ${l}`), + ); + else if (withSummaries && c.summaryPending) + lines.push(' Summary not ready yet: see Recordings in the web portal.'); + } + return lines.join('\n') + '\n'; +} + +const answerer = alias(extensions, 'answerer'); + +/** One run: every user whose period ended and wasn't reported on yet. */ +export async function sendCallReports(d: { + db: Db; + send: SendMail; + log: { error(o: object, m: string): void }; + now: Date; +}): Promise { + const tz = await getSetting(d.db, 'general.timezone'); + const asOf = new Date(d.now.getTime() - LAG_MS); + const due = await d.db + .select({ e: extensions, email: users.email }) + .from(extensions) + .innerJoin(users, eq(users.id, extensions.userId)) + .where(and(ne(extensions.callReport, 'off'), eq(users.isActive, true))); + let sent = 0; + for (const { e, email } of due) { + if (e.callReport === 'off') continue; + const period = await lastPeriod(d.db, tz, { ...e, callReport: e.callReport }, asOf); + if (e.callReportSentAt && e.callReportSentAt >= period.to) continue; + // ponytail: by hang-up time, and only the last period: one that passed while the server + // was down isn't caught up. + const rows = await d.db + .select({ + c: calls, + answeredBy: answerer.name, + answeredById: answerer.id, + summary: recordings.summary, + aiDoneAt: recordings.aiDoneAt, + out: sql`exists (select 1 from ${callExtensions} where ${callExtensions.callId} = ${calls.id} + and ${callExtensions.extensionId} = ${e.id} and ${callExtensions.role} = 'caller')`, + }) + .from(calls) + .leftJoin(answerer, eq(answerer.id, calls.answeredByExtensionId)) + .leftJoin(recordings, eq(recordings.id, calls.recordingId)) + .where( + and( + gte(calls.endedAt, period.from), + lt(calls.endedAt, period.to), + inArray( + calls.id, + d.db + .select({ id: callExtensions.callId }) + .from(callExtensions) + .where(eq(callExtensions.extensionId, e.id)), + ), + ), + ) + .orderBy(asc(calls.startedAt)); + // Marked first: a failing mail server is logged once, not retried every minute. + await d.db + .update(extensions) + .set({ callReportSentAt: period.to }) + .where(eq(extensions.id, e.id)); + if (!rows.length) continue; + const list = rows.map((r): ReportCall => ({ + startedAt: r.c.startedAt, + out: r.out, + status: r.c.status, + fromNumber: r.c.fromNumber, + fromName: r.c.fromName, + toNumber: r.c.toNumber, + talkSeconds: r.c.talkSeconds, + answeredBy: r.answeredById && r.answeredById !== e.id ? r.answeredBy : null, + recorded: r.c.recordingId !== null, + summary: r.summary, + summaryPending: r.c.recordingId !== null && r.aiDoneAt === null, + })); + const withSummaries = + e.callReportSummaries && + (await getSetting(d.db, 'ai.callTranscripts')) && + (await getSetting(d.db, 'ai.callSummaries')); + try { + await d.send({ + to: [email], + subject: `Your calls (${e.number}): ${list.length} call${list.length === 1 ? '' : 's'}`, + text: reportText(tz, period, list, withSummaries), + }); + sent++; + } catch (err) { + d.log.error({ err, extension: e.number }, 'call report not sent'); + } + } + return sent; +} diff --git a/apps/api/test/call-reports.test.ts b/apps/api/test/call-reports.test.ts new file mode 100644 index 0000000..8c34aa3 --- /dev/null +++ b/apps/api/test/call-reports.test.ts @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// The call report email (05 §4): its periods, and who gets what. +import type { LightMyRequestResponse } from 'fastify'; +import { eq } from 'drizzle-orm'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { callExtensions, calls, extensions, recordings } from '../src/db/schema.ts'; +import { lastPeriod, sendCallReports } from '../src/services/callreport.ts'; +import { setSetting } from '../src/services/settings.ts'; +import { hasTestDb } from './db.ts'; +import { ADMIN, H, createTestApp } from './app.ts'; + +describe.skipIf(!hasTestDb)('call report email', () => { + let t: Awaited>; + let dan: (method: string, url: string, payload?: unknown) => Promise; + let extId: string; + const tz = 'America/Chicago'; + const period = (callReport: 'hourly' | 'daily' | 'weekly', now: string, at = '08:00', wd = 1) => + lastPeriod( + t.db, + tz, + { callReport, callReportTime: at, callReportWeekday: wd }, + new Date(now), + ).then((p) => [p.from.toISOString(), p.to.toISOString()]); + const run = (now: string) => + sendCallReports({ db: t.db, send: t.app.sendMail, log: t.app.log, now: new Date(now) }); + + beforeAll(async () => { + t = await createTestApp(); + await t.clearMustChange(ADMIN.email); + await setSetting(t.db, 'general.timezone', tz); + const as = (cookie: string) => (method: string, url: string, payload?: unknown) => + t.app.inject({ + method: method as 'GET', + url, + payload: payload as object, + headers: { ...H, cookie }, + }); + const admin = as(await t.login(ADMIN.email, ADMIN.password)); + const danId = (await t.addUser('dan@example.com', 'dan-password-123')).id; + extId = ( + await admin('POST', '/api/extensions', { number: '101', name: 'Dan Doe', userId: danId }) + ).json().id; + dan = as(await t.login('dan@example.com', 'dan-password-123')); + }); + afterAll(() => t?.close()); + + it('periods end at the last scheduled local time, DST included', async () => { + // CDT is UTC-5. 2026-10-23 is a Friday. + expect(await period('hourly', '2026-10-23T14:25:00Z')).toEqual([ + '2026-10-23T13:00:00.000Z', + '2026-10-23T14:00:00.000Z', + ]); + expect(await period('daily', '2026-10-23T14:25:00Z')).toEqual([ + '2026-10-22T13:00:00.000Z', + '2026-10-23T13:00:00.000Z', + ]); + // 07:59 local: still yesterday's period. + expect(await period('daily', '2026-10-23T12:59:00Z')).toEqual([ + '2026-10-21T13:00:00.000Z', + '2026-10-22T13:00:00.000Z', + ]); + // Weekly on Monday 08:00, asked on Friday: the week up to this Monday. + expect(await period('weekly', '2026-10-23T14:25:00Z')).toEqual([ + '2026-10-12T13:00:00.000Z', + '2026-10-19T13:00:00.000Z', + ]); + // DST ends 02:00 Sunday 2026-11-01 (CDT to CST, UTC-6): Saturday 08:00 to Sunday 08:00 is 25 h. + expect(await period('daily', '2026-11-01T15:00:00Z')).toEqual([ + '2026-10-31T13:00:00.000Z', + '2026-11-01T14:00:00.000Z', + ]); + }); + + it('PATCH /api/me saves the schedule, starting with the next period', async () => { + const r = await dan('PATCH', '/api/me', { + callReport: 'daily', + callReportTime: '17:30', + callReportSummaries: true, + }); + expect(r.statusCode, r.body).toBe(200); + expect(r.json()).toMatchObject({ + callReport: 'daily', + callReportTime: '17:30', + callReportWeekday: 1, + callReportSummaries: true, + }); + const [e] = await t.db.select().from(extensions).where(eq(extensions.id, extId)); + expect(e!.callReportSentAt).not.toBeNull(); + expect((await dan('PATCH', '/api/me', { callReportTime: '25:00' })).statusCode).toBe(400); + }); + + it('emails the calls of the period once, and nothing for a period without calls', async () => { + await dan('PATCH', '/api/me', { callReport: 'daily', callReportTime: '08:00' }); + await t.db.update(extensions).set({ callReportSentAt: null }).where(eq(extensions.id, extId)); + await setSetting(t.db, 'ai.callTranscripts', true); + await setSetting(t.db, 'ai.callSummaries', true); + const [rec] = await t.db + .insert(recordings) + .values({ + linkedid: 'r1', + filePath: '2026/10/22/r1.wav', + startedAt: new Date('2026-10-22T15:00:00Z'), + summary: 'Acme wants a quote.\nAction items:\n- Dan sends the quote', + aiDoneAt: new Date(), + }) + .returning(); + const [inCall, outCall, before] = await t.db + .insert(calls) + .values([ + { + linkedid: 'c1', + direction: 'inbound', + status: 'answered', + startedAt: new Date('2026-10-22T15:00:00Z'), + endedAt: new Date('2026-10-22T15:03:12Z'), + fromNumber: '13125551234', + fromName: 'Acme', + toNumber: '13125550000', + talkSeconds: 192, + answeredByExtensionId: extId, + recordingId: rec!.id, + }, + { + linkedid: 'c2', + direction: 'outbound', + status: 'answered', + startedAt: new Date('2026-10-22T20:00:00Z'), + endedAt: new Date('2026-10-22T20:00:45Z'), + fromNumber: '101', + toNumber: '18005550100', + talkSeconds: 40, + }, + { + linkedid: 'c3', // the period before + direction: 'inbound', + status: 'missed', + startedAt: new Date('2026-10-21T15:00:00Z'), + endedAt: new Date('2026-10-21T15:00:20Z'), + fromNumber: '13125559999', + }, + ]) + .returning(); + await t.db.insert(callExtensions).values([ + { callId: inCall!.id, extensionId: extId, role: 'answered' }, + { callId: outCall!.id, extensionId: extId, role: 'caller' }, + { callId: before!.id, extensionId: extId, role: 'rang' }, + ]); + + t.mails.length = 0; + // Today 08:00 CDT is 13:00Z; the report waits a few minutes for the last calls and summaries. + expect(await run('2026-10-23T13:02:00Z')).toBe(1); // yesterday's period, never reported + t.mails.length = 0; + await t.db + .update(extensions) + .set({ callReportSentAt: new Date('2026-10-22T13:00:00Z') }) + .where(eq(extensions.id, extId)); + expect(await run('2026-10-23T13:06:00Z')).toBe(1); + expect(await run('2026-10-23T13:07:00Z')).toBe(0); // once + expect(t.mails).toHaveLength(1); + const m = t.mails[0]!; + expect(m.to).toEqual(['dan@example.com']); + expect(m.subject).toBe('Your calls (101): 2 calls'); + expect(m.text).toContain('Incoming from (312) 555-1234 (Acme) to (312) 555-0000'); + expect(m.text).toContain('answered · 3:12 · recorded'); + expect(m.text).toContain(' - Dan sends the quote'); + expect(m.text).toContain('Outgoing to (800) 555-0100'); + expect(m.text).toContain('answered · 0:40\n'); + expect(m.text).not.toContain('555-9999'); + expect(m.text.indexOf('Incoming')).toBeLessThan(m.text.indexOf('Outgoing')); + + // The next day had no calls: no email. + expect(await run('2026-10-24T13:06:00Z')).toBe(0); + expect(t.mails).toHaveLength(1); + + // Summaries off: the calls, not what was said. + await dan('PATCH', '/api/me', { callReportSummaries: false }); + await t.db + .update(extensions) + .set({ callReportSentAt: new Date('2026-10-22T13:00:00Z') }) + .where(eq(extensions.id, extId)); + await run('2026-10-23T13:06:00Z'); + expect(t.mails[1]!.text).not.toContain('quote'); + }); +}); diff --git a/apps/api/test/me-settings.test.ts b/apps/api/test/me-settings.test.ts index d00bcdb..b720b2a 100644 --- a/apps/api/test/me-settings.test.ts +++ b/apps/api/test/me-settings.test.ts @@ -62,6 +62,10 @@ describe.skipIf(!hasTestDb)('/api/me settings and phone toggles', () => { voicemailEmail: true, voicemailAttachAudio: true, voicemailDeleteAfterEmail: false, + callReport: 'off', + callReportTime: '08:00', + callReportWeekday: 1, + callReportSummaries: false, }); }); diff --git a/apps/api/test/schema.test.ts b/apps/api/test/schema.test.ts index d6a829f..49a7257 100644 --- a/apps/api/test/schema.test.ts +++ b/apps/api/test/schema.test.ts @@ -48,6 +48,11 @@ const EXPECTED: Record> = { language: "text|NO|'en'::text", moh_class: "text|NO|'default'::text", blf_numbers: "ARRAY:_text|NO|'{}'::text[]", + call_report: "USER-DEFINED:call_report|NO|'off'::call_report", + call_report_time: "text|NO|'08:00'::text", + call_report_weekday: 'integer|NO|1', + call_report_summaries: 'boolean|NO|false', + call_report_sent_at: 'timestamp with time zone|YES|', ...ts, }, trunks: { diff --git a/apps/web/src/pages/user/MeSettingsPage.tsx b/apps/web/src/pages/user/MeSettingsPage.tsx index 1316487..210b538 100644 --- a/apps/web/src/pages/user/MeSettingsPage.tsx +++ b/apps/web/src/pages/user/MeSettingsPage.tsx @@ -1,10 +1,11 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -// My settings (05 §4): call forwarding, do not disturb, voicemail PIN and email options. +// My settings (05 §4): call forwarding, do not disturb, voicemail PIN and email options, the call +// report email. // 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 { Link } from 'react-router'; -import { formatNumber, type MeDto, type MeUpdate } from '@selfphoneng/shared'; +import { formatNumber, type CallReport, type MeDto, type MeUpdate } from '@selfphoneng/shared'; import { meApi } from '../../api/client.ts'; import { PhoneAppSetup } from '../../components/PhoneAppSetup.tsx'; import { Alert, Button, Field, inputClass } from '../../components/ui.tsx'; @@ -61,6 +62,7 @@ function SettingsForms({ me }: { me: MeDto }) { {me.voicemailEnabled && } +

Set up a phone app @@ -196,3 +198,107 @@ function Voicemail({ me }: { me: MeDto }) {

); } + +const REPORTS: [CallReport, string][] = [ + ['off', 'Off'], + ['hourly', 'Every hour'], + ['daily', 'Every day'], + ['weekly', 'Every week'], +]; +const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + +function CallReportForm({ me }: { me: MeDto }) { + const save = useUpdateMe(); + const [form, setForm] = useState({ + callReport: me.callReport, + callReportTime: me.callReportTime, + callReportWeekday: me.callReportWeekday, + callReportSummaries: me.callReportSummaries, + }); + const on = form.callReport !== 'off'; + return ( +
+

+ Call report +

+

+ An email listing your calls: who, which numbers, how long, and if they were recorded. No + calls, no email. It goes out a few minutes after each period ends, in the company time zone. +

+
{ + e.preventDefault(); + save.mutate(form); + }} + > + + {(p) => ( + + )} + + {form.callReport === 'weekly' && ( + + {(p) => ( + + )} + + )} + {(form.callReport === 'daily' || form.callReport === 'weekly') && ( + + {(p) => ( + setForm({ ...form, callReportTime: e.target.value })} + /> + )} + + )} + {on && ( + + )} +
+ + {save.isSuccess && Saved.} +
+ {save.error && {save.error.message}} +
+
+ ); +} diff --git a/apps/web/src/pages/user/VoicemailPage.tsx b/apps/web/src/pages/user/VoicemailPage.tsx index 8a771a9..3cc743a 100644 --- a/apps/web/src/pages/user/VoicemailPage.tsx +++ b/apps/web/src/pages/user/VoicemailPage.tsx @@ -1,16 +1,21 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -// My voicemail (05 §4): messages (play marks read, download, delete) and greetings. +// My voicemail (05 §4): messages (play marks read, download, delete, search) and greetings. import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useRef, useState } from 'react'; import { formatNumber, type GreetingKind, type VoicemailDto } from '@selfphoneng/shared'; import { meApi } from '../../api/client.ts'; import { ConfirmDialog } from '../../components/Dialog.tsx'; -import { Alert, Button } from '../../components/ui.tsx'; +import { Alert, Button, inputClass } from '../../components/ui.tsx'; export const mmss = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; export const callerOf = (m: VoicemailDto) => m.callerName ?? (m.callerNumber ? formatNumber(m.callerNumber) : 'Unknown caller'); export const isNew = (m: VoicemailDto) => m.folder !== 'Old'; +/** Words in the transcript, caller name or number, ignoring case. */ +export const matches = (m: VoicemailDto, q: string) => + [m.transcript, m.callerName, m.callerNumber].some((f) => + f?.toLowerCase().includes(q.trim().toLowerCase()), + ); export function VoicemailPage() { return ( @@ -25,6 +30,10 @@ function Messages() { const qc = useQueryClient(); const list = useQuery({ queryKey: ['me', 'voicemail'], queryFn: meApi.voicemail }); const [deleting, setDeleting] = useState(null); + const [q, setQ] = useState(''); + // ponytail: the whole mailbox is already loaded, so search filters it here; move to the API if + // mailboxes ever get paged. + const shown = q.trim() ? list.data?.filter((m) => matches(m, q)) : list.data; const refresh = () => qc.invalidateQueries({ queryKey: ['me', 'voicemail'] }); const markRead = useMutation({ mutationFn: (id: string) => meApi.moveVoicemail(id, 'Old'), @@ -44,9 +53,22 @@ function Messages() { Voicemail {list.error && {list.error.message}} + {!!list.data?.length && ( + setQ(e.target.value)} + /> + )} {list.data?.length === 0 &&

No messages.

} + {!!list.data?.length && shown?.length === 0 && ( +

No message mentions that.

+ )}
    - {list.data?.map((m) => ( + {shown?.map((m) => (
  • ; @@ -37,4 +51,8 @@ export type MeDto = { voicemailEmail: boolean; voicemailAttachAudio: boolean; voicemailDeleteAfterEmail: boolean; + callReport: CallReport; + callReportTime: string; + callReportWeekday: number; + callReportSummaries: boolean; };