Skip to content

Teams Pipeline

Downloads team rosters from Sportlink, creates team posts in Rondo Club, and links members to teams via dated work history.

Runs weekly on Sunday at 6:00 AM (Amsterdam time).

Terminal window
scripts/sync.sh teams # Production (with locking + email report)
node pipelines/sync-teams.js --verbose # Direct execution (verbose)
pipelines/sync-teams.js
├── Step 1: steps/download-teams-from-sportlink.js → data/rondo-sync.sqlite
├── Step 2: steps/submit-rondo-club-teams.js → Rondo Club API (teams)
├── Step 3: steps/submit-rondo-club-work-history.js → Rondo Club API (quick work_history)
└── Step 4: steps/submit-rondo-club-player-history.js → Sportlink details + Rondo Club API (dated work_history)

Script: steps/download-teams-from-sportlink.js Function: runTeamDownload({ logger, verbose })

  1. Launches headless Chromium via Playwright
  2. Logs into Sportlink Club
  3. Calls Sportlink API to fetch team data:
    • UnionTeams (KNVB-assigned teams, preferred source)
    • ClubTeams (club-assigned teams, fallback)
  4. For each team, fetches the team roster:
    • Players with their roles (Speler, Keeper, etc.)
    • Staff members with their roles (Trainer, Leider, etc.)
  5. Stores team metadata in data/rondo-sync.sqlite → rondo_club_teams:
    • team_name, sportlink_id, game_activity, gender, player_count, staff_count
  6. Stores team membership in data/rondo-sync.sqlite → sportlink_team_members:
    • sportlink_team_id, sportlink_person_id, role_description

Output: { success, teamCount, memberCount, currentSportlinkIds }

currentSportlinkIds comes directly from validated UnionTeams and ClubTeams responses. Both pipelines pass this snapshot to the team sync; the accumulated tracking database must never be used as the current source list.

Rate limiting: 500ms-1.5s random jitter between member scrapes.

Script: steps/submit-rondo-club-teams.js Function: runSync({ logger, verbose, force, currentSportlinkIds })

  1. Reads all teams from data/rondo-sync.sqlite → rondo_club_teams
  2. For each team where source_hash != last_synced_hash:
    • No rondo_club_id: POST /wp/v2/teams (create new team)
    • Has rondo_club_id: PUT /wp/v2/teams/{rondo_club_id} (update existing)
  3. Stores returned WordPress post ID as rondo_club_id
  4. Updates last_synced_hash on success
  5. Detects tracked teams missing from the fresh, complete, non-empty Sportlink snapshot. Missing teams are excluded from create/update, including force runs.
  6. Verifies each missing team’s WordPress ID, post type, title and publicteamid. Untracked teams are never automatically removed.
  7. Reads all accessible non-deleted persons, including former members and unpublished records. Trashed persons remain outside REST access; their original team references stay recoverable because archived team posts are retained. A missing team with an unended role is deferred and reported for source-history reconciliation; disappearance alone never invents an end date.
  8. Converts historical references to team_id: null, team_name_text and entity_type: external_team, retaining roles, dates and other row values. Each save is independently read back, followed by a complete scan of accessible references. Verification also accounts for the existing former-member lifecycle: saving history can close other still-current roles at the membership end date (or today when that date is unavailable). Any other difference stops archival.
  9. Moves only verified, unreferenced teams to draft, verifies their status and then removes their local sync mapping. The team post is retained; normal published-team lists no longer include it.

Output: { total, synced, created, updated, skipped, archived, errors }

Team renames: Uses sportlink_id as the conflict key, so renamed teams update the existing WordPress post instead of creating duplicates.

Run on the production sync host as the service account, under the teams lock:

Terminal window
cd /home/rondo
sudo -u rondo flock -n /home/rondo/.sync-teams.lock node tools/retire-missing-teams.js
sudo -u rondo flock -n /home/rondo/.sync-teams.lock node tools/retire-missing-teams.js --apply

Both commands fetch only the fresh Sportlink team lists, without downloading rosters or changing the local source cache. Preview reports candidates and historical reference counts without writing to WordPress; --apply preserves history and archives the verified teams. Failed, malformed and empty snapshots cannot trigger cleanup. Individual roster failures do not make an existing team disappear from the source list.

Script: steps/submit-rondo-club-work-history.js Function: runSync({ logger, verbose, force })

  1. Reads team membership from sportlink_team_members joined with rondo_club_teams and rondo_club_members
  2. Compares current team assignments against rondo_club_work_history table
  3. For each member with changes:
    • Fetches current work_history ACF repeater from Rondo Club
    • Adds new team assignments (creates new rows in the repeater)
    • Ends removed assignments (sets is_current: false, end_date: today)
    • Only modifies sync-created entries (manual entries are preserved)
  4. Sends PUT /wp/v2/people/{rondo_club_id} with updated work_history repeater
  5. Skips members without a rondo_club_id

Output: { total, synced, created, ended, skipped, errors }

Important: The work history sync only touches entries it previously created (tracked via rondo_club_work_history table). Manually added work history entries in Rondo Club are left untouched.

Section titled “Step 4: Enrich Work History with Sportlink Dates”

Script: steps/submit-rondo-club-player-history.js Function: runSync({ logger, verbose, force })

The fast team-roster response used in step 1 does not expose the start and end dates of a team relation. This step immediately follows the quick work-history sync and:

  1. Compares each member’s current team signature with the last successful detail sync
  2. Skips members whose team data is unchanged
  3. Fetches changed memberships from Sportlink’s member-details endpoint
  4. Maps RelationStart and RelationEnd to the corresponding work-history row
  5. Reconciles the existing row instead of creating a dated duplicate

Before mapping, lib/team-membership-periods.js preserves an explicitly ACTIVE relation with no RelationEnd, even when SeasonDescription names an older season. That label can refer to the season in which a continuing staff role started; it is not evidence that the role ended. This prevents current team assignments and dependent guest passes from disappearing. An import also repairs an earlier inferred end on the matching team, role and start date; replaying it performs no additional write.

For rows without an explicit active status, the normalizer interprets an empty RelationEnd using SeasonDescription. A recognized closed season such as 2025/'26 gets June 30, 2026 as its inferred end date. The cutoff follows Europe/Amsterdam. Explicit source end dates remain authoritative; current/future seasons, unrecognized seasons, and contradictory start dates are left unchanged. A continuing current-season copy with the same team, role, and start date takes precedence over an inferred historical end. Multiple historical copies retain the latest season. The normal reconciliation closes the existing current row and keeps unrelated and already ended history.

Sportlink’s Status: INACTIVE takes precedence over an empty RelationEnd: the importer writes is_current: false and preserves the unknown end date. A status-only change is reconciled in place, retaining the team link and start date. Rondo excludes explicitly inactive undated roles from current team membership, counts, fee matching and staff views. Replaying the same source does not write again.

The standalone monthly player-history run remains a safety net, but new team assignments no longer wait for it before their dates appear in Rondo Club.

Output: { total, downloaded, synced, created, reconciled, skippedUnchanged, skippedQuarantined, errors }

Rondo Club FieldSportlink SourceNotes
titleTeamName / NamePost title
acf.publicteamidPublicTeamIdSportlink team identifier
acf.activiteitGameActivityDescription”Veld” or “Zaal”
acf.genderGenderMannen→male, Vrouwen→female, Gemengd→skipped

The ACF work_history is a repeater field on person posts:

Repeater FieldSourceNotes
teamrondo_club_teams.rondo_club_idWordPress post ID of the team
job_titlerole_description or fallback”Speler”, “Keeper”, “Trainer”, “Staflid”
is_currentComputedtrue if currently on team
start_dateRelationStart from member detailsThe quick roster step may temporarily use today; the detail pass immediately replaces it with Sportlink’s date
end_dateRelationEnd from member detailsEmpty while current
DatabaseTableUsage
rondo-sync.sqliterondo_club_teamsTeam → WordPress ID mapping + metadata
rondo-sync.sqlitesportlink_team_membersRaw team roster data from Sportlink
rondo-sync.sqliterondo_club_work_historyTracks which work_history entries sync created
rondo-sync.sqliterondo_club_membersKNVB ID → Rondo Club ID lookup (for work history)
FlagEffect
--verboseDetailed per-team/per-member logging
--forceSkip change detection, sync all teams and fetch player history for every member
  • Team download failure allows cached team updates but never cleanup; standalone submission without fresh source IDs also skips cleanup
  • Individual team sync failures don’t stop the pipeline
  • Work history sync skips members not yet in Rondo Club (counted as skipped)
  • Player-history detail sync skips unchanged members and reports quarantined or failed Sportlink detail records
  • All errors collected in summary report
FilePurpose
pipelines/sync-teams.jsPipeline orchestrator
steps/download-teams-from-sportlink.jsSportlink team scraping (Playwright)
steps/submit-rondo-club-teams.jsRondo Club team API sync
steps/submit-rondo-club-work-history.jsRondo Club work history API sync
steps/submit-rondo-club-player-history.jsSportlink relation-date enrichment and reconciliation
steps/prepare-rondo-club-teams.jsTeam data preparation
lib/rondo-club-db.jsSQLite operations
lib/rondo-club-client.jsRondo Club HTTP client
lib/sportlink-login.jsSportlink authentication