Skip to content

Reverse Sync (Rondo Club → Sportlink)

Detects field changes made in Rondo Club and pushes them back to Sportlink Club via browser automation.

Status: currently disabled. The detection and sync code is complete but needs testing and re-enabling.

When enabled: hourly via scripts/sync.sh reverse.

Terminal window
scripts/sync.sh reverse # Production (with locking + email report)
node tools/detect-rondo-club-changes.js --verbose # Detection only (no sync)
node pipelines/reverse-sync.js --verbose # Contact field sync only

The reverse sync operates in two phases:

Phase 1: Change Detection (hourly)
Rondo Club API → lib/detect-rondo-club-changes.js → rondo_club_change_detections table
Phase 2: Sync to Sportlink (when unsynced changes exist)
rondo_club_change_detections → lib/reverse-sync-sportlink.js → Sportlink Browser (Playwright)
FieldRondo Club ACF LocationSportlink PageSportlink SelectorType
emailcontact_info repeater (type=email)/generalinput[name="Email"]text
email2contact_info repeater (type=email2)/generalinput[name="Email2"]text
mobilecontact_info repeater (type=mobile)/generalinput[name="Mobile"]text
phonecontact_info repeater (type=phone)/generalinput[name="Phone"]text
datum_vogdatum-vog/otherinput[name="Remarks8"]text
freescout_idfreescout-id/otherinput[name="Remarks3"]text
financiele_blokkadefinanciele-blokkade/financialinput[name="HasFinancialTransferBlockOwnClub"]checkbox

Script: lib/detect-rondo-club-changes.js Function: detectChanges(options)

  1. Read last_detection_at from reverse_sync_state table
  2. Query Rondo Club API for members modified since that timestamp: GET /wp/v2/people?modified_after=...
  3. For each modified member:
    • Look up local record in rondo_club_members
    • Skip if sync_origin == 'sync_sportlink_to_rondo_club' (avoids infinite loops — this change came from forward sync)
    • Compute SHA-256 hash of all tracked fields
    • Compare to stored tracked_fields_hash
    • If hash differs, compare individual fields to find which ones changed
    • Log each changed field to rondo_club_change_detections table
  4. Update last_detection_at in reverse_sync_state

The sync_origin column on rondo_club_members tracks who last modified the record:

ValueMeaning
user_editManual edit in Rondo Club UI
sync_sportlink_to_rondo_clubForward sync (Sportlink → Rondo Club)
sync_rondo_club_to_sportlinkReverse sync (Rondo Club → Sportlink)

Change detection skips members where sync_origin == 'sync_sportlink_to_rondo_club' because those changes came from Sportlink and don’t need to be pushed back.

Script: lib/reverse-sync-sportlink.js Functions: runReverseSync(options) (contact fields) / runReverseSyncMultiPage(options) (all fields)

  1. Fetch unsynced changes from rondo_club_change_detections (where synced_at IS NULL)
  2. Group changes by member and by Sportlink page (general / other / financial)
  3. Launch headless Chromium and log into Sportlink
  4. For each member with changes:
    • Navigate to the appropriate Sportlink page(s)
    • Enter edit mode
    • Fill each changed field (text input or checkbox)
    • Save the form
    • Verify saved values by reading them back
    • Mark changes as synced (UPDATE ... SET synced_at = ...)
    • Update {field}_sportlink_modified timestamp in rondo_club_members
    • Set sync_origin = 'sync_rondo_club_to_sportlink'
  5. Wait 1-2 seconds between members (rate limiting with random jitter)
  • Up to 3 attempts per member with exponential backoff (1s, 3s, 7s)
  • Session timeout detection: if redirected to Sportlink login page, re-authenticate and retry
  • Fail-fast for multi-page: if any page fails, no timestamps are updated; all changes remain unsynced for retry on next run

Script: lib/conflict-resolver.js Function: resolveFieldConflicts(member, sportlinkData, rondoClubData, db, logger)

When both Sportlink and Rondo Club have modified the same field, conflict resolution determines which value wins.

Each tracked field has two timestamp columns in rondo_club_members:

  • {field}_rondo_club_modified — when forward sync last wrote this field to Rondo Club
  • {field}_sportlink_modified — when reverse sync last wrote this field to Sportlink

Resolution logic:

ConditionWinnerReason
Both timestamps NULLSportlinkDefault (forward sync is primary)
Only Sportlink has timestampSportlinkHas modification history
Only Rondo Club has timestampRondo ClubHas modification history
Both have timestamps, within 5 secondsSportlinkGrace period (clock drift tolerance)
Both have timestamps, Rondo Club >5s newerRondo ClubMore recent edit
Both have timestamps, Sportlink >5s newerSportlinkMore recent edit
Values match (timestamps differ)NeitherNo conflict (same data)

The 5-second grace period handles minor clock differences between systems.

All resolutions are logged to the conflict_resolutions table:

SELECT knvb_id, field_name, sportlink_value, rondo_club_value,
winning_system, resolution_reason, resolved_at
FROM conflict_resolutions
ORDER BY resolved_at DESC;

Audit log of all detected changes.

ColumnDescription
knvb_idMember KNVB ID
field_nameWhich field changed
old_valuePrevious value
new_valueNew value
detected_atWhen the change was detected
rondo_club_modified_gmtWordPress modification timestamp
detection_run_idID of the detection run
synced_atWhen change was synced to Sportlink (NULL = not yet synced)

Singleton table tracking detection progress.

ColumnDescription
idAlways 1
last_detection_atTimestamp of last detection run
updated_atWhen this record was last updated

Audit log of conflict resolution decisions.

ColumnDescription
knvb_idMember KNVB ID
field_nameConflicting field
sportlink_value / rondo_club_valueValues from each system
sportlink_modified / rondo_club_modifiedTimestamps from each system
winning_systemWhich system’s value was kept
resolution_reasonWhy (e.g., rondo_club_newer, grace_period_sportlink_wins)

Per-field modification timestamps added to the existing table:

Column PatternExample
{field}_rondo_club_modifiedemail_rondo_club_modified
{field}_sportlink_modifiedemail_sportlink_modified
sync_originLast edit source
tracked_fields_hashHash for quick change detection
FilePurpose
lib/detect-rondo-club-changes.jsChange detection (Rondo Club API → SQLite)
lib/reverse-sync-sportlink.jsSync to Sportlink (SQLite → Sportlink browser)
lib/conflict-resolver.jsTimestamp-based conflict resolution
lib/sync-origin.jsConstants and utilities for sync origin tracking
tools/detect-rondo-club-changes.jsCLI for running detection standalone
pipelines/reverse-sync.jsCLI for running contact field sync
steps/reverse-sync-contact-fields.jsCLI alias for contact field sync
  1. Forward sync downloads member email from Sportlink, writes to Rondo Club → sets sync_origin = 'sync_sportlink_to_rondo_club'
  2. User edits email in Rondo Club UI → WordPress updates modified_gmt
  3. Change detection (hourly): queries Rondo Club API for recently modified members
    • Finds the member, sees sync_origin != 'sync_sportlink_to_rondo_club' (user edit happened after)
    • Computes tracked fields hash, detects email changed
    • Logs to rondo_club_change_detections: email, old value, new value
  4. Reverse sync: reads unsynced changes from rondo_club_change_detections
    • Opens Chromium, logs into Sportlink
    • Navigates to member’s /general page
    • Enters edit mode, fills email field, saves
    • Verifies saved value
    • Marks change as synced, updates email_sportlink_modified, sets sync_origin = 'sync_rondo_club_to_sportlink'
  5. Next forward sync: downloads email from Sportlink (now matches Rondo Club value) → no change detected → no API call