diff --git a/AGENTS.md b/AGENTS.md index cb9779b..899c262 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,7 @@ write the software around it: database, config generator, API, web UI. | `docs/spec/11-contacts.md` | Contacts: the builtin phonebook or Contrack, lookup, the Contrack timeline | Working on contacts or caller names | | `docs/spec/12-sso.md` | Single sign-on: OpenID Connect (Keycloak and others) and LDAP/LDAPS | Working on sign-in, users or roles | | `docs/spec/13-mobile.md` | The Android/iPhone app (`mobile` repo): pairing, device tokens, its tasks | Working on the mobile app or its API | +| `docs/spec/14-spam.md` | Spam check: IPQualityScore and the FTC complaint list for unknown callers | Working on spam check or caller information | ## Hard rules diff --git a/apps/api/src/db/migrations/0014_spam_check.sql b/apps/api/src/db/migrations/0014_spam_check.sql new file mode 100644 index 0000000..79af039 --- /dev/null +++ b/apps/api/src/db/migrations/0014_spam_check.sql @@ -0,0 +1,25 @@ +CREATE TABLE "ftc_complaints" ( + "id" text PRIMARY KEY NOT NULL, + "number" text NOT NULL, + "reported_at" timestamp with time zone NOT NULL, + "subject" text, + "robocall" boolean, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "spam_lookups" ( + "number" text PRIMARY KEY NOT NULL, + "provider" text NOT NULL, + "risk" text NOT NULL, + "info" jsonb NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "spam_lookups_risk_check" CHECK ("spam_lookups"."risk" in ('low', 'medium', 'high')) +); +--> statement-breakpoint +ALTER TABLE "calls" ADD COLUMN "spam_risk" text;--> statement-breakpoint +ALTER TABLE "calls" ADD COLUMN "spam_info" jsonb;--> statement-breakpoint +CREATE INDEX "ftc_complaints_number_index" ON "ftc_complaints" USING btree ("number");--> statement-breakpoint +CREATE INDEX "ftc_complaints_reported_at_index" ON "ftc_complaints" USING btree ("reported_at");--> statement-breakpoint +CREATE INDEX "spam_lookups_expires_at_index" ON "spam_lookups" USING btree ("expires_at"); \ No newline at end of file diff --git a/apps/api/src/db/migrations/meta/0014_snapshot.json b/apps/api/src/db/migrations/meta/0014_snapshot.json new file mode 100644 index 0000000..f48f514 --- /dev/null +++ b/apps/api/src/db/migrations/meta/0014_snapshot.json @@ -0,0 +1,4790 @@ +{ + "id": "b1b1e555-676f-492e-b64e-da5244f74e5c", + "prevId": "ca681357-1ce8-48b8-9d7b-b60c3d457344", + "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 + }, + "spam_risk": { + "name": "spam_risk", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "spam_info": { + "name": "spam_info", + "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": { + "calls_started_at_index": { + "name": "calls_started_at_index", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "calls_did_id_dids_id_fk": { + "name": "calls_did_id_dids_id_fk", + "tableFrom": "calls", + "tableTo": "dids", + "columnsFrom": [ + "did_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "calls_answered_by_extension_id_extensions_id_fk": { + "name": "calls_answered_by_extension_id_extensions_id_fk", + "tableFrom": "calls", + "tableTo": "extensions", + "columnsFrom": [ + "answered_by_extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "calls_recording_id_recordings_id_fk": { + "name": "calls_recording_id_recordings_id_fk", + "tableFrom": "calls", + "tableTo": "recordings", + "columnsFrom": [ + "recording_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "calls_linkedid_unique": { + "name": "calls_linkedid_unique", + "nullsNotDistinct": false, + "columns": [ + "linkedid" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cdr": { + "name": "cdr", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "uniqueid": { + "name": "uniqueid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "linkedid": { + "name": "linkedid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_at": { + "name": "start_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "answer_at": { + "name": "answer_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "end_at": { + "name": "end_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "billsec": { + "name": "billsec", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "src": { + "name": "src", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dst": { + "name": "dst", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dcontext": { + "name": "dcontext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "clid": { + "name": "clid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dstchannel": { + "name": "dstchannel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastapp": { + "name": "lastapp", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lastdata": { + "name": "lastdata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disposition": { + "name": "disposition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accountcode": { + "name": "accountcode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userfield": { + "name": "userfield", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sp_did": { + "name": "sp_did", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sp_direction": { + "name": "sp_direction", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sp_recording": { + "name": "sp_recording", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw": { + "name": "raw", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "cdr_linkedid_index": { + "name": "cdr_linkedid_index", + "columns": [ + { + "expression": "linkedid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conferences": { + "name": "conferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pin_enc": { + "name": "pin_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "admin_pin_enc": { + "name": "admin_pin_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_members": { + "name": "max_members", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 20 + }, + "record": { + "name": "record", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "conferences_number_unique": { + "name": "conferences_number_unique", + "nullsNotDistinct": false, + "columns": [ + "number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contact_shares": { + "name": "contact_shares", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "from_user_id": { + "name": "from_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "to_user_id": { + "name": "to_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact": { + "name": "contact", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "contact_shares_to_user_id_index": { + "name": "contact_shares_to_user_id_index", + "columns": [ + { + "expression": "to_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"contact_shares\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "contact_shares_from_user_id_users_id_fk": { + "name": "contact_shares_from_user_id_users_id_fk", + "tableFrom": "contact_shares", + "tableTo": "users", + "columnsFrom": [ + "from_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "contact_shares_to_user_id_users_id_fk": { + "name": "contact_shares_to_user_id_users_id_fk", + "tableFrom": "contact_shares", + "tableTo": "users", + "columnsFrom": [ + "to_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "contact_shares_status_check": { + "name": "contact_shares_status_check", + "value": "\"contact_shares\".\"status\" in ('pending', 'accepted', 'denied')" + } + }, + "isRLSEnabled": false + }, + "public.contacts": { + "name": "contacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company": { + "name": "company", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "numbers": { + "name": "numbers", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "contacts_owner_user_id_users_id_fk": { + "name": "contacts_owner_user_id_users_id_fk", + "tableFrom": "contacts", + "tableTo": "users", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contrack_call_log": { + "name": "contrack_call_log", + "schema": "", + "columns": { + "call_id": { + "name": "call_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "interaction_id": { + "name": "interaction_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logged_at": { + "name": "logged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "contrack_call_log_call_id_calls_id_fk": { + "name": "contrack_call_log_call_id_calls_id_fk", + "tableFrom": "contrack_call_log", + "tableTo": "calls", + "columnsFrom": [ + "call_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "contrack_call_log_user_id_users_id_fk": { + "name": "contrack_call_log_user_id_users_id_fk", + "tableFrom": "contrack_call_log", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "contrack_call_log_call_id_user_id_pk": { + "name": "contrack_call_log_call_id_user_id_pk", + "columns": [ + "call_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.devices": { + "name": "devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "extension_id": { + "name": "extension_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "mac": { + "name": "mac", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vendor": { + "name": "vendor", + "type": "phone_vendor", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provisioning_password_enc": { + "name": "provisioning_password_enc", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_provisioned_at": { + "name": "last_provisioned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_provisioned_ip": { + "name": "last_provisioned_ip", + "type": "inet", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "devices_extension_id_extensions_id_fk": { + "name": "devices_extension_id_extensions_id_fk", + "tableFrom": "devices", + "tableTo": "extensions", + "columnsFrom": [ + "extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "devices_mac_unique": { + "name": "devices_mac_unique", + "nullsNotDistinct": false, + "columns": [ + "mac" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.did_sms_users": { + "name": "did_sms_users", + "schema": "", + "columns": { + "did_id": { + "name": "did_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "did_sms_users_did_id_dids_id_fk": { + "name": "did_sms_users_did_id_dids_id_fk", + "tableFrom": "did_sms_users", + "tableTo": "dids", + "columnsFrom": [ + "did_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "did_sms_users_user_id_users_id_fk": { + "name": "did_sms_users_user_id_users_id_fk", + "tableFrom": "did_sms_users", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "did_sms_users_did_id_user_id_pk": { + "name": "did_sms_users_did_id_user_id_pk", + "columns": [ + "did_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dids": { + "name": "dids", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "trunk_id": { + "name": "trunk_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination": { + "name": "destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "caller_id_name_prefix": { + "name": "caller_id_name_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "record_inbound": { + "name": "record_inbound", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sms_enabled": { + "name": "sms_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "e911_address": { + "name": "e911_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "dids_trunk_id_trunks_id_fk": { + "name": "dids_trunk_id_trunks_id_fk", + "tableFrom": "dids", + "tableTo": "trunks", + "columnsFrom": [ + "trunk_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "dids_number_unique": { + "name": "dids_number_unique", + "nullsNotDistinct": false, + "columns": [ + "number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.emergency_events": { + "name": "emergency_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "extension_number": { + "name": "extension_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dialed": { + "name": "dialed", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "caller_id_used": { + "name": "caller_id_used", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notified": { + "name": "notified", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.extensions": { + "name": "extensions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sip_password_enc": { + "name": "sip_password_enc", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webrtc_enabled": { + "name": "webrtc_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "max_contacts": { + "name": "max_contacts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "ring_seconds": { + "name": "ring_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 25 + }, + "voicemail_enabled": { + "name": "voicemail_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "voicemail_pin_enc": { + "name": "voicemail_pin_enc", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "voicemail_email": { + "name": "voicemail_email", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "voicemail_attach_audio": { + "name": "voicemail_attach_audio", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "voicemail_delete_after_email": { + "name": "voicemail_delete_after_email", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "outbound_class": { + "name": "outbound_class", + "type": "outbound_class", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'national'" + }, + "outbound_caller_id_did_id": { + "name": "outbound_caller_id_did_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "emergency_caller_id_did_id": { + "name": "emergency_caller_id_did_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "emergency_location": { + "name": "emergency_location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "record_mode": { + "name": "record_mode", + "type": "record_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'never'" + }, + "pickup_group": { + "name": "pickup_group", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "moh_class": { + "name": "moh_class", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "blf_numbers": { + "name": "blf_numbers", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "call_report": { + "name": "call_report", + "type": "call_report", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'off'" + }, + "call_report_time": { + "name": "call_report_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'08:00'" + }, + "call_report_weekday": { + "name": "call_report_weekday", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "call_report_summaries": { + "name": "call_report_summaries", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "call_report_sent_at": { + "name": "call_report_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "extensions_user_id_users_id_fk": { + "name": "extensions_user_id_users_id_fk", + "tableFrom": "extensions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "extensions_outbound_caller_id_did_id_dids_id_fk": { + "name": "extensions_outbound_caller_id_did_id_dids_id_fk", + "tableFrom": "extensions", + "tableTo": "dids", + "columnsFrom": [ + "outbound_caller_id_did_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "extensions_emergency_caller_id_did_id_dids_id_fk": { + "name": "extensions_emergency_caller_id_did_id_dids_id_fk", + "tableFrom": "extensions", + "tableTo": "dids", + "columnsFrom": [ + "emergency_caller_id_did_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "extensions_number_unique": { + "name": "extensions_number_unique", + "nullsNotDistinct": false, + "columns": [ + "number" + ] + }, + "extensions_userId_unique": { + "name": "extensions_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ftc_complaints": { + "name": "ftc_complaints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reported_at": { + "name": "reported_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "robocall": { + "name": "robocall", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ftc_complaints_number_index": { + "name": "ftc_complaints_number_index", + "columns": [ + { + "expression": "number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ftc_complaints_reported_at_index": { + "name": "ftc_complaints_reported_at_index", + "columns": [ + { + "expression": "reported_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.generator_runs": { + "name": "generator_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "files_changed": { + "name": "files_changed", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "commands": { + "name": "commands", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "ok": { + "name": "ok", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.holidays": { + "name": "holidays", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "recurring_yearly": { + "name": "recurring_yearly", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ivr_options": { + "name": "ivr_options", + "schema": "", + "columns": { + "ivr_id": { + "name": "ivr_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "digit": { + "name": "digit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "destination": { + "name": "destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ivr_options_ivr_id_ivrs_id_fk": { + "name": "ivr_options_ivr_id_ivrs_id_fk", + "tableFrom": "ivr_options", + "tableTo": "ivrs", + "columnsFrom": [ + "ivr_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ivr_options_ivr_id_digit_pk": { + "name": "ivr_options_ivr_id_digit_pk", + "columns": [ + "ivr_id", + "digit" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ivr_options_digit_check": { + "name": "ivr_options_digit_check", + "value": "\"ivr_options\".\"digit\" ~ '^[0-9*#]$'" + } + }, + "isRLSEnabled": false + }, + "public.ivrs": { + "name": "ivrs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "greeting_sound_id": { + "name": "greeting_sound_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invalid_sound_id": { + "name": "invalid_sound_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "timeout_seconds": { + "name": "timeout_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "max_retries": { + "name": "max_retries", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "allow_direct_dial": { + "name": "allow_direct_dial", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timeout_destination": { + "name": "timeout_destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "invalid_destination": { + "name": "invalid_destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ivrs_greeting_sound_id_sound_files_id_fk": { + "name": "ivrs_greeting_sound_id_sound_files_id_fk", + "tableFrom": "ivrs", + "tableTo": "sound_files", + "columnsFrom": [ + "greeting_sound_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "ivrs_invalid_sound_id_sound_files_id_fk": { + "name": "ivrs_invalid_sound_id_sound_files_id_fk", + "tableFrom": "ivrs", + "tableTo": "sound_files", + "columnsFrom": [ + "invalid_sound_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ivrs_number_unique": { + "name": "ivrs_number_unique", + "nullsNotDistinct": false, + "columns": [ + "number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mobile_devices": { + "name": "mobile_devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pair_code_hash": { + "name": "pair_code_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pair_expires_at": { + "name": "pair_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mobile_devices_user_id_index": { + "name": "mobile_devices_user_id_index", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mobile_devices_user_id_users_id_fk": { + "name": "mobile_devices_user_id_users_id_fk", + "tableFrom": "mobile_devices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mobile_devices_tokenHash_unique": { + "name": "mobile_devices_tokenHash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + }, + "mobile_devices_pairCodeHash_unique": { + "name": "mobile_devices_pairCodeHash_unique", + "nullsNotDistinct": false, + "columns": [ + "pair_code_hash" + ] + } + }, + "policies": {}, + "checkConstraints": { + "mobile_devices_platform_check": { + "name": "mobile_devices_platform_check", + "value": "\"mobile_devices\".\"platform\" in ('android', 'ios')" + } + }, + "isRLSEnabled": false + }, + "public.moh_classes": { + "name": "moh_classes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shuffle": { + "name": "shuffle", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "moh_classes_name_unique": { + "name": "moh_classes_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbound_routes": { + "name": "outbound_routes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "patterns": { + "name": "patterns", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "required_class": { + "name": "required_class", + "type": "outbound_class", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trunk_ids": { + "name": "trunk_ids", + "type": "uuid[]", + "primaryKey": false, + "notNull": true + }, + "caller_id_did_id": { + "name": "caller_id_did_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "outbound_routes_caller_id_did_id_dids_id_fk": { + "name": "outbound_routes_caller_id_did_id_dids_id_fk", + "tableFrom": "outbound_routes", + "tableTo": "dids", + "columnsFrom": [ + "caller_id_did_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paging_group_members": { + "name": "paging_group_members", + "schema": "", + "columns": { + "paging_group_id": { + "name": "paging_group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "extension_id": { + "name": "extension_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "paging_group_members_paging_group_id_paging_groups_id_fk": { + "name": "paging_group_members_paging_group_id_paging_groups_id_fk", + "tableFrom": "paging_group_members", + "tableTo": "paging_groups", + "columnsFrom": [ + "paging_group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "paging_group_members_extension_id_extensions_id_fk": { + "name": "paging_group_members_extension_id_extensions_id_fk", + "tableFrom": "paging_group_members", + "tableTo": "extensions", + "columnsFrom": [ + "extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "paging_group_members_paging_group_id_extension_id_pk": { + "name": "paging_group_members_paging_group_id_extension_id_pk", + "columns": [ + "paging_group_id", + "extension_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paging_groups": { + "name": "paging_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duplex": { + "name": "duplex", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "paging_groups_number_unique": { + "name": "paging_groups_number_unique", + "nullsNotDistinct": false, + "columns": [ + "number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.queue_calls": { + "name": "queue_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "queue_id": { + "name": "queue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "uniqueid": { + "name": "uniqueid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "linkedid": { + "name": "linkedid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "outcome": { + "name": "outcome", + "type": "queue_outcome", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "wait_seconds": { + "name": "wait_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "agent_extension_id": { + "name": "agent_extension_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "queue_calls_queue_id_joined_at_index": { + "name": "queue_calls_queue_id_joined_at_index", + "columns": [ + { + "expression": "queue_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "joined_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "queue_calls_uniqueid_index": { + "name": "queue_calls_uniqueid_index", + "columns": [ + { + "expression": "uniqueid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "queue_calls_queue_id_queues_id_fk": { + "name": "queue_calls_queue_id_queues_id_fk", + "tableFrom": "queue_calls", + "tableTo": "queues", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "queue_calls_agent_extension_id_extensions_id_fk": { + "name": "queue_calls_agent_extension_id_extensions_id_fk", + "tableFrom": "queue_calls", + "tableTo": "extensions", + "columnsFrom": [ + "agent_extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.queue_member_state": { + "name": "queue_member_state", + "schema": "", + "columns": { + "queue_id": { + "name": "queue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "extension_id": { + "name": "extension_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "paused": { + "name": "paused", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "queue_member_state_queue_id_queues_id_fk": { + "name": "queue_member_state_queue_id_queues_id_fk", + "tableFrom": "queue_member_state", + "tableTo": "queues", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "queue_member_state_extension_id_extensions_id_fk": { + "name": "queue_member_state_extension_id_extensions_id_fk", + "tableFrom": "queue_member_state", + "tableTo": "extensions", + "columnsFrom": [ + "extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "queue_member_state_queue_id_extension_id_pk": { + "name": "queue_member_state_queue_id_extension_id_pk", + "columns": [ + "queue_id", + "extension_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.queue_members": { + "name": "queue_members", + "schema": "", + "columns": { + "queue_id": { + "name": "queue_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "extension_id": { + "name": "extension_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "penalty": { + "name": "penalty", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "queue_members_queue_id_queues_id_fk": { + "name": "queue_members_queue_id_queues_id_fk", + "tableFrom": "queue_members", + "tableTo": "queues", + "columnsFrom": [ + "queue_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "queue_members_extension_id_extensions_id_fk": { + "name": "queue_members_extension_id_extensions_id_fk", + "tableFrom": "queue_members", + "tableTo": "extensions", + "columnsFrom": [ + "extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "queue_members_queue_id_extension_id_pk": { + "name": "queue_members_queue_id_extension_id_pk", + "columns": [ + "queue_id", + "extension_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.queues": { + "name": "queues", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "strategy": { + "name": "strategy", + "type": "queue_strategy", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'rrmemory'" + }, + "member_ring_seconds": { + "name": "member_ring_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 15 + }, + "retry_seconds": { + "name": "retry_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "wrapup_seconds": { + "name": "wrapup_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "max_wait_seconds": { + "name": "max_wait_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 300 + }, + "max_callers": { + "name": "max_callers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "moh_class": { + "name": "moh_class", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "announce_position": { + "name": "announce_position", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "announce_every_seconds": { + "name": "announce_every_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 60 + }, + "join_when_no_agents": { + "name": "join_when_no_agents", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "record": { + "name": "record", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sla_seconds": { + "name": "sla_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 20 + }, + "caller_id_name_prefix": { + "name": "caller_id_name_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timeout_destination": { + "name": "timeout_destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "queues_number_unique": { + "name": "queues_number_unique", + "nullsNotDistinct": false, + "columns": [ + "number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recordings": { + "name": "recordings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "linkedid": { + "name": "linkedid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "transcript": { + "name": "transcript", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_done_at": { + "name": "ai_done_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ai_error": { + "name": "ai_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "recordings_filePath_unique": { + "name": "recordings_filePath_unique", + "nullsNotDistinct": false, + "columns": [ + "file_path" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ring_group_members": { + "name": "ring_group_members", + "schema": "", + "columns": { + "ring_group_id": { + "name": "ring_group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "extension_id": { + "name": "extension_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ring_group_members_ring_group_id_ring_groups_id_fk": { + "name": "ring_group_members_ring_group_id_ring_groups_id_fk", + "tableFrom": "ring_group_members", + "tableTo": "ring_groups", + "columnsFrom": [ + "ring_group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ring_group_members_extension_id_extensions_id_fk": { + "name": "ring_group_members_extension_id_extensions_id_fk", + "tableFrom": "ring_group_members", + "tableTo": "extensions", + "columnsFrom": [ + "extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ring_group_members_ring_group_id_extension_id_pk": { + "name": "ring_group_members_ring_group_id_extension_id_pk", + "columns": [ + "ring_group_id", + "extension_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ring_groups": { + "name": "ring_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "strategy": { + "name": "strategy", + "type": "ring_strategy", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ringall'" + }, + "ring_seconds": { + "name": "ring_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 25 + }, + "caller_id_name_prefix": { + "name": "caller_id_name_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "music_instead_of_ring": { + "name": "music_instead_of_ring", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "no_answer_destination": { + "name": "no_answer_destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ring_groups_number_unique": { + "name": "ring_groups_number_unique", + "nullsNotDistinct": false, + "columns": [ + "number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip": { + "name": "ip", + "type": "inet", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'password'" + }, + "idp_refresh_enc": { + "name": "idp_refresh_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idp_id_token_enc": { + "name": "idp_id_token_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idp_checked_at": { + "name": "idp_checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sessions_user_id_index": { + "name": "sessions_user_id_index", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sessions_method_check": { + "name": "sessions_method_check", + "value": "\"sessions\".\"method\" in ('password', 'ldap', 'oidc')" + } + }, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sms_messages": { + "name": "sms_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "did_id": { + "name": "did_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "remote_number": { + "name": "remote_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "media": { + "name": "media", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sent_by_user_id": { + "name": "sent_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sms_messages_did_id_remote_number_created_at_index": { + "name": "sms_messages_did_id_remote_number_created_at_index", + "columns": [ + { + "expression": "did_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "remote_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sms_messages_did_id_dids_id_fk": { + "name": "sms_messages_did_id_dids_id_fk", + "tableFrom": "sms_messages", + "tableTo": "dids", + "columnsFrom": [ + "did_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sms_messages_sent_by_user_id_users_id_fk": { + "name": "sms_messages_sent_by_user_id_users_id_fk", + "tableFrom": "sms_messages", + "tableTo": "users", + "columnsFrom": [ + "sent_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sms_messages_direction_check": { + "name": "sms_messages_direction_check", + "value": "\"sms_messages\".\"direction\" in ('in', 'out')" + } + }, + "isRLSEnabled": false + }, + "public.sound_files": { + "name": "sound_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "moh_class_id": { + "name": "moh_class_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tts_text": { + "name": "tts_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_filename": { + "name": "original_filename", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "asterisk_name": { + "name": "asterisk_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sound_files_moh_class_id_moh_classes_id_fk": { + "name": "sound_files_moh_class_id_moh_classes_id_fk", + "tableFrom": "sound_files", + "tableTo": "moh_classes", + "columnsFrom": [ + "moh_class_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sound_files_asteriskName_unique": { + "name": "sound_files_asteriskName_unique", + "nullsNotDistinct": false, + "columns": [ + "asterisk_name" + ] + } + }, + "policies": {}, + "checkConstraints": { + "sound_files_kind_check": { + "name": "sound_files_kind_check", + "value": "\"sound_files\".\"kind\" in ('prompt', 'moh')" + }, + "sound_files_source_check": { + "name": "sound_files_source_check", + "value": "\"sound_files\".\"source\" in ('upload', 'tts', 'phone')" + } + }, + "isRLSEnabled": false + }, + "public.spam_lookups": { + "name": "spam_lookups", + "schema": "", + "columns": { + "number": { + "name": "number", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "risk": { + "name": "risk", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "info": { + "name": "info", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "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": { + "spam_lookups_expires_at_index": { + "name": "spam_lookups_expires_at_index", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "spam_lookups_risk_check": { + "name": "spam_lookups_risk_check", + "value": "\"spam_lookups\".\"risk\" in ('low', 'medium', 'high')" + } + }, + "isRLSEnabled": false + }, + "public.system_events": { + "name": "system_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "system_events_created_at_index": { + "name": "system_events_created_at_index", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "system_events_severity_check": { + "name": "system_events_severity_check", + "value": "\"system_events\".\"severity\" in ('info', 'warning', 'error')" + } + }, + "isRLSEnabled": false + }, + "public.time_conditions": { + "name": "time_conditions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "open_ranges": { + "name": "open_ranges", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "use_holidays": { + "name": "use_holidays", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "override_digit": { + "name": "override_digit", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "override_state": { + "name": "override_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "open_destination": { + "name": "open_destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "closed_destination": { + "name": "closed_destination", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "holiday_destination": { + "name": "holiday_destination", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "time_conditions_overrideDigit_unique": { + "name": "time_conditions_overrideDigit_unique", + "nullsNotDistinct": false, + "columns": [ + "override_digit" + ] + } + }, + "policies": {}, + "checkConstraints": { + "time_conditions_override_digit_check": { + "name": "time_conditions_override_digit_check", + "value": "\"time_conditions\".\"override_digit\" between 1 and 9" + }, + "time_conditions_override_state_check": { + "name": "time_conditions_override_state_check", + "value": "\"time_conditions\".\"override_state\" in ('open', 'closed')" + } + }, + "isRLSEnabled": false + }, + "public.trunks": { + "name": "trunks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "trunk_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'voipms'" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "server_host": { + "name": "server_host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "server_port": { + "name": "server_port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5060 + }, + "transport": { + "name": "transport", + "type": "sip_transport", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'udp'" + }, + "auth_mode": { + "name": "auth_mode", + "type": "trunk_auth_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'registration'" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password_enc": { + "name": "password_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from_user": { + "name": "from_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "from_domain": { + "name": "from_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "codecs": { + "name": "codecs", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{ulaw}'" + }, + "srtp": { + "name": "srtp", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "max_channels": { + "name": "max_channels", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "voipms_api_username": { + "name": "voipms_api_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "voipms_api_password_enc": { + "name": "voipms_api_password_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "trunks_name_unique": { + "name": "trunks_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_identities": { + "name": "user_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "identity_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "groups": { + "name": "groups", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_identities_user_id_users_id_fk": { + "name": "user_identities_user_id_users_id_fk", + "tableFrom": "user_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_identities_kind_subject_unique": { + "name": "user_identities_kind_subject_unique", + "nullsNotDistinct": false, + "columns": [ + "kind", + "subject" + ] + }, + "user_identities_userId_kind_unique": { + "name": "user_identities_userId_kind_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "kind" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_runtime_state": { + "name": "user_runtime_state", + "schema": "", + "columns": { + "extension_id": { + "name": "extension_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "dnd": { + "name": "dnd", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cfu": { + "name": "cfu", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cfb": { + "name": "cfb", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cfna": { + "name": "cfna", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_runtime_state_extension_id_extensions_id_fk": { + "name": "user_runtime_state_extension_id_extensions_id_fk", + "tableFrom": "user_runtime_state", + "tableTo": "extensions", + "columnsFrom": [ + "extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "citext", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "totp_secret_enc": { + "name": "totp_secret_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "totp_recovery_hashes": { + "name": "totp_recovery_hashes", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "totp_last_step": { + "name": "totp_last_step", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "must_change_password": { + "name": "must_change_password", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "contrack_token_enc": { + "name": "contrack_token_enc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contrack_log_calls": { + "name": "contrack_log_calls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "contrack_log_summaries": { + "name": "contrack_log_summaries", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "contrack_log_since": { + "name": "contrack_log_since", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.voicemail_messages": { + "name": "voicemail_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "extension_id": { + "name": "extension_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "msg_id": { + "name": "msg_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder": { + "name": "folder", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "caller_number": { + "name": "caller_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "caller_name": { + "name": "caller_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_s": { + "name": "duration_s", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emailed_at": { + "name": "emailed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "transcript": { + "name": "transcript", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "voicemail_messages_extension_id_extensions_id_fk": { + "name": "voicemail_messages_extension_id_extensions_id_fk", + "tableFrom": "voicemail_messages", + "tableTo": "extensions", + "columnsFrom": [ + "extension_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "voicemail_messages_msgId_unique": { + "name": "voicemail_messages_msgId_unique", + "nullsNotDistinct": false, + "columns": [ + "msg_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_deliveries": { + "name": "webhook_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "webhook_id": { + "name": "webhook_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "response_code": { + "name": "response_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "webhook_deliveries_webhook_id_webhooks_id_fk": { + "name": "webhook_deliveries_webhook_id_webhooks_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhooks", + "columnsFrom": [ + "webhook_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhooks": { + "name": "webhooks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_enc": { + "name": "secret_enc", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "events": { + "name": "events", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.call_direction": { + "name": "call_direction", + "schema": "public", + "values": [ + "inbound", + "outbound", + "internal" + ] + }, + "public.call_report": { + "name": "call_report", + "schema": "public", + "values": [ + "off", + "hourly", + "daily", + "weekly" + ] + }, + "public.call_status": { + "name": "call_status", + "schema": "public", + "values": [ + "answered", + "missed", + "voicemail", + "abandoned", + "busy", + "failed" + ] + }, + "public.identity_kind": { + "name": "identity_kind", + "schema": "public", + "values": [ + "oidc", + "ldap" + ] + }, + "public.outbound_class": { + "name": "outbound_class", + "schema": "public", + "values": [ + "internal", + "local", + "national", + "international" + ] + }, + "public.phone_vendor": { + "name": "phone_vendor", + "schema": "public", + "values": [ + "yealink", + "grandstream", + "polycom", + "generic" + ] + }, + "public.queue_outcome": { + "name": "queue_outcome", + "schema": "public", + "values": [ + "answered", + "abandoned", + "timed_out" + ] + }, + "public.queue_strategy": { + "name": "queue_strategy", + "schema": "public", + "values": [ + "ringall", + "leastrecent", + "fewestcalls", + "random", + "rrmemory", + "linear" + ] + }, + "public.record_mode": { + "name": "record_mode", + "schema": "public", + "values": [ + "never", + "inbound", + "outbound", + "all" + ] + }, + "public.ring_strategy": { + "name": "ring_strategy", + "schema": "public", + "values": [ + "ringall", + "hunt" + ] + }, + "public.sip_transport": { + "name": "sip_transport", + "schema": "public", + "values": [ + "udp", + "tcp", + "tls" + ] + }, + "public.trunk_auth_mode": { + "name": "trunk_auth_mode", + "schema": "public", + "values": [ + "registration", + "ip" + ] + }, + "public.trunk_provider": { + "name": "trunk_provider", + "schema": "public", + "values": [ + "voipms", + "generic" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "admin", + "user" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/src/db/migrations/meta/_journal.json b/apps/api/src/db/migrations/meta/_journal.json index cf9c337..f17f781 100644 --- a/apps/api/src/db/migrations/meta/_journal.json +++ b/apps/api/src/db/migrations/meta/_journal.json @@ -99,6 +99,13 @@ "when": 1793052019332, "tag": "0013_mobile_devices", "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1793127210386, + "tag": "0014_spam_check", + "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 c1cd7e9..9bb0f6a 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -630,11 +630,46 @@ export const calls = pgTable( answeredByExtensionId: uuid().references(() => extensions.id, { onDelete: 'set null' }), talkSeconds: integer().notNull().default(0), recordingId: uuid().references(() => recordings.id, { onDelete: 'set null' }), + /** The spam check of an incoming call's number (14 §6); null = not checked. */ + spamRisk: text(), + spamInfo: jsonb(), ...timestamps, }, (t) => [index().on(t.startedAt.desc())], ); +// ───────────── spam check (14 §7) ───────────── +/** IPQualityScore answers, so a caller who rings again costs no credit (14 §4). */ +export const spamLookups = pgTable( + 'spam_lookups', + { + number: text().primaryKey(), + provider: text().notNull(), + risk: text().notNull(), + info: jsonb().notNull(), + expiresAt: tstz().notNull(), + ...timestamps, + }, + (t) => [ + index().on(t.expiresAt), + check('spam_lookups_risk_check', sql`${t.risk} in ('low', 'medium', 'high')`), + ], +); + +/** The local copy of the FTC's Do Not Call complaints (14 §2.2). */ +export const ftcComplaints = pgTable( + 'ftc_complaints', + { + id: text().primaryKey(), + number: text().notNull(), + reportedAt: tstz().notNull(), + subject: text(), + robocall: boolean(), + createdAt: createdAt(), + }, + (t) => [index().on(t.number), index().on(t.reportedAt)], +); + export const callExtensions = pgTable( 'call_extensions', { diff --git a/apps/api/src/jobs.ts b/apps/api/src/jobs.ts index 6be84b3..9d5ad43 100644 --- a/apps/api/src/jobs.ts +++ b/apps/api/src/jobs.ts @@ -10,6 +10,7 @@ import type { Db } from './db/client.ts'; import { extensions, sessions, + spamLookups, systemEvents, trunks, users, @@ -30,6 +31,8 @@ import { logCallsToContrack } from './contacts/timeline.ts'; import { checkDirectory } from './sso/directory.ts'; import { checkOidcSessions } from './sso/oidcCheck.ts'; import { logEvent } from './services/events.ts'; +import { spamQuotaCheck } from './spam/check.ts'; +import { ftcSync } from './spam/ftc.ts'; import { deliverDue } from './services/webhooks.ts'; import type { SendMail } from './services/mail.ts'; import { getSetting, setSetting } from './services/settings.ts'; @@ -42,7 +45,11 @@ export type JobDeps = { generator: Pick; voicemail: { scan(ext: string): Promise }; send: SendMail; - log: { info(o: object, m: string): void; error(o: object, m: string): void }; + log: { + info(o: object, m: string): void; + warn(o: object, m: string): void; + error(o: object, m: string): void; + }; now?: () => Date; statfs?: typeof statfs; }; @@ -70,7 +77,8 @@ export async function mailAdmins(d: JobDeps, subject: string, text: string) { await d.send({ to, subject, text }).catch((err: unknown) => d.log.error({ err }, subject)); } -/** Recordings, voicemail, expired sessions and old webhook deliveries (04 §8 retentionCleanup). */ +/** Recordings, voicemail, expired sessions, old webhook deliveries and spam check answers + * (04 §8 retentionCleanup, 14 §4). */ export async function retentionCleanup(d: JobDeps) { const recordings = await cleanupRecordings( d.db, @@ -113,6 +121,7 @@ export async function retentionCleanup(d: JobDeps) { await d.db .delete(systemEvents) .where(lt(systemEvents.createdAt, sql`now() - interval '90 days'`)); + await d.db.delete(spamLookups).where(lt(spamLookups.expiresAt, sql`now()`)); await cleanupContactShares(d.db); if (recordings || voicemail) d.log.info({ recordings, voicemail }, 'old messages deleted'); } @@ -274,6 +283,12 @@ export function createJobs(d: JobDeps) { }; return { + spamQuotaCheck: () => spamQuotaCheck(d.db), + /** 14 §2.2: a failure is logged when it changes, and shown on Settings → Spam check. */ + spamFtcSync: async () => { + const reason = await ftcSync(d.db, { now: now() }); + if (reason) d.log.warn({ provider: 'ftc', reason }, 'FTC complaint download failed'); + }, oidcCheck, directoryCheck, daily, @@ -311,6 +326,8 @@ export function startJobs(d: JobDeps): () => void { run('contrackLog', j.contrackLog); if (minute % 5 === 0) run('oidcCheck', j.oidcCheck); if (minute % 60 === 0) run('directoryCheck', j.directoryCheck); + if (minute % 360 === 0) run('spamQuotaCheck', j.spamQuotaCheck); + run('spamFtcSync', j.spamFtcSync); run('callTranscripts', async () => { await transcribeCalls({ db: d.db, dir: resolve(d.config.RECORDINGS_DIR), log: d.log }); }); diff --git a/apps/api/src/routes/calls.ts b/apps/api/src/routes/calls.ts index 2ab2443..bbfb10c 100644 --- a/apps/api/src/routes/calls.ts +++ b/apps/api/src/routes/calls.ts @@ -10,10 +10,12 @@ import { type CallDto, type ListResponse, type MyCallDto, + type SpamCheck, } from '@selfphoneng/shared'; import { requireAdmin, requireAuth } from '../auth/guards.ts'; import type { Db } from '../db/client.ts'; import { callExtensions, calls, cdr, dids, extensions } from '../db/schema.ts'; +import { contactNames } from '../contacts/book.ts'; import { badRequest, notFound } from '../errors.ts'; import { getSetting } from '../services/settings.ts'; @@ -65,7 +67,8 @@ async function conditions(db: Db, f: Filter): Promise { return where; } -async function page(db: Db, where: SQL[], limit: number) { +/** `viewer`: whose books hide the spam check for a number they name (14 §6). */ +async function page(db: Db, where: SQL[], limit: number, viewer: string) { const rows = await db .select({ c: calls, did: dids, a: answerer }) .from(calls) @@ -76,6 +79,12 @@ async function page(db: Db, where: SQL[], limit: number) { .limit(limit + 1); const more = rows.length > limit; const items = rows.slice(0, limit); + const known = await contactNames( + db, + viewer, + items.filter(({ c }) => c.spamRisk).map(({ c }) => c.fromNumber), + { wait: false }, + ).catch(() => null); return { items: items.map(({ c, did, a }): CallDto => ({ id: c.id, @@ -91,6 +100,10 @@ async function page(db: Db, where: SQL[], limit: number) { answeredBy: a ? { id: a.id, number: a.number, name: a.name } : null, talkSeconds: c.talkSeconds, recordingId: c.recordingId, + spam: + c.spamRisk && known && !known.has(c.fromNumber ?? '') + ? ({ risk: c.spamRisk, ...(c.spamInfo as object) } as SpamCheck) + : null, })), nextCursor: more ? encodeCursor(items.at(-1)!.c) : null, }; @@ -111,7 +124,7 @@ export async function callRoutes(app: FastifyInstance) { { preHandler: requireAdmin }, async (req): Promise> => { const f = CallFilterSchema.parse(req.query); - return page(db, await conditions(db, f), f.limit); + return page(db, await conditions(db, f), f.limit, req.user!.id); }, ); @@ -123,7 +136,7 @@ export async function callRoutes(app: FastifyInstance) { 'started,direction,status,from,from_name,to,phone_number,answered_by,talk_seconds', ]; // ponytail: one query capped at 50,000 rows; stream with a cursor if exports get bigger. - const { items } = await page(db, where, 50_000); + const { items } = await page(db, where, 50_000, req.user!.id); const fmt = new Intl.DateTimeFormat('sv-SE', { timeZone: tz, dateStyle: 'short', @@ -156,7 +169,7 @@ export async function callRoutes(app: FastifyInstance) { app.get('/api/calls/:id', { preHandler: requireAdmin }, async (req): Promise => { const { id } = z.object({ id: z.uuid() }).parse(req.params); - const { items } = await page(db, [eq(calls.id, id)], 1); + const { items } = await page(db, [eq(calls.id, id)], 1, req.user!.id); const [c] = await db.select({ linkedid: calls.linkedid }).from(calls).where(eq(calls.id, id)); if (!items[0] || !c) throw notFound('Call not found'); const legs = await db @@ -189,7 +202,7 @@ export async function callRoutes(app: FastifyInstance) { .where(eq(extensions.userId, req.user!.id)); if (!ext) throw notFound("You don't have an extension yet. Ask your admin."); const where = await conditions(db, { ...f, extensionId: ext.id }); - const result = await page(db, where, f.limit); + const result = await page(db, where, f.limit, req.user!.id); const ids = result.items.map((c) => c.id); const roles = ids.length ? await db diff --git a/apps/api/src/routes/contacts.ts b/apps/api/src/routes/contacts.ts index 4d021e6..32cead4 100644 --- a/apps/api/src/routes/contacts.ts +++ b/apps/api/src/routes/contacts.ts @@ -15,6 +15,7 @@ import { type ContactImportResult, type ContactShareDto, type ListResponse, + type SpamCheck, } from '@selfphoneng/shared'; import { NO_LIMIT, SLOW, requireAdmin, requireAuth } from '../auth/guards.ts'; import { @@ -37,6 +38,7 @@ import { getSetting } from '../services/settings.ts'; import { decrypt } from '../crypto/secrets.ts'; import { contrackCheck } from '../contacts/contrack.ts'; import { callerNameToken, phoneSafeName, sameToken } from '../contacts/callername.ts'; +import { checkCaller, usNumber } from '../spam/check.ts'; const IdParams = z.object({ id: z.uuid() }); const CommitQuery = z.object({ commit: z.enum(['0', '1']).default('0') }); @@ -187,12 +189,52 @@ export async function contactRoutes(app: FastifyInstance) { .select({ userId: extensions.userId }) .from(extensions) .where(eq(extensions.number, q.data.ext)); - const names = await contactNames(db, e?.userId ?? null, [q.data.number], { + const owner = e?.userId ?? null; + const names = await contactNames(db, owner, [q.data.number], { wait: false, }).catch(() => new Map()); - return reply - .type('text/plain; charset=utf-8') - .send(phoneSafeName(names.get(q.data.number) ?? '')); + let name = names.get(q.data.number) ?? ''; + // 14 §5.2: no contact → the spam check's label, if it answers in 1.2 s (of the 2 s). + // app.log: this route's own log is silent (the token is in the query). + // ponytail: the label replaces the carrier's caller ID name, which the dialplan doesn't + // send; IPQualityScore's name for the line follows it when it has one. + if (!name) { + const c = await checkCaller({ db, log: app.log }, owner, usNumber(q.data.number), 1200); + if (c && c.risk !== 'low') { + const labels = (await getSetting(db, 'spam')).deskPhoneLabels; + name = `${labels[c.risk]} ${c.provider === 'ipqs' ? (c.info.name ?? '') : ''}`; + } + } + return reply.type('text/plain; charset=utf-8').send(phoneSafeName(name)); + }, + ); + + /** + * The softphone's spam check (14 §5.1): only for a caller ringing one of my extensions right + * now, so nobody can spend the credits on numbers that never called. + */ + app.get( + '/api/me/caller-check', + { preHandler: requireAuth }, + async (req): Promise<{ check: SpamCheck | null }> => { + const { number } = z.object({ number: z.string().max(30) }).parse(req.query); + const n = usNumber(number); + const mine = ( + await db + .select({ number: extensions.number }) + .from(extensions) + .where(eq(extensions.userId, req.user!.id)) + ).map((x) => x.number); + const ringing = app.live.calls + .list() + .some( + (c) => + c.direction === 'inbound' && + usNumber(c.from.number) === n && + c.extensions.some((x) => mine.includes(x)), + ); + if (!n || !ringing) return { check: null }; + return { check: await checkCaller({ db, log: req.log }, req.user!.id, n, 5000) }; }, ); diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts index 5ab0a51..9b743d3 100644 --- a/apps/api/src/routes/settings.ts +++ b/apps/api/src/routes/settings.ts @@ -11,6 +11,7 @@ import { OidcInputSchema, SettingsSchemas, SmtpInputSchema, + SpamInputSchema, type AiTestResult, type AuthMode, type Destination, @@ -19,13 +20,16 @@ import { type OidcTestResult, type SettingKey, type SettingsDto, + type SpamInput, + type SpamSettings, + type SpamTestResult, type Warning, } from '@selfphoneng/shared'; import type { Db } from '../db/client.ts'; import { dids, extensions, trunks } from '../db/schema.ts'; import { HttpError, badRequest } from '../errors.ts'; import { SLOW, requireAdmin } from '../auth/guards.ts'; -import { encrypt } from '../crypto/secrets.ts'; +import { decrypt, encrypt } from '../crypto/secrets.ts'; import { audit } from '../services/audit.ts'; import { assertDestinationExists } from '../services/destinations.ts'; import { getAllSettings, getSetting, setSetting } from '../services/settings.ts'; @@ -39,6 +43,10 @@ import { forgetBook } from '../contacts/contrack.ts'; import { assertLdap, testLdap, type LdapSettings } from '../sso/ldap.ts'; import { directoryUserFor } from '../sso/directory.ts'; import { assertIssuer, oidcConfig, oidcError, testOidc, type OidcSettings } from '../sso/oidc.ts'; +import { ftcTest } from '../spam/ftc.ts'; +import { SpamError } from '../spam/http.ts'; +import { ipqsCredits } from '../spam/ipqs.ts'; +import { patchSpamState, resumeIpqs } from '../spam/check.ts'; declare module 'fastify' { interface FastifyInstance { @@ -52,6 +60,7 @@ const READ_ONLY = new Set([ 'system.restartRequired', 'system.transportHash', 'system.directoryWarning', + 'spam.state', 'sms.webhookToken', 'setup.completed', ]); @@ -62,10 +71,20 @@ async function toDto(db: Db): Promise { 'contacts.contrack': ctk, 'auth.oidc': oidc, 'auth.ldap': ldap, + spam, ...rest } = await getAllSettings(db); return { ...rest, + spam: { + ...spam, + ipqs: spam.ipqs && { hasKey: true }, + ftc: spam.ftc && { + hasKey: true, + syncHours: spam.ftc.syncHours, + windowDays: spam.ftc.windowDays, + }, + }, 'auth.ldap': ldap && { enabled: ldap.enabled, preset: ldap.preset, @@ -209,6 +228,28 @@ export async function settingsRoutes(app: FastifyInstance) { trustEmail: input.trustEmail, }; } + /** The stored form of `spam` (14 §7): keys are write-only (empty keeps the stored one). */ + async function spamFromInput(input: SpamInput): Promise { + const v = SpamInputSchema.parse(input); + const old = await getSetting(db, 'spam'); + const keyEnc = (typed: string | undefined, stored: string | undefined, name: string) => { + if (typed) return encrypt(typed); + if (stored) return stored; + throw badRequest(`Enter the ${name} key`); + }; + const ipqs = v.ipqs && { + keyEnc: keyEnc(v.ipqs.key, old.ipqs?.keyEnc, 'IPQualityScore'), + }; + const ftc = v.ftc && { + keyEnc: keyEnc(v.ftc.key, old.ftc?.keyEnc, 'api.data.gov'), + syncHours: v.ftc.syncHours, + windowDays: v.ftc.windowDays, + }; + if (v.enabled && !ipqs && !ftc) + throw badRequest('Set up IPQualityScore or the FTC list to turn spam check on'); + return { ...v, ipqs, ftc }; + } + app.addHook('preHandler', requireAdmin); app.get('/api/settings', () => toDto(db)); @@ -258,6 +299,8 @@ export async function settingsRoutes(app: FastifyInstance) { forgetBook(null); } else if (key === 'auth.ldap') { values[key] = await ldapFromInput(raw); + } else if (key === 'spam') { + values[key] = await spamFromInput(raw as SpamInput); } else if (key === 'auth.oidc') { values[key] = await oidcFromInput(raw === null ? null : OidcInputSchema.parse(raw)); } else values[key] = SettingsSchemas[key as SettingKey].parse(raw); @@ -308,6 +351,8 @@ export async function settingsRoutes(app: FastifyInstance) { } for (const [key, value] of Object.entries(values)) await setSetting(db, key as SettingKey, value as never); + // A new IPQualityScore key may have credits: don't wait for spamQuotaCheck (14 §3). + if ((body.spam as SpamInput | undefined)?.ipqs?.key) await resumeIpqs(db, 'a new key'); const changed = Object.keys(values).filter( (k) => JSON.stringify(before[k as keyof typeof before]) !== JSON.stringify(values[k]), ); @@ -403,6 +448,32 @@ export async function settingsRoutes(app: FastifyInstance) { }, ); + /** + * Settings → Spam check → Test (14 §7.1), with the typed key or else the saved one. IPQualityScore + * answers with its credits left, which spends none. + */ + for (const provider of ['ipqs', 'ftc'] as const) + app.post( + `/api/settings/spam/${provider}/test`, + { config: SLOW }, + async (req): Promise => { + const { key } = z + .object({ key: z.string().trim().max(200).optional() }) + .parse(req.body ?? {}); + const keyEnc = (await getSetting(db, 'spam'))[provider]?.keyEnc; + const k = key || (keyEnc && decrypt(keyEnc)); + if (!k) return { ok: false, error: 'Enter the key first' }; + try { + if (provider === 'ftc') return { ok: true, detail: await ftcTest(k) }; + const credits = await ipqsCredits(k); + return { ok: true, detail: `Works: ${credits} credits left this billing period.` }; + } catch (err) { + if (err instanceof SpamError) return { ok: false, error: err.message }; + throw err; + } + }, + ); + /** The server's public IPv4, as an outside service sees it (only sent when asked). */ app.post('/api/settings/network/detect-public-ip', { config: SLOW }, async () => { const ip = await detectPublicIp(); @@ -488,6 +559,14 @@ export async function settingsRoutes(app: FastifyInstance) { message: s['system.directoryWarning'], link: '/admin/settings/sign-in', }); + const st = s['spam.state']; + if (s.spam.enabled && s.spam.ipqs && st.ipqsPausedAt && !st.warningDismissedAt) + items.push({ + code: 'spam_quota', + message: `IPQualityScore's lookups are used up. Spam checks ${s.spam.ftc ? 'use the FTC list' : 'are off'} until they come back.`, + link: '/admin/settings/spam', + dismissable: true, + }); if (!s.smtp) items.push({ code: 'smtp_missing', @@ -496,4 +575,17 @@ export async function settingsRoutes(app: FastifyInstance) { }); return { items }; }); + + /** Settings → Spam check → Download now (14 §2.2): due at the next minute, unless under way. */ + app.post('/api/settings/spam/ftc/sync', async () => { + if (!(await getSetting(db, 'spam')).ftc) throw badRequest('Set up the FTC list first'); + await patchSpamState(db, { ftcLastSyncAt: null }); + return { ok: true }; + }); + + /** Hidden until the next time the credits run out (14 §3). */ + app.post('/api/system/warnings/spam_quota/dismiss', async () => { + await patchSpamState(db, { warningDismissedAt: new Date().toISOString() }); + return { ok: true }; + }); } diff --git a/apps/api/src/services/cdr.ts b/apps/api/src/services/cdr.ts index 67865fd..50c1726 100644 --- a/apps/api/src/services/cdr.ts +++ b/apps/api/src/services/cdr.ts @@ -9,6 +9,7 @@ import { join } from 'node:path'; import { and, asc, eq, inArray, isNull, lt } from 'drizzle-orm'; import { field, type AmiMessage } from '../ami/events.ts'; import type { Db } from '../db/client.ts'; +import { checkNumber, usNumber, type SpamDeps } from '../spam/check.ts'; import { callExtensions, calls, cdr, dids, extensions, recordings } from '../db/schema.ts'; import { durationMs } from '../media/audio.ts'; @@ -160,7 +161,7 @@ async function linkRecording(db: Db | Tx, dir: string, s: CallSummary, linkedid: export async function aggregateCall( db: Db, linkedid: string, - opts: { recordingsDir?: string } = {}, + opts: { recordingsDir?: string; log?: SpamDeps['log'] } = {}, ): Promise { const rows = await db .select() @@ -180,6 +181,13 @@ export async function aggregateCall( const [d] = s.did ? await db.select({ id: dids.id }).from(dids).where(eq(dids.number, s.did)) : []; + // 14 §6: what the spam check knows now (the cache, the local FTC copy), kept with the call. + const spam = + s.direction === 'inbound' && s.fromNumber + ? await checkNumber({ db, log: opts.log ?? { warn() {} } }, usNumber(s.fromNumber), { + cachedOnly: true, + }) + : null; await db.transaction(async (tx) => { await tx.delete(calls).where(eq(calls.linkedid, linkedid)); const [call] = await tx @@ -197,6 +205,8 @@ export async function aggregateCall( didId: d?.id ?? null, answeredByExtensionId: idOf(s.answeredBy), talkSeconds: s.talkSeconds, + spamRisk: spam?.risk ?? null, + spamInfo: spam && { provider: spam.provider, info: spam.info }, recordingId: opts.recordingsDir ? await linkRecording(tx, opts.recordingsDir, s, linkedid) : null, @@ -218,7 +228,7 @@ export async function aggregateCall( export type CdrDeps = { db: Db; - log: { error(o: object, msg: string): void }; + log: { error(o: object, msg: string): void; warn(o: object, msg: string): void }; /** How long after a call's last Cdr event to aggregate it (04 §5.4: 5 s). */ settleMs?: number; /** Where MixMonitor writes (RECORDINGS_DIR); recordings are linked to their calls. */ @@ -233,7 +243,7 @@ export function watchCdr(d: CdrDeps, ami: Pick) { linkedid, setTimeout(() => { timers.delete(linkedid); - void aggregateCall(d.db, linkedid, { recordingsDir: d.recordingsDir }).catch( + void aggregateCall(d.db, linkedid, { recordingsDir: d.recordingsDir, log: d.log }).catch( (err: unknown) => d.log.error({ err, linkedid }, 'call aggregation failed'), ); }, d.settleMs ?? 5000), @@ -257,7 +267,7 @@ export function watchCdr(d: CdrDeps, ami: Pick) { .leftJoin(calls, eq(calls.linkedid, cdr.linkedid)) .where(and(isNull(calls.id), lt(cdr.createdAt, new Date(Date.now() - 60_000)))); for (const o of orphans) - await aggregateCall(d.db, o.linkedid, { recordingsDir: d.recordingsDir }); + await aggregateCall(d.db, o.linkedid, { recordingsDir: d.recordingsDir, log: d.log }); }, stop: () => timers.forEach(clearTimeout), }; diff --git a/apps/api/src/spam/check.ts b/apps/api/src/spam/check.ts new file mode 100644 index 0000000..8607e95 --- /dev/null +++ b/apps/api/src/spam/check.ts @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// The spam check (14 §5): the one way to ask what's known about a caller's number. Information +// only: nothing here changes a call, and a failure only means there's no answer (and one warning +// in the log). The contacts test (14 §1) is the caller's: it knows whose books to look in. +import { and, eq, gt, isNull, sql, type SQL } from 'drizzle-orm'; +import { + SpamStateSchema, + normalizeNumber, + type IpqsInfo, + type SpamCheck, + type SpamRisk, + type SpamSettings, + type SpamState, +} from '@selfphoneng/shared'; +import { contactNames } from '../contacts/book.ts'; +import type { Db } from '../db/client.ts'; +import { settings, spamLookups } from '../db/schema.ts'; +import { decrypt } from '../crypto/secrets.ts'; +import { logEvent } from '../services/events.ts'; +import { getSetting } from '../services/settings.ts'; +import { ftcLookup } from './ftc.ts'; +import { SpamError } from './http.ts'; +import { ipqsCredits, ipqsLookup } from './ipqs.ts'; + +export type SpamDeps = { db: Db; log: { warn(o: object, msg: string): void } }; + +/** US numbers only (owner, 2026-10-27): 1NXXNXXXXXX after normalizeNumber (00 §8). */ +export const isCheckable = (n: string) => /^1[2-9]\d{2}[2-9]\d{6}$/.test(n); + +/** `n` as 1NXXNXXXXXX-style digits (00 §8), or '' when it isn't a number. */ +export const usNumber = (n: string) => { + try { + return normalizeNumber(n); + } catch { + return ''; + } +}; + +/** A check under way, by number: a ring group ringing five people costs one credit (14 §4). */ +const pending = new Map>(); + +function warn(d: SpamDeps, provider: string, err: unknown) { + if (err instanceof SpamError) d.log.warn({ provider, reason: err.message }, 'spam check failed'); + else d.log.warn({ provider, err }, 'spam check failed'); +} + +/** + * IPQualityScore first when it's set up, then the FTC list (owner, 2026-10-27). `cachedOnly`: + * the cache and the local FTC copy, never a request out (call history, 14 §6). + */ +export function checkNumber( + d: SpamDeps, + number: string, + opts: { cachedOnly?: boolean } = {}, +): Promise { + if (opts.cachedOnly) return check(d, number, true); + // Shared from the start, before anything is awaited: else a check that finishes between + // another's cache read and its look here would cost a second request. + let p = pending.get(number); + if (!p) { + p = check(d, number, false).finally(() => pending.delete(number)); + pending.set(number, p); + } + return p; +} + +async function check(d: SpamDeps, number: string, cachedOnly: boolean): Promise { + if (!isCheckable(number)) return null; + const s = await getSetting(d.db, 'spam'); + if (!s.enabled) return null; + if (s.ipqs) { + const [hit] = await d.db + .select() + .from(spamLookups) + .where(and(eq(spamLookups.number, number), gt(spamLookups.expiresAt, sql`now()`))); + if (hit) return { risk: hit.risk as SpamRisk, provider: 'ipqs', info: hit.info as IpqsInfo }; + // Paused (14 §3): the cache still answers, IPQualityScore isn't asked. + if (!cachedOnly && !(await getSetting(d.db, 'spam.state')).ipqsPausedAt) { + const answer = await askIpqs(d, s, s.ipqs.keyEnc, number); + if (answer) return answer; + } + } + if (!s.ftc) return null; + try { + return { provider: 'ftc', ...(await ftcLookup(d.db, number, s.ftc.windowDays)) }; + } catch (err) { + warn(d, 'ftc', err); + return null; + } +} + +/** + * The check for a call ringing `userId` (14 §5): nothing for a number in their books (or when + * the books can't be read), else the answer if it comes within `waitMs`. A later answer still + * fills the cache. + */ +export async function checkCaller( + d: SpamDeps, + userId: string | null, + number: string, + waitMs: number, +): Promise { + if (!isCheckable(number)) return null; + const known = await contactNames(d.db, userId, [number], { wait: false }).catch(() => null); + if (!known || known.size) return null; + const check = checkNumber(d, number).catch((err: unknown) => { + warn(d, 'check', err); + return null; + }); + let timer: NodeJS.Timeout | undefined; + const late = new Promise((r) => (timer = setTimeout(r, waitMs, null))); + try { + return await Promise.race([check, late]); + } finally { + clearTimeout(timer); + } +} + +async function askIpqs( + d: SpamDeps, + s: SpamSettings, + keyEnc: string, + number: string, +): Promise { + try { + const a = await ipqsLookup(decrypt(keyEnc), number); + if (a.kind === 'exhausted') { + await pauseIpqs(d, s); + return null; + } + const days = a.risk === 'low' ? s.cache.cleanDays : s.cache.flaggedDays; + const expiresAt = sql`now() + make_interval(days => ${days})`; + await d.db + .insert(spamLookups) + .values({ number, provider: 'ipqs', risk: a.risk, info: a.info, expiresAt }) + .onConflictDoUpdate({ + target: spamLookups.number, + set: { risk: a.risk, info: a.info, expiresAt, updatedAt: new Date() }, + }); + return { risk: a.risk, provider: 'ipqs', info: a.info }; + } catch (err) { + warn(d, 'ipqs', err); + return null; + } +} + +/** + * Merges `patch` into spam.state in one statement, so the jobs and checks don't overwrite each + * other's fields. `onlyIf`: the condition the stored state must meet; false when it didn't. + */ +export async function patchSpamState( + db: Db, + patch: Partial, + onlyIf?: SQL, +): Promise { + await db + .insert(settings) + .values({ key: 'spam.state', value: SpamStateSchema.parse({}) }) + .onConflictDoNothing(); + const rows = await db + .update(settings) + .set({ + value: sql`${settings.value} || ${JSON.stringify(patch)}::jsonb`, + updatedAt: new Date(), + }) + .where(and(eq(settings.key, 'spam.state'), onlyIf)) + .returning({ key: settings.key }); + return rows.length > 0; +} + +const paused = sql`${settings.value}->>'ipqsPausedAt'`; + +/** Out of credits (14 §3): paused, and the event, warning and log line once per time. */ +async function pauseIpqs(d: SpamDeps, s: SpamSettings) { + const first = await patchSpamState( + d.db, + { ipqsPausedAt: new Date().toISOString(), warningDismissedAt: null }, + isNull(paused), + ); + if (!first) return; + d.log.warn({ provider: 'ipqs' }, 'spam check: IPQualityScore credits are used up, paused'); + await logEvent(d.db, { + severity: 'warning', + code: 'spam.quotaExhausted', + source: 'Spam check', + message: `IPQualityScore's lookups are used up. ${s.ftc ? 'Spam checks use the FTC list' : 'Spam checks are off'} until they come back.`, + }); +} + +/** Unpaused, by the job or a new key (14 §3). */ +export async function resumeIpqs(db: Db, why: string) { + const was = await patchSpamState(db, { ipqsPausedAt: null }, sql`${paused} is not null`); + if (was) + await logEvent(db, { + severity: 'info', + code: 'spam.quotaRestored', + source: 'Spam check', + message: `IPQualityScore lookups are back (${why}).`, + }); +} + +/** Job spamQuotaCheck (14 §3), every 6 hours: while paused, are credits back? A failure throws + * (the job's log line) and the next run tries again. */ +export async function spamQuotaCheck(db: Db) { + const s = await getSetting(db, 'spam'); + if (!s.ipqs || !(await getSetting(db, 'spam.state')).ipqsPausedAt) return; + const credits = await ipqsCredits(decrypt(s.ipqs.keyEnc)); + if (credits > 0) await resumeIpqs(db, `${credits} credits left`); +} diff --git a/apps/api/src/spam/ftc.ts b/apps/api/src/spam/ftc.ts new file mode 100644 index 0000000..c7e43b0 --- /dev/null +++ b/apps/api/src/spam/ftc.ts @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// The FTC's Do Not Call complaints (14 §2.2): a check reads the local copy, so it sends nothing +// out. spamFtcSync keeps the copy. +import { lt, sql } from 'drizzle-orm'; +import type { FtcInfo, SpamRisk } from '@selfphoneng/shared'; +import type { Db } from '../db/client.ts'; +import { ftcComplaints } from '../db/schema.ts'; +import { decrypt } from '../crypto/secrets.ts'; +import { getSetting } from '../services/settings.ts'; +import { todayIn } from '../generator/load.ts'; +import { isCheckable, patchSpamState } from './check.ts'; +import { SpamError, getJson } from './http.ts'; + +export const FTC_API = 'https://api.ftc.gov/v0/dnc-complaints'; + +/** Risk (owner, 2026-10-27): 3 or more complaints in the window high, 1–2 medium. */ +export const ftcRisk = (complaints: number): SpamRisk => + complaints >= 3 ? 'high' : complaints >= 1 ? 'medium' : 'low'; + +/** The complaints about `number` (1NXXNXXXXXX) in the last `windowDays`. */ +export async function ftcLookup( + db: Db, + number: string, + windowDays: number, +): Promise<{ risk: SpamRisk; info: FtcInfo }> { + const r = await db.execute<{ + complaints: number; + robocalls: number; + last: Date | string | null; + subject: string | null; + }>(sql` + select count(*)::int as complaints, + (count(*) filter (where robocall))::int as robocalls, + max(reported_at) as last, + mode() within group (order by subject) as subject + from ftc_complaints + where number = ${number} + and reported_at > now() - make_interval(days => ${windowDays})`); + const row = r.rows[0]!; + const info: FtcInfo = { + complaints: row.complaints, + robocalls: row.robocalls, + lastReportedAt: row.last === null ? null : new Date(row.last).toISOString(), + subject: row.subject, + }; + return { risk: ftcRisk(info.complaints), info }; +} + +const OVER_LIMIT = 'api.data.gov says the key is over its hourly limit'; + +async function ftcGet(key: string, q: Record) { + try { + return await getJson(`${FTC_API}?${new URLSearchParams(q)}`, { 'X-Api-Key': key }); + } catch (err) { + const m = (err as Error).message; + if (m === 'HTTP 403') throw new SpamError('api.data.gov refused the key'); + if (m === 'HTTP 429') throw new SpamError(OVER_LIMIT); + throw err; + } +} + +/** Settings → Spam check → Test: one request with the api.data.gov key. */ +export async function ftcTest(key: string): Promise { + await ftcGet(key, { items_per_page: '1' }); + return 'The FTC answered.'; +} + +// ponytail: the FTC's dates have no zone; taken as Eastern (it's in Washington). Hours off at +// worst, against a window of weeks. +const FTC_TZ = 'America/New_York'; +const PAGE = 50; +const DATE_TIME = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/; +const nextDay = (day: string) => + new Date(Date.parse(`${day}T00:00:00Z`) + 86_400_000).toISOString().slice(0, 10); +const daysBefore = (day: string, n: number) => + new Date(Date.parse(`${day}T00:00:00Z`) - n * 86_400_000).toISOString().slice(0, 10); + +/** One complaint as stored, or null for one without a usable US number or date. */ +function complaint(c: unknown) { + const { id, attributes: a } = (c ?? {}) as { id?: unknown; attributes?: Record }; + const phone = `1${String(a?.['company-phone-number'] ?? '')}`; + const at = a?.['created-date']; + if ( + typeof id !== 'string' || + !isCheckable(phone) || + typeof at !== 'string' || + !DATE_TIME.test(at) + ) + return null; + const robo = a?.['recorded-message-or-robocall']; + return { + id, + number: phone, + reportedAt: sql`${at}::timestamp at time zone ${FTC_TZ}`, + subject: typeof a?.subject === 'string' && a.subject ? a.subject : null, + robocall: robo === 'Y' ? true : robo === 'N' ? false : null, + }; +} + +/** + * Job spamFtcSync (14 §2.2), every minute: when a sync is due or under way, read up to `budget` + * pages (15 a minute is 900 an hour, under api.data.gov's 1,000) a day at a time, oldest first, + * saving where it got to. A failure (a 429 among them) is saved for the Settings tab and the next + * minute carries on from the same page. Returns the error, for the job's log line once. + */ +export async function ftcSync( + db: Db, + opts: { now?: Date; budget?: number } = {}, +): Promise { + const now = opts.now ?? new Date(); + const s = await getSetting(db, 'spam'); + if (!s.enabled || !s.ftc) return null; + const st = await getSetting(db, 'spam.state'); + let cursor = st.ftcCursor; + if (!cursor) { + const due = + !st.ftcLastSyncAt || + now.getTime() - Date.parse(st.ftcLastSyncAt) >= s.ftc.syncHours * 3_600_000; + if (!due) return null; + // From the newest complaint kept, less a day for ones posted late; at most the window. + const r = await db.execute<{ day: string | null }>( + sql`select to_char(max(reported_at) at time zone ${FTC_TZ}, 'YYYY-MM-DD') as day from ftc_complaints`, + ); + const first = daysBefore(todayIn(FTC_TZ, now), s.ftc.windowDays); + const last = r.rows[0]?.day ? daysBefore(r.rows[0].day, 1) : first; + cursor = { day: last > first ? last : first, offset: 0 }; + } + const key = decrypt(s.ftc.keyEnc); + const today = todayIn(FTC_TZ, now); + for (let n = 0; n < (opts.budget ?? 15); n++) { + let page: unknown[]; + try { + const j = await ftcGet(key, { + created_date_from: `"${cursor.day} 00:00:00"`, + created_date_to: `"${cursor.day} 23:59:59"`, + sort_order: 'ASC', + items_per_page: String(PAGE), + offset: String(cursor.offset), + }); + if (!Array.isArray(j.data)) throw new SpamError('the answer had no complaints list'); + page = j.data; + } catch (err) { + const error = err instanceof SpamError ? err.message : 'the sync failed'; + await patchSpamState(db, { ftcCursor: cursor, ftcLastError: error }); + if (!(err instanceof SpamError)) throw err; + return error === st.ftcLastError ? null : error; + } + const rows = page.map(complaint).filter((c) => c !== null); + if (rows.length) await db.insert(ftcComplaints).values(rows).onConflictDoNothing(); + if (page.length === PAGE) cursor = { day: cursor.day, offset: cursor.offset + PAGE }; + else if (cursor.day < today) cursor = { day: nextDay(cursor.day), offset: 0 }; + else { + await db + .delete(ftcComplaints) + .where( + lt( + ftcComplaints.reportedAt, + sql`${now.toISOString()}::timestamptz - make_interval(days => ${s.ftc.windowDays})`, + ), + ); + await patchSpamState(db, { + ftcCursor: null, + ftcLastSyncAt: now.toISOString(), + ftcLastError: null, + }); + return null; + } + } + await patchSpamState(db, { ftcCursor: cursor, ftcLastError: null }); + return null; +} diff --git a/apps/api/src/spam/http.ts b/apps/api/src/spam/http.ts new file mode 100644 index 0000000..898b687 --- /dev/null +++ b/apps/api/src/spam/http.ts @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// The spam check's providers over HTTPS (14 §2). Errors carry only these texts: never the URL +// or fetch's own error, which can quote it (IPQualityScore's key is in the path). + +/** A failure whose message is safe to log and show. */ +export class SpamError extends Error {} + +export async function getJson( + url: string, + headers: Record = {}, +): Promise> { + let r: Response; + try { + r = await fetch(url, { headers, signal: AbortSignal.timeout(4000) }); + } catch (err) { + throw new SpamError( + (err as Error).name === 'TimeoutError' ? 'no answer in 4 s' : "couldn't connect", + ); + } + if (!r.ok) throw new SpamError(`HTTP ${r.status}`); + try { + return (await r.json()) as Record; + } catch { + throw new SpamError('the answer was not JSON'); + } +} diff --git a/apps/api/src/spam/ipqs.ts b/apps/api/src/spam/ipqs.ts new file mode 100644 index 0000000..fdfac4d --- /dev/null +++ b/apps/api/src/spam/ipqs.ts @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// IPQualityScore (14 §2.1). Its key goes in the URL's path: see http.ts. +import type { IpqsInfo, SpamRisk } from '@selfphoneng/shared'; +import { SpamError, getJson } from './http.ts'; + +const BASE = 'https://www.ipqualityscore.com/api/json'; +export type IpqsAnswer = { kind: 'ok'; risk: SpamRisk; info: IpqsInfo } | { kind: 'exhausted' }; + +const get = (path: string) => getJson(`${BASE}/${path}`); + +const str = (v: unknown) => (typeof v === 'string' && v.trim() && v !== 'N/A' ? v.trim() : null); +const used = (msg: unknown) => typeof msg === 'string' && /insufficient credits/i.test(msg); +/** The provider's own message, cut short and without the key in case it quotes it. */ +const said = (msg: unknown, key: string) => + new SpamError( + typeof msg === 'string' && msg + ? msg.replaceAll(key, '***').slice(0, 200) + : 'the request failed', + ); + +/** Risk (owner, 2026-10-27): IPQualityScore's own advice, 75 suspicious and 85 high risk. */ +export function ipqsRisk(a: { + fraudScore: number; + spammer: boolean; + recentAbuse: boolean; + risky: boolean; +}): SpamRisk { + if (a.spammer || a.fraudScore >= 85) return 'high'; + if (a.fraudScore >= 75 || a.recentAbuse || a.risky) return 'medium'; + return 'low'; +} + +/** One phone lookup: `number` is 1NXXNXXXXXX. */ +export async function ipqsLookup(key: string, number: string): Promise { + const j = await get(`phone/${encodeURIComponent(key)}/${number}?country[]=US`); + if (j.success !== true) { + if (used(j.message)) return { kind: 'exhausted' }; + throw said(j.message, key); + } + const info: IpqsInfo = { + fraudScore: typeof j.fraud_score === 'number' ? j.fraud_score : 0, + spammer: j.spammer === true, + recentAbuse: j.recent_abuse === true, + lineType: str(j.line_type), + carrier: str(j.carrier), + name: str(j.name), + city: str(j.city), + region: str(j.region), + }; + return { kind: 'ok', risk: ipqsRisk({ ...info, risky: j.risky === true }), info }; +} + +/** The Credit Usage API: credits left in this billing period (Test, and 14 §3). */ +export async function ipqsCredits(key: string): Promise { + const j = await get(`account/${encodeURIComponent(key)}`); + if (j.success === false) throw said(j.message, key); + const credits = Number(j.credits); + if (!Number.isFinite(credits)) throw new SpamError('the answer had no credit count'); + return credits; +} diff --git a/apps/api/test/ftc-sync.test.ts b/apps/api/test/ftc-sync.test.ts new file mode 100644 index 0000000..ed9b29a --- /dev/null +++ b/apps/api/test/ftc-sync.test.ts @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// SD3: the FTC complaint download (14 §2.2) against a fake api.ftc.gov with three days of data. +import { randomBytes } from 'node:crypto'; +import { eq, sql } from 'drizzle-orm'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SPAM_DEFAULTS } from '@selfphoneng/shared'; +import { encrypt, setSecretKey } from '../src/crypto/secrets.ts'; +import type { Db } from '../src/db/client.ts'; +import { ftcComplaints, settings } from '../src/db/schema.ts'; +import { ftcSync } from '../src/spam/ftc.ts'; +import { getSetting, setSetting } from '../src/services/settings.ts'; +import { createTestDb, hasTestDb } from './db.ts'; + +const KEY = 'datagov-secret-key-456'; +const NOW = new Date('2026-10-27T18:00:00Z'); // 14:00 in Washington +const hour = 3_600_000; + +const at = (day: string, i: number) => + `${day} ${String(Math.floor(i / 60)).padStart(2, '0')}:${String(i % 60).padStart(2, '0')}:00`; +const ftc = (id: string, phone: string, created: string, robo = 'Y') => ({ + type: 'dnc_complaint', + id, + attributes: { + 'company-phone-number': phone, + 'created-date': created, + subject: 'Warranties', + 'recorded-message-or-robocall': robo, + }, +}); +/** 120 complaints on the 25th (three pages), 3 on the 26th, 3 today (one usable). */ +const DATA = [ + ...Array.from({ length: 120 }, (_, i) => ftc(`a${i}`, '3125550100', at('2026-10-25', i))), + ...[0, 1, 2].map((i) => ftc(`b${i}`, '3125550101', at('2026-10-26', i), 'N')), + ftc('c0', '3125550102', '2026-10-27 09:00:00', ''), + ftc('c1', '', '2026-10-27 09:01:00'), // no number + ftc('c2', '3125550102', '2026-10-27'), // no time +]; + +describe.skipIf(!hasTestDb)('FTC complaint download', () => { + let db: Db; + let drop: () => Promise; + /** Each request's day and offset. */ + const asked: string[] = []; + let fail: { at: number; status: number } | null = null; + + const turnOn = (keyEnc = encrypt(KEY)) => + setSetting(db, 'spam', { + ...SPAM_DEFAULTS, + enabled: true, + ftc: { keyEnc, syncHours: 24, windowDays: 7 }, + }); + const stored = async () => (await db.select().from(ftcComplaints)).length; + const reset = async () => { + await db.delete(ftcComplaints); + await db.delete(settings).where(eq(settings.key, 'spam.state')); + }; + + beforeAll(async () => { + ({ db, drop } = await createTestDb()); + setSecretKey(randomBytes(32).toString('base64')); + }); + afterAll(() => drop?.()); + + beforeEach(() => { + asked.length = 0; + fail = null; + vi.stubGlobal('fetch', async (url: string, init?: RequestInit) => { + const json = (j: unknown, status = 200) => new Response(JSON.stringify(j), { status }); + if ((init?.headers as Record)['X-Api-Key'] !== KEY) return json({}, 403); + const q = new URL(url).searchParams; + const day = /^"(\d{4}-\d{2}-\d{2}) 00:00:00"$/.exec(q.get('created_date_from')!)![1]!; + expect(q.get('created_date_to')).toBe(`"${day} 23:59:59"`); + expect(q.get('sort_order')).toBe('ASC'); + const offset = Number(q.get('offset')); + asked.push(`${day}+${offset}`); + if (fail && asked.length === fail.at) return json({}, fail.status); + const all = DATA.filter((c) => c.attributes['created-date'].startsWith(day)); + return json({ data: all.slice(offset, offset + 50), meta: { 'record-total': all.length } }); + }); + }); + afterEach(async () => { + vi.unstubAllGlobals(); + await reset(); + }); + + it('downloads nothing while spam check or the FTC list is off', async () => { + await setSetting(db, 'spam', SPAM_DEFAULTS); + expect(await ftcSync(db, { now: NOW })).toBeNull(); + await setSetting(db, 'spam', { ...SPAM_DEFAULTS, enabled: true }); + expect(await ftcSync(db, { now: NOW })).toBeNull(); + expect(asked).toEqual([]); + }); + + it('the first download fills the window a day at a time and drops old complaints', async () => { + await turnOn(); + await db.insert(ftcComplaints).values({ + id: 'old', + number: '13125550109', + reportedAt: sql`${NOW.toISOString()}::timestamptz - interval '8 days'`, + }); + expect(await ftcSync(db, { now: NOW, budget: 100 })).toBeNull(); + expect(asked).toEqual([ + '2026-10-20+0', + '2026-10-21+0', + '2026-10-22+0', + '2026-10-23+0', + '2026-10-24+0', + '2026-10-25+0', + '2026-10-25+50', + '2026-10-25+100', + '2026-10-26+0', + '2026-10-27+0', + ]); + expect(await stored()).toBe(124); + const row = async (id: string) => + (await db.select().from(ftcComplaints).where(eq(ftcComplaints.id, id)))[0]; + expect(await row('b0')).toMatchObject({ + number: '13125550101', + subject: 'Warranties', + robocall: false, + reportedAt: new Date('2026-10-26T04:00:00Z'), // midnight in Washington + }); + expect((await row('c0'))?.robocall).toBeNull(); + expect(await getSetting(db, 'spam.state')).toMatchObject({ + ftcCursor: null, + ftcLastSyncAt: NOW.toISOString(), + ftcLastError: null, + }); + }); + + it('not again until syncHours; then from the newest day kept, less one', async () => { + await turnOn(); + await ftcSync(db, { now: NOW, budget: 100 }); + asked.length = 0; + await ftcSync(db, { now: new Date(NOW.getTime() + 23 * hour) }); + expect(asked).toEqual([]); + await ftcSync(db, { now: new Date(NOW.getTime() + 24 * hour) }); + expect(asked).toEqual(['2026-10-26+0', '2026-10-27+0', '2026-10-28+0']); + }); + + it('15 pages a minute, the place saved; a 429 stops there and the next minute carries on', async () => { + await turnOn(); + await ftcSync(db, { now: NOW }); + expect(asked).toHaveLength(10); + await reset(); + asked.length = 0; + + await ftcSync(db, { now: NOW, budget: 2 }); + expect((await getSetting(db, 'spam.state')).ftcCursor).toEqual({ + day: '2026-10-22', + offset: 0, + }); + fail = { at: 7, status: 429 }; // the 25th's second page + const said = 'api.data.gov says the key is over its hourly limit'; + expect(await ftcSync(db, { now: NOW })).toBe(said); + expect(await getSetting(db, 'spam.state')).toMatchObject({ + ftcCursor: { day: '2026-10-25', offset: 50 }, + ftcLastError: said, + ftcLastSyncAt: null, + }); + fail = { at: 8, status: 429 }; // the same failure isn't returned (logged) twice + expect(await ftcSync(db, { now: NOW })).toBeNull(); + fail = null; + expect(await ftcSync(db, { now: NOW })).toBeNull(); + expect(await stored()).toBe(124); + expect(await getSetting(db, 'spam.state')).toMatchObject({ + ftcCursor: null, + ftcLastError: null, + }); + }); + + it('a refused key is saved for the Settings tab', async () => { + await turnOn(encrypt('wrong')); + expect(await ftcSync(db, { now: NOW })).toBe('api.data.gov refused the key'); + expect((await getSetting(db, 'spam.state')).ftcLastError).toBe('api.data.gov refused the key'); + }); +}); diff --git a/apps/api/test/schema.test.ts b/apps/api/test/schema.test.ts index 3081e8c..3ce55af 100644 --- a/apps/api/test/schema.test.ts +++ b/apps/api/test/schema.test.ts @@ -94,6 +94,8 @@ const EXPECTED: Record> = { answered_by_extension_id: 'uuid|YES|', talk_seconds: 'integer|NO|0', recording_id: 'uuid|YES|', + spam_risk: 'text|YES|', + spam_info: 'jsonb|YES|', ...ts, }, audit_log: { @@ -115,11 +117,11 @@ describe.skipIf(!hasTestDb)('schema matches spec 02', () => { beforeAll(async () => ({ db, drop } = await createTestDb())); afterAll(() => drop?.()); - it('creates all 45 tables', async () => { + it('creates all 47 tables', async () => { const r = await db.execute( sql`select count(*)::int as n from information_schema.tables where table_schema = 'public'`, ); - expect(r.rows[0]).toEqual({ n: 45 }); + expect(r.rows[0]).toEqual({ n: 47 }); }); it.each(Object.keys(EXPECTED))('%s columns', async (table) => { diff --git a/apps/api/test/spam.test.ts b/apps/api/test/spam.test.ts new file mode 100644 index 0000000..4e3721d --- /dev/null +++ b/apps/api/test/spam.test.ts @@ -0,0 +1,506 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// SD1, SD2, SD4, SD5: the spam check (14): IPQualityScore first, then the local FTC copy, against a fake fetch. +import { readFileSync } from 'node:fs'; +import { eq, like, sql } from 'drizzle-orm'; +import type { LightMyRequestResponse } from 'fastify'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SPAM_DEFAULTS, type SpamSettings } from '@selfphoneng/shared'; +import { encrypt } from '../src/crypto/secrets.ts'; +import { + auditLog, + cdr, + ftcComplaints, + settings as settingsTable, + spamLookups, + systemEvents, + users, +} from '../src/db/schema.ts'; +import type { ActiveCall } from '@selfphoneng/shared'; +import type { AmiMessage } from '../src/ami/events.ts'; +import { callerNameToken } from '../src/contacts/callername.ts'; +import { aggregateCall, toCdrRow } from '../src/services/cdr.ts'; +import { checkNumber, spamQuotaCheck } from '../src/spam/check.ts'; +import { getSetting, setSetting } from '../src/services/settings.ts'; +import { hasTestDb } from './db.ts'; +import { ADMIN, H, createTestApp } from './app.ts'; + +const IPQS_KEY = 'ipqs-secret-key-123'; +const FTC_KEY = 'datagov-secret-key-456'; +const SPAMMER = '13125550001'; +const SHADY = '13125550002'; +const CLEAN = '13125550003'; +const REPORTED = '13125550004'; + +/** IPQualityScore's phone answers by number; anything else is a valid, clean line. */ +const ANSWERS: Record> = { + [SPAMMER]: { fraud_score: 40, spammer: true, line_type: 'VOIP', carrier: 'Acme Tel' }, + [SHADY]: { fraud_score: 78, city: 'Austin', region: 'TX', name: 'N/A' }, + [CLEAN]: { fraud_score: 10, line_type: 'Wireless', carrier: 'Verizon' }, +}; + +describe.skipIf(!hasTestDb)('spam check', () => { + let t: Awaited>; + type Req = (method: string, url: string, payload?: unknown) => Promise; + let admin: Req; + const urls: string[] = []; + /** What the fake IPQualityScore does: answer, fail, or say the credits are used up. */ + let ipqs: 'ok' | 'down' | 'exhausted' | 'slow' | 'late' = 'ok'; + let credits = 912; + const log = { warn: vi.fn() }; + const d = () => ({ db: t.db, log }); + + const settings = (s: Partial) => + setSetting(t.db, 'spam', { ...SPAM_DEFAULTS, enabled: true, ...s }); + // Getters: the secret key is only set once createTestApp has run. + const keys = { + get withIpqs() { + return { ipqs: { keyEnc: encrypt(IPQS_KEY) } }; + }, + get withFtc() { + return { ftc: { keyEnc: encrypt(FTC_KEY), syncHours: 24, windowDays: 30 } }; + }, + }; + + beforeAll(async () => { + t = await createTestApp(); + await t.clearMustChange(ADMIN.email); + const cookie = await t.login(ADMIN.email, ADMIN.password); + admin = (method, url, payload) => + t.app.inject({ + method: method as 'GET', + url, + payload: payload as object, + headers: { ...H, cookie }, + }); + const day = (n: number) => sql`now() - make_interval(days => ${n})`; + await t.db.insert(ftcComplaints).values([ + { id: 'a1', number: REPORTED, reportedAt: day(1), subject: 'Warranties', robocall: true }, + { id: 'a2', number: REPORTED, reportedAt: day(2), subject: 'Warranties', robocall: true }, + { id: 'a3', number: REPORTED, reportedAt: day(3), subject: 'Debt', robocall: false }, + { id: 'a4', number: REPORTED, reportedAt: day(40), subject: 'Debt', robocall: true }, + { id: 'b1', number: SHADY, reportedAt: day(5), subject: null, robocall: null }, + ]); + }); + afterAll(() => t?.close()); + + beforeEach(() => { + urls.length = 0; + log.warn.mockClear(); + ipqs = 'ok'; + credits = 912; + vi.stubGlobal('fetch', async (url: string, init?: RequestInit) => { + urls.push(url); + const json = (j: unknown, status = 200) => new Response(JSON.stringify(j), { status }); + if (url.startsWith('https://api.ftc.gov/')) { + const key = (init?.headers as Record)['X-Api-Key']; + return key === FTC_KEY ? json({ data: [] }) : json({ error: {} }, 403); + } + const account = /\/api\/json\/account\/([^/]+)$/.exec(url); + if (account) + return account[1] === IPQS_KEY + ? json({ success: true, credits, usage: 88 }) + : json({ success: false, message: `Invalid or unauthorized key: ${account[1]}` }); + const phone = /\/api\/json\/phone\/[^/]+\/(\d+)\?country\[\]=US$/.exec(url); + if (!phone) return json({}, 404); + if (ipqs === 'down') return json({}, 503); + if (ipqs === 'slow') throw Object.assign(new Error('aborted'), { name: 'TimeoutError' }); + if (ipqs === 'exhausted') + return json({ + success: false, + message: 'You have insufficient credits to make this query.', + }); + await new Promise((r) => setTimeout(r, ipqs === 'late' ? 1500 : 20)); + return json({ success: true, valid: true, ...(ANSWERS[phone[1]!] ?? { fraud_score: 0 }) }); + }); + }); + afterEach(async () => { + vi.unstubAllGlobals(); + await t.db.delete(spamLookups); + await t.db.delete(settingsTable).where(eq(settingsTable.key, 'spam.state')); + await t.db.delete(systemEvents).where(like(systemEvents.code, 'spam.%')); + }); + + it('checks nothing while off, and only US numbers', async () => { + await setSetting(t.db, 'spam', { ...SPAM_DEFAULTS, ...keys.withIpqs }); + expect(await checkNumber(d(), SPAMMER)).toBeNull(); + await settings(keys.withIpqs); + for (const n of ['442079460958', '911', '101', '13125550', '10005550001']) + expect(await checkNumber(d(), n)).toBeNull(); + expect(urls).toEqual([]); + }); + + it('IPQualityScore: risk from its flags and score, only the kept fields', async () => { + await settings(keys.withIpqs); + expect(await checkNumber(d(), SPAMMER)).toEqual({ + risk: 'high', + provider: 'ipqs', + info: { + fraudScore: 40, + spammer: true, + recentAbuse: false, + lineType: 'VOIP', + carrier: 'Acme Tel', + name: null, + city: null, + region: null, + }, + }); + expect((await checkNumber(d(), SHADY))?.risk).toBe('medium'); + expect(await checkNumber(d(), CLEAN)).toMatchObject({ + risk: 'low', + info: { carrier: 'Verizon' }, + }); + expect(urls).toHaveLength(3); + }); + + it('caches answers: flagged for flaggedDays, the rest for cleanDays', async () => { + await settings({ ...keys.withIpqs, cache: { flaggedDays: 14, cleanDays: 3 } }); + await checkNumber(d(), SPAMMER); + await checkNumber(d(), CLEAN); + await checkNumber(d(), SPAMMER); + await checkNumber(d(), CLEAN); + expect(urls).toHaveLength(2); + const days = await t.db.execute<{ number: string; days: number }>( + sql`select number, round(extract(epoch from expires_at - now()) / 86400)::int as days from spam_lookups order by number`, + ); + expect(days.rows).toEqual([ + { number: SPAMMER, days: 14 }, + { number: CLEAN, days: 3 }, + ]); + // An expired answer is asked again. + await t.db.update(spamLookups).set({ expiresAt: sql`now() - interval '1 second'` }); + await checkNumber(d(), CLEAN); + expect(urls).toHaveLength(3); + }); + + it('a check under way is shared: five at once cost one request', async () => { + await settings(keys.withIpqs); + const all = await Promise.all([1, 2, 3, 4, 5].map(() => checkNumber(d(), SPAMMER))); + expect(new Set(all.map((a) => a?.risk))).toEqual(new Set(['high'])); + expect(urls).toHaveLength(1); + }); + + it('IPQualityScore failing: one warning without the key, nothing cached, the FTC answers', async () => { + await settings({ ...keys.withIpqs, ...keys.withFtc }); + for (const mode of ['down', 'slow'] as const) { + ipqs = mode; + expect(await checkNumber(d(), REPORTED)).toEqual({ + risk: 'high', + provider: 'ftc', + info: { + complaints: 3, + robocalls: 2, + lastReportedAt: expect.any(String), + subject: 'Warranties', + }, + }); + } + expect(log.warn.mock.calls.map((c) => c[0])).toEqual([ + { provider: 'ipqs', reason: 'HTTP 503' }, + { provider: 'ipqs', reason: 'no answer in 4 s' }, + ]); + expect(JSON.stringify(log.warn.mock.calls)).not.toContain(IPQS_KEY); + expect(await t.db.select().from(spamLookups)).toEqual([]); + }); + + it('without IPQualityScore the FTC list answers; without either, nothing', async () => { + ipqs = 'down'; + await settings(keys.withFtc); + expect((await checkNumber(d(), SHADY))?.info).toEqual({ + complaints: 1, + robocalls: 0, + lastReportedAt: expect.any(String), + subject: null, + }); + expect((await checkNumber(d(), SHADY))?.risk).toBe('medium'); + expect(await checkNumber(d(), CLEAN)).toMatchObject({ risk: 'low', provider: 'ftc' }); + expect(urls).toEqual([]); + await settings({}); + expect(await checkNumber(d(), REPORTED)).toBeNull(); + }); + + it('out of credits: paused once, the FTC answers, the warning, Dismiss, and back', async () => { + await settings({ ...keys.withIpqs, ...keys.withFtc }); + const warning = async () => + (await admin('GET', '/api/system/warnings')) + .json() + .items.find((w: { code: string }) => w.code === 'spam_quota'); + const events = async () => + ( + await t.db + .select({ code: systemEvents.code }) + .from(systemEvents) + .where(like(systemEvents.code, 'spam.%')) + .orderBy(systemEvents.createdAt) + ).map((e) => e.code); + ipqs = 'exhausted'; + const both = await Promise.all([checkNumber(d(), REPORTED), checkNumber(d(), SHADY)]); + expect(both.map((a) => a?.provider)).toEqual(['ftc', 'ftc']); + expect((await getSetting(t.db, 'spam.state')).ipqsPausedAt).not.toBeNull(); + expect(log.warn).toHaveBeenCalledTimes(1); + expect(await events()).toEqual(['spam.quotaExhausted']); + expect(await warning()).toEqual({ + code: 'spam_quota', + message: + "IPQualityScore's lookups are used up. Spam checks use the FTC list until they come back.", + link: '/admin/settings/spam', + dismissable: true, + }); + + // Paused: IPQualityScore isn't asked and nothing is logged. + urls.length = 0; + expect((await checkNumber(d(), CLEAN))?.provider).toBe('ftc'); + expect(urls).toEqual([]); + expect(log.warn).toHaveBeenCalledTimes(1); + + expect((await admin('POST', '/api/system/warnings/spam_quota/dismiss')).statusCode).toBe(200); + expect(await warning()).toBeUndefined(); + + credits = 0; + await spamQuotaCheck(t.db); + expect((await getSetting(t.db, 'spam.state')).ipqsPausedAt).not.toBeNull(); + credits = 5; + await spamQuotaCheck(t.db); + expect((await getSetting(t.db, 'spam.state')).ipqsPausedAt).toBeNull(); + expect(await events()).toEqual(['spam.quotaExhausted', 'spam.quotaRestored']); + ipqs = 'ok'; + expect((await checkNumber(d(), SPAMMER))?.provider).toBe('ipqs'); + + // Out again: the warning comes back though it was dismissed; without the FTC list, "off". + await settings(keys.withIpqs); + ipqs = 'exhausted'; + expect(await checkNumber(d(), SHADY)).toBeNull(); + expect((await warning())?.message).toBe( + "IPQualityScore's lookups are used up. Spam checks are off until they come back.", + ); + + // A new key doesn't wait for the job. + const r = await admin('PATCH', '/api/settings', { + spam: { + ...SPAM_DEFAULTS, + enabled: true, + ipqs: { key: IPQS_KEY }, + }, + }); + expect(r.statusCode).toBe(200); + expect((await getSetting(t.db, 'spam.state')).ipqsPausedAt).toBeNull(); + expect(await warning()).toBeUndefined(); + }); + + it('spamQuotaCheck asks nothing unless paused, and a failure throws without the key', async () => { + await settings(keys.withIpqs); + await spamQuotaCheck(t.db); + expect(urls).toEqual([]); + await setSetting(t.db, 'spam.state', { + ...(await getSetting(t.db, 'spam.state')), + ipqsPausedAt: new Date().toISOString(), + }); + await settings({ ipqs: { keyEnc: encrypt('wrong-key') } }); + const err = await spamQuotaCheck(t.db).catch((e: unknown) => e as Error); + expect(err).toBeInstanceOf(Error); + expect(err!.message).toBe('Invalid or unauthorized key: ***'); + expect((await getSetting(t.db, 'spam.state')).ipqsPausedAt).not.toBeNull(); + }); + + it('cachedOnly never asks IPQualityScore', async () => { + await settings({ ...keys.withIpqs, ...keys.withFtc }); + expect((await checkNumber(d(), REPORTED, { cachedOnly: true }))?.provider).toBe('ftc'); + await checkNumber(d(), SPAMMER); + expect((await checkNumber(d(), SPAMMER, { cachedOnly: true }))?.provider).toBe('ipqs'); + expect(urls).toHaveLength(1); + }); + + describe('while it rings', () => { + let ringing: ActiveCall[] = []; + const call = (from: string, ext: string): ActiveCall => ({ + linkedid: `l-${from}`, + startedAt: new Date().toISOString(), + direction: 'inbound', + from: { number: from }, + to: { number: '13125559999' }, + state: 'ringing', + extensions: [ext], + channels: [], + recording: false, + }); + const check = async (number: string) => + (await admin('GET', `/api/me/caller-check?number=${number}`)).json().check; + const deskName = async (number: string, ext = '101') => + ( + await t.app.inject({ + method: 'GET', + url: `/api/internal/caller-name?ext=${ext}&number=${number}&token=${callerNameToken(t.app.config.APP_SECRET)}`, + }) + ).body; + + beforeAll(async () => { + const [me] = await t.db.select().from(users).where(eq(users.email, ADMIN.email)); + expect( + (await admin('POST', '/api/extensions', { number: '101', name: 'Admin', userId: me!.id })) + .statusCode, + ).toBe(201); + await admin('POST', '/api/me/contacts', { + name: 'Pizza Place', + numbers: [{ number: SHADY.slice(1) }], + }); + t.app.live.calls.list = () => ringing; + }); + beforeEach(() => settings({ ...keys.withIpqs, ...keys.withFtc })); + + it('the softphone: only callers ringing me now, never my contacts', async () => { + ringing = [call(SPAMMER.slice(1), '101'), call(CLEAN, '102'), call(SHADY, '101')]; + expect(await check(SPAMMER)).toMatchObject({ risk: 'high', provider: 'ipqs' }); + expect(await check(`+1 ${SPAMMER.slice(1)}`)).toMatchObject({ risk: 'high' }); + expect(await check(CLEAN)).toBeNull(); // ringing someone else + expect(await check(REPORTED)).toBeNull(); // not ringing + expect(await check(SHADY)).toBeNull(); // a contact + expect(urls).toHaveLength(1); + ringing = [call(CLEAN, '101')]; + expect(await check(CLEAN)).toMatchObject({ risk: 'low' }); // low: the softphone hides it + expect((await admin('GET', '/api/me/caller-check?number=nonsense')).json().check).toBeNull(); + }); + + it('desk phones: the label in time, nothing for a contact, low risk or a late answer', async () => { + expect(await deskName(SPAMMER)).toBe('SPAM LIKELY'); + expect(await deskName(SHADY)).toBe('Pizza Place'); + expect(await deskName(SHADY, '999')).toBe('Spam risk'); // nobody's contact there + expect(await deskName(CLEAN)).toBe(''); + await t.db.delete(spamLookups); + ipqs = 'late'; + expect(await deskName(SPAMMER)).toBe(''); + await vi.waitFor(async () => expect(await t.db.select().from(spamLookups)).toHaveLength(1)); + expect(await deskName(SPAMMER)).toBe('SPAM LIKELY'); // from the cache + await settings({ ...keys.withFtc, deskPhoneLabels: { high: 'JUNK', medium: 'Maybe junk' } }); + expect(await deskName(REPORTED)).toBe('JUNK'); + }); + + it('call history keeps what the check knew, after the cache expires; contacts first', async () => { + const fixture = readFileSync( + new URL('./fixtures/cdr/inbound-user.json', import.meta.url), + 'utf8', + ); + const callFrom = async (number: string) => { + const events = (JSON.parse(fixture.replaceAll('13125559999', number)) as AmiMessage[]).map( + (e) => ({ ...e, LinkedID: `${number}-${String(e.LinkedID)}` }), + ); + for (const e of events) await t.db.insert(cdr).values(toCdrRow(e)); + await aggregateCall(t.db, String(events[0]!.LinkedID)); + }; + await checkNumber(d(), SPAMMER); + urls.length = 0; + for (const n of [SPAMMER, REPORTED, SHADY, CLEAN]) await callFrom(n); + expect(urls).toEqual([]); // cachedOnly: CLEAN isn't asked + await t.db.delete(spamLookups); + const spamOf = async (n: string) => + (await admin('GET', '/api/calls?direction=inbound')) + .json() + .items.find((c: { fromNumber: string }) => c.fromNumber === n)?.spam; + expect(await spamOf(SPAMMER)).toMatchObject({ + risk: 'high', + provider: 'ipqs', + info: { spammer: true }, + }); + expect(await spamOf(REPORTED)).toMatchObject({ + risk: 'high', + provider: 'ftc', + info: { complaints: 3 }, + }); + expect(await spamOf(SHADY)).toBeNull(); // the admin's contact + // Not in the cache, never reported: low (the page shows nothing for it). + expect(await spamOf(CLEAN)).toMatchObject({ risk: 'low', provider: 'ftc' }); + }); + }); + + describe('Settings → Spam check', () => { + const body = (s: Record = {}) => ({ + spam: { + enabled: true, + ipqs: { key: IPQS_KEY }, + ftc: null, + cache: { flaggedDays: 14, cleanDays: 3 }, + deskPhoneLabels: { high: 'SPAM LIKELY', medium: 'Spam risk' }, + ...s, + }, + }); + + it('Download now: due at the next minute; refused without the FTC list', async () => { + await settings(keys.withIpqs); + const r = await admin('POST', '/api/settings/spam/ftc/sync'); + expect(r.statusCode).toBe(400); + expect(r.body).toMatch('Set up the FTC list first'); + await settings(keys.withFtc); + await setSetting(t.db, 'spam.state', { + ...(await getSetting(t.db, 'spam.state')), + ftcLastSyncAt: new Date().toISOString(), + }); + expect((await admin('POST', '/api/settings/spam/ftc/sync')).statusCode).toBe(200); + expect((await getSetting(t.db, 'spam.state')).ftcLastSyncAt).toBeNull(); + }); + + it('keys are write-only, and empty keeps the saved one', async () => { + await setSetting(t.db, 'spam', SPAM_DEFAULTS); + const r = await admin('PATCH', '/api/settings', body()); + expect(r.statusCode).toBe(200); + expect(r.json().spam).toEqual({ + ...SPAM_DEFAULTS, + enabled: true, + ipqs: { hasKey: true }, + }); + expect(r.body).not.toContain(IPQS_KEY); + const saved = (await getSetting(t.db, 'spam')).ipqs!.keyEnc; + expect((await admin('PATCH', '/api/settings', body({ ipqs: {} }))).statusCode).toBe(200); + expect((await getSetting(t.db, 'spam')).ipqs!.keyEnc).toBe(saved); + const [a] = await t.db + .select() + .from(auditLog) + .where(eq(auditLog.summary, 'Changed settings: spam')); + expect(JSON.stringify(a!.diff)).not.toContain(saved); + }); + + it('refuses a provider without a key, nothing to check with, and odd labels', async () => { + await setSetting(t.db, 'spam', SPAM_DEFAULTS); + const no = async (b: unknown, error: RegExp) => { + const r = await admin('PATCH', '/api/settings', b); + expect(r.statusCode).toBe(400); + expect(r.body).toMatch(error); + }; + await no(body({ ipqs: {} }), /Enter the IPQualityScore key/); + await no( + body({ ipqs: null, ftc: { syncHours: 24, windowDays: 30 } }), + /Enter the api.data.gov key/, + ); + await no(body({ ipqs: null }), /Set up IPQualityScore or the FTC list/); + await no(body({ deskPhoneLabels: { high: 'Spam?', medium: 'Spam risk' } }), /Letters/); + await no( + body({ ftc: { key: FTC_KEY, syncHours: 0, windowDays: 30 } }), + /syncHours|too small|>=1/i, + ); + await no({ 'spam.state': { ipqsPausedAt: null } }, /can't be changed/); + // Off with no provider is fine. + expect( + (await admin('PATCH', '/api/settings', body({ enabled: false, ipqs: null }))).statusCode, + ).toBe(200); + }); + + it('Test uses the typed key, else the saved one, and never shows it', async () => { + await setSetting(t.db, 'spam', SPAM_DEFAULTS); + const test = async (provider: string, key?: string) => + (await admin('POST', `/api/settings/spam/${provider}/test`, { key })).json(); + expect(await test('ipqs')).toEqual({ ok: false, error: 'Enter the key first' }); + expect(await test('ipqs', IPQS_KEY)).toEqual({ + ok: true, + detail: 'Works: 912 credits left this billing period.', + }); + const bad = await test('ipqs', 'wrong-key-789'); + expect(bad.ok).toBe(false); + expect(JSON.stringify(bad)).not.toContain('wrong-key-789'); + await settings({ ...keys.withIpqs, ...keys.withFtc }); + expect((await test('ipqs')).ok).toBe(true); + expect(await test('ftc')).toEqual({ ok: true, detail: 'The FTC answered.' }); + expect(await test('ftc', 'nope')).toEqual({ + ok: false, + error: 'api.data.gov refused the key', + }); + }); + }); +}); diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index d9aa709..94c711e 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -3,6 +3,8 @@ import type { LdapInput, LdapTestResult, + SpamCheck, + SpamTestResult, AuthMethods, OidcInput, OidcTestResult, @@ -255,10 +257,16 @@ export const settingsApi = { '/api/settings/emergency/test', ), warnings: () => api<{ items: Warning[] }>('GET', '/api/system/warnings').then((r) => r.items), + dismissWarning: (code: string) => + api<{ ok: true }>('POST', `/api/system/warnings/${code}/dismiss`), testOidc: (v: OidcInput) => api('POST', '/api/settings/auth/oidc/test', v), /** 12 §4.1 */ testLdap: (v: LdapInput & { testLogin?: string; testPassword?: string }) => api('POST', '/api/settings/auth/ldap/test', v), + /** 14 §7.1: the typed key, else the saved one. */ + syncFtc: () => api<{ ok: true }>('POST', '/api/settings/spam/ftc/sync'), + testSpam: (provider: 'ipqs' | 'ftc', key?: string) => + api('POST', `/api/settings/spam/${provider}/test`, { key }), }; /** Sign out, and when the session came from OpenID, at the provider too (12 §3.5). */ @@ -299,6 +307,12 @@ export const meApi = { calls: (cursor?: string) => api>('GET', `/api/me/calls${cursor ? `?cursor=${cursor}` : ''}`), get: () => api('GET', '/api/me'), + /** 14 §5.1: only for a caller ringing me now. */ + callerCheck: (number: string) => + api<{ check: SpamCheck | null }>( + 'GET', + `/api/me/caller-check?number=${encodeURIComponent(number)}`, + ).then((r) => r.check), softphoneQr: () => api('GET', '/api/me/softphone-qr'), update: (m: MeUpdate) => api('PATCH', '/api/me', m), connectContrack: (token: string) => api('PUT', '/api/me/contrack', { token }), diff --git a/apps/web/src/components/SpamNote.test.ts b/apps/web/src/components/SpamNote.test.ts new file mode 100644 index 0000000..470853b --- /dev/null +++ b/apps/web/src/components/SpamNote.test.ts @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import { expect, it } from 'vitest'; +import { spamLine } from './SpamNote.tsx'; + +it('says what the spam check knows, in one line (14 §5.1)', () => { + expect( + spamLine({ + risk: 'high', + provider: 'ipqs', + info: { + fraudScore: 92, + spammer: true, + recentAbuse: false, + lineType: 'VOIP', + carrier: 'Verizon', + name: null, + city: 'Austin', + region: 'TX', + }, + }), + ).toBe('Fraud score 92 · spammer · VOIP · Verizon · Austin, TX'); + const ftc = (complaints: number, robocalls: number, subject: string | null) => + spamLine({ + risk: 'high', + provider: 'ftc', + info: { complaints, robocalls, lastReportedAt: '2026-10-20T15:00:00Z', subject }, + }); + expect(ftc(4, 3, 'Warranties')).toMatch( + /^Reported to the FTC 4 times \(3 robocalls\), last on .+: Warranties$/, + ); + expect(ftc(1, 0, null)).toMatch(/^Reported to the FTC once, last on [^:]+$/); +}); diff --git a/apps/web/src/components/SpamNote.tsx b/apps/web/src/components/SpamNote.tsx new file mode 100644 index 0000000..85e5670 --- /dev/null +++ b/apps/web/src/components/SpamNote.tsx @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// What the spam check said about a caller (14 §5.1): a badge and a line, for high and medium +// risk only. Shown in the softphone and call history. +import type { SpamCheck } from '@selfphoneng/shared'; + +const day = (iso: string) => + new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + +/** "Fraud score 92 · spammer · VoIP · Verizon · Austin, TX", or "Reported to the FTC 4 times…". */ +export function spamLine(c: SpamCheck): string { + if (c.provider === 'ftc') { + const i = c.info; + const times = i.complaints === 1 ? 'once' : `${i.complaints} times`; + const robo = i.robocalls ? ` (${i.robocalls} robocall${i.robocalls === 1 ? '' : 's'})` : ''; + const last = i.lastReportedAt ? `, last on ${day(i.lastReportedAt)}` : ''; + return `Reported to the FTC ${times}${robo}${last}${i.subject ? `: ${i.subject}` : ''}`; + } + const i = c.info; + const place = [i.city, i.region].filter(Boolean).join(', '); + return [ + i.name, + `Fraud score ${i.fraudScore}`, + i.spammer && 'spammer', + i.recentAbuse && 'recent abuse', + i.lineType, + i.carrier, + place, + ] + .filter(Boolean) + .join(' · '); +} + +export function SpamNote({ check }: { check: SpamCheck | null | undefined }) { + if (!check || check.risk === 'low') return null; + const high = check.risk === 'high'; + return ( + + + {high ? 'Likely spam' : 'Possible spam'} + + {spamLine(check)} + + ); +} diff --git a/apps/web/src/pages/admin/CallsPage.tsx b/apps/web/src/pages/admin/CallsPage.tsx index 9c6cf2e..46a733d 100644 --- a/apps/web/src/pages/admin/CallsPage.tsx +++ b/apps/web/src/pages/admin/CallsPage.tsx @@ -11,6 +11,7 @@ import { type CallFilter, } from '@selfphoneng/shared'; import { callsApi, extensionsApi, recordingsApi } from '../../api/client.ts'; +import { SpamNote } from '../../components/SpamNote.tsx'; import { Alert, Button, Field, inputClass } from '../../components/ui.tsx'; export const fmtNumber = (n: string | null) => @@ -170,6 +171,7 @@ export function CallsPage() { {c.answeredBy && ( · answered by {c.answeredBy.name} )} + {fmtDuration(c.talkSeconds)} diff --git a/apps/web/src/pages/admin/DashboardPage.tsx b/apps/web/src/pages/admin/DashboardPage.tsx index 786789d..6566ea9 100644 --- a/apps/web/src/pages/admin/DashboardPage.tsx +++ b/apps/web/src/pages/admin/DashboardPage.tsx @@ -1,9 +1,9 @@ // SPDX-License-Identifier: AGPL-3.0-or-later // Dashboard (05 §2). Warnings, telephony link, trunks, calls in progress (hang up / transfer) // queue tiles, parked calls and extensions online. -import { useMutation, useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useState, type ReactNode } from 'react'; -import type { ActiveCall, QueueLive } from '@selfphoneng/shared'; +import type { ActiveCall, QueueLive, Warning } from '@selfphoneng/shared'; import { Link } from 'react-router'; import { liveApi, settingsApi, type SpyMode } from '../../api/client.ts'; import { ConfirmDialog } from '../../components/Dialog.tsx'; @@ -144,7 +144,12 @@ function Warnings({ trunkDown }: { trunkDown: boolean }) { queryFn: settingsApi.warnings, refetchInterval: 30_000, }); - const items = [ + const qc = useQueryClient(); + const dismiss = useMutation({ + mutationFn: settingsApi.dismissWarning, + onSuccess: () => qc.invalidateQueries({ queryKey: ['warnings'] }), + }); + const items: Warning[] = [ ...(trunkDown ? [ { @@ -168,6 +173,19 @@ function Warnings({ trunkDown }: { trunkDown: boolean }) { Fix + {w.dismissable && ( + <> + {' '} + + + )} ))} diff --git a/apps/web/src/pages/admin/SettingsPage.tsx b/apps/web/src/pages/admin/SettingsPage.tsx index d14f95f..ef25cd3 100644 --- a/apps/web/src/pages/admin/SettingsPage.tsx +++ b/apps/web/src/pages/admin/SettingsPage.tsx @@ -1,6 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later // Settings (05 §2). M3: Email and Emergency; M7: Recording; M11: Network (public IP watch), Security (phone ACL, provisioning, SIP TLS), Backups, -// Webhooks, API tokens; AI (spec 10). +// Webhooks, API tokens; AI (spec 10); Spam check (spec 14). import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Fragment, useState } from 'react'; import { NavLink, useParams } from 'react-router'; @@ -43,6 +43,7 @@ const TABS = [ { id: 'ai', label: 'AI' }, { id: 'contacts', label: 'Contacts' }, { id: 'sign-in', label: 'Sign-in' }, + { id: 'spam', label: 'Spam check' }, ] as const; export function SettingsPage() { @@ -79,6 +80,7 @@ export function SettingsPage() { {tab === 'tokens' && } {settings.data && tab === 'ai' && } {settings.data && tab === 'contacts' && } + {settings.data && tab === 'spam' && } {settings.data && tab === 'sign-in' && (
@@ -1373,6 +1375,279 @@ export function ContactsTab({ s }: { s: SettingsDto }) { ); } +/** Test for one spam check provider (14 §7.1): the typed key, else the saved one. */ +function SpamTest({ provider, keyText }: { provider: 'ipqs' | 'ftc'; keyText: string }) { + const test = useMutation({ + mutationFn: () => settingsApi.testSpam(provider, keyText || undefined), + }); + return ( +
+ + {test.isError && {test.error.message}} + {test.data && ( + + {test.data.ok ? test.data.detail : test.data.error} + + )} +
+ ); +} + +const when = (iso: string | null) => (iso ? new Date(iso).toLocaleString() : null); + +/** Where the FTC download is, and Download now (14 §2.2). */ +function FtcSyncStatus({ st }: { st: SettingsDto['spam.state'] }) { + const qc = useQueryClient(); + const sync = useMutation({ + mutationFn: settingsApi.syncFtc, + onSuccess: () => qc.invalidateQueries({ queryKey: ['settings'] }), + }); + return ( +
+

+ {st.ftcCursor + ? `Downloading: at the complaints of ${st.ftcCursor.day}.` + : st.ftcLastSyncAt + ? `Last download ${when(st.ftcLastSyncAt)}.` + : 'Not downloaded yet: it starts within a minute.'} +

+ {st.ftcLastError && ( +

+ The last try failed: {st.ftcLastError}. It tries again every minute. +

+ )} + {!st.ftcCursor && ( +
+ +
+ )} + {sync.isError && {sync.error.message}} +
+ ); +} + +/** Spam check (spec 14 §7.1): IPQualityScore first, then the FTC list. Information only. */ +function SpamTab({ s }: { s: SettingsDto }) { + const save = useSave(); + const sp = s.spam; + const st = s['spam.state']; + const [v, setV] = useState({ + enabled: sp.enabled, + useIpqs: sp.ipqs !== null, + ipqsKey: '', + useFtc: sp.ftc !== null, + ftcKey: '', + syncHours: String(sp.ftc?.syncHours ?? 24), + windowDays: String(sp.ftc?.windowDays ?? 30), + flaggedDays: String(sp.cache.flaggedDays), + cleanDays: String(sp.cache.cleanDays), + high: sp.deskPhoneLabels.high, + medium: sp.deskPhoneLabels.medium, + }); + const [confirming, setConfirming] = useState(false); + const text = (k: keyof typeof v) => (e: { target: { value: string } }) => + setV({ ...v, [k]: e.target.value }); + const flag = (k: 'enabled' | 'useIpqs' | 'useFtc') => (e: { target: { checked: boolean } }) => + setV({ ...v, [k]: e.target.checked }); + const submit = () => { + setConfirming(false); + save.mutate( + { + spam: { + enabled: v.enabled, + ipqs: v.useIpqs ? { key: v.ipqsKey || undefined } : null, + ftc: v.useFtc + ? { + key: v.ftcKey || undefined, + syncHours: Number(v.syncHours), + windowDays: Number(v.windowDays), + } + : null, + cache: { flaggedDays: Number(v.flaggedDays), cleanDays: Number(v.cleanDays) }, + deskPhoneLabels: { high: v.high, medium: v.medium }, + }, + }, + { onSuccess: () => setV((x) => ({ ...x, ipqsKey: '', ftcKey: '' })) }, + ); + }; + // 14 §8: sending callers' numbers out is confirmed once, when IPQualityScore starts being used. + const sendsOut = v.enabled && v.useIpqs && !(sp.enabled && sp.ipqs); + const num = (k: 'syncHours' | 'windowDays' | 'flaggedDays' | 'cleanDays', label: string) => ( + + {(a) => ( + + )} + + ); + const keyField = (k: 'ipqsKey' | 'ftcKey', label: string, saved: boolean, hint: string) => ( + + {(a) => ( + + )} + + ); + return ( +
{ + e.preventDefault(); + if (sendsOut) setConfirming(true); + else submit(); + }} + > +

+ When an outside caller from the US isn’t in anyone’s contacts, look the number + up and show what’s known about them while it rings. Information only: calls are never + blocked or sent elsewhere. IPQualityScore answers first when it’s set up; the FTC list + answers when it isn’t, or can’t. +

+ + +
+ IPQualityScore + + {v.useIpqs && ( + <> + {keyField( + 'ipqsKey', + 'API key', + !!sp.ipqs?.hasKey, + 'From your IPQualityScore account settings.', + )} + + {st.ipqsPausedAt && ( +

+ Paused since {when(st.ipqsPausedAt)}: its lookups are used up until they come back. +

+ )} + + )} +
+ +
+ FTC complaint list + + {v.useFtc && ( + <> + {keyField( + 'ftcKey', + 'api.data.gov key', + !!sp.ftc?.hasKey, + 'Free, from api.data.gov/signup.', + )} + +
+ {num('syncHours', 'Download again every (hours, 1–168)')} + {num('windowDays', 'Keep complaints for (days, 7–365)')} +
+ {sp.ftc && } + + )} +
+ +
+ Keep IPQualityScore answers +
+ {num('flaggedDays', 'Possible or likely spam (days, 1–365)')} + {num('cleanDays', 'Everything else (days, 1–365)')} +
+
+ +
+ Desk phones +

+ Shown in front of the caller’s name when the answer comes in before the phone rings. + Letters, digits, spaces and . ' & @ - only. +

+
+ + {(a) => ( + + )} + + + {(a) => ( + + )} + +
+
+ + {save.isError && {save.error.message}} + {save.isSuccess && ( +

+ Saved. +

+ )} +
+ +
+ + The numbers of outside callers who aren’t in your contacts will be sent to + IPQualityScore. You may need to say so in your privacy notice, and to have a data + processing agreement with them. In the EU and UK the GDPR applies. + + } + confirmLabel="Turn on" + onConfirm={submit} + onCancel={() => setConfirming(false)} + busy={save.isPending} + /> + + ); +} + const lines = (s: string) => s .split(/[\n,]/) diff --git a/apps/web/src/pages/user/MyCallsPage.tsx b/apps/web/src/pages/user/MyCallsPage.tsx index d5db8d5..7faf69e 100644 --- a/apps/web/src/pages/user/MyCallsPage.tsx +++ b/apps/web/src/pages/user/MyCallsPage.tsx @@ -9,6 +9,7 @@ import { Alert, Button } from '../../components/ui.tsx'; import { softphone } from '../../softphone/SoftphoneEngine.ts'; import { DIRECTION_TEXT, StatusBadge, fmtDuration, fmtNumber } from '../admin/CallsPage.tsx'; import { AddToContacts, useContactName } from './ContactsPage.tsx'; +import { SpamNote } from '../../components/SpamNote.tsx'; /** The other side of the call, from where I sit. */ export const otherParty = (c: MyCallDto) => @@ -84,6 +85,7 @@ export function MyCallsPage() { {!known && other.number && c.direction !== 'internal' && ( )} + {c.myRole === 'in' && !known && } {c.recordingId && (