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: active. Runs every five minutes, syncing contact fields, address fields, administrative fields, and queued parent/guardian relationships back to Sportlink.

Every five minutes 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 independent tracks:

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)
Parent-slot track:
New relationships + audited parent e-mail replacements → parent_slot_sync_jobs
→ MemberParentalInfo editor → verified Sportlink parent slot
→ status callback to Rondo Club

Parent relationships use a separate incremental cursor and durable SQLite queue. One audited parent e-mail change can affect multiple children, so this track does not use the flat-field sync_origin shortcut.

New UI-created parent relationships queue a full parent slot containing the current name, primary e-mail address and optional phone number. Parent addresses are not part of this track because MemberParentalInfo has no address fields.

Existing Sportlink parent relationships are updated only from a pending Rondo profile-change audit entry containing an exact old-to-new e-mail replacement. A generic mismatch is ignored because a child slot may intentionally use the parent’s current email_2. The audit event is applied to every current child relationship; historical mappings and unrelated person modifications cannot authorize an overwrite.

Immediately before writing, the browser reads the child’s current MemberParentalInfo. For an audited replacement it must find exactly one slot containing the old e-mail and a compatible parent name. It changes only EmailAddressParent1/2; name and phone remain untouched and are verified after saving. If the old address is absent, duplicated or attached to a conflicting name, the job stays blocked for review. New relationship jobs retain their separate compatible-partial-or-empty-slot behavior and may fill all three fields.

After every child job for a parent has been resolved, the verified parent-slot writes complete the matching parent contact entries in Rondo’s profile-change audit. A callback failure is logged separately and never retries an already completed Sportlink write.

Jobs retry transient failures with bounded backoff. Two occupied slots become a visible blocked/error status in Rondo. Relationship removal cancels pending work but does not clear an already written Sportlink slot in version 1.

FieldRondo Club ACF FieldSportlink PageSportlink SelectorType
email_1email_1/generalinput[name="Email"]text
email_2email_2/generalinput[name="Email2"]text
mobile_1mobile_1/generalinput[name="Mobile"]text
mobile_2mobile_2/generalinput[name="Mobile2"]text
telephone_1telephone_1/generalinput[name="Phone"]text
telephone_2telephone_2/generalinput[name="Phone2"]text
FieldRondo Club ACF LocationSportlink PageSportlink SelectorType
street_nameaddresses repeater (Home row)/general (address section)input[name="StreetName"]text
house_numberaddresses repeater (Home row)/general (address section)input[name="AddressNumber"]text
house_number_additionaddresses repeater (Home row)/general (address section)input[name="AddressNumberAppendix"]text
postal_codeaddresses repeater (Home row)/general (address section)input[name="ZipCode"]text
cityaddresses repeater (Home row)/general (address section)input[name="City"]text
country_codeaddresses repeater (Home row)/general (address section)select[name="CountryCode"]select

Nieuwe en gewijzigde adresregels vereisen een niet-lege landnaam en ISO-landcode. Rondo gebruikt voor Nederlandse adressen standaard Nederland en NL, zodat de reverse-sync nooit een lege optie aan Sportlinks landenlijst aanbiedt.

FieldRondo Club ACF FieldSportlink PageSportlink SelectorType
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=...; transient 5xx, timeout, deadline, DNS, and connection-reset failures retry up to three times with 1s/2s/4s backoff, while permanent 4xx errors fail immediately
  3. For each modified member:
    • Skip contacts where person_type == 'contact'; contacts are local Rondo Club records and are never synced to Sportlink, even if stale data contains a KNVB ID
    • 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 active changes from rondo_club_change_detections (where both synced_at and superseded_at are NULL)
  2. Re-read each pending Rondo person and mark queued values that no longer match the current canonical field as superseded
  3. Group the remaining changes by member and by Sportlink page (general / other / financial)
  4. Launch headless Chromium and log into Sportlink
  5. 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'
  6. 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 recognizes both Sportlink’s legacy authentication path and the current idm.sportlink.com identity-provider host. If a silent token refresh lands on the dashboard instead of the requested member page, navigation retries that member page once before failing.
  • Fail-fast for multi-page: if any page fails, no timestamps are updated; all changes remain unsynced for retry on next run
  • A disabled Sportlink save button with a visible validation message is parked as action required for 24 hours. Rondo’s change log receives the repair reason and the operator receives one action email. The five-minute pipeline remains successful, so the parked item does not send a failure email every run. A daily retry resumes automatically after the underlying Sportlink data is repaired.

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)
superseded_atWhen a newer Rondo edit made the queued value obsolete
next_attempt_atEarliest retry time for a parked action-required change
last_errorLatest Sportlink validation reason
action_required_atWhen the operator was first asked to repair the item

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