Skip to content

People Pipeline

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

Sportlink members keep FirstName, Infix and LastName in Rondo’s first_name, infix and last_name. An empty source infix is sent explicitly so an old value can be cleared. Regular Laposta member lists retain their separate tussenvoegsel field.

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 1b: steps/download-inactive-members.js → deceased-member safety input
├── Step 2: steps/prepare-laposta-members.js → data/laposta-sync.sqlite (members table)
├── Step 3: steps/submit-laposta-list.js → Laposta API
├── Step 3b: steps/sync-deceased-members.js → Laposta unsubscribe reconciliation
├── Step 4: steps/submit-rondo-club-sync.js → Rondo Club API (members + parents + birthdate)
├── Step 4b: steps/sync-deceased-members.js → Rondo Club death dates
├── Step 5: steps/download-photos-from-api.js → photos/ directory
└── Step 6: steps/upload-photos-to-rondo-club.js → Rondo Club API (media)

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.sqlite → sportlink_runs table
  7. Upserts member data into data/rondo-sync.sqlite → rondo_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)

The same authenticated browser session also runs an inactive-member search. This is intentionally separate from the active-member import: former members are not re-imported, but their DateOfPassing remains visible for death-date reconciliation.

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

  1. Reads latest Sportlink results from data/laposta-sync.sqlite → sportlink_runs
  2. Applies field mappings from config/field-mapping.json to transform Sportlink fields to Laposta custom fields
  3. Reads the current-season obligation units from GET /rondo/v1/volunteer-obligations
  4. Maps each Rondo person ID to its tracked KNVB ID or standalone-parent email and adds the three numeric volunteer counters
  5. Handles parent extraction: creates separate list entries for EmailAddressParent1 / EmailAddressParent2
  6. Deduplicates parent entries across lists
  7. Computes source_hash for each member (SHA-256 of email + custom fields)
  8. Upserts into data/laposta-sync.sqlite → members 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
  • vrijwilligersplicht: total required duties across active obligations (required_count), independent of completed or planned shifts; -1 when all obligations are exempt or not applicable
  • vrijwilligersingepland: current-season planned duties (pending_count)
  • vrijwilligersafgerond: current-season completed/credited duties (completed_count), including Rondo’s qualifying cancellation credits, excluding no-shows

The counts use Rondo’s shared-family and player-before-family attribution. Exempt units still contribute any progress, but no requirement. People outside all obligation units have zero progress. A requirement of 2 with one planned and one completed duty is exported as 2 / 1 / 1, never as a reduced requirement. Missing or invalid source counts cause all three fields to be omitted, preserving the last known Laposta values. The four member lists need all three numeric fields, with in_form=false so subscribers cannot change these derived values.

Example recruitment segment: vrijwilligersplicht > 0, vrijwilligersingepland = 0, vrijwilligersafgerond = 0. Existing segments that interpreted vrijwilligersplicht = 0 as completed must be revised: the field now describes the full requirement.

An address in EmailAddressParent1 or EmailAddressParent2 belongs to a parent recipient even when the same address is also the child’s Email or EmailAlternative. Those child-derived rows use the parent’s name and include all linked child names in oudervan. Existing list assignments and child-specific team and membership fields remain unchanged.

Name resolution checks all children before choosing a name:

  1. Use the structured name of a unique member with that primary email, excluding every child who lists the address as a parent address.
  2. Otherwise, use a unique nonempty NameParent1 / NameParent2 from any child. Sportlink supplies this as one string, which goes into voornaam; the child’s tussenvoegsel and achternaam are cleared.
  3. Conflicting identities use Ouder/verzorger instead of whichever name happens to appear first. Missing names use Ouder/verzorger van <child>.

A child’s own separate address keeps the child’s name. Laposta submission retains its existing unsubscribe and notification suppression settings.

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)

After the normal desired-state submission, sync-deceased-members.js checks active Laposta relations whose address belongs to a deceased person. It changes those relations to unsubscribed; it never deletes them. An address remains active when the freshly prepared local list still needs that same address for another, living relation. Parent email fields are not treated as the deceased person’s own address.

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:
    • Before creating an untracked member, checks whether the same person already exists as a standalone parent. The parent post is reused only for one exact email plus normalized full-name match, when the incoming KNVB ID is not one of that parent’s known children and the post is not already mapped to another member. Ambiguous matches block creation and surface an error instead of creating a duplicate.
    • 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. If a tracked WordPress ID was merged away, resolves /rondo/v1/people/{id}/merge-target and checks the survivor’s KNVB ID before changing the mapping or writing fields. The same KNVB ID, or a survivor without one, permits the normal update. A different KNVB ID retires the old source instead.

Retired sources keep their original tracking row with retired_into_knvb_id and an empty active payload. Reimports cannot reactivate or remove this row, including after WordPress permanently deletes the old person. Former-member cleanup checks the survivor’s identity too, so removal of the old Sportlink source cannot make the surviving membership inactive. A mismatched mapping without a confirmed merge blocks writes but does not automatically retire an identity.

For a confirmed duplicate with two KNVB IDs, preserve the old ID and membership history in a Rondo note, retire its source with retireMemberIdentity(), and only then clear the old record’s conflicting ID and merge it into the active person. Verify the primary ID, active membership, history, account and sponsor relations afterwards. Keep the retired tracking row; do not remap it to the survivor. 8. 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
  • Exact email matching considers published and trashed people. Known children and siblings are excluded; when the remaining parent match is in trash, the existing person is restored before relationships are synchronized. A new parent is created only when no valid existing match remains.
  • Historical parent mappings that point to one of the known children are forced through synchronization even when their source hash is unchanged. The invalid mapping is cleared before parent discovery runs, preventing a child from being written back as a parent of its siblings.
  • Existing members, contacts, and sponsors can also be linked as parents. Active members, contacts, and sponsors keep their managed name and contact fields. A former member who is still a current parent keeps the historical identity and membership fields, while current parent contact and address data is refreshed from the child’s Sportlink data. Standalone parent profiles continue to receive their full name and contact profile from Sportlink.
  1. Publishes parent field observations from the same dated Sportlink snapshot, including unchanged parent records. Complete source slots are submitted in batches of at most 100 children; the Rondo endpoint verifies current identities and parent links, keeps pending/error states, and protects newer write confirmations. This records field numbers only and does not write to Sportlink.

To backfill existing labels without running contact, relationship, photo or email synchronization, run on the sync server as rondo:

Terminal window
node tools/sync-parent-slot-labels.js # preview source coverage
node tools/sync-parent-slot-labels.js --knvb-id <id> --apply # one child
node tools/sync-parent-slot-labels.js --apply # all complete mapped sources

The tool uses the stored snapshot’s timestamp, never its execution time. Partial source records and duplicate KNVB IDs are excluded. An unknown/ambiguous parent remains unlabeled until a later import can resolve it.

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.

After the regular active-member sync, the inactive snapshot is matched only against KNVB IDs that already have a tracked Rondo person ID. DateOfPassing is written to the canonical datum_overlijden field. This pass does not create people and does not refresh former-member contact or profile fields. A successfully verified date is stored in date_of_passing, so repeat runs are idempotent and a later Sportlink correction can be reconciled safely.

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

The normal People log records each successful photo upload or deletion with a timestamp, KNVB ID and Rondo person ID. Skipped or failed operations and photos already absent are identified separately. These entries do not require verbose mode and accompany the existing aggregate photo counts. They record operations from deployment onward; earlier logs only contain totals and error summaries. Every entry identifies its source as Sportlink/voetbal.nl and its destination as Rondo. Protected manual-photo responses (skipped: true) count as skipped, not uploaded. The same per-person outcomes are saved in the run summary and shown in the dashboard. Rondo-origin exports appear in the reverse-sync run with source Rondo.

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. Syncs contact fields (email, phone, mobile), home address fields, and administrative fields (VOG date, FreeScout ID, financial block).

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)
vrijwilligersplichtTotal active current-season requirement; -1 for exempt/not applicable
vrijwilligersingeplandCurrent-season Rondo pending count
vrijwilligersafgerondCurrent-season Rondo completed/credited count
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+)
datum_overlijdenDateOfPassing from the inactive-member safety pass
contact_info (repeater)Email, Mobile, Telephone
addresses (repeater)StreetName + AddressNumber, ZipCode, City
lid-sindsMemberSince
leeftijdsgroepAgeClassDescription; for Onder 6 through Onder 19, a missing or contradictory value is derived from DateOfBirth and the KNVB season boundary on 1 July
spelactiviteitKernelGameActivities; repeated whitespace is normalized, an explicitly empty value becomes null and clears the previous value, while a partial response that omits the property leaves the existing value untouched
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
wacht_op_overschrijvingtrue when Tooltip contains “overschrijving” (case-insensitive). Sportlink markeert overgeschreven leden van een andere club met de tooltip “Actie van een ander (overschrijving)” totdat de KNVB-overschrijving verwerkt is. Het veld wordt altijd weggeschreven (ook false), zodat de badge automatisch verdwijnt zodra Sportlink de tooltip weghaalt.

Youth age-class corrections are logged with the KNVB ID, Sportlink value, derived value, and birthdate. The fallback is intentionally limited to Onder 6 through Onder 19; adult and special categories remain fully owned by Sportlink.

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/download-inactive-members.jsFocused inactive-member Sportlink search
steps/sync-deceased-members.jsDeath-date and safe Laposta unsubscribe reconciliation
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/volunteer-obligation-sync.jsConverts Rondo obligation units to Laposta recipient values
lib/laposta-client.jsLaposta HTTP client
lib/sportlink-login.jsSportlink authentication