Skip to content

fix(sync): correctness overhaul of QRZ + LoTW sync flows#185

Open
patrickrb wants to merge 3 commits intomainfrom
fix/qrz-lotw-sync-correctness
Open

fix(sync): correctness overhaul of QRZ + LoTW sync flows#185
patrickrb wants to merge 3 commits intomainfrom
fix/qrz-lotw-sync-correctness

Conversation

@patrickrb
Copy link
Copy Markdown
Owner

Summary

Both QRZ and LoTW sync paths had bugs causing silent data loss in production. This PR ports wavelog's mature behavior into nextlog and replaces the broken LoTW signer with a Vercel-compatible pure-Node implementation.

Critical bugs fixed (all silent failures):

  • QRZ download was pulling the full logbook every run. The MODSINCE param was misspelled (MODIFIEDSINCE) and OPTION was sent as two separate FormData fields, dropping TYPE:ADIF. Date filter now actually filters.
  • LoTW download was pulling the full QSL history every run. Param names had wrong underscores (qso_qsl_sinceqso_qslsince). Detail flags qso_qsldetail/qso_mydetail were missing, so location enrichment was silently lost.
  • Every Vercel cron LoTW upload was being rejected. The OpenSSL fallback was a stub: console.warn('not fully implemented') then return unsigned ADIF. Replaced with a pure-Node node-forge signer that produces wavelog-compatible .tq8 files (gzip + per-QSO RSA-SHA1 signature in <SIGN_LOTW_V2.0>). Includes runtime self-verify before upload.
  • Match logic was too loose (call+date+time only, ±5min): legitimate confirmations on the same callsign at the same minute on different bands false-matched. Now requires call+band+mode+station_callsign exact, ±15min, with satellite-mode rule (sat_name must agree).

Wavelog parity added: callsign normalization (_/), INTERNET/RPT prop_mode filter (TQSL ≥ 2.7.3 rejects), multipart upfile + <!-- .UPL. accepted --> response check, CRL helper (lotw.arrl.org/lotw/crl?serial=...), confirmation enrichment (state/county/CQZ/ITUZ/DXCC/grid), incremental download bookmark, robust validateLoTWCredentials.

Cross-service sync: when LoTW confirms a QRZ-uploaded QSO, sets qrz_qsl_sent='M' so the next QRZ sync re-uploads with OPTION=REPLACE. Mirrored for the QRZ→LoTW direction.

Infrastructure: migrations/sync_qrz_lotw_fixes.sql (idempotent), node-forge + @types/node-forge deps, vercel.json maxDuration: 300s on cron + LoTW routes (RSA-2048 signing in JS scales as ~5–15 ms/QSO).

Action required before deploying

  • Run migrations/sync_qrz_lotw_fixes.sql against the database
  • Update the certificate-upload UI to accept and persist a P12 password (the upload route now reads lotw_credentials.p12_password; existing rows have NULL and will sign with empty password — fine for most TQSL exports but won't work for password-protected ones)

Test plan

  • npm run build passes (verified locally)
  • npm run lint clean on changed files (verified locally)
  • Apply migration to a staging DB; confirm columns + CHECK constraints land
  • QRZ download smoke test: with since=<30 days ago>, inspect outbound request — confirm single OPTION value with TYPE:ADIF;STATUS:CONFIRMED;MODSINCE:.... Response should be incremental, not full logbook.
  • LoTW download smoke test: with a date filter, confirm response is filtered (was previously a no-op). Confirm matched contacts get state/county/CQZ/ITUZ/DXCC/grid populated.
  • LoTW upload smoke test: with one test QSO, run via the upload route and verify the QSO appears in LoTW "Recent Activity". The signer's self-verify will throw locally if the canonical sign-string is wrong, before LoTW gets a bad payload.
  • Cross-sync: confirm a QSO in LoTW that QRZ already shipped → check qrz_qsl_sent='M' is set and the next QRZ sync re-uploads with REPLACE.

Follow-ups (not in this PR)

  • Unit tests for the signer canonicalization + matchers. Project uses Playwright (e2e only); adding vitest is its own deps decision. Self-verify in buildSignedTq8 is the runtime safety net for now.
  • Certificate-upload UI changes to capture the P12 password (route plumbing is in place, just needs the form field).

🤖 Generated with Claude Code

Both sync paths had bugs causing silent data loss in production:

QRZ download
- OPTION param: send a single value with `;` separators, not two
  separate fields (FormData duplicates lose `TYPE:ADIF`)
- MODSINCE (not MODIFIEDSINCE) — the date filter was being silently
  ignored, every "incremental" download pulled the full logbook
- add STATUS:CONFIRMED to limit payload, extract APP_QRZLOG_STATUS
- new matchQRZConfirmation: call+band+mode+station_callsign exact
  + ±15min tolerance (was call+date+time only with ±5min)

LoTW download
- URL params: qso_qslsince / qso_qslbefore (no underscores) — date
  filter was a no-op
- add qso_qsldetail/qso_mydetail so LoTW returns enriched location
  fields (state, county, CQZ, ITUZ, DXCC, grid)
- match: require call+band+mode+station_callsign exact, ±15min,
  proper UTC parsing, satellite-mode rule (sat_name must agree)
- on confirmation: enrich state/cnty/cqz/ituz/dxcc/country/grid;
  persist stations.lotw_last_qsl_rcvd_date for incremental fetches

LoTW upload (.tq8 signing)
- replace the OpenSSL fallback stub (which logged a warning then
  returned UNSIGNED ADIF — every Vercel cron upload was rejected by
  LoTW) with a pure-Node node-forge implementation that produces
  wavelog-compatible .tq8 files (gzip + per-QSO RSA-SHA1 signature
  in <SIGN_LOTW_V2.0>, with the canonical sign-string echoed in
  <SIGNDATA>); runtime self-verify before each upload
- multipart `upfile` POST + check for `<!-- .UPL. accepted -->`
- filter unsupported prop_modes (INTERNET, RPT) — flagged 'I'
- normalize callsigns (W1AW_P → W1AW/P)

Cross-service sync
- when LoTW confirms a QRZ-uploaded QSO, set qrz_qsl_sent='M' so
  the next QRZ sync re-uploads with OPTION=REPLACE; mirrored for
  the QRZ→LoTW direction

Other
- validateLoTWCredentials now actually parses the response body
  (the old response.url check always returned true)
- new checkLotwCertCrl helper queries lotw.arrl.org/lotw/crl?serial=
- vercel.json: maxDuration 300s on cron + LoTW routes

Migration: migrations/sync_qrz_lotw_fixes.sql adds p12_password,
cert_serial, crl_status to lotw_credentials; lotw_last_qsl_rcvd_date
to stations; prop_mode/sat_name/band_rx/freq_rx/iota to contacts;
CHECK constraints on the QSL-sent enums.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented May 9, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
nodelog Ready Ready Preview, Comment May 9, 2026 11:33pm

Request Review

Discovered while applying to local + prod that:
1. Some envs initialized contacts without qrz_qsl_sent / qrz_qsl_rcvd
   (those were added by the in-app /install route, not the base schema).
   Add them with IF NOT EXISTS to make the migration self-sufficient.
2. The PG 18+ syntax `CREATE TRIGGER IF NOT EXISTS` in the existing
   postgres-lotw-migration.sql breaks on PG 15/17. Added a one-line
   transform in scripts/run-migration.mjs to rewrite it as DROP+CREATE
   so the legacy file works without editing it.

scripts/run-migration.mjs is a small Node runner that takes
DATABASE_URL via env and a SQL file via argv; it wraps everything in
a transaction and rolls back on failure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The signer needs the P12 password to decrypt the private key. Until
now the certificate-upload route stored only the .p12 file blob, so
buildSignedTq8 had to assume an empty password — fine for unprotected
TQSL exports, broken for password-protected ones.

API: /api/lotw/certificate now accepts an optional p12_password form
field. Before insert, it parses the P12 with node-forge using that
password (via readCertMetadata). Bad password / corrupt file fails
fast with a clear error rather than landing an unusable cert in the DB.
We also persist cert_serial and cert_expires_at extracted from the
parse, populating columns the migration just added.

UI: both upload forms got a password input with show/hide toggle.
Helper text explains the password is required to sign uploads, stored
encrypted, and never returned to the browser. Empty is accepted for
TQSL exports without a password.

Side fix: the LoTW dashboard form was already broken — it didn't send
cert_name (required by the API). Added a Certificate Name field there
too, and restructured the 3-col grid into a 2x2 layout so all four
fields fit cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant