TC-PLAT-001: Platform Event Check-in — Super-Admin (Detailed)

Step-by-Step Guide — Follow each step exactly as written. All values are provided — do not improvise.
Module
Platform Ops — Super-Admin Event Check-in (#374–#399, #402, #427/#428)
Staging Frontend
https://stg-admin.membervu.com (NOT the org portal)
Tenant
RCME (tenant A) + a second tenant, e.g. ACME (tenant B), for the isolation gate
Test Users
Super Admin (only seeded platform operator)
Priority
P1
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)

StepActionExpected ResultTest Value
1Go 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!
2Open Tenants → select tenant rcmeEvents 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}}.
3Inspect 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.
4Open 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)

StepActionExpected ResultTest Value
1Pick a CONFIRMED (not yet checked-in) attendee row → click Check In.POST /api/platform/tenants/rcme/events/:eventId/attendance/:registrationId/mark200. Row updates: status → CHECKED_IN, a check-in timestamp appears.
2Click 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.
3Click Undo on the row from Step 1.POST …/attendance/:registrationId/undo200. Registration reverts to CONFIRMED, checked-in timestamp cleared.
4Attempt 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)

StepActionExpected ResultTest Value
1Select 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.
2Refresh the attendee list.All non-skipped rows now show CHECKED_IN.
3Attempt 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)

StepActionExpected ResultTest Value
1On 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).
2Scan (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.
3Scan 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.
4Scan the same valid code from Step 2 again.409 {"error":{"code":"ALREADY_CHECKED_IN"},"checkedInAt":...}.
5Scan 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.
StepActionExpected ResultTest Value
1Download 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).
2Fill 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.
3Click 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).
4Click Finalize Reconciliation.POST …/attendance/reconcile/finalize200 {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.
5Re-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.
StepActionExpected ResultTest Value
1Note tenant A (rcme) event's current attendee count and a specific attendee's status from Step 1–5 above.Recorded.
2Switch 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.
3Perform a check-in (mark) on a tenant B attendee.Only tenant B's registration changes.
4Verify 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.
5Attempt 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.
StepActionExpected ResultTest Value
1While logged in as super-admin, open DevTools → Application → Local Storage → note the JWT under platform_session.Token present, base64 payload includes "platformRoles":["SUPER_ADMIN"].
2Craft 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).
3Back 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)

StepActionExpected ResultTest Value
1Perform 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-in201, registration created and auto-checked-in.First Platform Last WalkinQA Email plat.walkin.qa@example.com
2Check 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