Skip to content
campaiDeveloper
campai.com

Your club takes course bookings in campai, but its coaches use another system to organize training. Build a review desk that maps each participant to an existing athlete and session, then prepares the corresponding enrollment. A cancellation withdraws the enrollment while leaving the athlete’s profile intact.

Use an external API integration for this job: your backend reads campai and connects to the training provider. A Developer App fits a tool that staff use inside campai, such as the volunteer-hours ledger. It does not make an external training system part of campai automatically.

The complete example includes a local training-system simulator. You can run its review, enrollment and recovery workflow immediately, then map the documented contract to your provider. No vendor API is connected by this example.

What you’ll have at the end

A review desk that turns mapped campai bookings into training-session enrollments and preserves athlete profiles on cancellation.

  1. campai bookings and occurrence
  2. Verified athlete/session mapping
  3. Review a complete plan
  4. Provider-owned enrollment and read-back
Before you start
  • Node.js 22+ and a private directory
  • One private future event with two synthetic single-person bookings
  • Mandates Read, Events Read and Bookings Read restricted to the test mandate
On this page

Use a test mandate with one private future event and two zero-price synthetic bookings, each for one participant. Keep notification sending disabled. Create a temporary API key restricted to that mandate with Mandates Read, Events Read and Bookings Read. Follow API permissions and Find organization and mandate IDs.

Verify the participant behind each booking. The buyer may be a parent or another organizer. A buyer email, a name match or a booking QR code is not a verified athlete identity.

Open the event in campai. Its full occurrence ID is the 24-character segment after /booking/events/ in the address. Use the event-specific listing under Find the booking IDs below, then match each returned bookingNumber to the native Bookings view. Click that number to compare Applicant and Attendees. Configure the returned _id, not the short booking number.

Verify the participant before linking an athlete.

Verify the participant before linking an athlete.

The campai booking detail separates the applicant from the confirmed attendee

Download the complete example into a private directory. Set apiKey, organization, mandate, and one mapping per booking in server.mjs:

Mapping Your choice
bookingId Full ID of this single-person booking.
eventId Full ID of its specific occurrence.
athleteId and athleteName An existing athlete you have verified; the name is a display label.
sessionId and sessionName An existing training session corresponding to this occurrence.
timeZone The IANA zone used to display the UTC event times.

For the exercise, use athlete-001 and athlete-002 for your two synthetic participants, mapped to session-001. The simulator seeds those profile labels from configuration. When connecting a provider, use its actual existing IDs and verify the session’s time there.

Map each booking and athlete/session pair once. A recurring course needs a distinct session mapping for each occurrence. This example deliberately rejects group bookings and partial cancellations that need participant-level interpretation.

Use Node.js 22 or newer. Run node server.mjs and open http://127.0.0.1:9381/. No packages are required. The API key remains on the server; the browser receives only the reviewed projection.

Select Read current bookings. The server retrieves each configured booking and its occurrence, checks their mandate and relationship, then saves a complete plan. It does not list unrelated records or update campai.

Review the participant, session and occurrence before applying.

Review the participant, session and occurrence before applying.

Two live booking mappings proposed for enrollment beside an empty training roster

Check each athlete, session, date, reason and proposed action. A confirmed booking should propose enroll. Payment status remains separate: this roster includes expected participants even when a booking is unpaid. It is not an admission gate, payment check or record of actual attendance.

The plan expires five minutes after reading starts. A failed or incomplete scan leaves the last complete plan and saved roster unchanged. A multi-record read is not an atomic snapshot; refresh after relevant source changes and keep the last successful read visible.

Select I checked each participant, session and proposed change, then Apply to local roster. The roster should show both athletes as enrolled and keep both profiles. This writes only the local simulator’s state file.

Both enrollments are saved under stable references.

Both enrollments are saved under stable references.

Two enrolled athletes in the local training-system roster

Read the bookings again without changing them and apply the new plan. Its actions should be unchanged, with the same two enrollments and roster revision. Stable references prevent duplicates within this saved roster.

Keep training-roster-state.json when restarting. It is bound to the organization, mandate, namespace and mappings. Changing those while reusing the file stops startup instead of silently taking ownership of another roster.

In campai, cancel only the disposable test occurrence and leave notification sending off. Return to the desk and select Read current bookings. Both mapped rows should now propose withdraw with the reason Occurrence canceled, unless campai also marks their booking canceled.

A cancellation produces a reviewable withdrawal plan.

A cancellation produces a reviewable withdrawal plan.

Two canceled bookings propose withdrawal while the previous roster is retained for review

Apply the plan and inspect the result. The enrollment status changes to withdrawn; both athlete profiles remain. This is the boundary your real provider adapter must preserve: remove only the integration-owned enrollment, never delete the athlete or their unrelated sessions.

An already canceled booking with no previous enrollment creates nothing. A missing record or failed request is not treated as a cancellation. Partial cancellations, multiple participants, blocked occurrences and missing fields stop the complete scan for manual review.

You can combine this check with the cancellation step: before applying that plan, select Test a lost reply after the local roster saves. The simulator commits its roster and then returns an error. Select Read saved result to verify that exact plan. It compares the saved roster checksum without a second write. You can restart the server before this recovery step to check persistence.

Verify the saved result without sending a second write.

Verify the saved result without sending a second write.

Read-only recovery confirms two withdrawn enrollments and two retained athlete profiles

For a real provider, persist the plan ID and reservation before sending its request. If the reply is lost, look up that reference and compare the saved enrollment. An unknown result needs investigation; do not turn it into another create request.

The example uses the exact booking and occurrence endpoints. Each request below is independent and uses the production URL. Replace the placeholders only in a private script or terminal.

This request lists the two bookings for your disposable event. Match bookingNumber to the native booking details and retain each _id. The result is private booking data. If you use an event with more than 50 bookings, continue with offsets 50, 100 and so on; this first page is not a complete roster by itself.

Example

curl --fail-with-body --silent --show-error --max-time 30 --globoff \
  --request POST 'https://cloud.campai.com/api/YOUR_ORGANIZATION_ID/YOUR_MANDATE_ID/booking/events/YOUR_EVENT_ID/bookings/list' \
  --header 'X-API-Key: YOUR_API_KEY' \
  --header 'Content-Type: application/json' \
  --data-raw '{
  "limit": 50,
  "offset": 0,
  "returnCount": true
}'

Example

curl --fail-with-body --silent --show-error --max-time 30 --globoff \
  --request GET 'https://cloud.campai.com/api/YOUR_ORGANIZATION_ID/YOUR_MANDATE_ID/booking/bookings/YOUR_BOOKING_ID' \
  --header 'X-API-Key: YOUR_API_KEY'

Check _id, mandate, type, event.event, records, status, paymentStatus and cancelations. Keep the API’s spelling. The example reads the related occurrence separately:

Example

curl --fail-with-body --silent --show-error --max-time 30 --globoff \
  --request GET 'https://cloud.campai.com/api/YOUR_ORGANIZATION_ID/YOUR_MANDATE_ID/booking/events/YOUR_EVENT_ID' \
  --header 'X-API-Key: YOUR_API_KEY'

Check its _id, mandate, period.from, period.to, period.status and cancelation. UTC instants should be displayed in the intended named time zone; do not treat them as date-only values. See Understand money, dates, and data types.

Open View and copy the provider-neutral contract. The JSON contains the reviewed decisions and a plan ID; it does not contain the API key or buyer contact details. It still identifies participants, so keep it private.

Contract field Provider responsibility
scope.namespace and decision key Identify an enrollment owned by this integration.
athleteId Resolve one existing, verified athlete.
sessionId, from, to Resolve the correct existing session and compare its actual time.
status: enrolled Upsert that enrollment with its stable reference.
status: withdrawn Remove or deactivate that enrollment only.
baseRevision Reject a stale update, or serialize it through a durable reservation if the provider has no revision API.
planId Find and verify the committed result after an interrupted reply.

The local /api/apply handler is the simulator’s commit boundary. Replace its local commit with your provider’s supported authentication, lookup, write and read-back calls. Keep that implementation on your backend. Never send the campai key to the training provider.

For unattended synchronization, read the current source immediately before updating the provider and reconcile regularly. The current generic record-change webhooks do not cover booking changes. A failed scan must not erase a roster or advance a successful checkpoint. Keep transactional reservations and audit history when moving beyond one local process.

Check Expected result
Scan without applying Roster stays unchanged.
Apply an unchanged complete plan No duplicate enrollments or new roster revision.
Cancel the synthetic occurrence Withdraw mapped enrollments; retain profiles.
Read a partial or group booking Stop for participant-level review.
Interrupt an apply reply Recover by its saved plan ID without another write.
Revoke the campai key Scan fails; prior complete state stays intact.

Revoke the temporary key, remove the exact synthetic bookings and event, then stop the local server. Delete only this exercise’s files. A shared deployment needs authenticated staff access, HTTPS, private storage and a reviewed provider adapter; the loopback example assumes a trusted operator on this computer.

The complete training-roster desk

Configure the read-only key and verified participant mappings, then review live bookings in a local training-system simulator. No packages or vendor account are required.

Download ZIP
View and copy the complete source

The complete training-roster desk

import { createServer } from 'node:http';
import { createHash, randomBytes } from 'node:crypto';
import { open, readFile, rename, unlink } from 'node:fs/promises';
import { resolve } from 'node:path';

// Run on your own computer. campai is read-only; roster writes stay in this local simulator.
const apiKey = 'YOUR_API_KEY';
const organization = 'YOUR_ORGANIZATION_ID';
const mandate = 'YOUR_MANDATE_ID';
const apiBase = 'https://cloud.campai.com/api';
const port = 9381;
const namespace = 'club-training';
const timeZone = 'Europe/Berlin';
// Verify the actual participant, not just the buyer. One participant per mapped booking.
const mappings = [
  { bookingId: 'YOUR_BOOKING_ID', eventId: 'YOUR_EVENT_ID', athleteId: 'athlete-001', athleteName: 'Alex Morgan', sessionId: 'session-001', sessionName: 'Evening training' },
];

const origin = `http://127.0.0.1:${port}`;
const scope = { organization, mandate, namespace };
const id = x => typeof x === 'string' && /^[a-f0-9]{24}$/.test(x);
const token = x => typeof x === 'string' && /^[A-Za-z0-9][A-Za-z0-9_-]{0,59}$/.test(x);
const label = x => typeof x === 'string' && x.trim() === x && x.length > 0 && x.length <= 100;
const hash = x => createHash('sha256').update(JSON.stringify(x)).digest('hex');
const fault = (message, status = 400) => Object.assign(new Error(message), { status });
if (!id(organization) || !id(mandate) || !token(namespace) || !apiKey || /\s/.test(apiKey)) throw Error('Configure the key and full organization/mandate IDs.');
new Intl.DateTimeFormat('en', { timeZone });
if (!Array.isArray(mappings) || mappings.length < 1 || mappings.length > 50) throw Error('Configure 1–50 explicitly reviewed single-person bookings.');
const bookingIds = new Set(), pairs = new Set(), sessionEvents = new Map(), athleteNames = new Map();
for (const m of mappings) {
  if (!id(m.bookingId) || !id(m.eventId) || !token(m.athleteId) || !token(m.sessionId) || !label(m.athleteName) || !label(m.sessionName)) throw Error('Invalid mapping. Use full campai IDs and stable provider IDs.');
  const pair = JSON.stringify([m.athleteId, m.sessionId]);
  if (bookingIds.has(m.bookingId) || pairs.has(pair)) throw Error('Map each booking and athlete/session pair exactly once.');
  if (sessionEvents.has(m.sessionId) && sessionEvents.get(m.sessionId) !== m.eventId) throw Error('A provider session must map to one campai occurrence.');
  if (athleteNames.has(m.athleteId) && athleteNames.get(m.athleteId) !== m.athleteName) throw Error('An athlete ID must have one reviewed name.');
  bookingIds.add(m.bookingId); pairs.add(pair); sessionEvents.set(m.sessionId, m.eventId); athleteNames.set(m.athleteId, m.athleteName);
}
const mappingHash = hash(mappings);
const profiles = [...athleteNames].map(([id, name]) => ({ id, name }));
const statePath = resolve('training-roster-state.json');
const csrf = randomBytes(24).toString('hex');
let state = { version: 1, scope, mappingHash, revision: 0, rosterRevision: 0, enrollments: [], plan: null, lastApplied: null };
const lock = await open(statePath + '.lock', 'wx', 0o600);
try {
  const saved = JSON.parse(await readFile(statePath, 'utf8'));
  if (saved.version !== 1 || hash(saved.scope) !== hash(scope) || saved.mappingHash !== mappingHash || !Number.isSafeInteger(saved.revision) || saved.revision < 0 || !Number.isSafeInteger(saved.rosterRevision) || saved.rosterRevision < 0 || !Array.isArray(saved.enrollments) || saved.enrollments.length > 50) throw Error('State, scope or mapping changed. Preserve the existing roster and reconcile deliberately.');
  const seen = new Set();
  for (const row of saved.enrollments) {
    const m = mappings.find(m => m.bookingId === row.bookingId);
    if (!m || row.key !== hash([scope, m.bookingId, m.athleteId, m.sessionId]) || row.athleteId !== m.athleteId || row.sessionId !== m.sessionId || !['enrolled', 'withdrawn'].includes(row.status) || seen.has(row.key)) throw Error('Invalid saved enrollment. Preserve the state.');
    seen.add(row.key);
  }
  if (saved.plan !== null && (!saved.plan || saved.plan.planId !== hash(saved.plan.contract) || saved.plan.contract.mappingHash !== mappingHash || hash(saved.plan.contract.scope) !== hash(scope))) throw Error('Invalid saved plan.');
  state = saved;
} catch (error) {
  if (error.code !== 'ENOENT') { await lock.close(); await unlink(statePath + '.lock'); throw error; }
}
let pending = Promise.resolve();
function exclusive(action) { const result = pending.then(action); pending = result.catch(() => {}); return result; }
async function save(next) {
  const temporary = statePath + '.' + process.pid + '.tmp';
  next.revision = state.revision + 1;
  const file = await open(temporary, 'wx', 0o600);
  try { await file.writeFile(JSON.stringify(next, null, 2)); await file.sync(); } finally { await file.close(); }
  await rename(temporary, statePath); state = next;
}
async function read(path) {
  const response = await fetch(`${apiBase}/${organization}/${mandate}${path}`, { headers: { 'X-API-Key': apiKey }, redirect: 'error', signal: AbortSignal.timeout(15000) });
  if (!response.ok || !response.body) throw fault(`campai HTTP ${response.status}. The saved plan and roster are unchanged.`, 502);
  const reader = response.body.getReader(), chunks = []; let size = 0;
  try { while (true) { const { done, value } = await reader.read(); if (done) break; size += value.length; if (size > 2 * 1024 * 1024) { await reader.cancel(); throw Error('Response too large.'); } chunks.push(value); } } finally { reader.releaseLock(); }
  return JSON.parse(Buffer.concat(chunks).toString('utf8'));
}
function instant(value) {
  if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value)) throw Error('Missing event time.');
  const normalized = value.includes('.') ? value : value.replace('Z', '.000Z');
  if (!Number.isFinite(Date.parse(value)) || new Date(value).toISOString() !== normalized) throw Error('Invalid event date.');
  return normalized;
}
async function scan() {
  const started = Date.now(), events = new Map(), decisions = [];
  for (const m of mappings) {
    if (Date.now() - started > 60000) throw Error('Scan exceeded one minute. Keep the prior complete plan.');
    const booking = await read('/booking/bookings/' + m.bookingId);
    if (!booking || booking._id !== m.bookingId || booking.mandate !== mandate || booking.type !== 'event' || booking.event?.event !== m.eventId || booking.records !== 1) throw Error('Booking scope, occurrence or single-participant mapping does not match.');
    if (!['confirmed', 'canceled', 'partialCancelation'].includes(booking.status) || !Array.isArray(booking.cancelations) || !Object.hasOwn(booking, 'paymentStatus') || ![null, 'unpaid', 'partial', 'paid'].includes(booking.paymentStatus) || !Number.isSafeInteger(booking.totalPrice) || booking.totalPrice < 0 || !Number.isSafeInteger(booking.totalPoints) || booking.totalPoints < 0) throw Error('Required booking evidence is unavailable.');
    // A partial cancellation may affect only a service. Do not guess its participant effect.
    if (booking.status === 'partialCancelation' || (booking.status === 'confirmed' && booking.cancelations.length)) throw Error('A partial booking change needs participant-level review. No plan was replaced.');
    if (!events.has(m.eventId)) events.set(m.eventId, await read('/booking/events/' + m.eventId));
    const event = events.get(m.eventId);
    if (!event || event._id !== m.eventId || event.mandate !== mandate || !Object.hasOwn(event, 'cancelation') || !['available', 'blockedResource', 'blockedCalendar'].includes(event.period?.status)) throw Error('Required occurrence evidence is unavailable.');
    const from = instant(event.period.from), to = instant(event.period.to);
    if (to <= from) throw Error('Invalid occurrence interval.');
    if (event.cancelation !== null && (!event.cancelation || typeof event.cancelation !== 'object' || Array.isArray(event.cancelation))) throw Error('Unknown cancellation shape.');
    if (event.period.status !== 'available' && event.cancelation === null) throw Error('Blocked occurrences need manual scheduling review.');
    const canceled = booking.status === 'canceled' || event.cancelation !== null;
    // Roster membership is based on confirmation, not financial settlement or door access.
    const status = canceled ? 'withdrawn' : 'enrolled';
    const key = hash([scope, m.bookingId, m.athleteId, m.sessionId]);
    const previous = state.enrollments.find(row => row.key === key);
    if (!canceled && Date.parse(to) <= started) throw Error('This recipe updates upcoming sessions only. Preserve completed-session history.');
    decisions.push({ key, bookingId: m.bookingId, eventId: m.eventId, athleteId: m.athleteId, athleteName: m.athleteName, sessionId: m.sessionId, sessionName: m.sessionName, from, to, status, paymentStatus: booking.paymentStatus, reason: canceled ? (booking.status === 'canceled' ? 'Booking canceled' : 'Occurrence canceled') : 'Confirmed single-person booking', action: !previous && status === 'withdrawn' ? 'no enrollment' : previous?.status === status && previous.from === from && previous.to === to ? 'unchanged' : status === 'enrolled' ? (previous?.status === 'enrolled' ? 'update time' : 'enroll') : 'withdraw' });
  }
  if (Date.now() - started > 60000) throw Error('Scan exceeded one minute. Keep the prior complete plan.');
  const contract = { version: 1, scope, mappingHash, baseRevision: state.rosterRevision, generatedAt: new Date(started).toISOString(), expiresAt: new Date(started + 300000).toISOString(), decisions };
  const next = structuredClone(state); next.plan = { planId: hash(contract), contract };
  await save(next); return view();
}
function view() { return { ...state, profiles, timeZone, now: new Date().toISOString() }; }
function json(res, status, data) { res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(data)); }
async function body(req) {
  if (req.headers['content-type']?.split(';')[0] !== 'application/json') throw fault('Use JSON.', 415);
  const chunks = []; let size = 0;
  for await (const chunk of req) { size += chunk.length; if (size > 8000) throw fault('Request too large.', 413); chunks.push(chunk); }
  try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { throw fault('Invalid JSON.'); }
}
const server = createServer(async (req, res) => {
  res.setHeader('Cache-Control', 'no-store'); res.setHeader('X-Robots-Tag', 'noindex, nofollow'); res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('Referrer-Policy', 'no-referrer');
  try {
    if (req.headers.host !== `127.0.0.1:${port}`) throw fault('Use the configured local address.', 403);
    if (req.method === 'GET' && req.url === '/') {
      const nonce = randomBytes(18).toString('base64');
      res.setHeader('Content-Security-Policy', `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'`);
      res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(page(nonce)); return;
    }
    if (req.method === 'GET' && req.url === '/api/state') { json(res, 200, view()); return; }
    if (req.method !== 'POST' || req.headers.origin !== origin || req.headers['x-csrf-token'] !== csrf) throw fault('Request rejected.', 403);
    const input = await body(req);
    const result = await exclusive(async () => {
      if (req.url === '/api/scan') { if (input.revision !== state.revision) throw fault('Refresh before scanning.', 409); return scan(); }
      if (req.url === '/api/reconcile') {
        if (!input.planId || input.planId !== state.lastApplied?.planId || state.lastApplied.rosterHash !== hash(state.enrollments)) throw fault('No matching committed result for this plan. Inspect the roster before trying another update.', 409);
        return { ...view(), reconciled: true };
      }
      if (req.url !== '/api/apply') throw fault('Not found.', 404);
      if (input.planId && state.lastApplied?.planId === input.planId) {
        if (state.lastApplied.rosterHash !== hash(state.enrollments)) throw fault('The saved roster differs from the committed result. Investigate before another update.', 409);
        return { ...view(), alreadyApplied: true };
      }
      const plan = state.plan;
      if (!plan || input.revision !== state.revision || input.planId !== plan.planId || input.confirmed !== true || plan.contract.baseRevision !== state.rosterRevision || Date.now() > Date.parse(plan.contract.expiresAt)) throw fault('Review a fresh complete plan before applying.', 409);
      const next = structuredClone(state);
      for (const d of plan.contract.decisions) {
        const index = next.enrollments.findIndex(row => row.key === d.key);
        if (index < 0 && d.status === 'withdrawn') continue;
        const row = { key: d.key, bookingId: d.bookingId, athleteId: d.athleteId, sessionId: d.sessionId, from: d.from, to: d.to, status: d.status };
        if (index < 0) next.enrollments.push(row); else next.enrollments[index] = row;
      }
      const changed = hash(next.enrollments) !== hash(state.enrollments);
      if (changed) next.rosterRevision++;
      next.lastApplied = { planId: plan.planId, appliedAt: new Date().toISOString(), changed, rosterHash: hash(next.enrollments) };
      await save(next);
      if (input.simulateLostResult === true) throw fault('The local roster committed, but its reply was interrupted. Read the saved result before applying anything else.', 502);
      return view();
    });
    json(res, 200, result);
  } catch (error) { json(res, error.status || 502, { error: error.status ? error.message : 'The complete scan could not be verified. Keep the saved roster and review the mapping, booking and occurrence.' }); }
});
server.listen(port, '127.0.0.1', () => console.log(`Training roster: ${origin}. campai reads only; local simulator writes only.`));
let stopping = false;
async function stop() { if (stopping) return; stopping = true; server.close(async () => { await pending; await lock.close(); await unlink(statePath + '.lock'); process.exit(0); }); server.closeIdleConnections(); }
process.on('SIGINT', stop); process.on('SIGTERM', stop);

function page(nonce) {
  return `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Training roster desk</title><style nonce="${nonce}">
/* Standard Tailwind palette values, embedded so the example needs no stylesheet dependency. */
:root{--color-zinc-50:oklch(98.5% 0 none);--color-zinc-950:oklch(14.1% 0.005 285.823);--color-zinc-600:oklch(44.2% 0.017 285.786);--color-zinc-200:oklch(92% 0.004 286.32);--color-white:#fff;--color-black:#000;--color-red-50:oklch(97.1% 0.013 17.38);--color-red-900:oklch(39.6% 0.141 25.723);--color-red-500:oklch(63.7% 0.237 25.331);--color-emerald-50:oklch(97.9% 0.021 166.113);--color-emerald-900:oklch(37.8% 0.077 168.94);}
*{box-sizing:border-box}body{margin:0;background:var(--color-zinc-50);color:var(--color-zinc-950);font:15px/1.55 system-ui,sans-serif}main{max-width:1120px;margin:auto;padding:36px 24px}h1{font-size:34px;letter-spacing:-.04em;margin:0}h2{font-size:21px;margin:0 0 12px}h3{font-size:16px;margin:0}p{margin:8px 0 16px;color:var(--color-zinc-600)}header{display:flex;justify-content:space-between;gap:24px;align-items:center}.badge{padding:7px 12px;border-radius:999px;background:var(--color-red-50);color:var(--color-red-900);font-size:12px;font-weight:650}.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin:24px 0}.stat,.panel{border:1px solid var(--color-zinc-200);border-radius:18px;background:var(--color-white);padding:22px}.stat strong{display:block;font-size:30px;margin-top:4px}.stat span{color:var(--color-zinc-600);font-size:13px}.tools{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin:20px 0}button{font:inherit;font-weight:600;border:1px solid var(--color-zinc-200);border-radius:9px;padding:11px 15px;background:var(--color-white);cursor:pointer}button.primary{background:var(--color-black);border-color:var(--color-black);color:var(--color-white)}button:disabled{opacity:.4;cursor:default}button:focus-visible,a:focus-visible{outline:3px solid var(--color-red-500);outline-offset:3px}.columns{display:grid;grid-template-columns:1.2fr 1fr;gap:20px}.row{border-top:1px solid var(--color-zinc-200);padding:18px 0}.row:first-child{border-top:0}.row:last-child{padding-bottom:0}.top{display:flex;justify-content:space-between;align-items:start;gap:12px}.muted,small{color:var(--color-zinc-600);font-size:13px}.mapping{margin:8px 0;padding:10px 12px;border-radius:9px;background:var(--color-zinc-50);font-size:13px;overflow-wrap:anywhere}.pill{background:var(--color-red-50);color:var(--color-red-900);padding:4px 9px;border-radius:6px;font-size:12px;white-space:nowrap}.pill.ok{background:var(--color-emerald-50);color:var(--color-emerald-900)}.notice{padding:14px 18px;border-radius:12px;background:var(--color-red-50);color:var(--color-red-900);margin:18px 0}.notice.ok{background:var(--color-emerald-50);color:var(--color-emerald-900)}.notice:empty{display:none}label{display:flex;gap:9px;align-items:start;margin:15px 0;font-size:14px}input{margin-top:5px;accent-color:var(--color-red-500)}details{margin-top:20px}summary{font-weight:650;cursor:pointer}textarea{width:100%;min-height:250px;padding:12px;border:1px solid var(--color-zinc-200);border-radius:9px;font:12px/1.5 monospace;margin:12px 0}.empty{padding:22px 0;color:var(--color-zinc-600)}footer{margin:22px 0;color:var(--color-zinc-600);font-size:13px}@media(max-width:760px){header{display:block}.badge{display:inline-block;margin-top:10px}.columns{grid-template-columns:1fr}main{padding:24px 16px}.stats{gap:8px}.stat{padding:14px}h1{font-size:29px}.top{flex-wrap:wrap}}
</style><main><header><div><h1>Training roster desk</h1><p>Review campai bookings before updating a training session.</p></div><span class="badge">Local training-system simulator</span></header><div class="stats"><div class="stat"><span>Confirmed enrollments</span><strong id="active">0</strong></div><div class="stat"><span>Changes to review</span><strong id="changes">0</strong></div><div class="stat"><span>Athlete profiles retained</span><strong id="profiles">0</strong></div></div><div class="tools"><button class="primary" id="scan">Read current bookings</button><button id="refresh">Refresh desk</button><span id="freshness" class="muted">No complete scan yet</span></div><div id="message" role="status" class="notice"></div><div class="columns"><section class="panel"><h2>Review the mapping and changes</h2><div id="plan"></div><label><input type="checkbox" id="confirmed">I checked each participant, session and proposed change.</label><label><input type="checkbox" id="interrupt">Test a lost reply after the local roster saves</label><div class="tools"><button id="apply" class="primary" disabled>Apply to local roster</button><button id="reconcile" disabled>Read saved result</button></div></section><section class="panel"><h2>Training-system roster</h2><p>Only this adapter’s enrollments change. Athlete profiles remain.</p><div id="roster"></div><div id="receipt" class="muted"></div></section></div><details class="panel"><summary>View and copy the provider-neutral contract</summary><p>Map these identifiers to your provider on your own server. No vendor is contacted by this example.</p><textarea id="contract" readonly aria-label="Provider-neutral roster contract"></textarea><button id="copy">Copy contract</button></details><footer>All times use ${timeZone}. Payment state is shown separately; this roster is not a payment check, attendance record or door-access permission.</footer></main><script nonce="${nonce}">
const csrf=${JSON.stringify(csrf)};
let current=null,busy=false;
const $=id=>document.getElementById(id);
const esc=value=>String(value).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const when=value=>new Intl.DateTimeFormat('en-GB',{timeZone:${JSON.stringify(timeZone)},month:'short',day:'numeric',hour:'2-digit',minute:'2-digit',hour12:false}).format(new Date(value));
async function call(path,body){const r=await fetch(path,{method:body===undefined?'GET':'POST',headers:body===undefined?{}:{'Content-Type':'application/json','X-CSRF-Token':csrf},body:body===undefined?undefined:JSON.stringify(body)});const data=await r.json();if(!r.ok)throw Error(data.error);return data;}
function controls(){const p=current?.plan;const valid=p&&Date.parse(p.contract.expiresAt)>Date.now()&&p.contract.baseRevision===current.rosterRevision&&current.lastApplied?.planId!==p.planId;$('scan').disabled=busy;$('refresh').disabled=busy;$('apply').disabled=busy||!valid||!$('confirmed').checked;$('reconcile').disabled=busy||!p;}
function render(data){current=data;const p=data.plan;const rows=p?.contract.decisions||[];$('active').textContent=data.enrollments.filter(r=>r.status==='enrolled').length;$('changes').textContent=(data.lastApplied?.planId===p?.planId?0:rows.filter(r=>!['unchanged','no enrollment'].includes(r.action)).length);$('profiles').textContent=data.profiles.length;$('freshness').textContent=p?'Read '+when(p.contract.generatedAt)+' · review expires '+when(p.contract.expiresAt):'No complete scan yet';$('plan').innerHTML=rows.length?rows.map(r=>'<div class="row"><div class="top"><h3>'+esc(r.athleteName)+'</h3><span class="pill">'+esc(r.action)+'</span></div><div class="mapping">'+esc(r.athleteId)+' · '+esc(r.sessionId)+'<br>'+esc(r.sessionName)+' · '+esc(when(r.from))+'</div><small>'+esc(r.reason)+' · payment: '+esc(r.paymentStatus??'none')+'<br>Booking '+esc(r.bookingId)+'</small></div>').join(''):'<div class="empty">Read the configured bookings to prepare a complete review. Nothing is enrolled yet.</div>';$('roster').innerHTML=data.enrollments.length?data.enrollments.map(r=>'<div class="row"><div class="top"><h3>'+esc(data.profiles.find(p=>p.id===r.athleteId)?.name||r.athleteId)+'</h3><span class="pill '+(r.status==='enrolled'?'ok':'')+'">'+esc(r.status)+'</span></div><p>'+esc(r.sessionId)+' · '+esc(when(r.from))+'</p><small>Athlete profile retained · '+esc(r.athleteId)+'</small></div>').join(''):'<div class="empty">The local roster is empty. Review the plan before applying it.</div>';$('receipt').textContent=data.lastApplied?'Roster revision '+data.rosterRevision+' · last plan '+data.lastApplied.planId.slice(0,12):'Roster revision '+data.rosterRevision;$('contract').value=p?JSON.stringify(p,null,2):'';$('confirmed').checked=false;controls();}
async function run(action){if(busy)return;busy=true;controls();$('message').textContent='';$('message').className='notice';try{await action();}catch(error){$('message').textContent=error.message;}finally{busy=false;controls();}}
$('scan').onclick=()=>run(async()=>{render(await call('/api/scan',{revision:current.revision}));$('message').textContent='Complete scan ready. Review every mapping before applying.';});
$('refresh').onclick=()=>run(async()=>render(await call('/api/state')));
$('apply').onclick=()=>run(async()=>{render(await call('/api/apply',{revision:current.revision,planId:current.plan.planId,confirmed:$('confirmed').checked,simulateLostResult:$('interrupt').checked}));$('message').className='notice ok';$('message').textContent='Local roster saved. No campai record or external provider was changed.';});
$('reconcile').onclick=()=>run(async()=>{render(await call('/api/reconcile',{planId:current.plan.planId}));$('message').className='notice ok';$('message').textContent='The saved plan is verified. No second roster write was sent.';});
$('confirmed').onchange=controls;
$('copy').onclick=async()=>{try{await navigator.clipboard.writeText($('contract').value);$('copy').textContent='Copied';}catch{$('contract').focus();$('contract').select();$('copy').textContent='Select and copy the contract';}};
setInterval(controls,1000);run(async()=>render(await call('/api/state')));
</script></html>`;
}

The complete review desk runs in Node.js. The three-language tabs above cover its underlying HTTP reads; cURL cannot host a website. Continue with Synchronize bookings with your system when you need a bounded polling scan beyond the explicitly mapped IDs.

Check your result

Repeat the plan
No duplicate enrollment or roster revision.
Cancel a booking
Withdraw its enrollment and retain its athlete profile.
Fail a scan
Keep the previous complete plan and roster.
Continue with Synchronize bookings with your system