Automated member data synchronization for Dutch sports clubs. Extracts data from Sportlink Club (KNVB's member administration — no API) via headless browser automation and syncs it to Laposta, Rondo Club (WordPress), and FreeScout. Club volunteers never enter the same data twice.
The people import also refreshes Sportlink parent field labels for existing relationships. To backfill labels without changing contact data, run node tools/sync-parent-slot-labels.js --apply on the sync server; omit --apply to preview source coverage.
Laposta receives three current-season volunteer counters from Rondo: vrijwilligersplicht (total required duties, -1 for exempt/not applicable), vrijwilligersingepland (planned), and vrijwilligersafgerond (completed/credited). Progress follows Rondo's shared-family attribution. Planning or completing a shift never lowers the total requirement.
graph LR
SL[Sportlink Club]
SYNC[Rondo Sync<br>+ SQLite databases]
ST[Rondo Club WordPress]
LP[Laposta]
FS[FreeScout]
SL -->|Members, teams,<br>functions, discipline| SYNC
SYNC -->|Members, custom fields| LP
SYNC -->|Members, parents, teams,<br>commissies, work history,<br>photos| ST
SYNC -->|Customers| FS
ST -->|Field changes| SYNC
SYNC -->|Reverse sync| SL
- Browser automation — Playwright (headless Chromium) with TOTP 2FA navigates Sportlink's UI to extract data from a system that has no API
- Hash-based change detection — SHA-256 diffing ensures only records that actually changed get synced, minimizing API calls and avoiding unnecessary updates
- State tracking — SQLite databases maintain ID mappings, sync history, and photo upload state across systems
- Pipeline locking — flock-based concurrency prevention ensures parallel cron jobs don't collide
- Operations dashboard — Manual starts confirm that the sync wrapper stayed alive and show immediate lock or launch failures inline
- Email reports — HTML summaries via Lettermint after every sync run
- Photo sync — Downloads member photos from Sportlink, uploads to WordPress with a state machine tracking each photo's lifecycle
- Reverse sync — Pushes Rondo Club field changes back to Sportlink via browser automation, retries transient Rondo API failures, normalizes Sportlink's localized date display during read-back verification, and supersedes queued intermediate values when a newer edit corrects or reverts them
Dutch name prefixes stay in Rondo's separate infix field for Sportlink members.
Sponsit may include them in the surname instead. Matching compares the complete
surname plus first name and email, retains stable source-ID precedence, and
quarantines ambiguous matches. Equivalent names keep their existing Rondo field
layout. FreeScout and the sponsor Laposta list receive the complete surname;
the regular member Laposta lists retain their separate tussenvoegsel field.
After a person merge, sponsor aliases with different emails share one primary pass. Sync preserves an existing eligible primary relation, including a manually managed Businessclub pass.
Member sync checks the surviving person's KNVB ID before following a merge.
When a merged source has a different KNVB ID, its original tracking row is
retained with retired_into_knvb_id. Reimports cannot reactivate that source,
even after the old WordPress post is permanently removed. Former-member cleanup
also leaves the surviving membership intact.
| Pipeline | Schedule | What it syncs |
|---|---|---|
| People | 4x daily | Members, parents, photos → Laposta + Rondo Club |
| Functions | 4x daily + weekly full | Commissies, free fields, work history → Rondo Club |
| FreeScout | Daily | Customer names and email addresses → FreeScout helpdesk |
| Teams | Weekly | Team rosters + work history with Sportlink relation dates → Rondo Club |
| Discipline | Weekly | Discipline cases → Rondo Club |
Sportlink-owned member fields are synchronized as desired state: when a complete
Sportlink response explicitly contains an empty game activity, the sync sends
spelactiviteit: null so Rondo Club clears the previous value. The individual
sync with --fetch (also used by Rondo Club) searches Sportlink by the exact KNVB
ID before overlaying its partial /general response, so game activity and age
class are refreshed immediately. Former members are searched with the inactive
status filter if needed. Missing, ambiguous, or incomplete search results stop
the sync before person updates; an explicitly empty activity still clears the
old value. Runs without --fetch continue to use the stored full member snapshot.
After a successful individual sync, its locally computed source hash is recorded
as synchronized so the next People run does not process the same payload again.
Team history uses the Sportlink season when a historical relation has no end date: a closed season ends on June 30. Explicit end dates and continuing current-season relations take precedence, so importing old history cannot reactivate an old role.
All times in Europe/Amsterdam timezone.
07:30 Functions sync (recent) → 08:00 People sync (1st) + FreeScout sync
10:30 Functions sync (recent) → 11:00 People sync (2nd)
13:30 Functions sync (recent) → 14:00 People sync (3rd)
16:30 Functions sync (recent) → 17:00 People sync (4th)
Sunday 01:00 Functions sync (full --all)
Sunday 06:00 Teams sync
Monday 23:30 Discipline sync
Prerequisites: Node.js 18+, a Sportlink Club account with TOTP 2FA configured.
npm install
npx playwright install chromium
cp .env.example .env # Fill in your credentialsRun a pipeline:
scripts/sync.sh people # Members, parents, photos
scripts/sync.sh functions # Commissies + free fields (recent)
scripts/sync.sh functions --all # Full commissie sync (all members)
scripts/sync.sh freescout # FreeScout customers
scripts/sync.sh teams # Team rosters + dated work history
scripts/sync.sh discipline # Discipline cases
scripts/sync.sh all # EverythingSee the Installation Guide for full setup instructions including server deployment and cron configuration.
| Document | Contents |
|---|---|
| Installation | Prerequisites, server setup, initial sync, cron |
| Architecture | System overview, schedules, field mappings, data flow |
| People Pipeline | 7-step flow, Laposta + Rondo Club field mappings |
| Teams Pipeline | Team download + work history |
| Functions Pipeline | Commissies, free fields, daily vs full mode |
| FreeScout Pipeline | Minimal customer identity sync |
| Discipline Pipeline | Discipline cases + season taxonomy |
| Reverse Sync | Rondo Club → Sportlink browser automation with parent e-mail propagation |
| Database Schema | All 4 databases, 21 tables |
| Operations | Server ops, monitoring, deploys |
| Troubleshooting | Common issues and solutions |
| Utility Scripts | Cleanup, validation, inspection tools |
Node.js 18+ · Playwright · better-sqlite3 · otplib · Lettermint · dotenv