From 1701e91db16b7a3444463d39189dbd6aa85ca2f3 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Mon, 13 Jul 2026 10:18:14 -0500 Subject: [PATCH 1/7] fix(qrz): rewrite auto-sync onto the API-key path, delete phantom status system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QRZ auto-sync could never work. It authenticated with username/password against logbook.qrz.com, which only accepts pre-issued logbook API keys, and it recorded results in qrz_sync_status/qrz_sync_date/qrz_logbook_id/ qrz_sync_error columns that exist in no migration or schema — every status UPDATE threw, and backgroundAutoSync swallowed the error. Uploads silently did nothing. - New src/lib/qrz-sync-service.ts: single home for QRZ upload/download, used by auto-sync, the manual per-contact and bulk routes, and (next PR) the sync cron. All paths use the station qrz_api_key and record state in the ADIF-style qrz_qsl_sent/qrz_qsl_rcvd columns the UI reads. 'M' (modified) rows re-upload with OPTION=REPLACE; failures mark 'R' and are retried on every sweep. - Auto-sync now runs inside next/server after() — a bare fire-and-forget promise is frozen on Vercel when the response returns. - Contact edits flag already-uploaded copies 'Y' -> 'M' (flagForReupload) instead of writing the nonexistent qrz_sync_status column, which threw on every edit of core QSO fields. - QRZ downloads keep an incremental cursor in stations.qrz_last_qsl_rcvd_date (previously never written) via MODSINCE. - Delete uploadQSOToQRZ (username/password handshake), updateQrzSyncStatus, dead Contact.matchQSO, dead generateAdifForLoTW, and the phantom fields on ContactData; migration 0004 drops the orphaned qrz_sync_status_enum type. - Rename the misleading contacts query param qrz_sync_status=not_synced to qrz_filter=not_sent (it always filtered by qrz_qsl_sent). Co-Authored-By: Claude Fable 5 --- ...004_drop_orphaned_qrz_sync_status_enum.sql | 8 + drizzle/migrations/meta/0004_snapshot.json | 3609 +++++++++++++++++ drizzle/migrations/meta/_journal.json | 7 + src/app/admin/page.tsx | 2 +- src/app/api/contacts/[id]/qrz-sync/route.ts | 81 +- src/app/api/contacts/[id]/route.ts | 13 +- src/app/api/contacts/qrz-download/route.ts | 78 +- src/app/api/contacts/qrz-sync/route.ts | 90 +- src/app/api/contacts/route.ts | 18 +- src/lib/lotw.ts | 95 - src/lib/qrz-auto-sync.ts | 75 +- src/lib/qrz-sync-service.ts | 216 + src/lib/qrz.ts | 102 - src/models/Contact.ts | 103 +- src/models/Station.ts | 10 + 15 files changed, 3997 insertions(+), 510 deletions(-) create mode 100644 drizzle/migrations/0004_drop_orphaned_qrz_sync_status_enum.sql create mode 100644 drizzle/migrations/meta/0004_snapshot.json create mode 100644 src/lib/qrz-sync-service.ts 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..9799504 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,12 @@ 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 + // Core QSO fields changed: flag already-uploaded copies for re-upload + // ('Y' → 'M', wavelog's modified marker) and auto-sync after the response. 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)); + 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..c36c6e8 100644 --- a/src/lib/qrz-auto-sync.ts +++ b/src/lib/qrz-auto-sync.ts @@ -1,69 +1,54 @@ +// 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: every failure is logged (and recorded on the contact as +// qrz_qsl_sent='R' by the service) so callers can fire-and-forget inside +// next/server's after() without swallowing errors silently. + 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..99a2e3f --- /dev/null +++ b/src/lib/qrz-sync-service.ts @@ -0,0 +1,216 @@ +// 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 }; + } + + const replace = contact.qrz_qsl_sent === 'M'; + + // 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); + const stationContacts = unconfirmed + .filter(c => c.station_id === station.id) + .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..3491089 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,23 +384,46 @@ 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; } + // 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): Promise { let sql = ` SELECT * FROM contacts @@ -423,67 +441,4 @@ export class Contact { 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 From 1d9a59b0786944ea773e7e6cfcc2138803216c68 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Mon, 13 Jul 2026 10:22:42 -0500 Subject: [PATCH 2/7] fix(security): require CRON_SECRET on cron + LoTW sync endpoints The cron routes trusted any request whose host/user-agent/x-vercel-id header mentioned vercel, which disabled the CRON_SECRET check on every *.vercel.app deployment. Separately, /api/lotw/{upload,download} skipped auth entirely on a spoofable X-Cron-Job: true header, letting anonymous callers trigger uploads/downloads for any station_id. - New src/lib/cron-auth.ts: strict Bearer CRON_SECRET check, no fallbacks. - Cron routes require the Bearer token unconditionally and fail closed (500) when CRON_SECRET is unset. Vercel attaches the header automatically when the env var exists. - X-Cron-Job is now only a mode discriminator: cron mode additionally requires the valid Bearer token; the cron routes' internal fetches send it. - README: "Scheduled sync" section for Vercel + self-hosted crontab setup. Co-Authored-By: Claude Fable 5 --- README.md | 18 ++++++++++++++++ src/app/api/cron/lotw-download/route.ts | 28 ++++++++++--------------- src/app/api/cron/lotw-upload/route.ts | 28 ++++++++++--------------- src/app/api/lotw/download/route.ts | 11 ++++++++-- src/app/api/lotw/upload/route.ts | 13 +++++++++--- src/lib/cron-auth.ts | 15 +++++++++++++ 6 files changed, 74 insertions(+), 39 deletions(-) create mode 100644 src/lib/cron-auth.ts diff --git a/README.md b/README.md index 692ade8..44541d5 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,24 @@ See `/tests/README.md` for detailed testing documentation. 3. **Log Contacts**: Add new contacts with frequency, mode, RST, and other details 4. **View Logbook**: Browse your logged contacts on the dashboard +## Scheduled sync + +The cron endpoints (`/api/cron/*`) upload pending QSOs to LoTW and download +confirmations on a schedule. They require a `CRON_SECRET` environment variable +and reject every request that does not carry it — if the secret is unset the +endpoints fail closed with a 500. + +- **Vercel**: set `CRON_SECRET` in the project's environment variables. Vercel + automatically attaches `Authorization: Bearer $CRON_SECRET` to the cron + invocations defined in `vercel.json`; no other setup is needed. +- **Self-hosted**: set `CRON_SECRET` in your environment and call the + endpoints from your scheduler, e.g. a crontab entry: + + ```cron + 0 * * * * curl -fsS -H "Authorization: Bearer $CRON_SECRET" https://your-host/api/cron/lotw-upload + 30 * * * * curl -fsS -H "Authorization: Bearer $CRON_SECRET" https://your-host/api/cron/lotw-download + ``` + ## Cloudlog API Compatibility Nextlog provides full compatibility with Cloudlog's API, allowing you to use any third-party amateur radio software that supports Cloudlog integration. diff --git a/src/app/api/cron/lotw-download/route.ts b/src/app/api/cron/lotw-download/route.ts index c55f690..178b59a 100644 --- a/src/app/api/cron/lotw-download/route.ts +++ b/src/app/api/cron/lotw-download/route.ts @@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { query } from '@/lib/db'; +import { hasValidCronSecret } from '@/lib/cron-auth'; export async function GET(request: NextRequest) { try { @@ -18,22 +19,14 @@ export async function GET(request: NextRequest) { }, { status: 500 }); } - // Verify this is a legitimate cron request - const authHeader = request.headers.get('authorization'); - const expectedAuth = `Bearer ${process.env.CRON_SECRET}`; - - // For Vercel cron jobs, we need to be more flexible with authentication - // Vercel cron jobs run in a trusted environment but may not include the auth header - const isVercelCron = request.headers.get('user-agent')?.includes('vercel') || - request.headers.get('x-vercel-id') || - request.headers.get('host')?.includes('vercel'); - - if (!isVercelCron && authHeader !== expectedAuth) { - console.error('Authentication failed:', { - hasAuthHeader: !!authHeader, - hasCronSecret: !!process.env.CRON_SECRET, - isVercelCron - }); + // Verify this is a legitimate cron request. Fail closed when the secret + // is missing — Vercel only attaches the Authorization header when + // CRON_SECRET is set, and an unset secret must not mean "open endpoint". + if (!process.env.CRON_SECRET) { + console.error('CRON_SECRET is not configured; refusing cron request'); + return NextResponse.json({ error: 'CRON_SECRET not configured' }, { status: 500 }); + } + if (!hasValidCronSecret(request)) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } @@ -79,7 +72,8 @@ export async function GET(request: NextRequest) { method: 'POST', headers: { 'Content-Type': 'application/json', - 'X-Cron-Job': 'true', // Internal identifier + 'X-Cron-Job': 'true', // Cron-mode discriminator (grants nothing by itself) + 'Authorization': `Bearer ${process.env.CRON_SECRET}`, }, body: JSON.stringify({ station_id: station.id, diff --git a/src/app/api/cron/lotw-upload/route.ts b/src/app/api/cron/lotw-upload/route.ts index db27bf4..0788678 100644 --- a/src/app/api/cron/lotw-upload/route.ts +++ b/src/app/api/cron/lotw-upload/route.ts @@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { query } from '@/lib/db'; +import { hasValidCronSecret } from '@/lib/cron-auth'; export async function GET(request: NextRequest) { try { @@ -18,22 +19,14 @@ export async function GET(request: NextRequest) { }, { status: 500 }); } - // Verify this is a legitimate cron request - const authHeader = request.headers.get('authorization'); - const expectedAuth = `Bearer ${process.env.CRON_SECRET}`; - - // For Vercel cron jobs, we need to be more flexible with authentication - // Vercel cron jobs run in a trusted environment but may not include the auth header - const isVercelCron = request.headers.get('user-agent')?.includes('vercel') || - request.headers.get('x-vercel-id') || - request.headers.get('host')?.includes('vercel'); - - if (!isVercelCron && authHeader !== expectedAuth) { - console.error('Authentication failed:', { - hasAuthHeader: !!authHeader, - hasCronSecret: !!process.env.CRON_SECRET, - isVercelCron - }); + // Verify this is a legitimate cron request. Fail closed when the secret + // is missing — Vercel only attaches the Authorization header when + // CRON_SECRET is set, and an unset secret must not mean "open endpoint". + if (!process.env.CRON_SECRET) { + console.error('CRON_SECRET is not configured; refusing cron request'); + return NextResponse.json({ error: 'CRON_SECRET not configured' }, { status: 500 }); + } + if (!hasValidCronSecret(request)) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } @@ -108,7 +101,8 @@ export async function GET(request: NextRequest) { method: 'POST', headers: { 'Content-Type': 'application/json', - 'X-Cron-Job': 'true', // Internal identifier + 'X-Cron-Job': 'true', // Cron-mode discriminator (grants nothing by itself) + 'Authorization': `Bearer ${process.env.CRON_SECRET}`, }, body: JSON.stringify({ station_id: station.id, diff --git a/src/app/api/lotw/download/route.ts b/src/app/api/lotw/download/route.ts index a0d75e7..513c0c6 100644 --- a/src/app/api/lotw/download/route.ts +++ b/src/app/api/lotw/download/route.ts @@ -2,14 +2,21 @@ import { NextRequest, NextResponse } from 'next/server'; import { verifyToken } from '@/lib/auth'; +import { hasValidCronSecret } from '@/lib/cron-auth'; import { query } from '@/lib/db'; import { parseLoTWAdif, matchLoTWConfirmations, buildLoTWDownloadUrl, decryptString } from '@/lib/lotw'; import { LotwDownloadRequest, LotwDownloadResponse, ContactWithLoTW } from '@/types/lotw'; export async function POST(request: NextRequest) { try { - // Check if this is a cron job request - const isCronJob = request.headers.get('X-Cron-Job') === 'true'; + // Cron mode requires the valid CRON_SECRET Bearer token — the X-Cron-Job + // header is only a mode discriminator and grants nothing by itself + // (it is spoofable by any caller). + const cronHeaderPresent = request.headers.get('X-Cron-Job') === 'true'; + const isCronJob = cronHeaderPresent && hasValidCronSecret(request); + if (cronHeaderPresent && !isCronJob) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } let user = null; if (isCronJob) { diff --git a/src/app/api/lotw/upload/route.ts b/src/app/api/lotw/upload/route.ts index fa3e4b8..a3f09e5 100644 --- a/src/app/api/lotw/upload/route.ts +++ b/src/app/api/lotw/upload/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { verifyToken } from '@/lib/auth'; +import { hasValidCronSecret } from '@/lib/cron-auth'; import { query } from '@/lib/db'; import { buildSignedTq8, @@ -28,10 +29,16 @@ const LOTW_UPLOAD_ACCEPTED_REGEX = //i; export async function POST(request: NextRequest) { try { - // Check if this is a cron job request - const isCronJob = request.headers.get('X-Cron-Job') === 'true'; + // Cron mode requires the valid CRON_SECRET Bearer token — the X-Cron-Job + // header is only a mode discriminator and grants nothing by itself + // (it is spoofable by any caller). + const cronHeaderPresent = request.headers.get('X-Cron-Job') === 'true'; + const isCronJob = cronHeaderPresent && hasValidCronSecret(request); + if (cronHeaderPresent && !isCronJob) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } let user = null; - + if (isCronJob) { // For cron jobs, we'll get the user from the station_id user = null; // Will be set later diff --git a/src/lib/cron-auth.ts b/src/lib/cron-auth.ts new file mode 100644 index 0000000..17cb0ff --- /dev/null +++ b/src/lib/cron-auth.ts @@ -0,0 +1,15 @@ +// Strict cron authentication. +// +// Vercel attaches `Authorization: Bearer ${CRON_SECRET}` to cron invocations +// automatically when the CRON_SECRET env var is set on the project. +// Self-hosted operators pass the same header from their external scheduler +// (see README "Scheduled sync"). There is no trusted-host fallback: host and +// user-agent headers are caller-controlled and must never grant auth. + +import { NextRequest } from 'next/server'; + +export function hasValidCronSecret(request: NextRequest): boolean { + const secret = process.env.CRON_SECRET; + if (!secret) return false; + return request.headers.get('authorization') === `Bearer ${secret}`; +} From 817f4fc5180ed8e8cfe5f805af13ce0a5579196c Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Mon, 13 Jul 2026 10:26:20 -0500 Subject: [PATCH 3/7] feat(sync): unified hourly sync cron for QRZ + LoTW MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the two daily LoTW-only cron routes with a single /api/cron/sync orchestrator that runs the full cycle per station, in order: QRZ upload sweep, LoTW upload, QRZ confirmation download, LoTW confirmation download. Uploads run before downloads so fresh QSOs can confirm in the same cycle, and QRZ/LoTW legs are serialized so the cross-service 'M' re-upload flags never race. QRZ finally gets automation — previously upload was on-save or manual only and confirmation download was manual only. - QRZ legs iterate active stations with an API key whose owner enabled qrz_auto_sync, via uploadPendingForStation (250 QSOs/station/run cap, 'R' failures retried every sweep) and downloadConfirmationsForStation (incremental MODSINCE cursor). - LoTW legs keep the existing per-station selection, <1h dedupe, and internal fetch to /api/lotw/{upload,download} with the CRON_SECRET Bearer header, preserving lotw_*_logs bookkeeping. - vercel.json: single hourly cron (Vercel Pro), maxDuration 300 for the new route; old cron routes deleted; README updated. Co-Authored-By: Claude Fable 5 --- README.md | 20 +- src/app/api/cron/lotw-download/route.ts | 128 ------------ src/app/api/cron/lotw-upload/route.ts | 156 -------------- src/app/api/cron/sync/route.ts | 259 ++++++++++++++++++++++++ vercel.json | 13 +- 5 files changed, 273 insertions(+), 303 deletions(-) delete mode 100644 src/app/api/cron/lotw-download/route.ts delete mode 100644 src/app/api/cron/lotw-upload/route.ts create mode 100644 src/app/api/cron/sync/route.ts diff --git a/README.md b/README.md index 44541d5..9a8df5f 100644 --- a/README.md +++ b/README.md @@ -162,20 +162,22 @@ See `/tests/README.md` for detailed testing documentation. ## Scheduled sync -The cron endpoints (`/api/cron/*`) upload pending QSOs to LoTW and download -confirmations on a schedule. They require a `CRON_SECRET` environment variable -and reject every request that does not carry it — if the secret is unset the -endpoints fail closed with a 500. +The `/api/cron/sync` endpoint runs the full sync cycle — QRZ upload sweep, +LoTW upload, QRZ confirmation download, LoTW confirmation download — for every +eligible station. It requires a `CRON_SECRET` environment variable and rejects +every request that does not carry it — if the secret is unset the endpoint +fails closed with a 500. All legs are incremental and idempotent, so any +cadence is safe. - **Vercel**: set `CRON_SECRET` in the project's environment variables. Vercel automatically attaches `Authorization: Bearer $CRON_SECRET` to the cron - invocations defined in `vercel.json`; no other setup is needed. -- **Self-hosted**: set `CRON_SECRET` in your environment and call the - endpoints from your scheduler, e.g. a crontab entry: + invocation defined in `vercel.json` (hourly by default); no other setup is + needed. +- **Self-hosted**: set `CRON_SECRET` in your environment and call the endpoint + from your scheduler, e.g. a crontab entry: ```cron - 0 * * * * curl -fsS -H "Authorization: Bearer $CRON_SECRET" https://your-host/api/cron/lotw-upload - 30 * * * * curl -fsS -H "Authorization: Bearer $CRON_SECRET" https://your-host/api/cron/lotw-download + 0 * * * * curl -fsS -H "Authorization: Bearer $CRON_SECRET" https://your-host/api/cron/sync ``` ## Cloudlog API Compatibility diff --git a/src/app/api/cron/lotw-download/route.ts b/src/app/api/cron/lotw-download/route.ts deleted file mode 100644 index 178b59a..0000000 --- a/src/app/api/cron/lotw-download/route.ts +++ /dev/null @@ -1,128 +0,0 @@ -// LoTW Download Cron Job -// This endpoint is called by Vercel Cron to automatically download confirmations from LoTW - -import { NextRequest, NextResponse } from 'next/server'; -import { query } from '@/lib/db'; -import { hasValidCronSecret } from '@/lib/cron-auth'; - -export async function GET(request: NextRequest) { - try { - // Environment validation - const requiredEnvVars = ['DATABASE_URL', 'JWT_SECRET', 'ENCRYPTION_SECRET']; - const missingEnvVars = requiredEnvVars.filter(varName => !process.env[varName]); - - if (missingEnvVars.length > 0) { - console.error('Missing required environment variables:', missingEnvVars); - return NextResponse.json({ - error: 'Server configuration error', - details: `Missing environment variables: ${missingEnvVars.join(', ')}` - }, { status: 500 }); - } - - // Verify this is a legitimate cron request. Fail closed when the secret - // is missing — Vercel only attaches the Authorization header when - // CRON_SECRET is set, and an unset secret must not mean "open endpoint". - if (!process.env.CRON_SECRET) { - console.error('CRON_SECRET is not configured; refusing cron request'); - return NextResponse.json({ error: 'CRON_SECRET not configured' }, { status: 500 }); - } - if (!hasValidCronSecret(request)) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - // Get all active stations that have LoTW credentials configured - const stationsResult = await query(` - SELECT DISTINCT s.id, s.callsign, s.user_id - FROM stations s - LEFT JOIN users u ON s.user_id = u.id - WHERE s.is_active = true - AND ( - (s.lotw_username IS NOT NULL AND s.lotw_password IS NOT NULL) - OR (u.third_party_services->>'lotw' IS NOT NULL) - ) - `); - - const results = []; - - for (const station of stationsResult.rows) { - try { - // Check if we've downloaded recently (within last hour) to avoid duplicate downloads - const recentDownloadResult = await query( - `SELECT id FROM lotw_download_logs - WHERE station_id = $1 - AND started_at > NOW() - INTERVAL '1 hour' - AND status IN ('processing', 'completed') - LIMIT 1`, - [station.id] - ); - - if (recentDownloadResult.rows.length > 0) { - results.push({ - station_id: station.id, - callsign: station.callsign, - status: 'skipped', - reason: 'downloaded_recently' - }); - continue; - } - - // Make internal API call to download endpoint - const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'; - const downloadResponse = await fetch(`${baseUrl}/api/lotw/download`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Cron-Job': 'true', // Cron-mode discriminator (grants nothing by itself) - 'Authorization': `Bearer ${process.env.CRON_SECRET}`, - }, - body: JSON.stringify({ - station_id: station.id, - download_method: 'automatic' - }) - }); - - const downloadData = await downloadResponse.json(); - - // Enhanced logging for troubleshooting - if (!downloadResponse.ok) { - console.error(`Download failed for station ${station.callsign}:`, { - status: downloadResponse.status, - statusText: downloadResponse.statusText, - error: downloadData.error || 'Unknown error' - }); - } - - results.push({ - station_id: station.id, - callsign: station.callsign, - status: downloadResponse.ok ? 'success' : 'error', - confirmations_found: downloadData.confirmations_found || 0, - confirmations_matched: downloadData.confirmations_matched || 0, - error: downloadResponse.ok ? null : downloadData.error - }); - - } catch (stationError) { - console.error(`Error processing station ${station.callsign}:`, stationError); - results.push({ - station_id: station.id, - callsign: station.callsign, - status: 'error', - error: stationError instanceof Error ? stationError.message : 'Unknown error' - }); - } - } - - return NextResponse.json({ - success: true, - processed_stations: results.length, - results - }); - - } catch (error) { - console.error('LoTW download cron error:', error); - return NextResponse.json( - { error: 'Internal server error' }, - { status: 500 } - ); - } -} \ No newline at end of file diff --git a/src/app/api/cron/lotw-upload/route.ts b/src/app/api/cron/lotw-upload/route.ts deleted file mode 100644 index 0788678..0000000 --- a/src/app/api/cron/lotw-upload/route.ts +++ /dev/null @@ -1,156 +0,0 @@ -// LoTW Upload Cron Job -// This endpoint is called by Vercel Cron to automatically upload QSOs to LoTW - -import { NextRequest, NextResponse } from 'next/server'; -import { query } from '@/lib/db'; -import { hasValidCronSecret } from '@/lib/cron-auth'; - -export async function GET(request: NextRequest) { - try { - // Environment validation - const requiredEnvVars = ['DATABASE_URL', 'JWT_SECRET', 'ENCRYPTION_SECRET']; - const missingEnvVars = requiredEnvVars.filter(varName => !process.env[varName]); - - if (missingEnvVars.length > 0) { - console.error('Missing required environment variables:', missingEnvVars); - return NextResponse.json({ - error: 'Server configuration error', - details: `Missing environment variables: ${missingEnvVars.join(', ')}` - }, { status: 500 }); - } - - // Verify this is a legitimate cron request. Fail closed when the secret - // is missing — Vercel only attaches the Authorization header when - // CRON_SECRET is set, and an unset secret must not mean "open endpoint". - if (!process.env.CRON_SECRET) { - console.error('CRON_SECRET is not configured; refusing cron request'); - return NextResponse.json({ error: 'CRON_SECRET not configured' }, { status: 500 }); - } - if (!hasValidCronSecret(request)) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - // Check if lotw_credentials table exists before proceeding - const tableCheckResult = await query(` - SELECT table_name - FROM information_schema.tables - WHERE table_schema = 'public' AND table_name = 'lotw_credentials' - `); - - if (tableCheckResult.rows.length === 0) { - return NextResponse.json({ - success: true, - message: 'LoTW upload skipped - lotw_credentials table not found', - processed_stations: 0, - results: [] - }); - } - - // Get all active stations that have LoTW credentials configured - const stationsResult = await query(` - SELECT DISTINCT s.id, s.callsign, s.user_id - FROM stations s - LEFT JOIN lotw_credentials lc ON s.id = lc.station_id AND lc.is_active = true - LEFT JOIN users u ON s.user_id = u.id - WHERE s.is_active = true - AND ( - (s.lotw_username IS NOT NULL AND s.lotw_password IS NOT NULL) - OR (u.third_party_services->>'lotw' IS NOT NULL) - ) - AND lc.id IS NOT NULL - `); - - // Check if lotw_upload_logs table exists before proceeding with duplicate checks - const uploadLogsTableCheckResult = await query(` - SELECT table_name - FROM information_schema.tables - WHERE table_schema = 'public' AND table_name = 'lotw_upload_logs' - `); - - const hasUploadLogsTable = uploadLogsTableCheckResult.rows.length > 0; - - const results = []; - - for (const station of stationsResult.rows) { - try { - // Check if we've uploaded recently (within last hour) to avoid duplicate uploads - if (hasUploadLogsTable) { - const recentUploadResult = await query( - `SELECT id FROM lotw_upload_logs - WHERE station_id = $1 - AND started_at > NOW() - INTERVAL '1 hour' - AND status IN ('processing', 'completed') - LIMIT 1`, - [station.id] - ); - - if (recentUploadResult.rows.length > 0) { - results.push({ - station_id: station.id, - callsign: station.callsign, - status: 'skipped', - reason: 'uploaded_recently' - }); - continue; - } - } - - // Make internal API call to upload endpoint - const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'; - const uploadResponse = await fetch(`${baseUrl}/api/lotw/upload`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Cron-Job': 'true', // Cron-mode discriminator (grants nothing by itself) - 'Authorization': `Bearer ${process.env.CRON_SECRET}`, - }, - body: JSON.stringify({ - station_id: station.id, - upload_method: 'automatic' - }) - }); - - const uploadData = await uploadResponse.json(); - - // Enhanced logging for troubleshooting - if (!uploadResponse.ok) { - console.error(`Upload failed for station ${station.callsign}:`, { - status: uploadResponse.status, - statusText: uploadResponse.statusText, - error: uploadData.error || 'Unknown error' - }); - } - - results.push({ - station_id: station.id, - callsign: station.callsign, - status: uploadResponse.ok ? 'success' : 'error', - qso_count: uploadData.qso_count || 0, - error: uploadResponse.ok ? null : uploadData.error - }); - - } catch (stationError) { - console.error(`Error processing station ${station.callsign}:`, stationError); - results.push({ - station_id: station.id, - callsign: station.callsign, - status: 'error', - error: stationError instanceof Error ? stationError.message : 'Unknown error' - }); - } - } - - return NextResponse.json({ - success: true, - processed_stations: results.length, - results - }); - - } catch (error) { - console.error('LoTW upload cron error:', error); - return NextResponse.json( - { error: 'Internal server error' }, - { status: 500 } - ); - } -} \ No newline at end of file diff --git a/src/app/api/cron/sync/route.ts b/src/app/api/cron/sync/route.ts new file mode 100644 index 0000000..8de4eb1 --- /dev/null +++ b/src/app/api/cron/sync/route.ts @@ -0,0 +1,259 @@ +// Unified sync cron. Called hourly (vercel.json) or by an external scheduler +// with the CRON_SECRET Bearer token. Runs, in order: +// +// 1. QRZ upload sweep — pending QSOs to each station's QRZ logbook +// 2. LoTW upload — pending QSOs signed + uploaded per station +// 3. QRZ confirmation download +// 4. LoTW confirmation download +// +// Uploads run before downloads so freshly logged QSOs can confirm in the same +// cycle, and QRZ/LoTW legs are serialized so the cross-service re-upload +// flags ('M') never race. Every leg is incremental and idempotent, so any +// cadence is safe. + +import { NextRequest, NextResponse } from 'next/server'; +import { query } from '@/lib/db'; +import { hasValidCronSecret } from '@/lib/cron-auth'; +import { + uploadPendingForStation, + downloadConfirmationsForStation, +} from '@/lib/qrz-sync-service'; +import { StationData } from '@/models/Station'; + +// Per-station cap per run; the next hourly run picks up the remainder. +const QRZ_UPLOAD_CAP = 250; + +interface StationLegResult { + station_id: number; + callsign: string; + status: 'success' | 'error' | 'skipped'; + [key: string]: unknown; +} + +// Stations eligible for QRZ sync: active, keyed, owner opted into auto-sync. +async function getQrzStations(): Promise> { + const result = await query(` + SELECT s.* + FROM stations s + JOIN users u ON s.user_id = u.id + WHERE s.is_active = true + AND s.qrz_api_key IS NOT NULL + AND u.qrz_auto_sync = true + `); + return result.rows; +} + +// Stations eligible for LoTW sync (same selection the dedicated LoTW cron +// routes used). requireCert: uploads need an active signing certificate; +// downloads only need username/password credentials. +async function getLotwStations(requireCert: boolean): Promise> { + const certJoin = requireCert + ? 'LEFT JOIN lotw_credentials lc ON s.id = lc.station_id AND lc.is_active = true' + : ''; + const certFilter = requireCert ? 'AND lc.id IS NOT NULL' : ''; + const result = await query(` + SELECT DISTINCT s.id, s.callsign, s.user_id + FROM stations s + ${certJoin} + LEFT JOIN users u ON s.user_id = u.id + WHERE s.is_active = true + AND ( + (s.lotw_username IS NOT NULL AND s.lotw_password IS NOT NULL) + OR (u.third_party_services->>'lotw' IS NOT NULL) + ) + ${certFilter} + `); + return result.rows; +} + +// Skip a LoTW leg when a run for the station completed or is still processing +// within the last hour (prevents overlap when the cron cadence is dense). +async function ranRecently(logTable: 'lotw_upload_logs' | 'lotw_download_logs', stationId: number): Promise { + const result = await query( + `SELECT id FROM ${logTable} + WHERE station_id = $1 + AND started_at > NOW() - INTERVAL '1 hour' + AND status IN ('processing', 'completed') + LIMIT 1`, + [stationId] + ); + return result.rows.length > 0; +} + +// Invoke the LoTW upload/download API routes internally. They own the +// lotw_upload_logs / lotw_download_logs bookkeeping; cron mode is authorized +// by the CRON_SECRET Bearer header. +async function callLotwRoute( + path: '/api/lotw/upload' | '/api/lotw/download', + body: Record +): Promise<{ ok: boolean; data: Record }> { + const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'; + const response = await fetch(`${baseUrl}${path}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Cron-Job': 'true', + 'Authorization': `Bearer ${process.env.CRON_SECRET}`, + }, + body: JSON.stringify(body), + }); + const data = await response.json(); + return { ok: response.ok, data }; +} + +export async function GET(request: NextRequest) { + try { + const requiredEnvVars = ['DATABASE_URL', 'JWT_SECRET', 'ENCRYPTION_SECRET']; + const missingEnvVars = requiredEnvVars.filter(varName => !process.env[varName]); + if (missingEnvVars.length > 0) { + console.error('Missing required environment variables:', missingEnvVars); + return NextResponse.json({ + error: 'Server configuration error', + details: `Missing environment variables: ${missingEnvVars.join(', ')}` + }, { status: 500 }); + } + + if (!process.env.CRON_SECRET) { + console.error('CRON_SECRET is not configured; refusing cron request'); + return NextResponse.json({ error: 'CRON_SECRET not configured' }, { status: 500 }); + } + if (!hasValidCronSecret(request)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const qrzUpload: StationLegResult[] = []; + const lotwUpload: StationLegResult[] = []; + const qrzDownload: StationLegResult[] = []; + const lotwDownload: StationLegResult[] = []; + + const qrzStations = await getQrzStations(); + + // 1. QRZ upload sweep + for (const station of qrzStations) { + try { + const sweep = await uploadPendingForStation(station.user_id, station, QRZ_UPLOAD_CAP); + qrzUpload.push({ + station_id: station.id, + callsign: station.callsign, + status: sweep.failed > 0 ? 'error' : 'success', + processed: sweep.processed, + sent: sweep.sent, + confirmed: sweep.confirmed, + failed: sweep.failed, + error: sweep.first_error ?? null, + }); + } catch (error) { + console.error(`QRZ upload sweep failed for station ${station.callsign}:`, error); + qrzUpload.push({ + station_id: station.id, + callsign: station.callsign, + status: 'error', + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + + // 2. LoTW upload + for (const station of await getLotwStations(true)) { + try { + if (await ranRecently('lotw_upload_logs', station.id)) { + lotwUpload.push({ station_id: station.id, callsign: station.callsign, status: 'skipped', reason: 'uploaded_recently' }); + continue; + } + const { ok, data } = await callLotwRoute('/api/lotw/upload', { + station_id: station.id, + upload_method: 'automatic', + }); + if (!ok) { + console.error(`LoTW upload failed for station ${station.callsign}:`, data.error ?? data.error_message); + } + lotwUpload.push({ + station_id: station.id, + callsign: station.callsign, + status: ok ? 'success' : 'error', + qso_count: data.qso_count ?? 0, + error: ok ? null : (data.error ?? data.error_message ?? 'Unknown error'), + }); + } catch (error) { + console.error(`Error processing LoTW upload for station ${station.callsign}:`, error); + lotwUpload.push({ + station_id: station.id, + callsign: station.callsign, + status: 'error', + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + + // 3. QRZ confirmation download (incremental via qrz_last_qsl_rcvd_date) + for (const station of qrzStations) { + try { + const download = await downloadConfirmationsForStation(station.user_id, station); + qrzDownload.push({ + station_id: station.id, + callsign: station.callsign, + status: download.success ? 'success' : 'error', + qsos_downloaded: download.qsos_downloaded, + confirmations_found: download.confirmations_found, + error: download.success ? null : (download.error ?? 'Unknown error'), + }); + } catch (error) { + console.error(`QRZ download failed for station ${station.callsign}:`, error); + qrzDownload.push({ + station_id: station.id, + callsign: station.callsign, + status: 'error', + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + + // 4. LoTW confirmation download + for (const station of await getLotwStations(false)) { + try { + if (await ranRecently('lotw_download_logs', station.id)) { + lotwDownload.push({ station_id: station.id, callsign: station.callsign, status: 'skipped', reason: 'downloaded_recently' }); + continue; + } + const { ok, data } = await callLotwRoute('/api/lotw/download', { + station_id: station.id, + download_method: 'automatic', + }); + if (!ok) { + console.error(`LoTW download failed for station ${station.callsign}:`, data.error ?? data.error_message); + } + lotwDownload.push({ + station_id: station.id, + callsign: station.callsign, + status: ok ? 'success' : 'error', + confirmations_found: data.confirmations_found ?? 0, + confirmations_matched: data.confirmations_matched ?? 0, + error: ok ? null : (data.error ?? data.error_message ?? 'Unknown error'), + }); + } catch (error) { + console.error(`Error processing LoTW download for station ${station.callsign}:`, error); + lotwDownload.push({ + station_id: station.id, + callsign: station.callsign, + status: 'error', + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + + return NextResponse.json({ + success: true, + qrz_upload: qrzUpload, + lotw_upload: lotwUpload, + qrz_download: qrzDownload, + lotw_download: lotwDownload, + }); + + } catch (error) { + console.error('Sync cron error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/vercel.json b/vercel.json index 1950e1a..5f4ea09 100644 --- a/vercel.json +++ b/vercel.json @@ -6,10 +6,7 @@ "src/app/api/adif/export/route.ts": { "maxDuration": 30 }, - "src/app/api/cron/lotw-upload/route.ts": { - "maxDuration": 300 - }, - "src/app/api/cron/lotw-download/route.ts": { + "src/app/api/cron/sync/route.ts": { "maxDuration": 300 }, "src/app/api/lotw/upload/route.ts": { @@ -21,12 +18,8 @@ }, "crons": [ { - "path": "/api/cron/lotw-upload", - "schedule": "0 1 * * *" - }, - { - "path": "/api/cron/lotw-download", - "schedule": "0 1 * * *" + "path": "/api/cron/sync", + "schedule": "0 * * * *" } ], "regions": ["iad1"] From bf458bbb7ef8d1fc1837309ecf76b2ace7ef7d89 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Mon, 13 Jul 2026 10:34:36 -0500 Subject: [PATCH 4/7] feat(sync): sync activity log, /sync page, truthful indicators + settings Sync failures were invisible: QRZ had no history at all, auto-sync failures vanished, and the settings UI implied username/password powered logbook sync. This adds the visibility layer: - New sync_logs table (migration 0005) + SyncLog model. QRZ upload sweeps, downloads, manual bulk syncs, and failed on-save auto-syncs all write rows (trigger manual/auto/cron); no-op cron sweeps are not logged so the feed stays readable. LoTW keeps its dedicated log tables. - GET /api/sync/logs: one reverse-chronological feed merging sync_logs with normalized lotw_upload_logs / lotw_download_logs rows. - New /sync page ("Sync Activity") listing every run with service, direction, station, trigger, status, and full error message; linked from the navbar More menu. - QRZSyncIndicator + LotwSyncIndicator learn the 'M' (modified - pending re-upload, amber) and 'I' (excluded, muted) states so flagged rows no longer render as "upload failed"/"not uploaded". - Settings truthfulness: profile QRZ section relabeled "XML Lookup Credentials" with a note that logbook sync uses per-station API keys, and an amber warning when auto-sync is on but no station has a key; station form API-key fields relabeled "QRZ.com Logbook API Key (required for logbook sync)". Co-Authored-By: Claude Fable 5 --- drizzle/migrations/0005_add_sync_logs.sql | 25 + drizzle/migrations/meta/0005_snapshot.json | 3794 ++++++++++++++++++++ drizzle/migrations/meta/_journal.json | 11 +- drizzle/schema.ts | 37 + src/app/api/contacts/qrz-sync/route.ts | 15 +- src/app/api/sync/logs/route.ts | 98 + src/app/profile/page.tsx | 39 +- src/app/stations/[id]/edit/page.tsx | 5 +- src/app/stations/new/page.tsx | 3 +- src/app/sync/page.tsx | 216 ++ src/components/LotwSyncIndicator.tsx | 6 +- src/components/Navbar.tsx | 1 + src/components/QRZSyncIndicator.tsx | 26 +- src/lib/qrz-auto-sync.ts | 16 +- src/lib/qrz-sync-service.ts | 61 +- src/models/SyncLog.ts | 77 + 16 files changed, 4410 insertions(+), 20 deletions(-) create mode 100644 drizzle/migrations/0005_add_sync_logs.sql create mode 100644 drizzle/migrations/meta/0005_snapshot.json create mode 100644 src/app/api/sync/logs/route.ts create mode 100644 src/app/sync/page.tsx create mode 100644 src/models/SyncLog.ts diff --git a/drizzle/migrations/0005_add_sync_logs.sql b/drizzle/migrations/0005_add_sync_logs.sql new file mode 100644 index 0000000..2e711b7 --- /dev/null +++ b/drizzle/migrations/0005_add_sync_logs.sql @@ -0,0 +1,25 @@ +CREATE TABLE "sync_logs" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" integer NOT NULL, + "station_id" integer, + "service" varchar(10) NOT NULL, + "direction" varchar(10) NOT NULL, + "trigger" varchar(10) NOT NULL, + "status" varchar(12) NOT NULL, + "started_at" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL, + "completed_at" timestamp, + "qso_count" integer DEFAULT 0, + "success_count" integer DEFAULT 0, + "matched_count" integer DEFAULT 0, + "error_message" text, + "details" jsonb, + CONSTRAINT "sync_logs_service_check" CHECK ((service)::text = ANY ((ARRAY['qrz'::character varying, 'lotw'::character varying, 'eqsl'::character varying])::text[])), + CONSTRAINT "sync_logs_direction_check" CHECK ((direction)::text = ANY ((ARRAY['upload'::character varying, 'download'::character varying])::text[])), + CONSTRAINT "sync_logs_trigger_check" CHECK ((trigger)::text = ANY ((ARRAY['manual'::character varying, 'auto'::character varying, 'cron'::character varying])::text[])), + CONSTRAINT "sync_logs_status_check" CHECK ((status)::text = ANY ((ARRAY['completed'::character varying, 'failed'::character varying])::text[])) +); +--> statement-breakpoint +ALTER TABLE "sync_logs" ADD CONSTRAINT "sync_logs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sync_logs" ADD CONSTRAINT "sync_logs_station_id_fkey" FOREIGN KEY ("station_id") REFERENCES "public"."stations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "idx_sync_logs_user_started" ON "sync_logs" USING btree ("user_id" int4_ops,"started_at" timestamp_ops);--> statement-breakpoint +CREATE INDEX "idx_sync_logs_station_id" ON "sync_logs" USING btree ("station_id" int4_ops); \ No newline at end of file diff --git a/drizzle/migrations/meta/0005_snapshot.json b/drizzle/migrations/meta/0005_snapshot.json new file mode 100644 index 0000000..544b2b6 --- /dev/null +++ b/drizzle/migrations/meta/0005_snapshot.json @@ -0,0 +1,3794 @@ +{ + "id": "8b896b52-e8f8-4b07-95b3-dba0a17b5335", + "prevId": "e73ec0cc-01ff-4dfd-a8a6-976365551cf9", + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "admin_audit_log_admin_user_id_fkey": { + "name": "admin_audit_log_admin_user_id_fkey", + "tableFrom": "admin_audit_log", + "tableTo": "users", + "columnsFrom": [ + "admin_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_keys_station_id_fkey": { + "name": "api_keys_station_id_fkey", + "tableFrom": "api_keys", + "tableTo": "stations", + "columnsFrom": [ + "station_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_keys_user_id_fkey": { + "name": "api_keys_user_id_fkey", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_api_key_key": { + "name": "api_keys_api_key_key", + "nullsNotDistinct": false, + "columns": [ + "api_key" + ] + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_contacts_callsign": { + "name": "idx_contacts_callsign", + "columns": [ + { + "expression": "callsign", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_contacts_datetime": { + "name": "idx_contacts_datetime", + "columns": [ + { + "expression": "datetime", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamp_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_contacts_frequency": { + "name": "idx_contacts_frequency", + "columns": [ + { + "expression": "frequency", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "numeric_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_contacts_mode": { + "name": "idx_contacts_mode", + "columns": [ + { + "expression": "mode", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_contacts_prop_mode": { + "name": "idx_contacts_prop_mode", + "columns": [ + { + "expression": "prop_mode", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_contacts_qsl_lotw": { + "name": "idx_contacts_qsl_lotw", + "columns": [ + { + "expression": "qsl_lotw", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "bool_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_contacts_sat_name": { + "name": "idx_contacts_sat_name", + "columns": [ + { + "expression": "sat_name", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_contacts_station_id": { + "name": "idx_contacts_station_id", + "columns": [ + { + "expression": "station_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_contacts_user_id": { + "name": "idx_contacts_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "contacts_user_id_fkey": { + "name": "contacts_user_id_fkey", + "tableFrom": "contacts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "contacts_station_id_fkey": { + "name": "contacts_station_id_fkey", + "tableFrom": "contacts", + "tableTo": "stations", + "columnsFrom": [ + "station_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "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", + "nullsNotDistinct": false, + "columns": [ + "adif" + ] + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "lotw_credentials_station_id_fkey": { + "name": "lotw_credentials_station_id_fkey", + "tableFrom": "lotw_credentials", + "tableTo": "stations", + "columnsFrom": [ + "station_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "lotw_download_logs_station_id_fkey": { + "name": "lotw_download_logs_station_id_fkey", + "tableFrom": "lotw_download_logs", + "tableTo": "stations", + "columnsFrom": [ + "station_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lotw_download_logs_user_id_fkey": { + "name": "lotw_download_logs_user_id_fkey", + "tableFrom": "lotw_download_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "lotw_job_queue_station_id_fkey": { + "name": "lotw_job_queue_station_id_fkey", + "tableFrom": "lotw_job_queue", + "tableTo": "stations", + "columnsFrom": [ + "station_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lotw_job_queue_user_id_fkey": { + "name": "lotw_job_queue_user_id_fkey", + "tableFrom": "lotw_job_queue", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "lotw_upload_logs_station_id_fkey": { + "name": "lotw_upload_logs_station_id_fkey", + "tableFrom": "lotw_upload_logs", + "tableTo": "stations", + "columnsFrom": [ + "station_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lotw_upload_logs_user_id_fkey": { + "name": "lotw_upload_logs_user_id_fkey", + "tableFrom": "lotw_upload_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "where": "(is_active = true)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_propagation_alerts_expires": { + "name": "idx_propagation_alerts_expires", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "timestamp_ops" + } + ], + "isUnique": false, + "where": "(expires_at IS NOT NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "propagation_alerts_user_id_fkey": { + "name": "propagation_alerts_user_id_fkey", + "tableFrom": "propagation_alerts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_propagation_forecasts_timestamp": { + "name": "idx_propagation_forecasts_timestamp", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamp_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_qsl_images_type": { + "name": "idx_qsl_images_type", + "columns": [ + { + "expression": "image_type", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "qsl_images_contact_id_fkey": { + "name": "qsl_images_contact_id_fkey", + "tableFrom": "qsl_images", + "tableTo": "contacts", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "qsl_images_user_id_fkey": { + "name": "qsl_images_user_id_fkey", + "tableFrom": "qsl_images", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "qsl_images_contact_id_image_type_key": { + "name": "qsl_images_contact_id_image_type_key", + "nullsNotDistinct": false, + "columns": [ + "contact_id", + "image_type" + ] + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "solar_activity_timestamp_key": { + "name": "solar_activity_timestamp_key", + "nullsNotDistinct": false, + "columns": [ + "timestamp" + ] + } + }, + "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", + "nullsNotDistinct": false, + "columns": [ + "dxcc_entity", + "code" + ] + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_stations_country": { + "name": "idx_stations_country", + "columns": [ + { + "expression": "country", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_stations_grid_locator": { + "name": "idx_stations_grid_locator", + "columns": [ + { + "expression": "grid_locator", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_stations_is_active": { + "name": "idx_stations_is_active", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "bool_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_stations_is_default": { + "name": "idx_stations_is_default", + "columns": [ + { + "expression": "is_default", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "bool_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_stations_user_id": { + "name": "idx_stations_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "stations_user_id_fkey": { + "name": "stations_user_id_fkey", + "tableFrom": "stations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_default_station_per_user": { + "name": "unique_default_station_per_user", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "is_default" + ] + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_storage_config_type": { + "name": "idx_storage_config_type", + "columns": [ + { + "expression": "config_type", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "storage_config_created_by_fkey": { + "name": "storage_config_created_by_fkey", + "tableFrom": "storage_config", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "storage_config_config_type_key": { + "name": "storage_config_config_type_key", + "nullsNotDistinct": false, + "columns": [ + "config_type" + ] + } + }, + "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.sync_logs": { + "name": "sync_logs", + "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 + }, + "service": { + "name": "service", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "qso_count": { + "name": "qso_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "success_count": { + "name": "success_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "matched_count": { + "name": "matched_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_sync_logs_user_started": { + "name": "idx_sync_logs_user_started", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": false, + "nulls": "first", + "opclass": "timestamp_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sync_logs_station_id": { + "name": "idx_sync_logs_station_id", + "columns": [ + { + "expression": "station_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "int4_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_logs_user_id_fkey": { + "name": "sync_logs_user_id_fkey", + "tableFrom": "sync_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sync_logs_station_id_fkey": { + "name": "sync_logs_station_id_fkey", + "tableFrom": "sync_logs", + "tableTo": "stations", + "columnsFrom": [ + "station_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sync_logs_service_check": { + "name": "sync_logs_service_check", + "value": "(service)::text = ANY ((ARRAY['qrz'::character varying, 'lotw'::character varying, 'eqsl'::character varying])::text[])" + }, + "sync_logs_direction_check": { + "name": "sync_logs_direction_check", + "value": "(direction)::text = ANY ((ARRAY['upload'::character varying, 'download'::character varying])::text[])" + }, + "sync_logs_trigger_check": { + "name": "sync_logs_trigger_check", + "value": "(trigger)::text = ANY ((ARRAY['manual'::character varying, 'auto'::character varying, 'cron'::character varying])::text[])" + }, + "sync_logs_status_check": { + "name": "sync_logs_status_check", + "value": "(status)::text = ANY ((ARRAY['completed'::character varying, 'failed'::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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_system_settings_key": { + "name": "idx_system_settings_key", + "columns": [ + { + "expression": "setting_key", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_system_settings_public": { + "name": "idx_system_settings_public", + "columns": [ + { + "expression": "is_public", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "bool_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "system_settings_updated_by_fkey": { + "name": "system_settings_updated_by_fkey", + "tableFrom": "system_settings", + "tableTo": "users", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "system_settings_setting_key_key": { + "name": "system_settings_setting_key_key", + "nullsNotDistinct": false, + "columns": [ + "setting_key" + ] + } + }, + "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", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_propagation_preferences_user_id_key": { + "name": "user_propagation_preferences_user_id_key", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_email": { + "name": "idx_users_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_role": { + "name": "idx_users_role", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_status": { + "name": "idx_users_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_key": { + "name": "users_email_key", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "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": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_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 3c8a206..3a9fcf9 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -1,4 +1,4 @@ -{ +{ "version": "7", "dialect": "postgresql", "entries": [ @@ -36,6 +36,13 @@ "when": 1783955769140, "tag": "0004_drop_orphaned_qrz_sync_status_enum", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1783956433941, + "tag": "0005_add_sync_logs", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 0bd95b5..f041480 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -343,6 +343,43 @@ export const lotwDownloadLogs = pgTable("lotw_download_logs", { check("lotw_download_logs_download_method_check", sql`(download_method)::text = ANY ((ARRAY['manual'::character varying, 'automatic'::character varying, 'scheduled'::character varying])::text[])`), ]); +// Unified sync activity log. QRZ writes here today; 'lotw' and 'eqsl' are +// reserved (LoTW keeps its dedicated lotw_upload_logs / lotw_download_logs; +// the /api/sync/logs feed merges them into one view). +export const syncLogs = pgTable("sync_logs", { + id: serial().primaryKey().notNull(), + userId: integer("user_id").notNull(), + stationId: integer("station_id"), + service: varchar({ length: 10 }).notNull(), + direction: varchar({ length: 10 }).notNull(), + trigger: varchar({ length: 10 }).notNull(), + status: varchar({ length: 12 }).notNull(), + startedAt: timestamp("started_at", { mode: 'string' }).default(sql`CURRENT_TIMESTAMP`).notNull(), + completedAt: timestamp("completed_at", { mode: 'string' }), + qsoCount: integer("qso_count").default(0), + successCount: integer("success_count").default(0), + matchedCount: integer("matched_count").default(0), + errorMessage: text("error_message"), + details: jsonb(), +}, (table) => [ + index("idx_sync_logs_user_started").using("btree", table.userId.asc().nullsLast().op("int4_ops"), table.startedAt.desc().nullsFirst().op("timestamp_ops")), + index("idx_sync_logs_station_id").using("btree", table.stationId.asc().nullsLast().op("int4_ops")), + foreignKey({ + columns: [table.userId], + foreignColumns: [users.id], + name: "sync_logs_user_id_fkey" + }).onDelete("cascade"), + foreignKey({ + columns: [table.stationId], + foreignColumns: [stations.id], + name: "sync_logs_station_id_fkey" + }).onDelete("cascade"), + check("sync_logs_service_check", sql`(service)::text = ANY ((ARRAY['qrz'::character varying, 'lotw'::character varying, 'eqsl'::character varying])::text[])`), + check("sync_logs_direction_check", sql`(direction)::text = ANY ((ARRAY['upload'::character varying, 'download'::character varying])::text[])`), + check("sync_logs_trigger_check", sql`(trigger)::text = ANY ((ARRAY['manual'::character varying, 'auto'::character varying, 'cron'::character varying])::text[])`), + check("sync_logs_status_check", sql`(status)::text = ANY ((ARRAY['completed'::character varying, 'failed'::character varying])::text[])`), +]); + export const lotwJobQueue = pgTable("lotw_job_queue", { id: serial().primaryKey().notNull(), jobType: varchar("job_type", { length: 20 }).notNull(), diff --git a/src/app/api/contacts/qrz-sync/route.ts b/src/app/api/contacts/qrz-sync/route.ts index d79b851..87ab4c2 100644 --- a/src/app/api/contacts/qrz-sync/route.ts +++ b/src/app/api/contacts/qrz-sync/route.ts @@ -3,7 +3,7 @@ import jwt from 'jsonwebtoken'; import { User } from '@/models/User'; import { Contact } from '@/models/Contact'; import { Station, StationData } from '@/models/Station'; -import { syncContactToQrz } from '@/lib/qrz-sync-service'; +import { syncContactToQrz, writeSyncLog } from '@/lib/qrz-sync-service'; interface BulkSyncResult { contactId: number; @@ -112,6 +112,19 @@ export async function POST(request: NextRequest) { const skipped = results.filter(r => r.skipped).length; const alreadyExisted = results.filter(r => r.already_existed).length; + const firstError = results.find(r => !r.success)?.error; + await writeSyncLog({ + user_id: decoded.userId, + service: 'qrz', + direction: 'upload', + trigger: 'manual', + status: failed > 0 ? 'failed' : 'completed', + qso_count: contactIds.length, + success_count: successful, + error_message: firstError, + details: { skipped, already_existed: alreadyExisted, failed }, + }); + return NextResponse.json({ results, summary: { diff --git a/src/app/api/sync/logs/route.ts b/src/app/api/sync/logs/route.ts new file mode 100644 index 0000000..4e436af --- /dev/null +++ b/src/app/api/sync/logs/route.ts @@ -0,0 +1,98 @@ +// Merged sync-activity feed: QRZ (and future eQSL) runs from sync_logs, +// LoTW runs normalized out of its dedicated lotw_upload_logs / +// lotw_download_logs tables. One reverse-chronological stream for the /sync page. + +import { NextRequest, NextResponse } from 'next/server'; +import { verifyToken } from '@/lib/auth'; +import { query } from '@/lib/db'; + +export async function GET(request: NextRequest) { + try { + const user = await verifyToken(request); + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const userId = typeof user.userId === 'string' ? parseInt(user.userId, 10) : user.userId; + const { searchParams } = new URL(request.url); + const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200); + const offset = parseInt(searchParams.get('offset') || '0'); + + const result = await query( + `SELECT * FROM ( + SELECT + 'sync-' || sl.id AS log_key, + sl.service, + sl.direction, + sl."trigger", + sl.status, + sl.started_at, + sl.completed_at, + sl.qso_count, + sl.success_count, + sl.matched_count, + sl.error_message, + sl.station_id, + st.callsign AS station_callsign, + sl.details + FROM sync_logs sl + LEFT JOIN stations st ON sl.station_id = st.id + WHERE sl.user_id = $1 + + UNION ALL + + SELECT + 'lotw-upload-' || ul.id AS log_key, + 'lotw' AS service, + 'upload' AS direction, + CASE WHEN ul.upload_method = 'manual' THEN 'manual' ELSE 'cron' END AS "trigger", + ul.status, + ul.started_at, + ul.completed_at, + ul.qso_count, + ul.success_count, + NULL::integer AS matched_count, + ul.error_message, + ul.station_id, + su.callsign AS station_callsign, + NULL::jsonb AS details + FROM lotw_upload_logs ul + LEFT JOIN stations su ON ul.station_id = su.id + WHERE ul.user_id = $1 + + UNION ALL + + SELECT + 'lotw-download-' || dl.id AS log_key, + 'lotw' AS service, + 'download' AS direction, + CASE WHEN dl.download_method = 'manual' THEN 'manual' ELSE 'cron' END AS "trigger", + dl.status, + dl.started_at, + dl.completed_at, + dl.qso_count, + NULL::integer AS success_count, + dl.confirmations_matched AS matched_count, + dl.error_message, + dl.station_id, + sd.callsign AS station_callsign, + NULL::jsonb AS details + FROM lotw_download_logs dl + LEFT JOIN stations sd ON dl.station_id = sd.id + WHERE dl.user_id = $1 + ) merged + ORDER BY started_at DESC NULLS LAST + LIMIT $2 OFFSET $3`, + [userId, limit, offset] + ); + + return NextResponse.json({ + logs: result.rows, + limit, + offset, + }); + } catch (error) { + console.error('Sync logs error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index 322b97c..3238e48 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -39,6 +39,9 @@ export default function ProfilePage() { const [success, setSuccess] = useState(''); const [validatingQrz, setValidatingQrz] = useState(false); const [qrzValidationResult, setQrzValidationResult] = useState<{valid?: boolean; error?: string} | null>(null); + // null = not yet loaded; auto-sync uploads need at least one station with a + // QRZ Logbook API key, so we warn when none has one. + const [stationsWithQrzKey, setStationsWithQrzKey] = useState(null); const router = useRouter(); const fetchUser = useCallback(async () => { @@ -74,6 +77,22 @@ export default function ProfilePage() { fetchUser(); }, [fetchUser]); + useEffect(() => { + const fetchStations = async () => { + try { + const response = await fetch('/api/stations'); + if (response.ok) { + const data = await response.json(); + const stations: Array<{ qrz_api_key?: string | null }> = data.stations || []; + setStationsWithQrzKey(stations.filter(s => s.qrz_api_key).length); + } + } catch { + // Non-fatal: the warning simply won't render. + } + }; + fetchStations(); + }, []); + const handleChange = (e: React.ChangeEvent) => { const { name, value } = e.target; setFormData(prev => ({ @@ -244,7 +263,7 @@ export default function ProfilePage() {
-

QRZ.com Callsign Lookup

+

QRZ.com XML Lookup Credentials

@@ -279,7 +298,9 @@ export default function ProfilePage() {
  • You need a valid QRZ.com account to use callsign lookup
  • Enter your QRZ.com username and password above
  • This enables automatic lookup of callsign information when adding contacts
  • -
  • Your credentials are stored securely and only used for lookups
  • +
  • These credentials are used for callsign lookups only — logbook + upload/download uses each station's QRZ Logbook API key + (configured in station settings)
  • Note: QRZ.com subscription may be required for full XML API access. @@ -340,10 +361,22 @@ export default function ProfilePage() { QRZ Logbook Sync

    - When enabled, new contacts will automatically sync to your QRZ.com logbook. + When enabled, new contacts will automatically sync to your QRZ.com logbook + using the QRZ Logbook API key configured on each station. You can also manually sync individual contacts or bulk sync from the contacts page.

    + + {formData.qrz_auto_sync && stationsWithQrzKey === 0 && ( +
    +

    + Auto-sync will not upload anything: no station has a + QRZ Logbook API key configured. Add one under{' '} + station settings — + the key is issued on the QRZ.com Logbook settings page. +

    +
    + )}
    diff --git a/src/app/stations/[id]/edit/page.tsx b/src/app/stations/[id]/edit/page.tsx index 381aa0b..152f056 100644 --- a/src/app/stations/[id]/edit/page.tsx +++ b/src/app/stations/[id]/edit/page.tsx @@ -807,7 +807,7 @@ export default function EditStationPage({ params }: { params: Promise<{ id: stri
    - +
    QRZ.com API documentation - . This API key is used for logbook sync operations. + . Required for QRZ logbook upload/download sync — + the user-level QRZ username/password only covers callsign lookups.

    diff --git a/src/app/stations/new/page.tsx b/src/app/stations/new/page.tsx index 8865123..edf2769 100644 --- a/src/app/stations/new/page.tsx +++ b/src/app/stations/new/page.tsx @@ -577,7 +577,7 @@ export default function NewStationPage() {
    - + QRZ.com API documentation + . Required for QRZ logbook upload/download sync.

    diff --git a/src/app/sync/page.tsx b/src/app/sync/page.tsx new file mode 100644 index 0000000..b97cad9 --- /dev/null +++ b/src/app/sync/page.tsx @@ -0,0 +1,216 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Loader2, ArrowLeft, RefreshCw, Upload, Download } from 'lucide-react'; +import Navbar from '@/components/Navbar'; +import { useUser } from '@/contexts/UserContext'; + +interface SyncLogEntry { + log_key: string; + service: 'qrz' | 'lotw' | 'eqsl'; + direction: 'upload' | 'download'; + trigger: 'manual' | 'auto' | 'cron'; + status: string; + started_at: string; + completed_at?: string | null; + qso_count?: number | null; + success_count?: number | null; + matched_count?: number | null; + error_message?: string | null; + station_id?: number | null; + station_callsign?: string | null; + details?: Record | null; +} + +export default function SyncActivityPage() { + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(''); + + const { user, loading: userLoading } = useUser(); + const router = useRouter(); + + const loadLogs = useCallback(async () => { + try { + const response = await fetch('/api/sync/logs?limit=100'); + if (!response.ok) { + throw new Error('Failed to load sync activity'); + } + const data = await response.json(); + setLogs(data.logs || []); + setError(''); + } catch (err) { + console.error('Failed to load sync activity:', err); + setError('Failed to load sync activity'); + } finally { + setLoading(false); + setRefreshing(false); + } + }, []); + + useEffect(() => { + if (userLoading) return; + if (!user) { + router.push('/login'); + return; + } + loadLogs(); + }, [user, userLoading, router, loadLogs]); + + const handleRefresh = () => { + setRefreshing(true); + loadLogs(); + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleString(); + }; + + const getStatusBadge = (status: string) => { + const variants: Record = { + completed: 'default', + processing: 'secondary', + failed: 'destructive', + pending: 'secondary' + }; + return {status}; + }; + + const getServiceBadge = (service: string) => { + return ( + + {service} + + ); + }; + + const resultSummary = (log: SyncLogEntry) => { + if (log.error_message) { + return {log.error_message}; + } + const parts: string[] = []; + if (log.direction === 'upload' && log.success_count != null) { + parts.push(`${log.success_count} uploaded`); + } + if (log.direction === 'download' && log.matched_count != null) { + parts.push(`${log.matched_count} confirmed`); + } + return parts.length > 0 ? {parts.join(', ')} : '-'; + }; + + if (loading) { + return ( +
    + + + + Back to Dashboard + + + } /> +
    +
    + +
    +
    +
    + ); + } + + return ( +
    + + + + Back to Dashboard + + + } /> +
    +
    + {error && ( + + {error} + + )} + + + +
    + Sync Activity + + QRZ and LoTW upload/download runs across all your stations — including failures + +
    + +
    + + {logs.length === 0 ? ( +
    +

    No sync activity yet. Runs appear here once QSOs sync to QRZ or LoTW.

    +
    + ) : ( +
    + + + + Service + Direction + Station + Trigger + QSOs + Status + Started + Result + + + + {logs.map((log) => ( + + {getServiceBadge(log.service)} + + + {log.direction === 'upload' ? ( + + ) : ( + + )} + {log.direction} + + + {log.station_callsign || '-'} + {log.trigger} + {log.qso_count ?? '-'} + {getStatusBadge(log.status)} + {formatDate(log.started_at)} + {resultSummary(log)} + + ))} + +
    +
    + )} +
    +
    +
    +
    +
    + ); +} diff --git a/src/components/LotwSyncIndicator.tsx b/src/components/LotwSyncIndicator.tsx index fe38657..a561c8c 100644 --- a/src/components/LotwSyncIndicator.tsx +++ b/src/components/LotwSyncIndicator.tsx @@ -6,7 +6,7 @@ import { useState } from 'react'; interface LotwSyncIndicatorProps { // Upload status - lotwQslSent?: string; // 'Y', 'N', 'R' (Yes, No, Requested) + lotwQslSent?: string; // 'Y', 'N', 'R', 'M', 'I' (Yes, No, Requested, Modified, Ignored) // Download/confirmation status lotwQslRcvd?: string; // 'Y', 'N' (Yes, No) @@ -125,6 +125,10 @@ export default function LotwSyncIndicator({ return { status: 'uploaded', color: 'text-ok', icon: Upload, tooltip: 'Uploaded to LoTW' }; } else if (lotwQslSent === 'R') { return { status: 'requested', color: 'text-warn', icon: Clock, tooltip: 'Upload requested' }; + } else if (lotwQslSent === 'M') { + return { status: 'modified', color: 'text-warn', icon: Upload, tooltip: 'Modified — pending re-upload to LoTW' }; + } else if (lotwQslSent === 'I') { + return { status: 'ignored', color: 'text-fg-2', icon: Upload, tooltip: 'Excluded from LoTW sync (unsupported propagation mode)' }; } else { return { status: 'not-uploaded', color: 'text-bad', icon: Upload, tooltip: 'Not uploaded to LoTW' }; } diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 6d2eac7..d164a13 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -44,6 +44,7 @@ const MORE_LINKS = [ { href: '/qsl-cards', label: 'QSL Cards' }, { href: '/adif', label: 'ADIF Import/Export' }, { href: '/lotw', label: 'LoTW' }, + { href: '/sync', label: 'Sync Activity' }, ]; export default function Navbar({ actions }: NavbarProps) { diff --git a/src/components/QRZSyncIndicator.tsx b/src/components/QRZSyncIndicator.tsx index 906d235..cb2f667 100644 --- a/src/components/QRZSyncIndicator.tsx +++ b/src/components/QRZSyncIndicator.tsx @@ -4,7 +4,7 @@ import { Upload, Download, AlertCircle } from 'lucide-react'; interface QRZSyncIndicatorProps { // Upload status - qrzQslSent?: string; // 'Y', 'N', 'R' (Yes, No, Request failed) + qrzQslSent?: string; // 'Y', 'N', 'R', 'M', 'I' (Yes, No, Request failed, Modified, Ignored) qrzQslSentDate?: Date | string; // Date when sent to QRZ // Download/confirmation status @@ -39,11 +39,25 @@ export default function QRZSyncIndicator({ tooltip: `Uploaded to QRZ${dateText}` }; } else if (qrzQslSent === 'R') { - return { - status: 'error', - color: 'text-bad', - icon: AlertCircle, - tooltip: 'QRZ upload failed' + return { + status: 'error', + color: 'text-bad', + icon: AlertCircle, + tooltip: 'QRZ upload failed' + }; + } else if (qrzQslSent === 'M') { + return { + status: 'modified', + color: 'text-warn', + icon: Upload, + tooltip: 'Modified — pending re-upload to QRZ' + }; + } else if (qrzQslSent === 'I') { + return { + status: 'ignored', + color: 'text-fg-2', + icon: Upload, + tooltip: 'Excluded from QRZ sync' }; } else { return { diff --git a/src/lib/qrz-auto-sync.ts b/src/lib/qrz-auto-sync.ts index c36c6e8..c6880ea 100644 --- a/src/lib/qrz-auto-sync.ts +++ b/src/lib/qrz-auto-sync.ts @@ -9,7 +9,7 @@ import { User } from '@/models/User'; import { Contact } from '@/models/Contact'; import { Station } from '@/models/Station'; -import { syncContactToQrz } from '@/lib/qrz-sync-service'; +import { syncContactToQrz, writeSyncLog } from '@/lib/qrz-sync-service'; import { logger } from '@/lib/logger'; export async function autoSyncContactToQRZ(contactId: number, userId: number): Promise { @@ -45,7 +45,19 @@ export async function autoSyncContactToQRZ(contactId: number, userId: number): P 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. + // contact 'R'; record a sync-activity row so the failure is visible + // on the /sync page. + await writeSyncLog({ + user_id: userId, + station_id: station.id, + service: 'qrz', + direction: 'upload', + trigger: 'auto', + status: 'failed', + qso_count: 1, + error_message: outcome.error, + details: { contact_id: contact.id, callsign: contact.callsign }, + }); return; } } catch (error) { diff --git a/src/lib/qrz-sync-service.ts b/src/lib/qrz-sync-service.ts index 99a2e3f..93fab53 100644 --- a/src/lib/qrz-sync-service.ts +++ b/src/lib/qrz-sync-service.ts @@ -14,6 +14,16 @@ import { } from '@/lib/qrz'; import { query } from '@/lib/db'; import { logger } from '@/lib/logger'; +import { SyncLog, SyncTrigger } from '@/models/SyncLog'; + +// Record a sync_logs row; never let bookkeeping break the sync itself. +export async function writeSyncLog(data: Parameters[0]): Promise { + try { + await SyncLog.create(data); + } catch (error) { + logger.error('Failed to write sync log', error); + } +} export interface QrzSyncOutcome { status: 'sent' | 'confirmed' | 'skipped' | 'failed'; @@ -97,8 +107,10 @@ export interface QrzUploadSweepResult { export async function uploadPendingForStation( userId: number, station: StationData, - limit = 250 + limit = 250, + trigger: SyncTrigger = 'cron' ): Promise { + const startedAt = new Date(); const pending = await Contact.findQrzNotSent(userId, limit, station.id); const result: QrzUploadSweepResult = { processed: pending.length, @@ -119,6 +131,23 @@ export async function uploadPendingForStation( } } + // Log only runs that had work to do; hourly no-op sweeps would drown the feed. + if (result.processed > 0) { + await writeSyncLog({ + user_id: userId, + station_id: station.id, + service: 'qrz', + direction: 'upload', + trigger, + status: result.failed > 0 ? 'failed' : 'completed', + started_at: startedAt, + qso_count: result.processed, + success_count: result.sent + result.confirmed, + error_message: result.first_error, + details: { sent: result.sent, confirmed: result.confirmed, skipped: result.skipped, failed: result.failed }, + }); + } + return result; } @@ -136,8 +165,11 @@ export interface QrzDownloadResult { export async function downloadConfirmationsForStation( userId: number, station: StationData, - since?: string + since?: string, + trigger: SyncTrigger = 'cron' ): Promise { + const startedAt = new Date(); + if (!station.qrz_api_key) { return { success: false, @@ -155,6 +187,16 @@ export async function downloadConfirmationsForStation( const downloadResult = await downloadQSOsFromQRZ(station.qrz_api_key, effectiveSince); if (!downloadResult.success) { + await writeSyncLog({ + user_id: userId, + station_id: station.id, + service: 'qrz', + direction: 'download', + trigger, + status: 'failed', + started_at: startedAt, + error_message: downloadResult.error, + }); return { success: false, qsos_downloaded: 0, @@ -208,6 +250,21 @@ export async function downloadConfirmationsForStation( // modifications on the next run. await Station.updateQrzLastQslRcvdDate(station.id); + // Log only runs that fetched something; hourly empty fetches would drown the feed. + if (downloadResult.qsos.length > 0 || confirmationsFound > 0) { + await writeSyncLog({ + user_id: userId, + station_id: station.id, + service: 'qrz', + direction: 'download', + trigger, + status: 'completed', + started_at: startedAt, + qso_count: downloadResult.qsos.length, + matched_count: confirmationsFound, + }); + } + return { success: true, qsos_downloaded: downloadResult.qsos.length, diff --git a/src/models/SyncLog.ts b/src/models/SyncLog.ts new file mode 100644 index 0000000..2fba444 --- /dev/null +++ b/src/models/SyncLog.ts @@ -0,0 +1,77 @@ +import { query } from '@/lib/db'; + +export type SyncService = 'qrz' | 'lotw' | 'eqsl'; +export type SyncDirection = 'upload' | 'download'; +export type SyncTrigger = 'manual' | 'auto' | 'cron'; +export type SyncStatus = 'completed' | 'failed'; + +export interface SyncLogData { + id: number; + user_id: number; + station_id?: number | null; + service: SyncService; + direction: SyncDirection; + trigger: SyncTrigger; + status: SyncStatus; + started_at: Date; + completed_at?: Date | null; + qso_count?: number | null; + success_count?: number | null; + matched_count?: number | null; + error_message?: string | null; + details?: Record | null; +} + +export interface CreateSyncLogData { + user_id: number; + station_id?: number; + service: SyncService; + direction: SyncDirection; + trigger: SyncTrigger; + status: SyncStatus; + started_at?: Date; + qso_count?: number; + success_count?: number; + matched_count?: number; + error_message?: string; + details?: Record; +} + +export class SyncLog { + static async create(data: CreateSyncLogData): Promise { + const result = await query( + `INSERT INTO sync_logs ( + user_id, station_id, service, direction, trigger, status, + started_at, completed_at, qso_count, success_count, matched_count, + error_message, details + ) VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, NOW()), NOW(), $8, $9, $10, $11, $12) + RETURNING *`, + [ + data.user_id, + data.station_id ?? null, + data.service, + data.direction, + data.trigger, + data.status, + data.started_at ?? null, + data.qso_count ?? 0, + data.success_count ?? 0, + data.matched_count ?? 0, + data.error_message ?? null, + data.details ? JSON.stringify(data.details) : null, + ] + ); + return result.rows[0]; + } + + static async findByUserId(userId: number, limit = 50, offset = 0): Promise { + const result = await query( + `SELECT * FROM sync_logs + WHERE user_id = $1 + ORDER BY started_at DESC + LIMIT $2 OFFSET $3`, + [userId, limit, offset] + ); + return result.rows; + } +} From 34b662f86e15353512c96fd07115afd19b50a30a Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Mon, 13 Jul 2026 12:13:28 -0500 Subject: [PATCH 5/7] fix(qrz): address Copilot review feedback on #219 - Retry failed re-uploads with OPTION=REPLACE: infer a prior ship from qrz_qsl_sent_date so 'R' retries of modified QSOs don't get deduped as inserts and silently never update. - Push the station filter into SQL: findQrzSentNotConfirmed now takes stationId instead of loading all stations' rows and filtering in memory. - Flag re-upload on actual core-field changes: compare payload values against the stored contact instead of truthiness, so clearing a field triggers sync and no-op PUTs don't false-flag 'Y' rows to 'M'. - Reword qrz-auto-sync header: pre-flight skips only log, they don't mark the contact 'R'. Co-Authored-By: Claude Fable 5 --- src/app/api/contacts/[id]/route.ts | 13 ++++++++++++- src/lib/qrz-auto-sync.ts | 8 +++++--- src/lib/qrz-sync-service.ts | 14 +++++++++----- src/models/Contact.ts | 16 +++++++++++----- 4 files changed, 37 insertions(+), 14 deletions(-) diff --git a/src/app/api/contacts/[id]/route.ts b/src/app/api/contacts/[id]/route.ts index 9799504..6befe58 100644 --- a/src/app/api/contacts/[id]/route.ts +++ b/src/app/api/contacts/[id]/route.ts @@ -59,7 +59,18 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ // Core QSO fields changed: flag already-uploaded copies for re-upload // ('Y' → 'M', wavelog's modified marker) and auto-sync after the response. - if (data.callsign || data.datetime || data.frequency || data.mode || data.band) { + // 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)); diff --git a/src/lib/qrz-auto-sync.ts b/src/lib/qrz-auto-sync.ts index c36c6e8..2d49d17 100644 --- a/src/lib/qrz-auto-sync.ts +++ b/src/lib/qrz-auto-sync.ts @@ -2,9 +2,11 @@ // 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: every failure is logged (and recorded on the contact as -// qrz_qsl_sent='R' by the service) so callers can fire-and-forget inside -// next/server's after() without swallowing errors silently. +// 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'; diff --git a/src/lib/qrz-sync-service.ts b/src/lib/qrz-sync-service.ts index 99a2e3f..30289d3 100644 --- a/src/lib/qrz-sync-service.ts +++ b/src/lib/qrz-sync-service.ts @@ -36,7 +36,13 @@ export async function syncContactToQrz( return { status: 'skipped', message: 'Excluded from QRZ sync', contact }; } - const replace = contact.qrz_qsl_sent === 'M'; + // 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). @@ -166,10 +172,8 @@ export async function downloadConfirmationsForStation( // 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); - const stationContacts = unconfirmed - .filter(c => c.station_id === station.id) - .map(c => ({ ...c, station_callsign: station.callsign })); + const unconfirmed = await Contact.findQrzSentNotConfirmed(userId, undefined, station.id); + const stationContacts = unconfirmed.map(c => ({ ...c, station_callsign: station.callsign })); let confirmationsFound = 0; diff --git a/src/models/Contact.ts b/src/models/Contact.ts index 3491089..fdf02ae 100644 --- a/src/models/Contact.ts +++ b/src/models/Contact.ts @@ -424,19 +424,25 @@ export class Contact { ); } - static async findQrzSentNotConfirmed(userId: number, limit?: number): Promise { + 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; } From b199de155b0300e7bc6929da3c998009d7d148a4 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Mon, 13 Jul 2026 12:14:57 -0500 Subject: [PATCH 6/7] fix(cron): authenticate before env validation in unified sync route The detailed missing-env-var response was reachable by unauthenticated callers, letting them probe deployment configuration. Fail closed on a missing CRON_SECRET (generic message), enforce the Bearer token, then run the remaining env checks. Addresses Copilot review feedback on #221. Co-Authored-By: Claude Fable 5 --- src/app/api/cron/sync/route.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/app/api/cron/sync/route.ts b/src/app/api/cron/sync/route.ts index 8de4eb1..2cdac3d 100644 --- a/src/app/api/cron/sync/route.ts +++ b/src/app/api/cron/sync/route.ts @@ -103,6 +103,17 @@ async function callLotwRoute( export async function GET(request: NextRequest) { try { + // Auth gates first: fail closed when CRON_SECRET is unset and reject bad + // tokens before any other checks, so unauthenticated callers can't probe + // deployment configuration (and the cheapest check runs first). + if (!process.env.CRON_SECRET) { + console.error('CRON_SECRET is not configured; refusing cron request'); + return NextResponse.json({ error: 'Server configuration error' }, { status: 500 }); + } + if (!hasValidCronSecret(request)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + const requiredEnvVars = ['DATABASE_URL', 'JWT_SECRET', 'ENCRYPTION_SECRET']; const missingEnvVars = requiredEnvVars.filter(varName => !process.env[varName]); if (missingEnvVars.length > 0) { @@ -113,14 +124,6 @@ export async function GET(request: NextRequest) { }, { status: 500 }); } - if (!process.env.CRON_SECRET) { - console.error('CRON_SECRET is not configured; refusing cron request'); - return NextResponse.json({ error: 'CRON_SECRET not configured' }, { status: 500 }); - } - if (!hasValidCronSecret(request)) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - const qrzUpload: StationLegResult[] = []; const lotwUpload: StationLegResult[] = []; const qrzDownload: StationLegResult[] = []; From 76ce56792eb2d58ffe4ff96021d5ab470f614580 Mon Sep 17 00:00:00 2001 From: Patrick Burns Date: Mon, 13 Jul 2026 12:17:15 -0500 Subject: [PATCH 7/7] fix(sync): strip BOM from drizzle journal, guard NaN limit/offset - Remove the UTF-8 BOM (and CRLF endings) from drizzle/migrations/meta/_journal.json so strict JSON parsers and the Drizzle migrator can read it. - /api/sync/logs now falls back to defaults on non-numeric or negative limit/offset instead of passing NaN into SQL and returning a 500. Addresses Copilot review feedback on #222. Co-Authored-By: Claude Fable 5 --- drizzle/migrations/meta/_journal.json | 2 +- src/app/api/sync/logs/route.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json index 3a9fcf9..20b4f7c 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -1,4 +1,4 @@ -{ +{ "version": "7", "dialect": "postgresql", "entries": [ diff --git a/src/app/api/sync/logs/route.ts b/src/app/api/sync/logs/route.ts index 4e436af..841c84b 100644 --- a/src/app/api/sync/logs/route.ts +++ b/src/app/api/sync/logs/route.ts @@ -15,8 +15,12 @@ export async function GET(request: NextRequest) { const userId = typeof user.userId === 'string' ? parseInt(user.userId, 10) : user.userId; const { searchParams } = new URL(request.url); - const limit = Math.min(parseInt(searchParams.get('limit') || '50'), 200); - const offset = parseInt(searchParams.get('offset') || '0'); + // Fall back to defaults on non-numeric/negative input instead of passing + // NaN into LIMIT/OFFSET and 500ing. + const parsedLimit = parseInt(searchParams.get('limit') || '', 10); + const limit = Number.isNaN(parsedLimit) || parsedLimit < 1 ? 50 : Math.min(parsedLimit, 200); + const parsedOffset = parseInt(searchParams.get('offset') || '', 10); + const offset = Number.isNaN(parsedOffset) || parsedOffset < 0 ? 0 : parsedOffset; const result = await query( `SELECT * FROM (