Onboarding (new members & volunteers)
The Onboarding feature is a UI for sending a welcome email to every member or volunteer who joined recently. It replaces an external send-via-LaPosta workflow that produced false positives. Once a person has been onboarded, the system stamps a per-type timestamp on them so they drop out of the list and cannot be onboarded again by accident.
Overview
Section titled “Overview”Two cohorts, two templates, two stamps:
| Cohort | Period | Required state | Sent-timestamp field |
|---|---|---|---|
| Nieuwe leden | lid-sinds within last 30 days | not a former member and not a current volunteer | onboarding-email-lid-sent |
| Nieuwe vrijwilligers | vrijwilliger-sinds within last 60 days | huidig-vrijwilliger = 1, not a former member | onboarding-email-vrijwilliger-sent |
The cohorts are mutually exclusive. Every current volunteer is excluded from Nieuwe leden, regardless of when their volunteer work began or whether their volunteer welcome email was already sent. This also covers established volunteers who recently rejoined as members and prevents them from receiving the member welcome email.
VolunteerStatus derives a missing vrijwilliger-sinds value from the earliest start date among active staff and committee positions when work history makes someone a current volunteer. Sportlink team rosters do not expose a role start date; on a genuine non-volunteer-to-volunteer transition, Rondo therefore falls back to the synchronization date. Existing volunteer-start dates are never overwritten, and an already-current volunteer without a source date is not assigned a new date. This keeps newly synchronized staff eligible for the 60-day onboarding cohort without treating an established volunteer’s later role change as a new start.
Automatic onboarding foundation (35.73.0)
Section titled “Automatic onboarding foundation (35.73.0)”The existing manual sender above remains unchanged. The new Instellingen → Beheer → E-mails → Onboarding simulatie tab is a read-only administrator tool. It lists current records, recipient addresses, blocked addresses, source coverage, possible due times and mail-block decisions. It is not a full rendered email preview. No cron, transport call, automatic send toggle or generic follow-up task is added.
Rondo\Onboarding\Recipients collects all own addresses and, only below age 18,
addresses from explicit parent relationships. It deduplicates within the person,
preserves plus-addressing, excludes deceased contacts and synthetic login addresses,
and shows Lettermint suppressions. An unknown birthdate never authorizes parent mail.
The same mailbox may legitimately receive separate messages for two children.
Foundation stores internal observations in _rondo_onboarding_observation person
meta and historical rounds in the private rondo_onboard_round CPT. Neither is a
client-editable domain field. Dispatch uses the private rondo_onboard_mail CPT.
They are not exposed through generic WordPress REST routes or exports.
Foundation API (all routes require manage_options)
Section titled “Foundation API (all routes require manage_options)”| Route | Behavior |
|---|---|
GET /rondo/v1/onboarding/simulation?page=1&search=Emma | Read-only list, 20 records/page; current people, not a claim that every listed record is a new member |
GET /rondo/v1/onboarding/simulation/{person_id} | One current simulation, including snapshot_hash |
POST /rondo/v1/onboarding/observations/{person_id} | Source-owned observation; no sending, no scheduling |
Observation JSON requires exactly observation_id (8–100 ASCII letters, digits,
hyphens or underscores), knvb_id, observed_at (RFC 3339 seconds with timezone),
membership_state, snapshot_hash, and coverage. Coverage has boolean entries
person, parents, teams, functions, vog: true means successfully fetched
and saved, including an explicitly empty result. The hash must match the current
stored fields/recipient set. A partial or failed check records false and blocks planning;
it does not change the last confirmed membership state.
Membership states are not_member, preregistration, definitive, ended, and
unknown (only valid with incomplete coverage).
The first observed definitive member is baseline, not a new candidate. Only a
subsequently confirmed transition from non-member, preregistration or a terminated
period opens a round. A termination requires an actual end date; rejoining requires
a later start. Source disappearance alone must never be submitted as termination.
Recognition uses server receipt time. The due time is 24 elapsed hours after the
later of recognition and the membership start at midnight in the club timezone.
Repeated observations do not postpone it. An open transfer or incomplete/newly changed
data blocks the simulation. No record creation date is used to infer new membership.
The producer must obtain source evidence before reading the hash and submit it after all relevant saves. The hash alone is not proof of a complete Sportlink fetch. The targeted Sync producer is connected to the regular people pipeline after the member and parent import. A successful full 26-letter Sportlink search registers an inventory; any failed/expanded term prevents inventory advancement. The first inventory establishes existing source identities as baseline, without welcome rounds or a mass refresh. Identities missing from later searches remain in the inventory. New identities and changed source fingerprints enter a persistent work queue, with up to ten targeted checks per people run. New identities have priority; unsuccessful checks remain pending and rotate by attempt time. A registered pending check blocks the simulation even when an older successful observation exists.
POST /rondo/v1/onboarding/sources accepts observed_at and a non-empty sources
array of {knvb_id, fingerprint, membership_state}. An optional check_ids list requests up to ten present identities explicitly;
their baseline classification is preserved. Storage is the non-autoloaded
native option rondo_onboarding_sources, protected by the same invariant option lock
as observations. POST /rondo/v1/onboarding/sources/finish accepts knvb_id,
fingerprint, person_id, observation_id; only a complete stored observation for
that identity, unchanged current snapshot, and matching inventory fingerprint clears
pending state. Both endpoints require manage_options.
The observed definitive source combination is TypeOfMember=CLUBMEMBER|KERNELMEMBER,
MemberStatus=ACTIVE, Status=insync, StatusDescription=Definitief, and a valid
MemberSince. Transfer requests and unrecognized/pre-registration labels stay
unknown and cannot start a round. No guessed pre-registration enum is accepted.
A new identity first seen after baseline may open its first round only when the
membership start is on or after baseline initialization. The same applies when an
initially unconfirmed identity becomes definitive. Older recovery imports stay
baseline. Confirmed baseline members require the existing proven-end/new-start
transition; a new source hash or season alone is not enough.
Strict targeted reads require successful person/contact/address/parent responses,
both function and committee arrays, VOG free-field coverage, and an explicit team
array. An absent team panel, failed response or malformed shape is not an empty list.
Writes use the existing person, parent, function and team services. Parent link errors,
missing current-team mappings, skipped writes and differences in stored enrollment/contact/age/VOG
values keep coverage incomplete. Historical teams saved by name (external_team)
are valid completed history and do not require a current Rondo team mapping. The
team writer reports these separately from unresolved current teams, after applying
Sportlink end dates, closed seasons and explicit inactive statuses. Save failures
still block completion for any team row. Empty teams conflicting with current stored team
roles require review. No unrelated photos, invoice fetches or newsletters are part
of these checks. The final snapshot is read after all saves.
node tools/check-onboarding-sources.js --inventory-only is a production-only,
source-only bootstrap/inspection command: it performs a fresh full Sportlink search
and registers the baseline without checking or mailing people. Omitting the flag
processes pending checks as well. --knvb-id ID limits execution to one explicitly
requested identity while still obtaining a full fresh source inventory. Run as the rondo user and with the normal people
lock to avoid concurrent browser/import work. Ordinary individual/function/team
pipelines do not assert full coverage: their partial success cannot clear these
pending checks. Volunteer return rounds, full conditional templates, per-recipient
account matching and outbound transport remain subsequent milestones.
Sportlink source-read recovery
Section titled “Sportlink source-read recovery”Targeted onboarding reads use lib/sportlink-source-retry.js. The People pipeline and production inspection tool pass their shared SportlinkSession to runSourceChecks(); the caller retains ownership of closing it. General data, functions/committees, VOG and team responses wait past an initial HTTP 401 for the SPA token-refresh retry. The usual response timeout is 15 seconds; MemberTeams uses 30 seconds. A wait that saw only 401 responses becomes ERR_SPORTLINK_SESSION_EXPIRED.
readSourceWithRecovery() allows at most two read attempts. After a failed read, confirmed session expiry renews the session once with session.relogin() and reacquires the page. Retryable timeouts, ERR_SPORTLINK_SOURCE_INCOMPLETE and a missing team panel can repeat the read without unnecessary login. The team reader uses retryPanel: false here so nested retries do not multiply navigation attempts. A complete, validated response takes precedence over a stale logout notice; the notice alone cannot invalidate a successful read.
ERR_SPORTLINK_MEMBER_NOT_FOUND is never retried. A permanent error without session-expiry evidence is propagated, and failed or incomplete coverage stays incomplete. The helper retries source reads only, not saves or outbound mail. Tests in sportlink-source-retry.test.js cover 401 recovery, persistent expiry, stale notices, strict VOG coverage and the two-navigation limit; sportlink-login.test.js covers the delayed OTP redirect.
Merged source identities in Sync
Section titled “Merged source identities in Sync”sync-onboarding-sources.js resolves the tracked person through the confirmed merge-target endpoint before every pending source check, including unchanged or deferred Sportlink registrations that never reach the normal person writer. A same-identity merge updates rondo_club_id to the survivor and clears the completed sync hash before simulation or observation; a healthy mapping leaves the hash intact.
If a confirmed merge points to a survivor with a different KNVB ID, retireMemberIdentity() preserves the old mapping as a tombstone with retired_into_knvb_id and an empty active payload. The source remains incomplete: the producer acknowledges it with person_id: 0 and an empty observation_id, records a deferred result, and performs no simulation, observation or person write against the survivor. Repeated checks and reimports preserve that retirement without making each People run fail.
A missing person without a confirmed merge, a denied/failed merge lookup, or an unmerged KNVB mismatch remains an error and preserves the original mapping. test/onboarding-merge.test.js covers these boundaries, unchanged parked registrations, replay and same-identity remapping.
Delivery reservations
Section titled “Delivery reservations”Dispatch::reserve() creates one durable reservation per round, message kind and
normalized address before future external work. A native unique option name enforces
the claim; the complete request payload and its hash are frozen in the private CPT.
Another claim is rejected, including after failure or acceptance. Crashed claims are
not automatically expired. accepted() requires and preserves the provider message ID.
No production code invokes these methods to send mail in this increment.
Provider submission, the 24-hour Lettermint idempotency window, status reconciliation, partial retries, and webhook routing are not yet connected. A later sender must use the same key and payload, record its first actual provider-attempt time, and refuse blind retries after an uncertain outcome. A lock that survives a process crash needs operator inspection; it must not be deleted just because it is old.
Parent-name fallback in Sync
Section titled “Parent-name fallback in Sync”The parent preparer now uses Ouder van {FirstName} when Sportlink supplied a valid
parent email without a parent name. Siblings retain one shared mailbox record and
their child links. A real source name takes precedence over a generated name, and a
fallback never overwrites an existing known name. If even the child’s first name is
missing, the preparer still omits the record rather than inventing a child identity;
the targeted check keeps parent coverage incomplete when its source addresses cannot be verified in the saved relationships.
Verification
Section titled “Verification”OnboardingFoundationTest covers baseline/replay, future starts, proven rejoining,
partial/stale observations, recipient age boundaries and suppressions, durable
reservations, lock exclusion, API permissions and absence of email side effects.
VolunteerStatusTest covers explicit dates overriding stale flags. The Sync parent
tests cover generated names, real-name precedence and shared siblings. Local lint
may need composer lint -- --ignore='tests/_output/*' to exclude downloaded WordPress
test caches; production source remains fully checked.
Access control
Section titled “Access control”The Onboarding screen is gated behind a dedicated capability: ledenadministratie (Ledenadministratie). Administrators auto-receive it; everybody else needs an admin to grant it via Instellingen → Beheer → Capabilities (Ledenadministratie column) or by being assigned the rondo_ledenadministratie / rondo_bestuur role.
| Surface | Permission |
|---|---|
| Sidebar item “Onboarding” | hidden unless user has can_access_ledenadministratie |
Route /people/onboarding | ”Geen toegang” page unless capability present |
POST /rondo/v1/people/onboarding-email | check_ledenadministratie_permission (ledenadministratie cap or admin) |
| `GET | POST /rondo/v1/onboarding/email-settings/{type}` |
The cap is exposed on GET /rondo/v1/user/me as can_access_ledenadministratie.
Route: /people/onboarding (linked under Leden → Onboarding in the sidebar, visible only to users with ledenadministratie).
Two tabs (URL: ?tab=leden or ?tab=vrijwilligers). Each row shows the person, their start date, their email address, and a Verstuur button. A header checkbox plus row checkboxes feed a bulk Verstuur geselecteerde action.
The Voornaam, Achternaam, and start-date columns are sortable. The date column uses field_lid_sinds on the member tab and field_vrijwilliger_sinds on the volunteer tab; its first click sorts descending so the newest people appear first. Server-side ordering normalizes both compact YYYYMMDD storage values and legacy YYYY-MM-DD values before comparing them. When the active date sort is selected, switching tabs carries that sort over to the corresponding date field.
People without an email address show “Geen e-mailadres” inline and cannot be selected — the server reports them back in the response payload, but they are skipped without erroring the batch.
The “E-mailteksten beheren” link in the header jumps to /settings/admin/welkomstmail, where the three templates live in three sub-tabs.
REST endpoints
Section titled “REST endpoints”GET /rondo/v1/people/filtered gains two boolean parameters:
| Parameter | Meaning |
|---|---|
onboarding_new_members=1 | lid-sinds ≤ 30 days ago AND onboarding-email-lid-sent is empty, excluding every current volunteer |
onboarding_new_volunteers=1 | vrijwilliger-sinds ≤ 60 days ago AND huidig-vrijwilliger=1 AND onboarding-email-vrijwilliger-sent is empty |
The default former-member exclusion still applies, so people who already left do not appear.
POST /rondo/v1/people/onboarding-email
Permission: any approved Rondo user.
Body:
{ "person_ids": [1234, 1235, 1236], "type": "lid"}type is either "lid" or "vrijwilliger".
Response:
{ "success": true, "type": "lid", "counts": { "sent": 2, "already_sent": 0, "no_email": 1, "send_failed": 0, "not_found": 0, "invalid_type": 0 }, "results": [ { "person_id": 1234, "status": "sent", "recipient": "[email protected]", "sent_at": "2026-05-26 14:51:03" }, { "person_id": 1235, "status": "sent", "recipient": "[email protected]", "sent_at": "2026-05-26 14:51:03" }, { "person_id": 1236, "status": "no_email", "message": "Deze persoon heeft geen e-mailadres." } ]}Status values per person: sent, already_sent, no_email, send_failed, not_found, invalid_type. The timestamp is stamped only on a successful wp_mail() return — failures do not poison future retries.
Template settings (admin only)
Section titled “Template settings (admin only)”GET / POST /rondo/v1/onboarding/email-settings/{lid|vrijwilliger}
Reads / writes a single template:
{ "subject": "Welkom als lid van {club_naam}", "body": "<p>Beste {first_name},...</p>"}Each type is stored as two WP options (rondo_onboarding_lid_subject / …_body, rondo_onboarding_vrijwilliger_subject / …_body). The account-provisioning email options are untouched.
Templates
Section titled “Templates”The onboarding templates are grouped with the other editable messages under Instellingen → Beheer → E-mails:
| Sub-tab | Stored options | From address |
|---|---|---|
| Account aanmaken | rondo_welcome_email_subject / _body / rondo_welcome_from_email / _name | Configurable |
| Nieuw lid | rondo_onboarding_lid_subject / _body | WordPress default (wp_mail()) |
| Nieuwe vrijwilliger | rondo_onboarding_vrijwilliger_subject / _body | WordPress default (wp_mail()) |
| IVA-goedkeuring | rondo_iva_approval_email_subject / _body | WordPress default (wp_mail()) |
The onboarding templates support these placeholders:
| Variable | Meaning |
|---|---|
{first_name} | Voornaam |
{infix} | Tussenvoegsel |
{last_name} | Achternaam |
{full_name} | Voornaam + tussenvoegsel + achternaam, properly joined |
{email} | Person’s email address |
{club_naam} | Club name from ClubConfig::get_club_name() (falls back to get_bloginfo('name')) |
Body content is rendered through Rondo\Notifications\EmailTemplate::render() to wrap the message in the shared branded HTML layout. Plain text is auto-escaped through EmailTemplate::format_plain_text(); HTML is sanitized with wp_kses_post().
Data model
Section titled “Data model”Two ACF datetime fields are added to the person post type:
onboarding-email-lid-sent—date_time_picker, readonly, formatY-m-d H:i:sonboarding-email-vrijwilliger-sent— same shape
These are written by the server only — there is no UI to clear them. If you need to re-onboard someone, delete the post meta directly.
Implementation
Section titled “Implementation”| File | Role |
|---|---|
includes/class-onboarding-email-sender.php | Rondo\Notifications\OnboardingEmailSender — template loading, placeholder substitution, send, stamp, timeline log |
includes/class-rest-people.php | Filter params + POST /rondo/v1/people/onboarding-email handler (send_onboarding_emails) |
includes/class-rest-users.php | Template settings endpoints (get_onboarding_email_settings / update_onboarding_email_settings) |
acf-json/group_person_fields.json | The two date_time_picker fields |
src/pages/People/PeopleOnboarding.jsx | The screen |
src/hooks/usePeople.js | useSendOnboardingEmail mutation, new filter params in buildFilteredPeopleParams |
src/pages/Settings/Settings.jsx | Four-sub-tab WelkomstmailTab for editable email templates |
Each successful send also writes a timeline entry on the person via CommentTypes::create_email_log(), so the person’s history shows when they received the onboarding email and which template was used.
Related Documentation
Section titled “Related Documentation”- User Provisioning — the third welkomstmail template (account-creation email)
- Email Delivery —
wp_mail/ SMTP infrastructure - People API — list endpoint and shared filters
Editable draft blocks and full situation previews (35.76.0)
Section titled “Editable draft blocks and full situation previews (35.76.0)”Administrators can prepare automatic welcome emails in Instellingen → Beheer → E-mails → Welkomstmailblokken. This is a separate draft from the existing manual member and volunteer templates. The latter remain unchanged and are returned as read-only reference text, including any existing HTML. No automatic splitting or migration of arbitrary club copy takes place. Member contribution, clothing, training and volunteering information starts empty, visibly marked for editorial review; empty optional blocks are omitted. Review and transfer club copy before switching any sender to these templates.
Rondo\Onboarding\Templates owns the block definitions, conditions and defaults.
All subjects, greetings, introductions, member information, volunteer information,
VOG variants, clothing/return variants, recipient account copy, button labels and
closing are independently editable. Blocks use plain text with paragraphs and only
{first_name}, {club_naam} and {email} substitutions; links in the copy become
clickable. Unknown placeholders, invalid types, oversized values and empty required
blocks are rejected atomically. Eligibility is not a template field. Account button
URLs remain /activeren and /login; rendering creates no tokens or accounts.
Endpoint (requires manage_options) | Contract |
|---|---|
GET /rondo/v1/onboarding/templates | Definitions, draft blocks, content revision, supported variables and legacy reference templates; no default option writes |
POST /rondo/v1/onboarding/templates | {blocks, revision}; saves the complete draft in non-autoloaded rondo_onboarding_template_blocks; stale revisions or simultaneous saves receive HTTP 409 |
POST /rondo/v1/onboarding/templates/preview | {blocks, scenario}; renders unsaved draft text without writes, mail, cron scheduling, dispatch reservation or account activation |
The preview scenario has exactly these fields:
type:member,volunteer,combined.age:adult,minor;recipient:self,parent. Adult plus parent is rejected.account:activate,exists, describing this recipient, not the linked member.vog:none,missing,requested,review,valid,renew,resubmit.clothingandreturning:no,yes.
These are fictional scenario controls, not verified current person states. The
preview always uses Emma/Robin and .invalid example recipient addresses. Only a
volunteer or combined mail includes volunteer blocks. At most one VOG block appears;
none omits it. Clothing readiness in the example follows no VOG requirement or a
valid VOG; review/renewal/request/resubmission waits. Returning volunteers receive
copy about assessing remaining clothing needs, not a promised new package.
Resubmission and returning-clothing copy are editorial proposals for review before
activation. IVA approval retains its existing separate email settings.
Responses include sending_enabled: false; previews also return simulation_only,
subject, recipient, block_ids, full branded html and template_version (the
SHA-256 revision of normalized block contents). The iframe is sandboxed and disallows
scripts and external resources. All successful template responses use no-store.
Future sending must pass verified person/recipient conditions and reserve the final
rendered content plus this version with Dispatch; this milestone does not connect
the draft renderer to any sender or prove actual recipient-account matching.
Validation covers conditional composition, under-18 parent scenarios, recipient account variants, unchanged legacy content, atomic validation and save conflicts, REST administrator permissions, and zero mail/scheduling side effects. The browser editor and branded iframe are exercised locally with backend-rendered fixtures.