diff --git a/drizzle/migrations/0004_drop_orphaned_qrz_sync_status_enum.sql b/drizzle/migrations/0004_drop_orphaned_qrz_sync_status_enum.sql new file mode 100644 index 0000000..e3e5f64 --- /dev/null +++ b/drizzle/migrations/0004_drop_orphaned_qrz_sync_status_enum.sql @@ -0,0 +1,8 @@ +-- Custom SQL migration file, put your code below! -- + +-- Drop the orphaned qrz_sync_status_enum type. It was created for a +-- contacts.qrz_sync_status column that never existed in any migration or in +-- drizzle/schema.ts; the code that referenced it wrote to nonexistent columns +-- and has been removed. QRZ sync state lives in the ADIF-style +-- qrz_qsl_sent / qrz_qsl_rcvd columns. +DROP TYPE IF EXISTS "public"."qrz_sync_status_enum"; diff --git a/drizzle/migrations/meta/0004_snapshot.json b/drizzle/migrations/meta/0004_snapshot.json new file mode 100644 index 0000000..012a610 --- /dev/null +++ b/drizzle/migrations/meta/0004_snapshot.json @@ -0,0 +1,3609 @@ +{ + "id": "e73ec0cc-01ff-4dfd-a8a6-976365551cf9", + "prevId": "773468de-aa5e-412c-8469-6d2d9a3f8a5f", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin_audit_log": { + "name": "admin_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "admin_user_id": { + "name": "admin_user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "old_values": { + "name": "old_values", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "new_values": { + "name": "new_values", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "inet", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_audit_log_action": { + "name": "idx_audit_log_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_audit_log_admin_user": { + "name": "idx_audit_log_admin_user", + "columns": [ + { + "expression": "admin_user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_audit_log_created_at": { + "name": "idx_audit_log_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamp_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_audit_log_target": { + "name": "idx_audit_log_target", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "admin_audit_log_admin_user_id_fkey": { + "name": "admin_audit_log_admin_user_id_fkey", + "tableFrom": "admin_audit_log", + "columnsFrom": [ + "admin_user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "station_id": { + "name": "station_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key_name": { + "name": "key_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "api_key": { + "name": "api_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"read\":true,\"write\":false,\"delete\":false}'::jsonb" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "read_only": { + "name": "read_only", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "usage_count": { + "name": "usage_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rate_limit_per_hour": { + "name": "rate_limit_per_hour", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1000 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_api_keys_api_key": { + "name": "idx_api_keys_api_key", + "columns": [ + { + "expression": "api_key", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_api_keys_expires_at": { + "name": "idx_api_keys_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "timestamp_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_api_keys_is_active": { + "name": "idx_api_keys_is_active", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "bool_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_api_keys_last_used_at": { + "name": "idx_api_keys_last_used_at", + "columns": [ + { + "expression": "last_used_at", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamp_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_api_keys_station_id": { + "name": "idx_api_keys_station_id", + "columns": [ + { + "expression": "station_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_api_keys_user_id": { + "name": "idx_api_keys_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "api_keys_station_id_fkey": { + "name": "api_keys_station_id_fkey", + "tableFrom": "api_keys", + "columnsFrom": [ + "station_id" + ], + "tableTo": "stations", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "api_keys_user_id_fkey": { + "name": "api_keys_user_id_fkey", + "tableFrom": "api_keys", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_api_key_key": { + "name": "api_keys_api_key_key", + "columns": [ + "api_key" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contacts": { + "name": "contacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "station_id": { + "name": "station_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "callsign": { + "name": "callsign", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "frequency": { + "name": "frequency", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": false + }, + "mode": { + "name": "mode", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "band": { + "name": "band", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "datetime": { + "name": "datetime", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "rst_sent": { + "name": "rst_sent", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "rst_received": { + "name": "rst_received", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "qth": { + "name": "qth", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "grid_locator": { + "name": "grid_locator", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "latitude": { + "name": "latitude", + "type": "numeric(10, 8)", + "primaryKey": false, + "notNull": false + }, + "longitude": { + "name": "longitude", + "type": "numeric(11, 8)", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "dxcc": { + "name": "dxcc", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cont": { + "name": "cont", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "cqz": { + "name": "cqz", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ituz": { + "name": "ituz", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "cnty": { + "name": "cnty", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "qsl_rcvd": { + "name": "qsl_rcvd", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "qsl_sent": { + "name": "qsl_sent", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "qsl_via": { + "name": "qsl_via", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "eqsl_qsl_rcvd": { + "name": "eqsl_qsl_rcvd", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "eqsl_qsl_sent": { + "name": "eqsl_qsl_sent", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "lotw_qsl_rcvd": { + "name": "lotw_qsl_rcvd", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "lotw_qsl_sent": { + "name": "lotw_qsl_sent", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "qrz_qsl_sent": { + "name": "qrz_qsl_sent", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "qrz_qsl_rcvd": { + "name": "qrz_qsl_rcvd", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "qrz_qsl_sent_date": { + "name": "qrz_qsl_sent_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "qrz_qsl_rcvd_date": { + "name": "qrz_qsl_rcvd_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "qso_date_off": { + "name": "qso_date_off", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "time_off": { + "name": "time_off", + "type": "time", + "primaryKey": false, + "notNull": false + }, + "operator": { + "name": "operator", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "distance": { + "name": "distance", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + }, + "qsl_lotw": { + "name": "qsl_lotw", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "qsl_lotw_date": { + "name": "qsl_lotw_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "lotw_match_status": { + "name": "lotw_match_status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "prop_mode": { + "name": "prop_mode", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "sat_name": { + "name": "sat_name", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "band_rx": { + "name": "band_rx", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "freq_rx": { + "name": "freq_rx", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": false + }, + "iota": { + "name": "iota", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "lotw_qslrdate": { + "name": "lotw_qslrdate", + "type": "date", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_contacts_band": { + "name": "idx_contacts_band", + "columns": [ + { + "expression": "band", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_contacts_callsign": { + "name": "idx_contacts_callsign", + "columns": [ + { + "expression": "callsign", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_contacts_datetime": { + "name": "idx_contacts_datetime", + "columns": [ + { + "expression": "datetime", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamp_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_contacts_frequency": { + "name": "idx_contacts_frequency", + "columns": [ + { + "expression": "frequency", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "numeric_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_contacts_lotw_match_status": { + "name": "idx_contacts_lotw_match_status", + "columns": [ + { + "expression": "lotw_match_status", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_contacts_mode": { + "name": "idx_contacts_mode", + "columns": [ + { + "expression": "mode", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_contacts_prop_mode": { + "name": "idx_contacts_prop_mode", + "columns": [ + { + "expression": "prop_mode", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_contacts_qrz_qsl_rcvd": { + "name": "idx_contacts_qrz_qsl_rcvd", + "columns": [ + { + "expression": "qrz_qsl_rcvd", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_contacts_qrz_qsl_sent": { + "name": "idx_contacts_qrz_qsl_sent", + "columns": [ + { + "expression": "qrz_qsl_sent", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_contacts_qsl_lotw": { + "name": "idx_contacts_qsl_lotw", + "columns": [ + { + "expression": "qsl_lotw", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "bool_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_contacts_qsl_lotw_date": { + "name": "idx_contacts_qsl_lotw_date", + "columns": [ + { + "expression": "qsl_lotw_date", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "date_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_contacts_sat_name": { + "name": "idx_contacts_sat_name", + "columns": [ + { + "expression": "sat_name", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_contacts_station_id": { + "name": "idx_contacts_station_id", + "columns": [ + { + "expression": "station_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_contacts_user_id": { + "name": "idx_contacts_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "contacts_user_id_fkey": { + "name": "contacts_user_id_fkey", + "tableFrom": "contacts", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "contacts_station_id_fkey": { + "name": "contacts_station_id_fkey", + "tableFrom": "contacts", + "columnsFrom": [ + "station_id" + ], + "tableTo": "stations", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "contacts_lotw_match_status_check": { + "name": "contacts_lotw_match_status_check", + "value": "(lotw_match_status)::text = ANY ((ARRAY['confirmed'::character varying, 'partial'::character varying, 'mismatch'::character varying, NULL::character varying])::text[])" + }, + "contacts_qrz_qsl_sent_check": { + "name": "contacts_qrz_qsl_sent_check", + "value": "(qrz_qsl_sent IS NULL) OR ((qrz_qsl_sent)::text = ANY ((ARRAY['Y'::character varying, 'N'::character varying, 'R'::character varying, 'M'::character varying, 'I'::character varying, 'Q'::character varying])::text[]))" + }, + "contacts_lotw_qsl_sent_check": { + "name": "contacts_lotw_qsl_sent_check", + "value": "(lotw_qsl_sent IS NULL) OR ((lotw_qsl_sent)::text = ANY ((ARRAY['Y'::character varying, 'N'::character varying, 'R'::character varying, 'M'::character varying, 'I'::character varying, 'Q'::character varying])::text[]))" + } + }, + "isRLSEnabled": false + }, + "public.dxcc_entities": { + "name": "dxcc_entities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "adif": { + "name": "adif", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cq_zone": { + "name": "cq_zone", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "itu_zone": { + "name": "itu_zone", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "continent": { + "name": "continent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "longitude": { + "name": "longitude", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "latitude": { + "name": "latitude", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "dxcc_entities_adif_key": { + "name": "dxcc_entities_adif_key", + "columns": [ + "adif" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lotw_credentials": { + "name": "lotw_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "station_id": { + "name": "station_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "callsign": { + "name": "callsign", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "p12_cert": { + "name": "p12_cert", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "cert_created_at": { + "name": "cert_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + }, + "cert_expires_at": { + "name": "cert_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + }, + "p12_password": { + "name": "p12_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cert_serial": { + "name": "cert_serial", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "crl_status": { + "name": "crl_status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "crl_checked_at": { + "name": "crl_checked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_lotw_credentials_callsign": { + "name": "idx_lotw_credentials_callsign", + "columns": [ + { + "expression": "callsign", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_lotw_credentials_cert_serial": { + "name": "idx_lotw_credentials_cert_serial", + "columns": [ + { + "expression": "cert_serial", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_lotw_credentials_is_active": { + "name": "idx_lotw_credentials_is_active", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "bool_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_lotw_credentials_station_active": { + "name": "idx_lotw_credentials_station_active", + "columns": [ + { + "expression": "station_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "bool_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_lotw_credentials_station_id": { + "name": "idx_lotw_credentials_station_id", + "columns": [ + { + "expression": "station_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "lotw_credentials_station_id_fkey": { + "name": "lotw_credentials_station_id_fkey", + "tableFrom": "lotw_credentials", + "columnsFrom": [ + "station_id" + ], + "tableTo": "stations", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lotw_download_logs": { + "name": "lotw_download_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "station_id": { + "name": "station_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "date_from": { + "name": "date_from", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "date_to": { + "name": "date_to", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "qso_count": { + "name": "qso_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "confirmations_found": { + "name": "confirmations_found", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "confirmations_matched": { + "name": "confirmations_matched", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "confirmations_unmatched": { + "name": "confirmations_unmatched", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "download_method": { + "name": "download_method", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'manual'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_lotw_download_logs_started_at": { + "name": "idx_lotw_download_logs_started_at", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamp_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_lotw_download_logs_station_id": { + "name": "idx_lotw_download_logs_station_id", + "columns": [ + { + "expression": "station_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_lotw_download_logs_status": { + "name": "idx_lotw_download_logs_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_lotw_download_logs_user_id": { + "name": "idx_lotw_download_logs_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "lotw_download_logs_station_id_fkey": { + "name": "lotw_download_logs_station_id_fkey", + "tableFrom": "lotw_download_logs", + "columnsFrom": [ + "station_id" + ], + "tableTo": "stations", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "lotw_download_logs_user_id_fkey": { + "name": "lotw_download_logs_user_id_fkey", + "tableFrom": "lotw_download_logs", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "lotw_download_logs_status_check": { + "name": "lotw_download_logs_status_check", + "value": "(status)::text = ANY ((ARRAY['pending'::character varying, 'processing'::character varying, 'completed'::character varying, 'failed'::character varying])::text[])" + }, + "lotw_download_logs_download_method_check": { + "name": "lotw_download_logs_download_method_check", + "value": "(download_method)::text = ANY ((ARRAY['manual'::character varying, 'automatic'::character varying, 'scheduled'::character varying])::text[])" + } + }, + "isRLSEnabled": false + }, + "public.lotw_job_queue": { + "name": "lotw_job_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "job_type": { + "name": "job_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "station_id": { + "name": "station_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "job_params": { + "name": "job_params", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "is_running": { + "name": "is_running", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_lotw_job_queue_is_running": { + "name": "idx_lotw_job_queue_is_running", + "columns": [ + { + "expression": "is_running", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "bool_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_lotw_job_queue_job_type": { + "name": "idx_lotw_job_queue_job_type", + "columns": [ + { + "expression": "job_type", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_lotw_job_queue_priority": { + "name": "idx_lotw_job_queue_priority", + "columns": [ + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_lotw_job_queue_scheduled_at": { + "name": "idx_lotw_job_queue_scheduled_at", + "columns": [ + { + "expression": "scheduled_at", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "timestamp_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_lotw_job_queue_station_id": { + "name": "idx_lotw_job_queue_station_id", + "columns": [ + { + "expression": "station_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_lotw_job_queue_status": { + "name": "idx_lotw_job_queue_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "lotw_job_queue_station_id_fkey": { + "name": "lotw_job_queue_station_id_fkey", + "tableFrom": "lotw_job_queue", + "columnsFrom": [ + "station_id" + ], + "tableTo": "stations", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "lotw_job_queue_user_id_fkey": { + "name": "lotw_job_queue_user_id_fkey", + "tableFrom": "lotw_job_queue", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "lotw_job_queue_job_type_check": { + "name": "lotw_job_queue_job_type_check", + "value": "(job_type)::text = ANY ((ARRAY['upload'::character varying, 'download'::character varying])::text[])" + }, + "lotw_job_queue_status_check": { + "name": "lotw_job_queue_status_check", + "value": "(status)::text = ANY ((ARRAY['pending'::character varying, 'processing'::character varying, 'completed'::character varying, 'failed'::character varying, 'cancelled'::character varying])::text[])" + } + }, + "isRLSEnabled": false + }, + "public.lotw_upload_logs": { + "name": "lotw_upload_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "station_id": { + "name": "station_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "qso_count": { + "name": "qso_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "date_from": { + "name": "date_from", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "date_to": { + "name": "date_to", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "file_hash": { + "name": "file_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "file_size_bytes": { + "name": "file_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "success_count": { + "name": "success_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "error_count": { + "name": "error_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lotw_response": { + "name": "lotw_response", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "upload_method": { + "name": "upload_method", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'manual'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_lotw_upload_logs_started_at": { + "name": "idx_lotw_upload_logs_started_at", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamp_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_lotw_upload_logs_station_id": { + "name": "idx_lotw_upload_logs_station_id", + "columns": [ + { + "expression": "station_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_lotw_upload_logs_status": { + "name": "idx_lotw_upload_logs_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_lotw_upload_logs_user_id": { + "name": "idx_lotw_upload_logs_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "lotw_upload_logs_station_id_fkey": { + "name": "lotw_upload_logs_station_id_fkey", + "tableFrom": "lotw_upload_logs", + "columnsFrom": [ + "station_id" + ], + "tableTo": "stations", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "lotw_upload_logs_user_id_fkey": { + "name": "lotw_upload_logs_user_id_fkey", + "tableFrom": "lotw_upload_logs", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "lotw_upload_logs_status_check": { + "name": "lotw_upload_logs_status_check", + "value": "(status)::text = ANY ((ARRAY['pending'::character varying, 'processing'::character varying, 'completed'::character varying, 'failed'::character varying])::text[])" + }, + "lotw_upload_logs_upload_method_check": { + "name": "lotw_upload_logs_upload_method_check", + "value": "(upload_method)::text = ANY ((ARRAY['manual'::character varying, 'automatic'::character varying, 'scheduled'::character varying])::text[])" + } + }, + "isRLSEnabled": false + }, + "public.propagation_alerts": { + "name": "propagation_alerts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "alert_type": { + "name": "alert_type", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_propagation_alerts_active": { + "name": "idx_propagation_alerts_active", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "bool_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "(is_active = true)", + "concurrently": false + }, + "idx_propagation_alerts_expires": { + "name": "idx_propagation_alerts_expires", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "timestamp_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "(expires_at IS NOT NULL)", + "concurrently": false + }, + "idx_propagation_alerts_user_id": { + "name": "idx_propagation_alerts_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "propagation_alerts_user_id_fkey": { + "name": "propagation_alerts_user_id_fkey", + "tableFrom": "propagation_alerts", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "propagation_alerts_alert_type_check": { + "name": "propagation_alerts_alert_type_check", + "value": "(alert_type)::text = ANY ((ARRAY['solar_storm'::character varying, 'enhanced_propagation'::character varying, 'band_opening'::character varying, 'aurora'::character varying])::text[])" + }, + "propagation_alerts_severity_check": { + "name": "propagation_alerts_severity_check", + "value": "(severity)::text = ANY ((ARRAY['low'::character varying, 'medium'::character varying, 'high'::character varying])::text[])" + }, + "valid_alert_type": { + "name": "valid_alert_type", + "value": "(alert_type)::text = ANY ((ARRAY['solar_storm'::character varying, 'enhanced_propagation'::character varying, 'band_opening'::character varying, 'aurora'::character varying])::text[])" + }, + "valid_severity": { + "name": "valid_severity", + "value": "(severity)::text = ANY ((ARRAY['low'::character varying, 'medium'::character varying, 'high'::character varying])::text[])" + } + }, + "isRLSEnabled": false + }, + "public.propagation_forecasts": { + "name": "propagation_forecasts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "forecast_for": { + "name": "forecast_for", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "band_conditions": { + "name": "band_conditions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "general_conditions": { + "name": "general_conditions", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_propagation_forecasts_forecast_for": { + "name": "idx_propagation_forecasts_forecast_for", + "columns": [ + { + "expression": "forecast_for", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamp_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_propagation_forecasts_timestamp": { + "name": "idx_propagation_forecasts_timestamp", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamp_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "propagation_forecasts_general_conditions_check": { + "name": "propagation_forecasts_general_conditions_check", + "value": "(general_conditions)::text = ANY ((ARRAY['poor'::character varying, 'fair'::character varying, 'good'::character varying, 'excellent'::character varying])::text[])" + }, + "valid_general_conditions": { + "name": "valid_general_conditions", + "value": "(general_conditions)::text = ANY ((ARRAY['poor'::character varying, 'fair'::character varying, 'good'::character varying, 'excellent'::character varying])::text[])" + } + }, + "isRLSEnabled": false + }, + "public.qsl_images": { + "name": "qsl_images", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "contact_id": { + "name": "contact_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "image_type": { + "name": "image_type", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "original_filename": { + "name": "original_filename", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "storage_path": { + "name": "storage_path", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "storage_url": { + "name": "storage_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "storage_type": { + "name": "storage_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'azure_blob'" + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_qsl_images_contact_id": { + "name": "idx_qsl_images_contact_id", + "columns": [ + { + "expression": "contact_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_qsl_images_created_at": { + "name": "idx_qsl_images_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamp_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_qsl_images_type": { + "name": "idx_qsl_images_type", + "columns": [ + { + "expression": "image_type", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_qsl_images_user_id": { + "name": "idx_qsl_images_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "qsl_images_contact_id_fkey": { + "name": "qsl_images_contact_id_fkey", + "tableFrom": "qsl_images", + "columnsFrom": [ + "contact_id" + ], + "tableTo": "contacts", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "qsl_images_user_id_fkey": { + "name": "qsl_images_user_id_fkey", + "tableFrom": "qsl_images", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "qsl_images_contact_id_image_type_key": { + "name": "qsl_images_contact_id_image_type_key", + "columns": [ + "contact_id", + "image_type" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": { + "qsl_images_image_type_check": { + "name": "qsl_images_image_type_check", + "value": "(image_type)::text = ANY ((ARRAY['front'::character varying, 'back'::character varying])::text[])" + }, + "qsl_images_mime_type_check": { + "name": "qsl_images_mime_type_check", + "value": "(mime_type)::text = ANY ((ARRAY['image/jpeg'::character varying, 'image/jpg'::character varying, 'image/png'::character varying, 'image/webp'::character varying])::text[])" + }, + "qsl_images_storage_type_check": { + "name": "qsl_images_storage_type_check", + "value": "(storage_type)::text = ANY ((ARRAY['azure_blob'::character varying, 'aws_s3'::character varying, 'local_storage'::character varying])::text[])" + } + }, + "isRLSEnabled": false + }, + "public.solar_activity": { + "name": "solar_activity", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "solar_flux_index": { + "name": "solar_flux_index", + "type": "numeric(6, 2)", + "primaryKey": false, + "notNull": true + }, + "a_index": { + "name": "a_index", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true + }, + "k_index": { + "name": "k_index", + "type": "numeric(3, 1)", + "primaryKey": false, + "notNull": true + }, + "solar_wind_speed": { + "name": "solar_wind_speed", + "type": "numeric(7, 2)", + "primaryKey": false, + "notNull": false + }, + "solar_wind_density": { + "name": "solar_wind_density", + "type": "numeric(6, 3)", + "primaryKey": false, + "notNull": false + }, + "xray_class": { + "name": "xray_class", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "idx_solar_activity_timestamp": { + "name": "idx_solar_activity_timestamp", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamp_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "solar_activity_timestamp_key": { + "name": "solar_activity_timestamp_key", + "columns": [ + "timestamp" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.states_provinces": { + "name": "states_provinces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "dxcc_entity": { + "name": "dxcc_entity", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cq_zone": { + "name": "cq_zone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "itu_zone": { + "name": "itu_zone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "states_provinces_dxcc_entity_code_key": { + "name": "states_provinces_dxcc_entity_code_key", + "columns": [ + "dxcc_entity", + "code" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stations": { + "name": "stations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "callsign": { + "name": "callsign", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "station_name": { + "name": "station_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "operator_name": { + "name": "operator_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "qth_name": { + "name": "qth_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "street_address": { + "name": "street_address", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "county": { + "name": "county", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "state_province": { + "name": "state_province", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "postal_code": { + "name": "postal_code", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "dxcc_entity_code": { + "name": "dxcc_entity_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "grid_locator": { + "name": "grid_locator", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "latitude": { + "name": "latitude", + "type": "numeric(10, 8)", + "primaryKey": false, + "notNull": false + }, + "longitude": { + "name": "longitude", + "type": "numeric(11, 8)", + "primaryKey": false, + "notNull": false + }, + "itu_zone": { + "name": "itu_zone", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cq_zone": { + "name": "cq_zone", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "power_watts": { + "name": "power_watts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rig_info": { + "name": "rig_info", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "antenna_info": { + "name": "antenna_info", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "station_equipment": { + "name": "station_equipment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "qrz_username": { + "name": "qrz_username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "qrz_password": { + "name": "qrz_password", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "qrz_api_key": { + "name": "qrz_api_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "qrz_auto_sync": { + "name": "qrz_auto_sync", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "lotw_username": { + "name": "lotw_username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "club_callsign": { + "name": "club_callsign", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + }, + "lotw_password": { + "name": "lotw_password", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "lotw_p12_cert": { + "name": "lotw_p12_cert", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "lotw_cert_created_at": { + "name": "lotw_cert_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "lotw_last_qsl_rcvd_date": { + "name": "lotw_last_qsl_rcvd_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "qrz_last_qsl_rcvd_date": { + "name": "qrz_last_qsl_rcvd_date", + "type": "date", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_stations_callsign": { + "name": "idx_stations_callsign", + "columns": [ + { + "expression": "callsign", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_stations_country": { + "name": "idx_stations_country", + "columns": [ + { + "expression": "country", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_stations_dxcc_entity": { + "name": "idx_stations_dxcc_entity", + "columns": [ + { + "expression": "dxcc_entity_code", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_stations_grid_locator": { + "name": "idx_stations_grid_locator", + "columns": [ + { + "expression": "grid_locator", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_stations_is_active": { + "name": "idx_stations_is_active", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "bool_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_stations_is_default": { + "name": "idx_stations_is_default", + "columns": [ + { + "expression": "is_default", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "bool_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_stations_user_id": { + "name": "idx_stations_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "stations_user_id_fkey": { + "name": "stations_user_id_fkey", + "tableFrom": "stations", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_default_station_per_user": { + "name": "unique_default_station_per_user", + "columns": [ + "user_id", + "is_default" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.storage_config": { + "name": "storage_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "config_type": { + "name": "config_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "account_name": { + "name": "account_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "account_key": { + "name": "account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "container_name": { + "name": "container_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "endpoint_url": { + "name": "endpoint_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + }, + "created_by": { + "name": "created_by", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_storage_config_enabled": { + "name": "idx_storage_config_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "bool_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_storage_config_type": { + "name": "idx_storage_config_type", + "columns": [ + { + "expression": "config_type", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "storage_config_created_by_fkey": { + "name": "storage_config_created_by_fkey", + "tableFrom": "storage_config", + "columnsFrom": [ + "created_by" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "storage_config_config_type_key": { + "name": "storage_config_config_type_key", + "columns": [ + "config_type" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": { + "valid_config_type": { + "name": "valid_config_type", + "value": "(config_type)::text = ANY ((ARRAY['azure_blob'::character varying, 'aws_s3'::character varying, 'local_storage'::character varying])::text[])" + } + }, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "setting_key": { + "name": "setting_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "setting_value": { + "name": "setting_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data_type": { + "name": "data_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'string'" + }, + "category": { + "name": "category", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_by": { + "name": "updated_by", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_system_settings_category": { + "name": "idx_system_settings_category", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_system_settings_key": { + "name": "idx_system_settings_key", + "columns": [ + { + "expression": "setting_key", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_system_settings_public": { + "name": "idx_system_settings_public", + "columns": [ + { + "expression": "is_public", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "bool_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "system_settings_updated_by_fkey": { + "name": "system_settings_updated_by_fkey", + "tableFrom": "system_settings", + "columnsFrom": [ + "updated_by" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "system_settings_setting_key_key": { + "name": "system_settings_setting_key_key", + "columns": [ + "setting_key" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_propagation_preferences": { + "name": "user_propagation_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "home_grid_locator": { + "name": "home_grid_locator", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "preferred_bands": { + "name": "preferred_bands", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "alert_solar_storms": { + "name": "alert_solar_storms", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "alert_enhanced_propagation": { + "name": "alert_enhanced_propagation", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "alert_band_openings": { + "name": "alert_band_openings", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "alert_aurora": { + "name": "alert_aurora", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "update_interval_minutes": { + "name": "update_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 60 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": { + "user_propagation_preferences_user_id_fkey": { + "name": "user_propagation_preferences_user_id_fkey", + "tableFrom": "user_propagation_preferences", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_propagation_preferences_user_id_key": { + "name": "user_propagation_preferences_user_id_key", + "columns": [ + "user_id" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": { + "user_propagation_preferences_update_interval_minutes_check": { + "name": "user_propagation_preferences_update_interval_minutes_check", + "value": "update_interval_minutes >= 15" + } + }, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "callsign": { + "name": "callsign", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "grid_locator": { + "name": "grid_locator", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "qrz_username": { + "name": "qrz_username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "qrz_password": { + "name": "qrz_password", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "qrz_auto_sync": { + "name": "qrz_auto_sync", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "role": { + "name": "role", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_login": { + "name": "last_login", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "CURRENT_TIMESTAMP" + }, + "third_party_services": { + "name": "third_party_services", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "idx_users_callsign": { + "name": "idx_users_callsign", + "columns": [ + { + "expression": "callsign", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_users_email": { + "name": "idx_users_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_users_role": { + "name": "idx_users_role", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idx_users_status": { + "name": "idx_users_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_key": { + "name": "users_email_key", + "columns": [ + "email" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": { + "valid_role": { + "name": "valid_role", + "value": "(role)::text = ANY ((ARRAY['user'::character varying, 'admin'::character varying, 'moderator'::character varying])::text[])" + }, + "valid_status": { + "name": "valid_status", + "value": "(status)::text = ANY ((ARRAY['active'::character varying, 'inactive'::character varying, 'suspended'::character varying])::text[])" + } + }, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json index 34d1523..3c8a206 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1778530000000, "tag": "0003_add_lotw_credentials_name", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1783955769140, + "tag": "0004_drop_orphaned_qrz_sync_status_enum", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index ec8538b..57637e3 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -25,7 +25,7 @@ export default function AdminDashboard() { setSyncMessage(null); // First, get all contacts not sent to QRZ - const response = await fetch('/api/contacts?qrz_sync_status=not_synced'); + const response = await fetch('/api/contacts?qrz_filter=not_sent'); if (!response.ok) { throw new Error('Failed to fetch contacts'); } diff --git a/src/app/api/contacts/[id]/qrz-sync/route.ts b/src/app/api/contacts/[id]/qrz-sync/route.ts index e94fc50..57cfe1f 100644 --- a/src/app/api/contacts/[id]/qrz-sync/route.ts +++ b/src/app/api/contacts/[id]/qrz-sync/route.ts @@ -2,7 +2,8 @@ import { NextRequest, NextResponse } from 'next/server'; import jwt from 'jsonwebtoken'; import { User } from '@/models/User'; import { Contact } from '@/models/Contact'; -import { uploadQSOToQRZ, contactToQRZFormat } from '@/lib/qrz'; +import { Station } from '@/models/Station'; +import { syncContactToQrz } from '@/lib/qrz-sync-service'; export async function POST( request: NextRequest, @@ -29,80 +30,46 @@ export async function POST( return NextResponse.json({ error: 'Contact not found or access denied' }, { status: 404 }); } - // Get user's QRZ credentials + // Verify user exists const user = await User.findById(decoded.userId); if (!user) { return NextResponse.json({ error: 'User not found' }, { status: 404 }); } - if (!user.qrz_username || !user.qrz_password) { - return NextResponse.json({ - error: 'QRZ credentials not configured. Please add your QRZ username and password in your profile settings.' + if (!contact.station_id) { + return NextResponse.json({ + error: 'Contact has no associated station' }, { status: 400 }); } - // Decrypt the password if it's encrypted - const decryptedPassword = User.getDecryptedQrzPassword(user); - if (!decryptedPassword) { - return NextResponse.json({ - error: 'Failed to decrypt QRZ password. Please update your credentials in profile settings.' - }, { status: 400 }); + // The QRZ Logbook API only accepts the station's pre-issued API key — + // user-level username/password credentials are for XML lookups only. + const station = await Station.findByUserIdAndId(decoded.userId, contact.station_id); + if (!station) { + return NextResponse.json({ error: 'Station not found for this contact' }, { status: 404 }); } - // Convert contact to QRZ format - const qrzData = contactToQRZFormat(contact); - - // Upload to QRZ - const uploadResult = await uploadQSOToQRZ(qrzData, user.qrz_username, decryptedPassword); + const outcome = await syncContactToQrz(contact, station); - if (uploadResult.success) { - // Update contact sync status - const updatedContact = await Contact.updateQrzSyncStatus(contactId, 'synced', uploadResult.logbook_id); - + if (outcome.status === 'failed') { return NextResponse.json({ - success: true, - contact: updatedContact, - logbook_id: uploadResult.logbook_id, - message: 'Successfully synced to QRZ' - }); - } else if (uploadResult.already_exists) { - // Mark as already exists - const updatedContact = await Contact.updateQrzSyncStatus(contactId, 'already_exists', undefined, uploadResult.error); - - return NextResponse.json({ - success: true, - contact: updatedContact, - skipped: true, - message: 'QSO already exists in QRZ logbook' - }); - } else { - // Mark as error - await Contact.updateQrzSyncStatus(contactId, 'error', undefined, uploadResult.error); - - return NextResponse.json({ success: false, - error: uploadResult.error + error: outcome.error }, { status: 400 }); } + return NextResponse.json({ + success: true, + contact: outcome.contact ?? contact, + ...(outcome.status === 'skipped' ? { skipped: true } : {}), + message: outcome.message + }); + } catch (error) { console.error('QRZ sync error:', error); - - // Try to mark as error if we have the contact ID - try { - const resolvedParams = await params; - const contactId = parseInt(resolvedParams.id); - if (!isNaN(contactId)) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - await Contact.updateQrzSyncStatus(contactId, 'error', undefined, errorMessage); - } - } catch { - // Ignore errors in error handling - } - - return NextResponse.json({ + return NextResponse.json({ success: false, - error: 'Failed to sync with QRZ' + error: 'Failed to sync with QRZ' }, { status: 500 }); } -} \ No newline at end of file +} diff --git a/src/app/api/contacts/[id]/route.ts b/src/app/api/contacts/[id]/route.ts index eba618a..6befe58 100644 --- a/src/app/api/contacts/[id]/route.ts +++ b/src/app/api/contacts/[id]/route.ts @@ -1,7 +1,7 @@ -import { NextRequest, NextResponse } from 'next/server'; +import { NextRequest, NextResponse, after } from 'next/server'; import { Contact } from '@/models/Contact'; import { verifyToken } from '@/lib/auth'; -import { backgroundAutoSync } from '@/lib/qrz-auto-sync'; +import { autoSyncContactToQRZ } from '@/lib/qrz-auto-sync'; export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { @@ -57,11 +57,23 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ ); } - // Trigger auto-sync in background if enabled - // Reset sync status when contact is updated so it can be re-synced - if (data.callsign || data.datetime || data.frequency || data.mode || data.band) { - await Contact.updateQrzSyncStatus(parseInt(id), 'not_synced'); - backgroundAutoSync(parseInt(id), parseInt(user.userId, 10)); + // Core QSO fields changed: flag already-uploaded copies for re-upload + // ('Y' → 'M', wavelog's modified marker) and auto-sync after the response. + // Compare payload values against the stored row — the edit form sends the + // core fields on every PUT, so mere presence (or truthiness) would flag + // unmodified QSOs and miss updates that clear a field. + const strChanged = (key: 'callsign' | 'mode' | 'band') => + key in data && String(data[key] ?? '') !== String(existingContact[key] ?? ''); + const freqChanged = + 'frequency' in data && + Number(data.frequency ?? 0) !== Number(existingContact.frequency ?? 0); + const datetimeChanged = + 'datetime' in data && + new Date(data.datetime).getTime() !== new Date(existingContact.datetime).getTime(); + if (strChanged('callsign') || strChanged('mode') || strChanged('band') || freqChanged || datetimeChanged) { + await Contact.flagForReupload(parseInt(id)); + const userId = parseInt(user.userId, 10); + after(() => autoSyncContactToQRZ(parseInt(id), userId)); } return NextResponse.json(contact); diff --git a/src/app/api/contacts/qrz-download/route.ts b/src/app/api/contacts/qrz-download/route.ts index 95e3f0b..93c57b4 100644 --- a/src/app/api/contacts/qrz-download/route.ts +++ b/src/app/api/contacts/qrz-download/route.ts @@ -1,10 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; import jwt from 'jsonwebtoken'; import { User } from '@/models/User'; -import { Contact } from '@/models/Contact'; import { Station } from '@/models/Station'; -import { downloadQSOsFromQRZ, matchQRZConfirmation } from '@/lib/qrz'; -import { query } from '@/lib/db'; +import { downloadConfirmationsForStation } from '@/lib/qrz-sync-service'; export async function POST(request: NextRequest) { try { @@ -29,20 +27,23 @@ export async function POST(request: NextRequest) { // Get all stations with QRZ API keys for this user const userStations = await Station.findByUserId(decoded.userId); - const stationsWithKeys = userStations.filter(station => + const stationsWithKeys = userStations.filter(station => station.qrz_api_key && (!stationIds || stationIds.includes(station.id)) ); if (stationsWithKeys.length === 0) { - return NextResponse.json({ - error: 'No stations found with QRZ API keys configured' + return NextResponse.json({ + error: 'No stations found with QRZ API keys configured' }, { status: 400 }); } for (const station of stationsWithKeys) { try { - // Download QSOs from QRZ for this station - const downloadResult = await downloadQSOsFromQRZ(station.qrz_api_key!, since); + const downloadResult = await downloadConfirmationsForStation( + decoded.userId, + station, + since + ); if (!downloadResult.success) { results.push({ @@ -54,62 +55,17 @@ export async function POST(request: NextRequest) { continue; } - // Get contacts for this station that were sent to QRZ but not confirmed - const unconfirmedContacts = await Contact.findQrzSentNotConfirmed(decoded.userId); - const stationContacts = unconfirmedContacts.filter(c => c.station_id === station.id); - - let confirmationsFound = 0; - - // Annotate each contact with the station callsign so the matcher can - // cross-check against QRZ's STATION_CALLSIGN field. ContactData has - // station_id but not station_callsign — fill it in from the iterated station. - const stationCall = station.callsign; - const annotated = stationContacts.map(c => ({ ...c, station_callsign: stationCall })); - - // Match QRZ QSOs with our contacts. Tighter rules: callsign + band + - // mode + station_callsign + ±15min, so two QSOs on different bands at - // the same minute don't false-match. - for (const contact of annotated) { - for (const qrzQSO of downloadResult.qsos) { - if (!matchQRZConfirmation(contact, qrzQSO)) continue; - - // QRZ marks confirmed records with app_qrzlog_status='C'. The legacy - // qsl_rcvd / qsl_sent fields aren't always populated, so prefer the - // app field when present. - const isConfirmed = - qrzQSO.app_qrzlog_status?.toUpperCase() === 'C' || - qrzQSO.qsl_rcvd === 'Y' || - qrzQSO.qsl_sent === 'Y'; - if (!isConfirmed) { - break; - } - - await Contact.updateQrzQsl(contact.id, undefined, 'Y'); - confirmationsFound++; - - // Cross-sync: if LoTW already shipped this QSO, flag for re-upload - // so the new qrz_qsl_rcvd value propagates back into LoTW (wavelog 'M'). - if (contact.lotw_qsl_sent === 'Y') { - await query( - `UPDATE contacts SET lotw_qsl_sent = 'M', updated_at = NOW() WHERE id = $1`, - [contact.id] - ); - } - break; - } - } - results.push({ stationId: station.id, stationCallsign: station.callsign, success: true, - qsosDownloaded: downloadResult.qsos.length, - confirmationsFound, - message: `Downloaded ${downloadResult.qsos.length} QSOs, found ${confirmationsFound} confirmations` + qsosDownloaded: downloadResult.qsos_downloaded, + confirmationsFound: downloadResult.confirmations_found, + message: `Downloaded ${downloadResult.qsos_downloaded} QSOs, found ${downloadResult.confirmations_found} confirmations` }); - + stationsProcessed.add(station.id); - + } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; results.push({ @@ -140,8 +96,8 @@ export async function POST(request: NextRequest) { } catch (error) { console.error('QRZ download error:', error); - return NextResponse.json({ - error: 'Failed to download from QRZ' + return NextResponse.json({ + error: 'Failed to download from QRZ' }, { status: 500 }); } -} \ No newline at end of file +} diff --git a/src/app/api/contacts/qrz-sync/route.ts b/src/app/api/contacts/qrz-sync/route.ts index a51f0ab..d79b851 100644 --- a/src/app/api/contacts/qrz-sync/route.ts +++ b/src/app/api/contacts/qrz-sync/route.ts @@ -2,8 +2,17 @@ import { NextRequest, NextResponse } from 'next/server'; import jwt from 'jsonwebtoken'; import { User } from '@/models/User'; import { Contact } from '@/models/Contact'; -import { Station } from '@/models/Station'; -import { uploadQSOToQRZWithApiKey, contactToQRZFormat } from '@/lib/qrz'; +import { Station, StationData } from '@/models/Station'; +import { syncContactToQrz } from '@/lib/qrz-sync-service'; + +interface BulkSyncResult { + contactId: number; + success: boolean; + skipped?: boolean; + already_existed?: boolean; + message?: string; + error?: string; +} export async function POST(request: NextRequest) { try { @@ -27,7 +36,8 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'User not found' }, { status: 404 }); } - const results = []; + const results: BulkSyncResult[] = []; + const stationCache = new Map(); // Process each contact for (const contactId of contactIds) { @@ -43,30 +53,6 @@ export async function POST(request: NextRequest) { continue; } - // Skip if already sent to QRZ - if (contact.qrz_qsl_sent === 'Y') { - results.push({ - contactId, - success: true, - skipped: true, - message: 'Already sent to QRZ' - }); - continue; - } - - // If we've received confirmation from QRZ, mark as sent too (it exists in QRZ) - if (contact.qrz_qsl_rcvd === 'Y') { - await Contact.updateQrzQsl(contactId, 'Y'); - results.push({ - contactId, - success: true, - skipped: true, - message: 'Already confirmed by QRZ (marked as sent)' - }); - continue; - } - - // Get the station for this contact to get QRZ API key if (!contact.station_id) { results.push({ contactId, @@ -76,7 +62,13 @@ export async function POST(request: NextRequest) { continue; } - const station = await Station.findByUserIdAndId(decoded.userId, contact.station_id); + if (!stationCache.has(contact.station_id)) { + stationCache.set( + contact.station_id, + await Station.findByUserIdAndId(decoded.userId, contact.station_id) + ); + } + const station = stationCache.get(contact.station_id) ?? null; if (!station) { results.push({ contactId, @@ -86,52 +78,26 @@ export async function POST(request: NextRequest) { continue; } - if (!station.qrz_api_key) { + const outcome = await syncContactToQrz(contact, station); + + if (outcome.status === 'failed') { results.push({ contactId, success: false, - error: 'No QRZ API key configured for this station. Please add your QRZ API key in station settings.' + error: outcome.error }); - continue; - } - - // Convert contact to QRZ format and upload - const qrzData = contactToQRZFormat(contact); - const uploadResult = await uploadQSOToQRZWithApiKey(qrzData, station.qrz_api_key); - - if (uploadResult.success) { - if (uploadResult.already_exists) { - // Mark as both sent AND received since it exists in QRZ (it's confirmed!) - await Contact.updateQrzQsl(contactId, 'Y', 'Y'); - results.push({ - contactId, - success: true, - already_existed: true, - message: 'QSO already exists in QRZ logbook (marked as sent and confirmed)' - }); - } else { - // Mark as sent to QRZ (but not yet confirmed) - await Contact.updateQrzQsl(contactId, 'Y'); - results.push({ - contactId, - success: true, - message: 'Successfully sent to QRZ' - }); - } } else { - // Mark as request failed - await Contact.updateQrzQsl(contactId, 'R'); results.push({ contactId, - success: false, - error: uploadResult.error + success: true, + ...(outcome.status === 'skipped' ? { skipped: true } : {}), + ...(outcome.status === 'confirmed' ? { already_existed: true } : {}), + message: outcome.message }); } } catch (error) { - // Mark as error const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - await Contact.updateQrzQsl(contactId, 'R'); results.push({ contactId, success: false, diff --git a/src/app/api/contacts/route.ts b/src/app/api/contacts/route.ts index 7fcdc9d..909714d 100644 --- a/src/app/api/contacts/route.ts +++ b/src/app/api/contacts/route.ts @@ -1,7 +1,7 @@ -import { NextRequest, NextResponse } from 'next/server'; +import { NextRequest, NextResponse, after } from 'next/server'; import { Contact } from '@/models/Contact'; import { verifyToken } from '@/lib/auth'; -import { backgroundAutoSync } from '@/lib/qrz-auto-sync'; +import { autoSyncContactToQRZ } from '@/lib/qrz-auto-sync'; export async function GET(request: NextRequest) { try { @@ -16,7 +16,7 @@ export async function GET(request: NextRequest) { const offset = (page - 1) * limit; const since = searchParams.get('since'); const countOnly = searchParams.get('countOnly') === 'true'; - const qrzSyncStatus = searchParams.get('qrz_sync_status'); + const qrzFilter = searchParams.get('qrz_filter'); const userId = typeof user.userId === 'string' ? parseInt(user.userId, 10) : user.userId; @@ -26,8 +26,8 @@ export async function GET(request: NextRequest) { return NextResponse.json({ count }); } - // Handle QRZ sync status filtering (for admin bulk sync - get ALL contacts, no limit) - if (qrzSyncStatus === 'not_synced') { + // Handle QRZ filter (for admin bulk sync - get ALL contacts, no limit) + if (qrzFilter === 'not_sent') { const contacts = await Contact.findQrzNotSent(userId); // No limit - get all not sent to QRZ return NextResponse.json({ contacts, @@ -70,8 +70,12 @@ export async function POST(request: NextRequest) { user_id: user.userId }); - // Trigger auto-sync in background if enabled - backgroundAutoSync(contact.id, parseInt(user.userId, 10)); + // Trigger auto-sync after the response is sent. after() keeps the + // serverless function alive until the sync finishes — a bare + // fire-and-forget promise is frozen mid-flight on Vercel as soon as the + // response returns. + const userId = parseInt(user.userId, 10); + after(() => autoSyncContactToQRZ(contact.id, userId)); return NextResponse.json(contact, { status: 201 }); } catch (error) { diff --git a/src/lib/lotw.ts b/src/lib/lotw.ts index de74113..716d794 100644 --- a/src/lib/lotw.ts +++ b/src/lib/lotw.ts @@ -21,101 +21,6 @@ export function decryptString(encryptedText: string): string { return decrypt(encryptedText); } -// ADIF generation for LoTW upload -export function generateAdifForLoTW(contacts: ContactWithLoTW[], stationCallsign: string): string { - const header = `ADIF Export for LoTW Upload -3.1.5 -Nextlog -1.0.0 - - -`; - - const records = contacts.map(contact => { - let adifRecord = ''; - - // Required fields for LoTW - adifRecord += `${contact.callsign}`; - - // Format date as YYYYMMDD - const qsoDate = contact.datetime.toISOString().slice(0, 10).replace(/-/g, ''); - adifRecord += `${qsoDate}`; - - // Format time as HHMMSS - const qsoTime = contact.datetime.toISOString().slice(11, 19).replace(/:/g, ''); - adifRecord += `${qsoTime}`; - - // Band is required - if (contact.band) { - adifRecord += `${contact.band}`; - } - - // Mode is required - if (contact.mode) { - adifRecord += `${contact.mode}`; - } - - // Station callsign - adifRecord += `${stationCallsign}`; - - // Optional fields - if (contact.frequency) { - const freqStr = contact.frequency.toString(); - adifRecord += `${freqStr}`; - } - - if (contact.rst_sent) { - adifRecord += `${contact.rst_sent}`; - } - - if (contact.rst_received) { - adifRecord += `${contact.rst_received}`; - } - - if (contact.grid_locator) { - adifRecord += `${contact.grid_locator}`; - } - - if (contact.name) { - adifRecord += `${contact.name}`; - } - - if (contact.qth) { - adifRecord += `${contact.qth}`; - } - - if (contact.state) { - adifRecord += `${contact.state}`; - } - - if (contact.country) { - adifRecord += `${contact.country}`; - } - - if (contact.dxcc) { - const dxccStr = contact.dxcc.toString(); - adifRecord += `${dxccStr}`; - } - - if (contact.cqz) { - const cqzStr = contact.cqz.toString(); - adifRecord += `${cqzStr}`; - } - - if (contact.ituz) { - const ituzStr = contact.ituz.toString(); - adifRecord += `${ituzStr}`; - } - - // End of record - adifRecord += '\n'; - - return adifRecord; - }); - - return header + records.join(''); -} - // Parse ADIF file from LoTW download. // Captures both the core matching fields AND the enriched fields LoTW returns when // qso_qsldetail=yes / qso_mydetail=yes are set on the request: state, county, CQZ, diff --git a/src/lib/qrz-auto-sync.ts b/src/lib/qrz-auto-sync.ts index c9b7193..2d49d17 100644 --- a/src/lib/qrz-auto-sync.ts +++ b/src/lib/qrz-auto-sync.ts @@ -1,69 +1,56 @@ +// On-save QRZ auto-sync. Runs after a contact is created or updated when the +// user has qrz_auto_sync enabled. Uses the station's QRZ Logbook API key via +// qrz-sync-service — the logbook API does not accept username/password. +// +// Never throws, so callers can fire-and-forget inside next/server's after() +// without swallowing errors silently. Upload failures are logged and recorded +// on the contact as qrz_qsl_sent='R' by the service; pre-flight skips (sync +// disabled, missing station or API key) are logged only and leave the +// contact untouched. + import { User } from '@/models/User'; import { Contact } from '@/models/Contact'; -import { uploadQSOToQRZ, contactToQRZFormat } from './qrz'; +import { Station } from '@/models/Station'; +import { syncContactToQrz } from '@/lib/qrz-sync-service'; +import { logger } from '@/lib/logger'; export async function autoSyncContactToQRZ(contactId: number, userId: number): Promise { try { - // Get user and check if auto-sync is enabled const user = await User.findById(userId); if (!user || !user.qrz_auto_sync) { return; // Auto-sync not enabled } - // Check if user has QRZ credentials - if (!user.qrz_username || !user.qrz_password) { - return; // No QRZ credentials - } - - // Get the contact const contact = await Contact.findById(contactId); if (!contact || contact.user_id !== userId) { return; // Contact not found or access denied } - // Skip if already synced or failed too many times - if (contact.qrz_sync_status === 'synced' || contact.qrz_sync_status === 'already_exists') { - return; // Already synced + if (!contact.station_id) { + logger.warn(`QRZ auto-sync skipped: contact ${contactId} has no station`); + return; } - // Decrypt the password - const decryptedPassword = User.getDecryptedQrzPassword(user); - if (!decryptedPassword) { - return; // Failed to decrypt password + const station = await Station.findByUserIdAndId(userId, contact.station_id); + if (!station) { + logger.warn(`QRZ auto-sync skipped: station ${contact.station_id} not found for contact ${contactId}`); + return; } - // Convert contact to QRZ format - const qrzData = contactToQRZFormat(contact); - - // Upload to QRZ - const uploadResult = await uploadQSOToQRZ(qrzData, user.qrz_username, decryptedPassword); - - if (uploadResult.success) { - // Update contact sync status - await Contact.updateQrzSyncStatus(contactId, 'synced', uploadResult.logbook_id); - } else if (uploadResult.already_exists) { - // Mark as already exists - await Contact.updateQrzSyncStatus(contactId, 'already_exists', undefined, uploadResult.error); - } else { - // Mark as error - await Contact.updateQrzSyncStatus(contactId, 'error', undefined, uploadResult.error); + if (!station.qrz_api_key) { + logger.warn( + `QRZ auto-sync skipped: station ${station.id} (${station.callsign}) has no QRZ Logbook API key configured` + ); + return; } - } catch (error) { - // Mark as error on any exception - try { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - await Contact.updateQrzSyncStatus(contactId, 'error', undefined, errorMessage); - } catch { - // Ignore errors in error handling + const outcome = await syncContactToQrz(contact, station); + if (outcome.status === 'failed') { + // syncContactToQrz already logged the upload error and marked the + // contact 'R'; nothing more to do here. + return; } + } catch (error) { + logger.error(`QRZ auto-sync failed for contact ${contactId}`, error); } } - -// Function to perform auto-sync in the background (non-blocking) -export function backgroundAutoSync(contactId: number, userId: number): void { - // Run auto-sync in background, don't wait for it - autoSyncContactToQRZ(contactId, userId).catch(() => { - // Ignore errors in background sync - }); -} \ No newline at end of file diff --git a/src/lib/qrz-sync-service.ts b/src/lib/qrz-sync-service.ts new file mode 100644 index 0000000..30289d3 --- /dev/null +++ b/src/lib/qrz-sync-service.ts @@ -0,0 +1,220 @@ +// QRZ logbook sync service — the single home for QRZ upload/download logic. +// Reused by the on-save auto-sync, the manual per-contact and bulk routes, +// and the scheduled sync cron. All paths use the station's QRZ Logbook API +// key: the logbook API (logbook.qrz.com) only accepts pre-issued keys — the +// user-level username/password credentials are for the XML lookup API only. + +import { Contact, ContactData } from '@/models/Contact'; +import { Station, StationData } from '@/models/Station'; +import { + uploadQSOToQRZWithApiKey, + downloadQSOsFromQRZ, + matchQRZConfirmation, + contactToQRZFormat, +} from '@/lib/qrz'; +import { query } from '@/lib/db'; +import { logger } from '@/lib/logger'; + +export interface QrzSyncOutcome { + status: 'sent' | 'confirmed' | 'skipped' | 'failed'; + message?: string; + error?: string; + contact?: ContactData | null; +} + +// Upload a single contact to the station's QRZ logbook and record the result +// in the ADIF-style qrz_qsl_sent / qrz_qsl_rcvd fields (the ones the UI +// indicators read). 'M' (modified) rows re-upload with OPTION=REPLACE. +export async function syncContactToQrz( + contact: ContactData, + station: StationData +): Promise { + if (contact.qrz_qsl_sent === 'Y') { + return { status: 'skipped', message: 'Already sent to QRZ', contact }; + } + if (contact.qrz_qsl_sent === 'I') { + return { status: 'skipped', message: 'Excluded from QRZ sync', contact }; + } + + // Re-upload with OPTION=REPLACE for locally-modified rows ('M') and for + // failed retries ('R') of contacts that already shipped once (they have a + // qrz_qsl_sent_date) — a plain insert would be deduped by QRZ and the + // modified QSO would never actually update. + const replace = + contact.qrz_qsl_sent === 'M' || + (contact.qrz_qsl_sent === 'R' && !!contact.qrz_qsl_sent_date); + + // If QRZ already confirmed this QSO it necessarily exists in the logbook — + // mark it sent without re-uploading (unless it was modified locally). + if (!replace && contact.qrz_qsl_rcvd === 'Y') { + const updated = await Contact.updateQrzQsl(contact.id, 'Y'); + return { + status: 'skipped', + message: 'Already confirmed by QRZ (marked as sent)', + contact: updated, + }; + } + + if (!station.qrz_api_key) { + return { + status: 'failed', + error: + 'No QRZ API key configured for this station. Please add your QRZ Logbook API key in station settings.', + }; + } + + const qrzData = contactToQRZFormat(contact); + const uploadResult = await uploadQSOToQRZWithApiKey(qrzData, station.qrz_api_key, replace); + + if (uploadResult.success) { + if (uploadResult.already_exists) { + // Duplicate means it is present in the QRZ logbook: sent and confirmed. + const updated = await Contact.updateQrzQsl(contact.id, 'Y', 'Y'); + return { + status: 'confirmed', + message: 'QSO already exists in QRZ logbook (marked as sent and confirmed)', + contact: updated, + }; + } + const updated = await Contact.updateQrzQsl(contact.id, 'Y'); + return { status: 'sent', message: 'Successfully sent to QRZ', contact: updated }; + } + + const updated = await Contact.updateQrzQsl(contact.id, 'R'); + logger.error( + `QRZ upload failed for contact ${contact.id} (${contact.callsign})`, + undefined, + { error: uploadResult.error, station_id: station.id } + ); + return { status: 'failed', error: uploadResult.error, contact: updated }; +} + +export interface QrzUploadSweepResult { + processed: number; + sent: number; + confirmed: number; + skipped: number; + failed: number; + first_error?: string; +} + +// Upload every pending contact for one station. 'R' (previously failed) rows +// are included by findQrzNotSent, so each sweep retries earlier failures. +export async function uploadPendingForStation( + userId: number, + station: StationData, + limit = 250 +): Promise { + const pending = await Contact.findQrzNotSent(userId, limit, station.id); + const result: QrzUploadSweepResult = { + processed: pending.length, + sent: 0, + confirmed: 0, + skipped: 0, + failed: 0, + }; + + for (const contact of pending) { + const outcome = await syncContactToQrz(contact, station); + if (outcome.status === 'sent') result.sent++; + else if (outcome.status === 'confirmed') result.confirmed++; + else if (outcome.status === 'skipped') result.skipped++; + else { + result.failed++; + if (!result.first_error) result.first_error = outcome.error; + } + } + + return result; +} + +export interface QrzDownloadResult { + success: boolean; + qsos_downloaded: number; + confirmations_found: number; + error?: string; +} + +// Fetch confirmed QSOs from the station's QRZ logbook and flag matching local +// contacts as confirmed. Defaults to an incremental fetch from the station's +// qrz_last_qsl_rcvd_date cursor (MODSINCE filters by QRZ-side modification +// date, so the cursor is simply the date of the last successful run). +export async function downloadConfirmationsForStation( + userId: number, + station: StationData, + since?: string +): Promise { + if (!station.qrz_api_key) { + return { + success: false, + qsos_downloaded: 0, + confirmations_found: 0, + error: 'No QRZ API key configured for this station', + }; + } + + let effectiveSince = since; + if (!effectiveSince && station.qrz_last_qsl_rcvd_date) { + const last = station.qrz_last_qsl_rcvd_date; + effectiveSince = last instanceof Date ? last.toISOString().split('T')[0] : String(last); + } + + const downloadResult = await downloadQSOsFromQRZ(station.qrz_api_key, effectiveSince); + if (!downloadResult.success) { + return { + success: false, + qsos_downloaded: 0, + confirmations_found: 0, + error: downloadResult.error, + }; + } + + // Contacts for this station that were sent to QRZ but not yet confirmed, + // annotated with the station callsign so the matcher can cross-check + // against QRZ's STATION_CALLSIGN field. + const unconfirmed = await Contact.findQrzSentNotConfirmed(userId, undefined, station.id); + const stationContacts = unconfirmed.map(c => ({ ...c, station_callsign: station.callsign })); + + let confirmationsFound = 0; + + for (const contact of stationContacts) { + for (const qrzQSO of downloadResult.qsos) { + if (!matchQRZConfirmation(contact, qrzQSO)) continue; + + // QRZ marks confirmed records with app_qrzlog_status='C'. The legacy + // qsl_rcvd / qsl_sent fields aren't always populated, so prefer the + // app field when present. + const isConfirmed = + qrzQSO.app_qrzlog_status?.toUpperCase() === 'C' || + qrzQSO.qsl_rcvd === 'Y' || + qrzQSO.qsl_sent === 'Y'; + if (!isConfirmed) { + break; + } + + await Contact.updateQrzQsl(contact.id, undefined, 'Y'); + confirmationsFound++; + + // Cross-sync: if LoTW already shipped this QSO, flag for re-upload + // so the new qrz_qsl_rcvd value propagates back into LoTW (wavelog 'M'). + if (contact.lotw_qsl_sent === 'Y') { + await query( + `UPDATE contacts SET lotw_qsl_sent = 'M', updated_at = NOW() WHERE id = $1`, + [contact.id] + ); + } + break; + } + } + + // Advance the incremental cursor only after a successful fetch. MODSINCE is + // date-granular and inclusive, so today's date never skips later same-day + // modifications on the next run. + await Station.updateQrzLastQslRcvdDate(station.id); + + return { + success: true, + qsos_downloaded: downloadResult.qsos.length, + confirmations_found: confirmationsFound, + }; +} diff --git a/src/lib/qrz.ts b/src/lib/qrz.ts index 7c39cf5..f1832a1 100644 --- a/src/lib/qrz.ts +++ b/src/lib/qrz.ts @@ -342,108 +342,6 @@ export async function uploadQSOToQRZWithApiKey( } } -// Function to upload QSO to QRZ Logbook (legacy username/password method) -export async function uploadQSOToQRZ( - qsoData: QRZQSOData, - username: string, - password: string -): Promise { - if (!username || !password) { - return { - success: false, - error: 'Missing QRZ username or password' - }; - } - - try { - // First, get a session key from QRZ - const sessionUrl = `https://logbook.qrz.com/api`; - - const authFormData = new FormData(); - authFormData.append('username', username); - authFormData.append('password', password); - authFormData.append('agent', 'Nextlog_1.0'); - - const sessionResponse = await fetch(sessionUrl, { - method: 'POST', - body: authFormData - }); - - const sessionResult = await sessionResponse.text(); - - // Parse the response to check for session key or error - if (sessionResult.includes('AUTH_FAILED') || sessionResult.includes('INVALID')) { - return { - success: false, - error: 'Invalid QRZ credentials for logbook access' - }; - } - - // Extract session key - const keyMatch = sessionResult.match(/KEY=([A-Za-z0-9]+)/); - if (!keyMatch) { - return { - success: false, - error: 'Failed to authenticate with QRZ logbook API' - }; - } - - const sessionKey = keyMatch[1]; - - // Now upload the QSO - const uploadFormData = new FormData(); - uploadFormData.append('KEY', sessionKey); - uploadFormData.append('ACTION', 'INSERT'); - uploadFormData.append('ADIF', formatQSOAsADIF(qsoData)); - - const uploadResponse = await fetch(sessionUrl, { - method: 'POST', - body: uploadFormData - }); - - const uploadResult = await uploadResponse.text(); - - // Parse upload result - if (uploadResult.includes('FAIL')) { - // Check for duplicate (case-insensitive) - if (uploadResult.toLowerCase().includes('duplicate')) { - return { - success: true, // Changed to true - duplicate means it's in QRZ (success!) - already_exists: true, - error: 'QSO already exists in QRZ logbook' - }; - } - - return { - success: false, - error: `QRZ upload failed: ${uploadResult}` - }; - } - - if (uploadResult.includes('OK')) { - // Try to extract logbook ID if provided - const idMatch = uploadResult.match(/LOGID=(\d+)/); - const logbook_id = idMatch ? parseInt(idMatch[1]) : undefined; - - return { - success: true, - logbook_id - }; - } - - return { - success: false, - error: `Unexpected response from QRZ: ${uploadResult}` - }; - - } catch (error) { - return { - success: false, - error: `Network error during QRZ upload: ${error instanceof Error ? error.message : 'Unknown error'}` - }; - } -} - // Function to format QSO data as ADIF function formatQSOAsADIF(qso: QRZQSOData): string { let adif = ''; diff --git a/src/models/Contact.ts b/src/models/Contact.ts index 34aa017..fdf02ae 100644 --- a/src/models/Contact.ts +++ b/src/models/Contact.ts @@ -42,11 +42,6 @@ export interface ContactData { qrz_qsl_rcvd?: string; qrz_qsl_sent_date?: Date; qrz_qsl_rcvd_date?: Date; - // QRZ sync fields - qrz_sync_status?: 'not_synced' | 'synced' | 'error' | 'already_exists'; - qrz_sync_date?: Date; - qrz_logbook_id?: number; - qrz_sync_error?: string; // Additional fields qso_date_off?: Date; time_off?: string; @@ -389,101 +384,67 @@ export class Contact { return result.rows[0] || null; } - static async findQrzNotSent(userId: number, limit?: number): Promise { + // Contacts pending QRZ upload: never sent (NULL/'N'), previously failed + // ('R' — retried on every sweep), or modified since upload ('M'). Excludes + // 'I' (permanently ignored) and 'Q' (queued elsewhere). + static async findQrzNotSent(userId: number, limit?: number, stationId?: number): Promise { let sql = ` - SELECT * FROM contacts - WHERE user_id = $1 AND (qrz_qsl_sent IS NULL OR qrz_qsl_sent != 'Y') - ORDER BY datetime DESC + SELECT * FROM contacts + WHERE user_id = $1 AND (qrz_qsl_sent IS NULL OR qrz_qsl_sent IN ('N', 'R', 'M')) `; const params = [userId]; - + + if (stationId !== undefined) { + sql += ` AND station_id = $${params.length + 1}`; + params.push(stationId); + } + + sql += ` ORDER BY datetime DESC`; + if (limit) { sql += ` LIMIT $${params.length + 1}`; params.push(limit); } - + const result = await query(sql, params); return result.rows; } - static async findQrzSentNotConfirmed(userId: number, limit?: number): Promise { + // Flag a contact for re-upload after its core QSO fields changed: services + // that already shipped it ('Y') get wavelog's 'M' (modified) marker, which + // the QRZ path re-uploads with OPTION=REPLACE and the LoTW path re-signs. + static async flagForReupload(id: number): Promise { + await query( + `UPDATE contacts SET + qrz_qsl_sent = CASE WHEN qrz_qsl_sent = 'Y' THEN 'M' ELSE qrz_qsl_sent END, + lotw_qsl_sent = CASE WHEN lotw_qsl_sent = 'Y' THEN 'M' ELSE lotw_qsl_sent END, + updated_at = NOW() + WHERE id = $1`, + [id] + ); + } + + static async findQrzSentNotConfirmed(userId: number, limit?: number, stationId?: number): Promise { let sql = ` - SELECT * FROM contacts + SELECT * FROM contacts WHERE user_id = $1 AND qrz_qsl_sent = 'Y' AND (qrz_qsl_rcvd IS NULL OR qrz_qsl_rcvd != 'Y') - ORDER BY datetime DESC `; const params = [userId]; - + + if (stationId !== undefined) { + sql += ` AND station_id = $${params.length + 1}`; + params.push(stationId); + } + + sql += ` ORDER BY datetime DESC`; + if (limit) { sql += ` LIMIT $${params.length + 1}`; params.push(limit); } - + const result = await query(sql, params); return result.rows; } - // Helper function to match QSO records by callsign, date, and time - static matchQSO(contact: ContactData, qrzQSO: { call: string; qso_date: string; time_on: string }): boolean { - if (!contact.callsign || !qrzQSO.call) return false; - - // Normalize callsigns for comparison - const contactCall = contact.callsign.toUpperCase().trim(); - const qrzCall = qrzQSO.call.toUpperCase().trim(); - - if (contactCall !== qrzCall) return false; - - // Compare dates - const contactDate = new Date(contact.datetime).toISOString().split('T')[0].replace(/-/g, ''); - const qrzDate = qrzQSO.qso_date.replace(/-/g, ''); - - if (contactDate !== qrzDate) return false; - - // Compare times (within a few minutes tolerance) - const contactTime = new Date(contact.datetime).toISOString().split('T')[1].substring(0, 5).replace(':', ''); - const qrzTime = qrzQSO.time_on.replace(':', ''); - - // Allow 5 minute tolerance - const contactMinutes = parseInt(contactTime.substring(0, 2)) * 60 + parseInt(contactTime.substring(2)); - const qrzMinutes = parseInt(qrzTime.substring(0, 2)) * 60 + parseInt(qrzTime.substring(2)); - - return Math.abs(contactMinutes - qrzMinutes) <= 5; - } - - // QRZ sync status management - static async updateQrzSyncStatus( - id: number, - status: 'not_synced' | 'synced' | 'error' | 'already_exists', - logbookId?: number, - error?: string - ): Promise { - const updates = ['qrz_sync_status = $2', 'qrz_sync_date = NOW()']; - const values: (number | string)[] = [id, status]; - let paramCount = 3; - - if (logbookId !== undefined) { - updates.push(`qrz_logbook_id = $${paramCount}`); - values.push(logbookId); - paramCount++; - } - - if (error !== undefined) { - updates.push(`qrz_sync_error = $${paramCount}`); - values.push(error); - paramCount++; - } else if (status === 'synced' || status === 'already_exists') { - // Clear error on successful sync - updates.push('qrz_sync_error = NULL'); - } - - const sql = ` - UPDATE contacts - SET ${updates.join(', ')} - WHERE id = $1 - RETURNING * - `; - - const result = await query(sql, values); - return result.rows[0] || null; - } } \ No newline at end of file diff --git a/src/models/Station.ts b/src/models/Station.ts index accf051..7ec1a84 100644 --- a/src/models/Station.ts +++ b/src/models/Station.ts @@ -28,6 +28,7 @@ export interface StationData { qrz_username?: string; qrz_password?: string; qrz_api_key?: string; + qrz_last_qsl_rcvd_date?: Date | string; club_callsign?: string; lotw_username?: string; lotw_password?: string; @@ -225,6 +226,15 @@ export class Station { } } + // Advance the incremental QRZ download cursor after a successful + // confirmation fetch (used as MODSINCE on the next run). + static async updateQrzLastQslRcvdDate(stationId: number): Promise { + await query( + 'UPDATE stations SET qrz_last_qsl_rcvd_date = CURRENT_DATE, updated_at = CURRENT_TIMESTAMP WHERE id = $1', + [stationId] + ); + } + static async getActiveStations(userId: number): Promise { const sqlQuery = ` SELECT * FROM stations