Documentation — How it works
Last updated: August 6, 2026
1. Flow overview
An appointment is created by a signed-in organizer, then sent to a guest via a unique public link. The guest doesn't need an account: they respond from a public page, and the response instantly appears on the organizer's dashboard.
Organizer (signed in)
└─ creates an appointment ──▶ appointments (status = pending, invite_token generated)
├─ "Send by email" ──▶ server fn sendInviteEmail ──▶ Resend
└─ "Copy link" ──▶ /invite/<token>
Guest (no account)
└─ opens /invite/<token> ──▶ RPC get_invite(_token) (filtered public read)
└─ Accept / Decline ──▶ RPC respond_to_invite(_token, _accept, _message)
└─ status = accepted | declined, responded_at = now()
Organizer
└─ dashboard: status badge, response date, guest message, stats2. Invitation email content
The email is composed server-side in the sendInviteEmail function and sent via Resend. Sender: value of RESEND_FROM (defaults to "Cadence <onboarding@resend.dev>"). Subject: "Appointment: {{title}}".
Body structure, in order:
- Title "Appointment proposal".
- Greeting "Hi {{guestName}},".
- Personalized message block (Business organizers only).
- Appointment title in bold, then date and time slot formatted for the locale.
- Location and description, each shown only if filled in.
- Green "Confirm or decline" action button pointing to the invite link.
- Plain-text link reminder, for mail clients that block buttons.
- "Google reviews" block (Business): business name, rating, review link, Maps link.
All dynamic values are HTML-escaped before insertion: nothing an organizer types can inject markup into the email.
Available variables
| Variable | Source | Usage |
|---|---|---|
| {{guestName}} | contacts.name | Greeting line "Hi {{guestName}},". Empty if no contact is attached. |
| {{title}} | appointments.title | Email subject "Appointment: {{title}}" and bold title in the body. |
| {{dateRange}} | appointments.starts_at / ends_at | Formatted per locale: "Tuesday, Aug 12, 3:00 PM → 3:30 PM" (Intl.DateTimeFormat). |
| {{location}} | appointments.location | Line "Location: …". The whole block is omitted if the field is empty. |
| {{description}} | appointments.description | Free-text paragraph shown below the date. Omitted if empty. |
| {{inviteLink}} | origin + /invite/ + appointments.invite_token | "Confirm or decline" button + plain-text link reminder at the bottom of the email (copy/paste). |
| {{inviteMessage}} | profiles.invite_message (Business) | Green custom-message box, shown only for a Business organizer. |
| {{businessName}} / {{googleRating}} / {{googleReviewUrl}} / {{googleMapsUrl}} | profiles.* (Business) | "Google reviews" block in the email footer and on the invite page, if enabled. |
Cases where sending doesn't complete
- no_email : the contact has no email address. The organizer has to copy the link and send it through another channel.
- no_api_key : the sending key isn't configured. The invite link is still returned to the UI for manual copying.
- provider_error : the email provider rejected it (invalid address, unverified domain…). The appointment status stays unchanged.
- On success, invite_sent_at is timestamped: this field feeds the "links sent" tracking in the stats.
3. Confirmation screens (page /invite/<token>)
A public page, no authentication required, rendered from the secured get_invite function, which only returns the fields needed for display.
- Loading : animated skeleton (calendar) while the response hasn't arrived yet.
- Invalid or expired link : error message; no information about the organizer or the appointment is disclosed.
- "Pending" state : subject, date and time slot, duration, location, organizer name, personalized message (Business), optional free-text field (500 characters max), then two buttons "Accept" and "Decline".
- After accepting : green "Accepted" badge, on-screen confirmation, reminder of practical details, and Google reviews block if the organizer is on Business.
- After declining : red "Declined" badge, message stating the organizer has been notified; the guest can reopen the link and change their response as long as the appointment isn't cancelled.
- Cancelled appointment : the response buttons no longer have any effect, the displayed status stays "Cancelled".
The guest's free-text message is truncated to 500 characters client-side and 1,000 in the database, then shown to the organizer on the appointment record.
4. Appointment statuses
| Status | Label | Trigger | Effect |
|---|---|---|---|
| pending | Pending | On appointment creation (default value in the database). | The link is active, the guest can respond. Counted in "responses expected". |
| accepted | Accepted | The guest clicks "Accept" — calls respond_to_invite(_accept = true). | responded_at and response_message are filled in; green badge on the dashboard. |
| declined | Declined | The guest clicks "Decline" — respond_to_invite(_accept = false). | Same record as acceptance, red badge. The slot stays visible for follow-up. |
| cancelled | Cancelled | Organizer action from their dashboard. | The link no longer accepts a response (respond_to_invite only updates other statuses). |
5. Server-side flow
- Create / edit / delete : done by the authenticated client; data isolation is enforced by database access rules.
- sendInviteEmail: protected server function. It reloads the appointment filtered on the requester's ID, refuses if the appointment doesn't belong to them, composes the email, calls Resend, then timestamps the send.
- get_invite(_token): elevated-privilege database function, the only public read path. It only returns display fields, and hides branding information for a non-Business organizer.
- respond_to_invite(_token, _accept, _message): updates the status, message, and response timestamp. It only acts on the row matching the token and ignores cancelled appointments.
- get_plan_usage(): returns the plan, this month's usage, and the admin flag for displaying quotas.
6. Authorization logic
- Organizer : full read/write access to their own contacts and appointments, and only those.
- Signed-out guest : no direct access to tables. They can only read an appointment via their token and respond to it. The token is random (36 hex characters) and grants access to nothing else.
- Roles : stored in a dedicated table (user, moderator, admin) and checked server-side; they are never read from the browser to grant a permission.
- Admin : access to the /admin area (all accounts, contacts, and appointments) and exemption from Free-mium plan quotas.
- Plan : tier changes can't be forced from the client; only the Stripe payment flow or an admin can change it.
- Quotas : enforced in the database before every creation — 10/15 (Free-mium), 30/50 (Basic), 50/100 (Standard), 250/500 (Economic) contacts/appointments per calendar month, unlimited on Business and for admins.
7. Checklist for the editorial team
- Editing an email text: change the sending function's HTML template, not the invite page.
- Editing a confirmation screen: change the public invite page.
- Any new variable must come from a field already exposed by the public read function.
- Never show data from another appointment or another contact on the public page.
- Always test all three states: pending, accepted, declined.