Appointment requests with availability windows and email confirmation.
An appointment-request flow: visitors pick a free slot inside the availability windows the owner sets in Studio, requests arrive as a queue in Studio, and confirming a request emails the visitor automatically. It's a request-and-confirm flow by design — the owner always has the final say, so no double bookings and no calendar sync required.
Availability lives in a global (weekly windows + slot length) the owner edits in Studio. A custom route computes free slots for a given date — windows minus already-confirmed bookings. Requests are drafts of a booking type; publishing a booking is confirming it, which both blocks the slot for everyone else and triggers the confirmation email via the afterPublish hook.
You are adding appointment booking to a Prelo project. Outbound email is required for confirmations — check the SMTP env vars `prelo doctor` reports and tell the user if they're missing.
1. **Availability global.** In `cms.config.js` under `globals:`:
```js
booking: {
fields: {
slotMinutes: { type: "number", required: true }, // e.g. 30
leadHours: { type: "number" }, // min notice, default 24
windows: { type: "list", of: {
day: { type: "select", options: ["mon","tue","wed","thu","fri","sat","sun"], required: true },
start: { type: "text", required: true }, // "09:00"
end: { type: "text", required: true }, // "17:00"
}},
},
},
```
Read it via `GET /api/globals/booking`; the owner edits it in Studio's globals.
2. **Booking type.**
```js
booking: {
label: "Bookings",
access: "private", // confirmed bookings hold visitor PII — never public
fields: {
when: { type: "text", required: true }, // ISO start, e.g. 2026-09-12T10:00
name: { type: "text", required: true },
email: { type: "text", required: true },
message: { type: "text" },
},
studio: { columns: ["when", "name"] },
},
```
Draft = requested. **Published = confirmed.** Deleted = declined (see step 6 for the decline email caveat).
`access: "private"` is **required**: publishing is how the owner confirms a booking, and without it every confirmed booking — visitor name, email, message and appointment time — would be readable by anyone at `GET /api/booking`. The slots route (step 3) queries the database directly, so it is unaffected.
3. **Slots route.** `"GET /booking/slots"` in `routes:` — query `?date=2026-09-12`:
- Load the global; find windows matching that weekday; slice into `slotMinutes` steps.
- Remove slots that collide with **published** bookings on that date (query the `content` table for type `booking`, status published, `when` starting with the date).
- Remove slots earlier than now + `leadHours` (default 24).
- Respond `{ date, slots: ["09:00","09:30",…] }`. Empty array is a valid answer.
4. **Request route.** `"POST /booking"`:
- Validate name/email/message; validate `when` is exactly one of the currently free slots for its date (recompute server-side — never trust the client's slot list). Honeypot `website`: silently accept and discard. Rate-limit 3/hour/IP via `x_booking_hits` created in `hooks.boot`.
- Insert as a **draft** booking (title = `{when} — {name}`). Requests do **not** block the slot until confirmed; if two people request the same slot, the owner confirms one and the other's slot validation would have passed — handle this by re-checking collisions in the confirmation hook (step 5) and refusing there is impossible (hooks can't veto), so instead: on `afterPublish`, if another published booking already holds the slot, email the owner a conflict warning instead of confirming the visitor. Keep this logic small but present.
- Optionally email the owner "New booking request" per new draft.
5. **Confirmation email.** In `cms.config.js` `hooks:`, add `afterPublish: (row, db) => …`. When `row.type === "booking"`, send the visitor a confirmation email: date/time formatted, site name, plus the owner's reply-to. This makes Studio's Publish button the entire confirmation workflow — no custom Studio work at all.
6. **Frontend.** A booking page/section: date picker (native `<input type="date">`, min = today) → fetches `/api/x/booking/slots?date=…` → renders slot buttons → form (name, email, message, honeypot) posts to `/api/x/booking`. States: "Requested — you'll get an email when it's confirmed" on success; slot-taken (`409` from the route) re-fetches slots and asks the visitor to pick again; inputs preserved on failure. For declines, note to the owner (README comment) that deleting a request sends nothing — decline by emailing the visitor directly, or ask the agent to add a `declined` flag flow later.
7. **Verify.** Set windows in Studio; request a slot; confirm it in Studio; check the email, the slot vanishing from `/slots`, and the double-request conflict path. Confirm unauthenticated `GET /api/booking` returns `404 unknown_type` — visitor details must never be publicly readable.The owner sets their week once in Studio globals, then lives in the Bookings list: publish to confirm (visitor gets the email, slot locks), leave as draft to think, delete to decline. Changing slotMinutes or windows reshapes future availability instantly.
Contact form with spam protection and email notifications, plus a submissions inbox in Studio.
Threaded comments with a moderation queue in Studio. No third-party embed.
Events as a content type with a monthly calendar view and iCal feed.