Docs / Admin

Consultation attendees and agenda

Summary: This is the field-level reference for the Attendees and Agenda tabs on a consultation detail page — every RSVP status transition, how the QR check-in flow actually verifies a scan, and how the agenda builder orders and displays program items. For the full consultation lifecycle (create, invites, minutes), see Running a consultation.

Audience: Staff and above (staff, reviewer, lead, admin) manage attendees and agendas. Public attendees interact only through unauthenticated links (/respond/rsvp, /checkin, /agenda/{id}) — no login.

Prerequisites: A consultation record must already exist. Role checks are enforced server-side via requireRole in src/lib/modules/consultations/actions.ts.


Overview

Key concepts:

  • Attendee — a row in consultation_attendees, optionally linked to a contact (contactId) and/or organization (organizationId), or entered manually with just a name/email.
  • RSVP status — a six-value enum tracked per attendee, driving both the UI badge and downstream email automation.
  • Check-in — a one-way transition (rsvp_status = 'attended') triggered by a QR scan or manual staff action; idempotent by design.
  • Agenda item — a row in agenda_items with day/time/sort ordering, shown identically on the staff detail page and the public /agenda/{id} page.

Screenshot: Consultation detail page, Attendees tab, showing the RSVP funnel stat row (Invited / Confirmed / Attended / Declined) above the attendee table.


Attendee data model

From src/lib/modules/consultations/types.ts:

FieldTypeNotes
iduuidPrimary key
consultationIduuidParent consultation
contactIduuid | nullSet when added from the contacts database
organizationIduuid | nullAuto-filled from the contact, or picked manually
namestringRequired, max 200 characters
emailstring | nullOptional, validated as an email if present
rsvpStatusenumSee below
checkedInAtDate | nullSet only by check-in
metadatajsonbFree-form

Add an attendee — two entry points

From contacts (addAttendeeAction in src/lib/modules/consultations/actions.ts): pass contactId, and the action looks up the contact via getContact() from the stakeholders module and auto-fills name = "${firstName} ${lastName}", email, and organizationId when not explicitly supplied.

Manual entry: name is required (throws "Name is required" if blank after the contact lookup); email and organizationId are optional. Validated by addAttendeeSchema (z.object({ consultationId: uuid, contactId?: uuid, organizationId?: uuid, name: min(1).max(200), email?: email-or-empty-string })).

Both paths call services.addAttendee, which writes the row and logs an attendee.added audit event, then revalidate the consultation detail path.


RSVP statuses — verified enum and transitions

The RsvpStatus type (src/lib/modules/consultations/types.ts) and updateRsvpSchema (validators.ts) define exactly six values:

StatusLabel shown in UIHow it's reached
registeredRegisteredDefault status when an attendee is added, or set after sendInviteAction adds them from an email list
confirmedConfirmedAttendee clicks "Yes" on the public RSVP page, or staff manually changes the status
attendedAttendedQR/manual check-in (checkInAttendee) — always sets checked_in_at = now() in the same update
cancelledCancelledAttendee clicks "No" on the public RSVP page, or staff sets it manually
waitlistedWaitlistedManual only — no code path sets this automatically
no_showNo ShowManual only — no code path sets this automatically

Labels come from RSVP_STATUS_LABELS in src/lib/shared/utils/labels.ts (formatRsvpStatus), which also defines declined → "Declined" and invited → "Invited" as display-only aliases used in reporting, not as actual rsvp_status values.

Staff can force any transition from the attendee row via updateRsvpAction, validated only against the six-value enum — there is no state-machine guard (e.g., you can move attended back to registered manually).

Side effects wired to RSVP changes

services.updateAttendeeRsvp (in src/lib/modules/consultations/services.ts) fires Inngest events on two specific transitions:

  • rsvpStatus === "registered" and the attendee has an email → sends consultation/attendee.registered (triggers the confirmation email + QR + reminder schedule).
  • rsvpStatus === "cancelled" → sends consultation/attendee.cancelled.

No event fires for confirmed, attended (via this path — check-in uses its own event, see below), waitlisted, or no_show.

RSVP funnel counts

The Attendees tab header (src/app/(authenticated)/consultations/[id]/page.tsx) computes four counts client-side from the loaded attendee list — not a separate query:

invited   = attendees.length
confirmed = count where rsvpStatus === "confirmed"
attended  = count where rsvpStatus === "attended"
declined  = count where rsvpStatus === "cancelled"

"Invited" is simply the total attendee count, regardless of actual RSVP status — every added attendee counts as invited.


Sending invites and the public RSVP flow

Path: Consultation detail → Send Invite button (SendInviteDialog in send-invite-dialog.tsx).

The dialog lets you add/remove recipient emails (pre-filled with existing attendee emails), Preview the rendered email (via previewInviteAction), Download .ics standalone, or Send. Verbatim dialog copy: "Each recipient is added as an attendee and emailed an RSVP invite for "{title}". Their check-in QR code is sent once they confirm."

The result screen reports {sent} invite(s) sent successfully and, if any failed, {failed} failed with the per-recipient error strings from result.errors.

The public RSVP page (/respond/rsvp)

src/app/(public)/respond/rsvp/page.tsx renders RsvpResponder given a token (and optional a=yes|no query param for one-click email buttons). No token → shows "Missing RSVP token."

submitRsvpAction (src/lib/modules/consultations/public-rsvp.ts) is the server action behind this page:

  1. Rate-limited to 20 requests/minute per client IP (rateLimit("rsvp:${ip}", 20, "1 m")) — silently returns null over the limit, to block token probing.
  2. Verifies the token via verifyRsvpToken(token), which resolves to an attendeeId or null.
  3. Guard: if the attendee is already attended, the response returns alreadyResponded: true without downgrading the status — a stale RSVP link can never undo a real check-in.
  4. Otherwise maps answer === "yes"confirmed, "no"cancelled, and skips the write (alreadyResponded: true) if the status is already the target value.
  5. Logs attendee.rsvp_confirmed or attendee.rsvp_declined with metadata.source = "public_rsvp", userId: null (unauthenticated action).
  6. On a fresh "yes" confirmation, sends the consultation/attendee.registered Inngest event (full registration workflow: confirmation email + QR + reminder schedule). If Inngest is unreachable, it falls back to calling sendAttendeeConfirmation directly so the attendee still gets a code.

Check-in — QR and manual

QR code generation

checkInUrl(subdomain, attendeeId) (src/lib/modules/consultations/checkin-url.ts) builds the encoded URL: {tenant origin}/checkin?id={attendeeId}. The AttendeeQrDialog component renders this per attendee row. parseScannedAttendeeId accepts either a full check-in URL or a bare id string, so the in-app scanner and a pasted id both work.

The public check-in page (/checkin)

publicCheckInAction (src/lib/modules/consultations/public-checkin.ts):

  1. Rejects if attendeeId is missing or longer than 50 characters.
  2. Rate-limited 20/minute per IP (checkin:${ip}).
  3. Idempotent: if checked_in_at is already set, returns alreadyCheckedIn: true with the original timestamp — no re-write, no duplicate thank-you email.
  4. Otherwise sets rsvp_status = 'attended', checked_in_at = now(), logs attendee.checked_in with metadata.source = "public_checkin".
  5. If the attendee has an email, sends consultation/email.send-post-event with emailType: "thank_you" immediately — the code comment notes the scheduled post-event handler also dedupes, so triggering both is safe.

Staff-side check-in

checkInAttendeeActionservices.checkInAttendee → same underlying rsvp_status = 'attended' + checked_in_at = now() update, logged as attendee.checked_in with the acting staff userId (vs. null for the public path).


Building the agenda

Path: Consultation detail → Agenda tab → Build Agenda / Edit Agenda/consultations/{id}/agenda (AgendaStepPage).

The agenda step page description reads: "Add the indicative program. It's included in the confirmation email and on the public agenda page." A Preview button opens /agenda/{id} in a new tab; Done returns to the consultation detail page.

Agenda item fields (AddAgendaItemInput, addAgendaItemSchema)

FieldConstraint
titleRequired, max 255 characters
dayIndexOptional integer ≥ 0 (defaults to 0 — single-day events don't need it)
dayLabelOptional string, max 120 characters
startTime / endTimeOptional free-text strings, max 40 characters each (not a time type — e.g. "9:00 AM" is valid)
sortOrderOptional integer ≥ 0 (defaults to 0)
descriptionOptional, max 2,000 characters
durationMinutesOptional integer ≥ 1
presenterOptional, max 200 characters
remarksOptional, max 500 characters — internal, not shown on the public page's AgendaProgram (verify in your build if remarks should stay staff-only)

Ordering

listAgendaItems (repository.ts) always sorts ORDER BY day_index ASC, sort_order ASC, created_at ASC — this is the single source of order for both the staff detail page's read-only agenda summary and the public /agenda/{id} page. There is no drag-and-drop reorder API in the read code path; the agenda builder component (AgendaBuilder) is the write surface for day/time/sort values.

Removing an item

removeAgendaItemActionservices.removeAgendaItem → hard DELETE FROM agenda_items (not soft-deleted, unlike consultations themselves), logged as agenda_item.removed.


The public agenda page (/agenda/{id})

src/app/(public)/agenda/[id]/page.tsx — no auth. Returns a 404 (notFound()) if the consultation doesn't exist or isArchived is true. Renders the consultation title, a formatted date (en-PH locale, e.g. "Thursday, July 16, 2026"), venue, and the AgendaProgram component fed the same listAgendaItems result and sort order as the staff view.


Roles and permissions

ActionMinimum roleSource
Add attendee, update RSVP, check in, add/remove agenda itemstaffrequireRole("staff", { module: "consultations" }) in actions.ts
Archive a consultationleadrequireRole("lead", { module: "consultations" })
View public RSVP / check-in / agenda pagesnone (unauthenticated)dedicated public server actions with IP rate limiting instead of auth

Best practices

  1. Prefer the contacts lookup over manual entry. It keeps the attendee linked to the underlying contact record (contactId), which is what makes the attendee name a clickable link to /stakeholders/{id} on the detail page.
  2. Don't rely on "Invited" as a delivery signal. It counts every attendee row, including ones added manually without ever being emailed. Use the Send Invite result screen's sent/failed counts for delivery confirmation instead.
  3. Regenerate the QR after re-adding a deleted attendee. A new attendee row gets a new id, so the old printed/emailed QR code is stale — see Troubleshooting.
  4. Fill dayIndex and sortOrder explicitly for multi-day agendas. Both default to 0, so unset items on a multi-day event will all sort together at the top.

Warnings

RSVP transitions are not state-machine guarded. Any staff member can move an attendee from attended back to registered from the row UI — there is no code-level prevention. The only automatic guard against downgrading is inside the public RSVP action itself (it will not override an already-attended status).

Agenda item deletion is a hard delete, unlike consultations (which use is_archived). There is no undo.

no_show and waitlisted are manual-only. No workflow in this codebase sets these statuses automatically — if you want to track no-shows, someone has to update the row after the fact.


Troubleshooting

"Missing RSVP token."

Symptom: Public RSVP page shows this text instead of the Yes/No responder.

Cause: The page was loaded without a token query parameter — usually a mis-copied or truncated link.

Fix: Re-send the invite from the Send Invite dialog so the attendee gets a fresh, correctly-formed link.

QR check-in shows the attendee as already checked in but they say they haven't arrived

Symptom: publicCheckInAction returns alreadyCheckedIn: true for someone who insists they just scanned for the first time.

Cause: checked_in_at was already set — possibly from someone else scanning on their behalf earlier, or a duplicate/forwarded QR.

Fix: Check checked_in_at on the attendee row (visible in the Attendees table, "Checked In" column) to confirm the actual time, then resolve manually if it's wrong.

Attendee's RSVP link no longer works after being re-added

Cause: Deleting and re-adding an attendee creates a new row with a new id. The QR (/checkin?id=...) and RSVP token both key off that id, so old links point to a nonexistent attendee.

Fix: Regenerate the QR from the Attendees tab (AttendeeQrDialog) and re-send the invite so the RSVP token is reissued too.

Agenda items appear in the wrong order

Cause: dayIndex and sortOrder both default to 0 when not set. Items sharing the same day/sort value fall back to created_at ASC, which may not match intended reading order.

Fix: Open the agenda builder and set explicit dayIndex (for multi-day) and sortOrder values on each item.


FAQ

Q: Can I add the same person twice as an attendee? Not prevented at the schema level for manual entries, but the "Add from Contacts" dropdown filters out contacts already added (!attendees.some(a => a.contactId === c.id)), so duplicate additions via that path are UI-blocked, not database-blocked.

Q: Does confirming attendance via email also check the attendee in? No. Confirming (answer=yes) sets rsvp_status = 'confirmed'. Only a QR scan or manual check-in sets attended and checked_in_at.

Q: What happens to queued reminder emails if someone cancels? See Running a consultation — Warnings: already-queued reminders still send unless "Pause All Emails" is toggled for the event.

Q: Can agenda startTime/endTime enforce a real time format? No — they're free-text strings up to 40 characters (validated by length only, not format), so entries like "Morning" or "TBD" are accepted.


Related articles