People Pipeline
Syncs member data from Sportlink Club to Laposta email marketing lists and Rondo Club, including photos.
Schedule
Section titled “Schedule”Runs 4x daily at 8:00, 11:00, 14:00, and 17:00 (Amsterdam time).
scripts/sync.sh people # Production (with locking + email report)node pipelines/sync-people.js --verbose # Direct execution (verbose)Pipeline Flow
Section titled “Pipeline Flow”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)Step-by-Step Details
Section titled “Step-by-Step Details”Step 1: Download from Sportlink
Section titled “Step 1: Download from Sportlink”Script: steps/download-data-from-sportlink.js
Function: runDownload({ logger, verbose })
- Launches headless Chromium via Playwright
- Logs into
https://club.sportlink.com/usinglib/sportlink-login.js - Handles TOTP 2FA with
lib/totp.js - Calls Sportlink
SearchMembersAPI to get all members - Calls
MemberHeaderAPI for each member (photo URLs, financial block status) - Stores raw JSON results in
data/laposta-sync.sqlite→sportlink_runstable - Upserts member data into
data/rondo-sync.sqlite→rondo_club_memberstable
Output: { success, memberCount }
Databases written:
data/laposta-sync.sqlite:sportlink_runs(full JSON dump)data/rondo-sync.sqlite:rondo_club_members(per-member data withsource_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.
Step 2: Prepare Laposta Members
Section titled “Step 2: Prepare Laposta Members”Script: steps/prepare-laposta-members.js
Function: runPrepare({ logger, verbose })
- Reads latest Sportlink results from
data/laposta-sync.sqlite→sportlink_runs - Applies field mappings from
config/field-mapping.jsonto transform Sportlink fields to Laposta custom fields - Reads the current-season obligation units from
GET /rondo/v1/volunteer-obligations - Maps each Rondo person ID to its tracked KNVB ID or standalone-parent email and adds the numeric
vrijwilligersplichtfield - Handles parent extraction: creates separate list entries for
EmailAddressParent1/EmailAddressParent2 - Deduplicates parent entries across lists
- Computes
source_hashfor each member (SHA-256 of email + custom fields) - Upserts into
data/laposta-sync.sqlite→memberstable
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:-1when exempt or not applicable,0when completed, otherwise the summed number of duties still to complete
Parent names in Laposta
Section titled “Parent names in Laposta”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:
- Use the structured name of a unique member with that primary email, excluding every child who lists the address as a parent address.
- Otherwise, use a unique nonempty
NameParent1/NameParent2from any child. Sportlink supplies this as one string, which goes intovoornaam; the child’stussenvoegselandachternaamare cleared. - Conflicting identities use
Ouder/verzorgerinstead of whichever name happens to appear first. Missing names useOuder/verzorger van <child>.
A child’s own separate address keeps the child’s name. Laposta submission retains its existing unsubscribe and notification suppression settings.
Step 3: Submit to Laposta
Section titled “Step 3: Submit to Laposta”Script: steps/submit-laposta-list.js
Function: runSubmit({ logger, verbose, force })
- Reads members from
data/laposta-sync.sqlitewheresource_hash != last_synced_hash - For each changed member, calls Laposta API:
- New member (no existing Laposta record):
POST /api/v2/member - Updated member:
POST /api/v2/memberwith update
- New member (no existing Laposta record):
- Updates
last_synced_hashon success - 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.
Step 4: Sync to Rondo Club
Section titled “Step 4: Sync to Rondo Club”Script: steps/submit-rondo-club-sync.js
Function: runSync({ logger, verbose, force })
- Reads members from
data/rondo-sync.sqlitewheresource_hash != last_synced_hash - Reads free fields from
sportlink_member_free_fieldstable (FreeScout ID, VOG date, financial block) - Builds WordPress API payload with ACF fields (see field mappings below)
- 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)
- Stores returned WordPress post ID as
rondo_club_id - Updates
last_synced_hashon success - If a tracked WordPress ID was merged away, resolves
/rondo/v1/people/{id}/merge-target, stores the surviving ID, and continues the update there instead of recreating the member - Then processes parent members (from
rondo_club_parentstable):- Identified by email (no KNVB ID)
- Linked to children via ACF
relationshipsfield - 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.
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.
Step 5: Photo Download
Section titled “Step 5: Photo Download”Script: steps/download-photos-from-api.js
Function: runPhotoDownload({ logger, verbose })
- Queries
rondo_club_membersfor members withphoto_state = 'pending_download' - If none pending, returns early (no browser launched)
- Launches headless Chromium via Playwright
- Logs into Sportlink Club
- For each pending member: navigates to
/member/member-details/{knvbId}/other, capturesMemberHeaderAPI response - Extracts signed photo URL via
parseMemberHeaderResponse()fromlib/photo-utils.js - Downloads photo from CDN URL via
downloadPhotoFromUrl()fromlib/photo-utils.js - Saves to
photos/{knvb_id}.{ext} - Updates
photo_stateto'downloaded' - Rate limited: 500ms-1.5s random jitter between members
Output: { success, total, downloaded, failed, errors }
Step 6: Photo Upload
Section titled “Step 6: Photo Upload”Script: steps/upload-photos-to-rondo-club.js
Function: runPhotoSync({ logger, verbose })
- Queries
rondo_club_membersforphoto_state = 'downloaded'or'pending_upload' - Uploads each photo to
POST /wp-json/rondo/v1/people/{rondo_club_id}/photo(multipart form-data) - Updates
photo_stateto'synced'on success - Also handles photo deletion: members with
photo_state = 'pending_delete'get their Rondo Club photo removed - Rate limited: 2s between uploads/deletes
Output: { upload: { synced, skipped, errors }, delete: { deleted, errors } }
Step 7: Reverse Sync (Currently Disabled)
Section titled “Step 7: Reverse Sync (Currently Disabled)”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).
Field Mappings
Section titled “Field Mappings”Sportlink → Laposta
Section titled “Sportlink → Laposta”See config/field-mapping.json for the complete mapping. Key fields:
| Laposta Field | Sportlink Source |
|---|---|
| (email) | Email |
voornaam | FirstName |
tussenvoegsel | Infix |
achternaam | LastName |
geboortedatum | DateOfBirth |
team | UnionTeams |
geslacht | GenderCode (Male→M, Female→V) |
relatiecode | PublicPersonId (KNVB ID) |
vrijwilligersplicht | Derived current-season Rondo obligation (-1, 0, or a positive integer) |
Sportlink → Rondo Club Members
Section titled “Sportlink → Rondo Club Members”| Rondo Club ACF Field | Source |
|---|---|
first_name | FirstName |
infix | Infix (lowercased tussenvoegsel) |
last_name | LastName |
knvb-id | PublicPersonId |
gender | GenderCode (Male→male, Female→female) |
birth_year | Year from DateOfBirth |
birthdate | DateOfBirth (YYYY-MM-DD format, v2.3+) |
datum_overlijden | DateOfPassing from the inactive-member safety pass |
contact_info (repeater) | Email, Mobile, Telephone |
addresses (repeater) | StreetName + AddressNumber, ZipCode, City |
lid-sinds | MemberSince |
leeftijdsgroep | AgeClassDescription; for Onder 6 through Onder 19, a missing or contradictory value is derived from DateOfBirth and the KNVB season boundary on 1 July |
spelactiviteit | KernelGameActivities; 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-lid | TypeOfMemberDescription |
freescout-id | From sportlink_member_free_fields.freescout_id |
datum-vog | From sportlink_member_free_fields.vog_datum |
financiele-blokkade | From sportlink_member_free_fields.has_financial_block |
wacht_op_overschrijving | true 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.
Database Tables Used
Section titled “Database Tables Used”| Database | Table | Usage |
|---|---|---|
laposta-sync.sqlite | sportlink_runs | Raw download results |
laposta-sync.sqlite | members | Prepared Laposta members with hashes |
laposta-sync.sqlite | laposta_fields | Cached field definitions |
rondo-sync.sqlite | rondo_club_members | Member → WordPress ID mapping + hashes |
rondo-sync.sqlite | rondo_club_parents | Parent → WordPress ID mapping |
rondo-sync.sqlite | sportlink_member_free_fields | Free fields (read by Step 4) |
CLI Flags
Section titled “CLI Flags”| Flag | Effect |
|---|---|
--verbose | Detailed per-member logging |
--force | Skip change detection, sync all members |
Error Handling
Section titled “Error Handling”- 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
Source Files
Section titled “Source Files”| File | Purpose |
|---|---|
pipelines/sync-people.js | Pipeline orchestrator |
steps/download-data-from-sportlink.js | Sportlink browser automation |
steps/prepare-laposta-members.js | Field transformation for Laposta |
steps/submit-laposta-list.js | Laposta API sync |
steps/download-inactive-members.js | Focused inactive-member Sportlink search |
steps/sync-deceased-members.js | Death-date and safe Laposta unsubscribe reconciliation |
steps/submit-rondo-club-sync.js | Rondo Club API sync (members + parents + birthdate) |
steps/prepare-rondo-club-members.js | Rondo Club member data preparation |
steps/prepare-rondo-club-parents.js | Parent extraction and dedup |
steps/download-photos-from-api.js | Photo download (Playwright) |
steps/upload-photos-to-rondo-club.js | Photo upload/delete |
lib/photo-utils.js | Shared photo helpers (MIME types, download, MemberHeader parsing) |
config/field-mapping.json | Laposta field mapping config |
lib/laposta-db.js | Laposta SQLite operations |
lib/rondo-club-db.js | Rondo Club SQLite operations |
lib/rondo-club-client.js | Rondo Club HTTP client |
lib/volunteer-obligation-sync.js | Converts Rondo obligation units to Laposta recipient values |
lib/laposta-client.js | Laposta HTTP client |
lib/sportlink-login.js | Sportlink authentication |