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 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 ClubStep-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)
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 - 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
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)
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:
- No
rondo_club_id:POST /wp/v2/people(create new person) - Has
rondo_club_id:PUT /wp/v2/people/{rondo_club_id}(update existing)
- No
- Stores returned WordPress post ID as
rondo_club_id - Updates
last_synced_hashon success - 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
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.
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) |
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+) |
contact_info (repeater) | Email, Mobile, Telephone |
addresses (repeater) | StreetName + AddressNumber, ZipCode, City |
lid-sinds | MemberSince |
leeftijdsgroep | AgeClassDescription |
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 |
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/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/laposta-client.js | Laposta HTTP client |
lib/sportlink-login.js | Sportlink authentication |