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/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..20b4f7c 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -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 810463d..290a39a 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -348,6 +348,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/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..2cdac3d --- /dev/null +++ b/src/app/api/cron/sync/route.ts @@ -0,0 +1,262 @@ +// 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 { + // 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) { + console.error('Missing required environment variables:', missingEnvVars); + return NextResponse.json({ + error: 'Server configuration error', + details: `Missing environment variables: ${missingEnvVars.join(', ')}` + }, { status: 500 }); + } + + 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/src/app/api/sync/logs/route.ts b/src/app/api/sync/logs/route.ts new file mode 100644 index 0000000..841c84b --- /dev/null +++ b/src/app/api/sync/logs/route.ts @@ -0,0 +1,102 @@ +// 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); + // 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 ( + 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 2d49d17..b96f11a 100644 --- a/src/lib/qrz-auto-sync.ts +++ b/src/lib/qrz-auto-sync.ts @@ -11,7 +11,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 { @@ -47,7 +47,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 30289d3..f0a310d 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'; @@ -103,8 +113,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, @@ -125,6 +137,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; } @@ -142,8 +171,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, @@ -161,6 +193,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, @@ -212,6 +254,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; + } +} 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"]