Skip to content

People Pipeline

Syncs member data from Sportlink Club to Laposta email marketing lists and Rondo Club, including photos.

Runs 4x daily at 8:00, 11:00, 14:00, and 17:00 (Amsterdam time).

Terminal window
scripts/sync.sh people # Production (with locking + email report)
node pipelines/sync-people.js --verbose # Direct execution (verbose)
pipelines/sync-people.js
├── Step 1: steps/download-data-from-sportlink.js → data/laposta-sync.sqlite, data/rondo-sync.sqlite
├── Step 2: steps/prepare-laposta-members.js → data/laposta-sync.sqlite (members table)
├── Step 3: steps/submit-laposta-list.js → Laposta API
├── Step 4: steps/submit-rondo-club-sync.js → Rondo Club API (members + parents + birthdate)
├── Step 5: steps/download-photos-from-api.js → photos/ directory
├── Step 6: steps/upload-photos-to-rondo-club.js → Rondo Club API (media)
└── Step 7: lib/reverse-sync-sportlink.js → Sportlink Club (currently disabled)

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

  1. Launches headless Chromium via Playwright
  2. Logs into https://club.sportlink.com/ using lib/sportlink-login.js
  3. Handles TOTP 2FA with lib/totp.js
  4. Calls Sportlink SearchMembers API to get all members
  5. Calls MemberHeader API for each member (photo URLs, financial block status)
  6. Stores raw JSON results in data/laposta-sync.sqlitesportlink_runs table
  7. Upserts member data into data/rondo-sync.sqliterondo_club_members table

Output: { success, memberCount }

Databases written:

  • data/laposta-sync.sqlite: sportlink_runs (full JSON dump)
  • data/rondo-sync.sqlite: rondo_club_members (per-member data with source_hash)

Script: steps/prepare-laposta-members.js Function: runPrepare({ logger, verbose })

  1. Reads latest Sportlink results from data/laposta-sync.sqlitesportlink_runs
  2. Applies field mappings from config/field-mapping.json to transform Sportlink fields to Laposta custom fields
  3. Handles parent extraction: creates separate list entries for EmailAddressParent1 / EmailAddressParent2
  4. Deduplicates parent entries across lists
  5. Computes source_hash for each member (SHA-256 of email + custom fields)
  6. Upserts into data/laposta-sync.sqlitemembers table

Output: { success, lists: [{ total }], excluded }

Key transformations (configured in config/field-mapping.json):

  • GenderCode: “Male” → “M”, “Female” → “V”
  • UnionTeams: comma-separated team list
  • Parent entries: creates person entries with oudervan (child names) field

Script: steps/submit-laposta-list.js Function: runSubmit({ logger, verbose, force })

  1. Reads members from data/laposta-sync.sqlite where source_hash != last_synced_hash
  2. For each changed member, calls Laposta API:
    • New member (no existing Laposta record): POST /api/v2/member
    • Updated member: POST /api/v2/member with update
  3. Updates last_synced_hash on success
  4. Rate limited: 2s delay between API calls

Output: { lists: [{ index, listId, total, synced, added, updated, errors }] }

CLI flags:

  • --force: Sync all members regardless of hash (ignores change detection)

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

  1. Reads members from data/rondo-sync.sqlite where source_hash != last_synced_hash
  2. Reads free fields from sportlink_member_free_fields table (FreeScout ID, VOG date, financial block)
  3. Builds WordPress API payload with ACF fields (see field mappings below)
  4. For each changed member:
    • No rondo_club_id: POST /wp/v2/people (create new person)
    • Has rondo_club_id: PUT /wp/v2/people/{rondo_club_id} (update existing)
  5. Stores returned WordPress post ID as rondo_club_id
  6. Updates last_synced_hash on success
  7. Then processes parent members (from rondo_club_parents table):
    • Identified by email (no KNVB ID)
    • Linked to children via ACF relationships field
    • Deduplicated across multiple children’s parent fields

Output: { total, synced, created, updated, skipped, errors, parents: { ... } }

Important: first_name and last_name are required on every PUT request, even for partial ACF updates.

Birthday field: As of v2.3, birthdate is synced as acf.birthdate (YYYY-MM-DD) on the person record during Step 4. Previous versions used a separate important_date post type which is now deprecated.

Script: steps/download-photos-from-api.js Function: runPhotoDownload({ logger, verbose })

  1. Queries rondo_club_members for members with photo_state = 'pending_download'
  2. If none pending, returns early (no browser launched)
  3. Launches headless Chromium via Playwright
  4. Logs into Sportlink Club
  5. For each pending member: navigates to /member/member-details/{knvbId}/other, captures MemberHeader API response
  6. Extracts signed photo URL via parseMemberHeaderResponse() from lib/photo-utils.js
  7. Downloads photo from CDN URL via downloadPhotoFromUrl() from lib/photo-utils.js
  8. Saves to photos/{knvb_id}.{ext}
  9. Updates photo_state to 'downloaded'
  10. Rate limited: 500ms-1.5s random jitter between members

Output: { success, total, downloaded, failed, errors }

Script: steps/upload-photos-to-rondo-club.js Function: runPhotoSync({ logger, verbose })

  1. Queries rondo_club_members for photo_state = 'downloaded' or 'pending_upload'
  2. Uploads each photo to POST /wp-json/rondo/v1/people/{rondo_club_id}/photo (multipart form-data)
  3. Updates photo_state to 'synced' on success
  4. Also handles photo deletion: members with photo_state = 'pending_delete' get their Rondo Club photo removed
  5. Rate limited: 2s between uploads/deletes

Output: { upload: { synced, skipped, errors }, delete: { deleted, errors } }

Script: lib/reverse-sync-sportlink.js Function: runReverseSync({ logger, verbose })

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

See config/field-mapping.json for the complete mapping. Key fields:

Laposta FieldSportlink Source
(email)Email
voornaamFirstName
tussenvoegselInfix
achternaamLastName
geboortedatumDateOfBirth
teamUnionTeams
geslachtGenderCode (Male→M, Female→V)
relatiecodePublicPersonId (KNVB ID)
Rondo Club ACF FieldSource
first_nameFirstName
infixInfix (lowercased tussenvoegsel)
last_nameLastName
knvb-idPublicPersonId
genderGenderCode (Male→male, Female→female)
birth_yearYear from DateOfBirth
birthdateDateOfBirth (YYYY-MM-DD format, v2.3+)
contact_info (repeater)Email, Mobile, Telephone
addresses (repeater)StreetName + AddressNumber, ZipCode, City
lid-sindsMemberSince
leeftijdsgroepAgeClassDescription
type-lidTypeOfMemberDescription
freescout-idFrom sportlink_member_free_fields.freescout_id
datum-vogFrom sportlink_member_free_fields.vog_datum
financiele-blokkadeFrom sportlink_member_free_fields.has_financial_block
DatabaseTableUsage
laposta-sync.sqlitesportlink_runsRaw download results
laposta-sync.sqlitemembersPrepared Laposta members with hashes
laposta-sync.sqlitelaposta_fieldsCached field definitions
rondo-sync.sqliterondo_club_membersMember → WordPress ID mapping + hashes
rondo-sync.sqliterondo_club_parentsParent → WordPress ID mapping
rondo-sync.sqlitesportlink_member_free_fieldsFree fields (read by Step 4)
FlagEffect
--verboseDetailed per-member logging
--forceSkip change detection, sync all members
  • Each step runs in a try/catch; failures are logged but don’t stop the pipeline
  • Rondo Club sync failure is non-critical (Laposta sync still completes)
  • Photo download/upload failures are non-critical
  • All errors are collected and included in the email summary report
  • Exit code 1 if any errors occurred
FilePurpose
pipelines/sync-people.jsPipeline orchestrator
steps/download-data-from-sportlink.jsSportlink browser automation
steps/prepare-laposta-members.jsField transformation for Laposta
steps/submit-laposta-list.jsLaposta API sync
steps/submit-rondo-club-sync.jsRondo Club API sync (members + parents + birthdate)
steps/prepare-rondo-club-members.jsRondo Club member data preparation
steps/prepare-rondo-club-parents.jsParent extraction and dedup
steps/download-photos-from-api.jsPhoto download (Playwright)
steps/upload-photos-to-rondo-club.jsPhoto upload/delete
lib/photo-utils.jsShared photo helpers (MIME types, download, MemberHeader parsing)
config/field-mapping.jsonLaposta field mapping config
lib/laposta-db.jsLaposta SQLite operations
lib/rondo-club-db.jsRondo Club SQLite operations
lib/rondo-club-client.jsRondo Club HTTP client
lib/laposta-client.jsLaposta HTTP client
lib/sportlink-login.jsSportlink authentication