TC-PLAT-001: Platform Event Check-in — Super-Admin (Detailed)
Where this lives in code. All routes below are registered in
auth-service/src/server.ts under /api/platform/tenants/:slug/events/... and gated by requirePlatformSupport (rbac.ts, allows SUPER_ADMIN or PLATFORM_SUPPORT). Handlers live in handlers/platformEventCheckinHandlers.ts (list/stats/report/mark/undo/bulk/QR/codes/walk-in/audit) and handlers/attendanceCsvReconcileHandlers.ts + attendanceReconcileHandlers.ts (CSV reconcile + finalize + no-show). Tenant is resolved from the URL :slug, never from the operator's own session — that is the isolation mechanism.
Step 1: Log in and open a tenant's Attendees page (CM-01)
| Step | Action | Expected Result | Test Value |
|---|---|---|---|
| 1 | Go to https://stg-admin.membervu.com; log in. | Platform dashboard loads. Note: a non-PlatformAdmin account (e.g. an org admin) is rejected at THIS login step with 403 NOT_PLATFORM_ADMIN — it never reaches the app. | superadmin@zeniark.com / SuperAdmin123! |
| 2 | Open Tenants → select tenant rcme → Events tab → pick an event with registered attendees → Attendees. | EventAttendeesPage loads (/tenants/rcme/events/:eventId/attendees in the admin-portal router). GET /api/platform/tenants/rcme/events/:eventId/attendance returns 200 with {data: {event, summary, attendees}}. | — |
| 3 | Inspect the attendee table columns. | Columns show attendee name, Member/Guest pill, status, and a payment status pill (Paid / Unpaid / No invoice) if the event is paid. There is no dollar amount, invoice number, or currency figure rendered anywhere on the page. | — |
| 4 | Open DevTools → Network → inspect the raw JSON response for the attendance request. | Each attendee object DOES include a nested invoice: {id, invoiceNumber, amountCents, status} when an invoice exists — the payload is not amount-redacted server-side, only the UI omits rendering it. Data assertion: confirm this is a UI-layer omission (EventAttendeesPage.tsx renders only <PaymentStatusPill invoice=.../>, never amountCents) — not a backend redaction. Flag as a note, not a bug: the section table's older "no billing fields shown" language oversimplified this. | — |
Step 2: Mark / undo a single check-in (CM-02)
| Step | Action | Expected Result | Test Value |
|---|---|---|---|
| 1 | Pick a CONFIRMED (not yet checked-in) attendee row → click Check In. | POST /api/platform/tenants/rcme/events/:eventId/attendance/:registrationId/mark → 200. Row updates: status → CHECKED_IN, a check-in timestamp appears. | — |
| 2 | Click Check In again on the SAME already-checked-in row (or replay the request). | 409 {"error":{"code":"ALREADY_CHECKED_IN"}} — re-check-in is rejected, not silently accepted. | — |
| 3 | Click Undo on the row from Step 1. | POST …/attendance/:registrationId/undo → 200. Registration reverts to CONFIRMED, checked-in timestamp cleared. | — |
| 4 | Attempt Undo on a row that was never checked in. | 409 {"error":{"code":"NOT_CHECKED_IN"}}. | — |
Data assertion (operator audit — do not skip): after Steps 1 and 3, a new row must exist in the
PlatformAuditLog table with adminId = the super-admin's user id, action = "EVENT_CHECKIN" (mark) or "EVENT_CHECKIN_UNDO" (undo), and metadata containing this exact tenantId/eventId/registrationId. If your access lets you query the DB (psql or an internal audit viewer), confirm the row exists — a UI toast saying "checked in" is NOT sufficient proof of the audit write.
Step 3: Bulk check-in (CM-03)
| Step | Action | Expected Result | Test Value |
|---|---|---|---|
| 1 | Select 3 CONFIRMED attendees via row checkboxes (include one that is already CHECKED_IN, if available, to test the skip path) → click Bulk Check-in. | POST …/attendance/bulk-mark body {registrationIds: [...]} → 200 {marked, skipped, results}. The already-checked-in id appears in skipped with reason: "ALREADY_CHECKED_IN" — NOT counted in marked, and the call does NOT fail. | — |
| 2 | Refresh the attendee list. | All non-skipped rows now show CHECKED_IN. | — |
| 3 | Attempt a bulk-mark mixing registration ids from TWO different events (craft the request manually if the UI doesn't expose this). | 400 {"error":{"code":"CROSS_EVENT_BULK"}} — rejected outright, no partial apply. | — |
Data assertion: exactly ONE
PlatformAuditLog row is written for the successful bulk call, action = "EVENT_CHECKIN_BULK", metadata.registrationIds = the array of successfully-marked ids only (excludes skipped ones).
Step 4: QR scan-to-check-in (CM-04)
| Step | Action | Expected Result | Test Value |
|---|---|---|---|
| 1 | On the Attendees page, open the QR Scanner panel (QRScanner.tsx). | Scanner UI opens with no console errors (#405 removed an invalid audio prop that used to crash it). | — |
| 2 | Scan (or manually submit, if no camera) a valid attendee's ticket code for THIS event. | POST …/attendance/checkin-by-code body {code} → 200 {checkInStatus:"checked_in", checkedInAt, attendee:{name,type,...}}. Attendee row updates to CHECKED_IN. | — |
| 3 | Scan a code belonging to a DIFFERENT event in the same tenant. | 400 {"error":{"code":"WRONG_EVENT"},"correctEvent":{"id","name","date"}} — check-in is rejected, the response tells you which event the ticket actually belongs to. | — |
| 4 | Scan the same valid code from Step 2 again. | 409 {"error":{"code":"ALREADY_CHECKED_IN"},"checkedInAt":...}. | — |
| 5 | Scan a nonsense code. | 404 {"error":{"code":"CODE_NOT_FOUND"}}. | ZZZZ-BOGUS-9999 |
Data assertion: the audit row for Step 2 has
action = "EVENT_CHECKIN" with metadata.via = "qr" — this is how a QR-sourced check-in is distinguished from a manual mark in the audit trail.
Step 5: CSV reconcile + finalize (CM-05)
Payment gateway is INERT on staging. None of this step involves payment — reconcile only touches attendance/registration status.
| Step | Action | Expected Result | Test Value |
|---|---|---|---|
| 1 | Download the roster template: GET …/attendance/roster-template.csv (button on the reconcile panel). | A CSV downloads with the expected header row for matching (name/email + Attended column). | — |
| 2 | Fill it with a couple of real registrants marked Attended = Y, upload via Reconcile → Upload CSV. | POST …/attendance/reconcile/preview (multipart) → 200 {success:true, uploadToken, preview:{willChange, alreadyCheckedIn, matched, unmatched, counts}}. Preview table shows which rows will change. | — |
| 3 | Click Commit. | POST …/attendance/reconcile/commit body {uploadToken} → 200. The previewed rows are now CHECKED_IN; re-submitting the SAME uploadToken a second time is rejected (token is single-use / tied to the previewing user, tenant, and event). | — |
| 4 | Click Finalize Reconciliation. | POST …/attendance/reconcile/finalize → 200 {noShowCount, reconciledAt}. Every remaining CONFIRMED registration for this event (i.e. never scanned/checked-in) is now NO_SHOW — verify by re-fetching the attendance report and counting status === "NO_SHOW" rows == the returned noShowCount. | — |
| 5 | Re-run finalize a second time immediately. | 200 {noShowCount: 0, ...} — idempotent, no error (there is nothing left in CONFIRMED to convert). | — |
Data assertion: the finalize call writes ONE
PlatformAuditLog row, action = "EVENT_RECONCILE_FINALIZE", metadata.count == the returned noShowCount.
Step 6: Cross-tenant isolation — SECURITY GATE (CM-06)
This is the primary security assertion for this feature — do not skip. Every handler in
platformEventCheckinHandlers.ts resolves tenantId from the URL :slug via resolveTenantFromSlug() and scopes every Prisma query with that tenantId — never from the operator's own session. A denied/mismatched tenant returns 404 TENANT_NOT_FOUND, never a cross-tenant leak.
| Step | Action | Expected Result | Test Value |
|---|---|---|---|
| 1 | Note tenant A (rcme) event's current attendee count and a specific attendee's status from Step 1–5 above. | Recorded. | — |
| 2 | Switch the admin-portal tenant selector to a SECOND tenant (e.g. acme-corporation) → open an event with the SAME name/shape (or any event) → note its attendee count. | Tenant B's list loads independently — a completely different registrant set (or empty, if no seeded data), never tenant A's rows. | — |
| 3 | Perform a check-in (mark) on a tenant B attendee. | Only tenant B's registration changes. | — |
| 4 | Verify independently: log into tenant A's own org portal (stg-rcme.membervu.com) as admin@rcme.membervu.com / Admin123! → open the same tenant A event's attendance report. | Tenant A's attendee count and the specific attendee's status from Step 1 are UNCHANGED by the tenant B check-in in Step 3. UI-only confirmation from the admin portal is NOT enough — this cross-check via the tenant's OWN portal is the actual isolation proof. | — |
| 5 | Attempt to hit tenant A's attendance route with a bogus/inactive slug. | 404 {"error":{"code":"TENANT_NOT_FOUND"}} — never a 500, never tenant B's data returned as a fallback. | Slug not-a-real-tenant |
Step 7: Access-denied is 403, not a forced logout (CM-07)
Seed-data limitation. Only ONE
PlatformAdmin row is seeded on staging (superadmin@zeniark.com, role SUPER_ADMIN) — there is no seeded operator with a lesser platform role (e.g. PLATFORM_FINANCE only) to reproduce a real 403 end-to-end through the UI. This step verifies the gate via the code path + a controlled devtools simulation; it is not a substitute for a real second-account test if/when a PLATFORM_SUPPORT-less seed account is added.
| Step | Action | Expected Result | Test Value |
|---|---|---|---|
| 1 | While logged in as super-admin, open DevTools → Application → Local Storage → note the JWT under platform_session. | Token present, base64 payload includes "platformRoles":["SUPER_ADMIN"]. | — |
| 2 | Craft a request to any platform check-in route (e.g. GET /api/platform/tenants/rcme/events) using a forged/expired Authorization header, OR ask backend/QA to mint a token whose platformRoles is [] or ["PLATFORM_FINANCE"] only, and replay the request via curl/Postman. | 403 {"error":{"code":"FORBIDDEN"}} from requirePlatformRole in rbac.ts (confirmed in code: it calls errors.forbidden(res), a plain 403 JSON response — there is no session/cookie mutation, no revocation, nothing that would log out any session). | — |
| 3 | Back in the real browser session (still logged in as super-admin), refresh the Attendees page. | Session is still valid — the forged/denied request in Step 2 had zero effect on the real super-admin session. This confirms "403, not logout" behaviorally, not just by code inspection. | — |
Step 8: Check-in confirmation email uses the tenant origin (CM-08, coupling with #341)
| Step | Action | Expected Result | Test Value |
|---|---|---|---|
| 1 | Perform a platform walk-in registration for tenant A (rcme) on a free event: Attendees page → Walk-in tab → fill name/email → submit. | POST …/checkin/walk-in → 201, registration created and auto-checked-in. | First Platform Last WalkinQA Email plat.walkin.qa@example.com |
| 2 | Check https://stg-webmail.membervu.com for any welcome/confirmation email to that address. | If an email is queued (best-effort per code comment — the platform walk-in path leaves welcomeEmailQueued:false in the response, i.e. NOT wired for this path as of this release), confirm there is no email OR, if one does arrive via a different trigger, its links point at the tenant A origin (stg-rcme.membervu.com), never the admin-portal host. | — |
Code note:
platformWalkInRegistrationHandler explicitly returns welcomeEmailQueued: false with the comment "#425 welcome email seam — not wired for platform path". Do not fail this step if no email arrives for a platform-initiated walk-in — that is current, intentional behavior, distinct from the org-portal walk-in (TC-EVT-008 Step 6) which DOES send one.
CLEANUP
- Undo any test check-ins on tenant A/B attendees that should remain in their original state for other testers.
- Delete/cancel the walk-in registration created in Step 8 (plat.walkin.qa@example.com) via the org portal's Events > Registrations.
- If Step 5's reconcile/finalize converted real seeded attendees to
NO_SHOW, note this for other testers relying on that event's attendee state, or use a disposable/duplicate event for reconcile runs going forward. - Revoke any access codes generated incidentally while exploring the Attendees page.