| 1 | # Booking Pages Implementation Plan |
| 2 | |
| 3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| 4 | |
| 5 | **Goal:** A public "book time with me" page per host, with plain-text availability, a mailed link whose button confirms, a tokenised ICS download, and booker-side reschedule and cancel. |
| 6 | |
| 7 | **Architecture:** Booking pages and their parsed windows are new tables owned by a person. A hold is a TENTATIVE event on the host's personal calendar plus a `bookings` row keyed by a hashed (and sealed) token; the existing range query makes a hold block a slot. Hold creation and rescheduling serialise on one process-wide mutex. Every state change is a conditional UPDATE naming the state and revision it expects and checking `RowsAffected`. Mail is a queue table with a claim lease — the reminder queue's own pattern — retried by the existing drain. Public routes live beside `/feed/{token}.ics`: inside CSRF, outside the session group. |
| 8 | |
| 9 | **Tech Stack:** Go, rastrillo v0.23.0 (chi, GORM + modernc SQLite, `mail.Sender`, `csrf.Protect`, `carlos.Tick`), html/template + text/template, Playwright for the browser gate. |
| 10 | |
| 11 | **Spec:** `docs/superpowers/specs/2026-09-07-booking-design.md` **revision 6**. Read it first, in full, including §12. Tasks 1–3 below were written against revision 3 and Task 1 has landed; the section **"Revision 4–6 addenda"** at the end of this plan amends Tasks 3–8 and is BINDING over the task text where they differ. Every task cites the sections it implements. Where this plan and the spec disagree, the spec wins and this plan is wrong — fix the plan in the same commit. |
| 12 | |
| 13 | ## Global Constraints |
| 14 | |
| 15 | Copied from AGENTS.md and the spec. Every task inherits them. |
| 16 | |
| 17 | - **The one rule.** Booker name, address, note, the token, page title, description and availability text are SEALED (`s.seal.SealString`). The booker address ALSO gets `s.seal.Fingerprint(seal.PurposeAttendeeLookup, addr)`. Windows, `booker_zone`, `code`, `ip_fp`, states, revisions and instants are plaintext. |
| 18 | - **No title, place, note, address or token in any log line, error string or metric label.** `scrubErr(err)` on every logged error, with a `"stage"` key. |
| 19 | - **A row that isn't yours 404s, never 403s.** Unknown handle, code or token → the same body the router gives an unknown route. |
| 20 | - **Never bind a request body onto a model.** Explicit `Input` structs; explicit column allowlists in every `Updates`. |
| 21 | - **Every booking transition is a conditional UPDATE** (`WHERE id = ? AND state = ?` plus its guard) checking `RowsAffected == 1`. Zero rows → re-read and render, never retry. Sequence bumps are `gorm.Expr("sequence + 1")`, never `existing.Sequence + 1`. |
| 22 | - **Hold and reschedule run inside `Store.holdMu`** (`sync.Mutex` on `Store`), with caps and the slot check inside the lock. |
| 23 | - **No sender error is definite.** Nothing is freed, deleted or marked failed because `Send` returned an error. Attempts go up; the lease expires; the drain retries. |
| 24 | - **Never `sessions.UserID`.** `CurrentPerson(r)`. |
| 25 | - **Migrations are generated.** `rm internal/calendar/migrations/schema.sql`; add models to `Models`; `go run github.com/carlosframework/rastrillo/cmd/rastrillo migration generate`; then `... migration check`. |
| 26 | - **300-line cap on every `.go` and `.js`.** Split by responsibility. |
| 27 | - **`rst-*` attribute grammar**, never class spellings, except the seven class-only utilities. This app's components take `cal-`. Rastrillo partials: `field-text`, `field-textarea`, `field-select`, `field-check`, `form-foot`, `callout`, `page-header`, `detail-list`, `empty-state`, `badge` — `settings.html` shows the `dict` convention; `~/go/pkg/mod/github.com/carlosframework/rastrillo@v0.23.0/ui/partials/*.html` has the keys. |
| 28 | - **No affordance may require script.** `booking.js` is a debounced fetch plus a zone preselect. |
| 29 | - **Every range is half-open.** `DayBounds`, never `Add(24h)`. |
| 30 | - **Public zone input** is `CommonZones` ∪ {host zone}. |
| 31 | - **Work inside the request.** The one goroutine allowed is `timedSender`'s, and the request WAITS on it (§6). |
| 32 | - **`make ci` before every commit** (sandbox off if the go cache is read-only). Trailer: `Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>`. |
| 33 | - **Tests** in `internal/calendartest` unless they need unexported names. Helpers: `signedIn`, `getAs`, `postAs`, `follow`, `newAppWithMailer(t, *pickyMailer)`, `tick(t, h)`, `app.LastMailForTest()`, `app.GrepDatabaseForTest`, `app.DBForTest()`, `app.SignInForTest`, `calendar.RequestForTest(session, method, target, form)`. `pickyMailer.Send` refuses when `subject+body` contains `refuse`; Task 6 extends it to also match `to` and records `to` per delivery. |
| 34 | |
| 35 | --- |
| 36 | |
| 37 | ## File map |
| 38 | |
| 39 | | File | Responsibility | Task | |
| 40 | |---|---|---| |
| 41 | | `internal/calendar/models_booking.go` | `BookingPage`, `BookingWindow`, `Booking`, `BookingMail`, `Window`, constants | 1 | |
| 42 | | `internal/calendar/models.go`, `seal/seal.go`, `testing.go` | `Person.Handle`; register; purposes; test hooks | 1 | |
| 43 | | `internal/calendar/migrations/0005_*.sql`, `schema.sql` | generated | 1 | |
| 44 | | `internal/calendar/store_booking_page.go` | handle, page CRUD, publish, public lookup, ownership | 1 | |
| 45 | | `internal/calendar/clock.go`, `zones.go` | `Zone()` safe; `IsCommonZone` | 2 | |
| 46 | | `internal/calendar/avail.go`, `avail_describe.go`, `templates/_bookingpane.html` | parser; merge/describe; pane | 2 | |
| 47 | | `internal/calendar/store_range.go` | override-of-finished-series fix | 3 | |
| 48 | | `internal/calendar/slots.go`, `store_slots.go` | `ComputeSlots`; `BusyFor`; `FreeSlots` | 3 | |
| 49 | | `internal/calendar/store_booking_flow.go` | hold, confirm, reschedule, cancel | 4 | |
| 50 | | `internal/calendar/store_booking_read.go` | `BookingByToken`, `BookingView`, counts | 4 | |
| 51 | | `internal/calendar/store_booking_mail.go` | queue, claim, ack, `NextBookingMailDue` | 4 | |
| 52 | | `internal/calendar/store_booking_sweep.go` | expire, forget IPs, `NextHoldExpiry`, `NextIPForget` | 4 | |
| 53 | | `internal/calendar/store_reminder_next.go`, `store_drain.go`, `store_event.go`, `store.go` | `NextDueAt` four sources; claim path checks status; booked-event rules; `holdMu` | 4 | |
| 54 | | `internal/calendar/handlers_booking_settings.go`, `handlers_booking_form.go`, templates, `booking.js` | host UI | 5 | |
| 55 | | `internal/calendar/handlers_booking_public.go`, `handlers_booking_manage.go`, `handlers_booking_util.go` | public flow; IP; bucket; 404 | 6 | |
| 56 | | `internal/calendar/booking_ics.go`, `booking_mail.go`, `mail_timed.go`, `templates/mail/*.txt` | ICS; mail rendering; `timedSender` | 6 | |
| 57 | | `internal/calendar/templates/book_*.html` | public UI | 6 | |
| 58 | | `internal/calendar/handlers_jobs.go` | drain ordering; `DrainOnRequest` one thing | 7 | |
| 59 | | `internal/calendar/routes.go`, `render.go`, `templates/layout.html`, `static/calendar.css` | wiring | 5, 6 | |
| 60 | | tests: `booking_store_test.go`, `zone_test.go`, `avail_test.go`, `slots_test.go`, `occurrence_test.go` (+1), `booking_flow_test.go`, `booking_mail_test.go`, `booking_http_test.go`, `booking_abuse_test.go`, `booking_sweep_test.go`, `events_test.go` (+fixture) | | 1–7 | |
| 61 | | `test/browser/booking.mjs`, `playwright_test.go`, `Makefile`, `package.json` | browser | 7 | |
| 62 | | `AGENTS.md` | §11 | 8 | |
| 63 | |
| 64 | --- |
| 65 | |
| 66 | ### Task 1: Models, migration, purposes, page store, ownership |
| 67 | |
| 68 | Spec §2.1–2.6, §7.4 (publish). |
| 69 | |
| 70 | **Produces:** |
| 71 | |
| 72 | ```go |
| 73 | // models_booking.go |
| 74 | const ( |
| 75 | BookingHeld = "held" |
| 76 | BookingConfirmed = "confirmed" |
| 77 | BookingCancelled = "cancelled" |
| 78 | BookingExpired = "expired" |
| 79 | |
| 80 | CancelledByNobody = "" |
| 81 | CancelledByBooker = "booker" |
| 82 | CancelledByHost = "host" |
| 83 | |
| 84 | MailHold = "hold" |
| 85 | MailConfirmed = "confirmed" |
| 86 | MailRescheduled = "rescheduled" |
| 87 | MailCancelled = "cancelled" |
| 88 | ToBooker = "booker" |
| 89 | ToHost = "host" |
| 90 | |
| 91 | HoldTTL = 30 * time.Minute |
| 92 | MinNotice = 10 * time.Minute |
| 93 | MaxReschedules = 5 |
| 94 | IPForgetAfter = 24 * time.Hour |
| 95 | MailGaveUp = int64(-1) // sent_at value meaning "stopped trying" |
| 96 | |
| 97 | MaxPageTitleBytes = 100 |
| 98 | MaxPageDescBytes = 1000 |
| 99 | MaxBookerNameRunes = 60 |
| 100 | MaxBookerNameBytes = 80 |
| 101 | MaxNoteRunes = 1000 |
| 102 | MaxNoteBytes = 4000 |
| 103 | ) |
| 104 | |
| 105 | type Window struct{ Weekday time.Weekday; StartMin, EndMin int } |
| 106 | |
| 107 | type BookingPage struct { |
| 108 | ID int64 `gorm:"primaryKey"` |
| 109 | PersonID int64 `gorm:"column:person_id;not null;uniqueIndex:idx_booking_page_code"` |
| 110 | Code string `gorm:"column:code;not null;uniqueIndex:idx_booking_page_code"` |
| 111 | TitleSealed []byte `gorm:"column:title_sealed"` |
| 112 | DescriptionSealed []byte `gorm:"column:description_sealed"` |
| 113 | DurationMin int `gorm:"column:duration_min;not null"` |
| 114 | BufferMin int `gorm:"column:buffer_min;not null"` |
| 115 | NoticeMin int `gorm:"column:notice_min;not null"` |
| 116 | HorizonDays int `gorm:"column:horizon_days;not null"` |
| 117 | AvailabilitySealed []byte `gorm:"column:availability_text_sealed"` |
| 118 | PublishedAt *int64 `gorm:"column:published_at"` |
| 119 | CreatedAt time.Time `gorm:"column:created_at;not null"` |
| 120 | UpdatedAt time.Time `gorm:"column:updated_at;not null"` |
| 121 | } |
| 122 | |
| 123 | type BookingWindow struct { |
| 124 | ID int64 `gorm:"primaryKey"` |
| 125 | PageID int64 `gorm:"column:page_id;not null;index"` |
| 126 | Weekday int `gorm:"column:weekday;not null"` |
| 127 | StartMin int `gorm:"column:start_min;not null"` |
| 128 | EndMin int `gorm:"column:end_min;not null"` |
| 129 | } |
| 130 | |
| 131 | type Booking struct { |
| 132 | ID int64 `gorm:"primaryKey"` |
| 133 | PageID int64 `gorm:"column:page_id;not null;index"` |
| 134 | EventID int64 `gorm:"column:event_id;not null;index"` |
| 135 | TokenHash string `gorm:"column:token_hash;not null;uniqueIndex"` |
| 136 | TokenSealed []byte `gorm:"column:token_sealed"` |
| 137 | State string `gorm:"column:state;not null;index"` |
| 138 | BookerNameSealed []byte `gorm:"column:booker_name_sealed"` |
| 139 | BookerAddressSealed []byte `gorm:"column:booker_address_sealed"` |
| 140 | BookerAddressFP string `gorm:"column:booker_address_fp;not null;index"` |
| 141 | NoteSealed []byte `gorm:"column:note_sealed"` |
| 142 | BookerZone string `gorm:"column:booker_zone;not null"` |
| 143 | HoldExpiresAt int64 `gorm:"column:hold_expires_at;not null;index"` |
| 144 | ConfirmedAt *int64 `gorm:"column:confirmed_at"` |
| 145 | CancelledAt *int64 `gorm:"column:cancelled_at"` |
| 146 | CancelledBy string `gorm:"column:cancelled_by;not null"` |
| 147 | IPFP *string `gorm:"column:ip_fp;index"` |
| 148 | IPForgetAt int64 `gorm:"column:ip_forget_at;not null;index"` |
| 149 | Reschedules int `gorm:"column:reschedules;not null"` |
| 150 | CreatedAt time.Time `gorm:"column:created_at;not null"` |
| 151 | UpdatedAt time.Time `gorm:"column:updated_at;not null"` |
| 152 | } |
| 153 | |
| 154 | type BookingMail struct { |
| 155 | ID int64 `gorm:"primaryKey"` |
| 156 | BookingID int64 `gorm:"column:booking_id;not null;uniqueIndex:idx_booking_mail_once"` |
| 157 | Kind string `gorm:"column:kind;not null;uniqueIndex:idx_booking_mail_once"` |
| 158 | Recipient string `gorm:"column:recipient;not null;uniqueIndex:idx_booking_mail_once"` |
| 159 | Revision int `gorm:"column:revision;not null;uniqueIndex:idx_booking_mail_once"` |
| 160 | DueAt int64 `gorm:"column:due_at;not null;index"` |
| 161 | Attempts int `gorm:"column:attempts;not null"` |
| 162 | ClaimedAt *int64 `gorm:"column:claimed_at"` |
| 163 | SentAt *int64 `gorm:"column:sent_at"` |
| 164 | CreatedAt time.Time `gorm:"column:created_at;not null"` |
| 165 | } |
| 166 | |
| 167 | // models.go, on Person after Zone: |
| 168 | Handle *string `gorm:"column:handle;uniqueIndex"` |
| 169 | |
| 170 | // seal/seal.go |
| 171 | PurposeBookingTitle = "booking.title" |
| 172 | PurposeBookingDescription = "booking.description" |
| 173 | PurposeBookingAvailability = "booking.availability" |
| 174 | PurposeBookingToken = "booking.token" |
| 175 | PurposeBookerName = "booking.booker.name" |
| 176 | PurposeBookerAddress = "booking.booker.address" |
| 177 | PurposeBookerNote = "booking.booker.note" |
| 178 | PurposeBookingIP = "booking.ip" |
| 179 | |
| 180 | // store_booking_page.go |
| 181 | type BookingPageInput struct { |
| 182 | Title, Description, Availability string |
| 183 | DurationMin, BufferMin, NoticeMin, HorizonDays int |
| 184 | } |
| 185 | type BookingPageView struct { |
| 186 | BookingPage |
| 187 | Title, Description, Availability string |
| 188 | Windows []Window |
| 189 | Published bool |
| 190 | } |
| 191 | var ErrHandleTaken = errors.New("calendar: handle taken") |
| 192 | |
| 193 | func ValidateHandle(h string) error // ^[a-z0-9-]{2,32}$ after lowercasing; not m/new/static |
| 194 | func ValidateBookingPageInput(in *BookingPageInput) error // trims; len(title) 1..100 BYTES; len(desc) <= 1000 BYTES; duration 5..480; buffer 0..120; notice 0..10080; horizon 1..90 |
| 195 | func newPageCode() string // 8 × [a-z0-9], crypto/rand |
| 196 | func (s *Store) SaveHandle(ctx, personID int64, handle string) error |
| 197 | func (s *Store) PersonByHandle(ctx, handle string) (Person, error) |
| 198 | func (s *Store) CreateBookingPage(ctx, personID int64, in BookingPageInput, windows []Window) (BookingPage, error) |
| 199 | func (s *Store) UpdateBookingPage(ctx, personID, pageID int64, in BookingPageInput, windows []Window) error |
| 200 | func (s *Store) PublishBookingPage(ctx, personID, pageID int64, publish bool) error |
| 201 | func (s *Store) BookingPages(ctx, personID int64) ([]BookingPageView, error) |
| 202 | func (s *Store) BookingPage(ctx, personID, pageID int64) (BookingPageView, error) |
| 203 | func (s *Store) PublicBookingPage(ctx, handle, code string) (BookingPageView, Person, error) |
| 204 | |
| 205 | // testing.go |
| 206 | func (a *App) StoreForTest() *Store |
| 207 | func (a *App) PersonByAddressForTest(t TB, address string) Person |
| 208 | ``` |
| 209 | |
| 210 | - [ ] **Step 1: Failing tests** — `booking_store_test.go`: `TestABookingPageRoundTripsSealedWithItsWindows` (8-char code; unpublished → `NotFound`; publish → visible with `Published`; sealed grep for title/description/availability), `TestUnknownHandleUnknownCodeAndUnpublishedAllVanishTheSameWay`, `TestAHandleIsUniqueLowercaseAndNotReserved` (`"Paul"` saves as `paul`; second claim `ErrHandleTaken`; `m`, `new`, `static`, `a`, `has space`, `üñí` refused; `""` clears), `TestAnotherPersonCannotReadEditOrPublishMyPage` (all three `NotFound`), `TestPageLimitsAreBytes` (a 101-byte title of 34 three-byte runes is refused; a 100-byte one accepted). |
| 211 | - [ ] **Step 2: Run; compile failure.** |
| 212 | - [ ] **Step 3: Models, purposes, `Handle`, `Models` registration (all four new models), `testing.go` hooks.** |
| 213 | - [ ] **Step 4: Generate the migration; check.** Inspect: `handle` nullable + unique; `ip_fp` nullable; `published_at` nullable; `token_sealed` blob; the four-column unique on `booking_mails`. |
| 214 | - [ ] **Step 5: `store_booking_page.go`.** Every scoped call is `Where("id = ? AND person_id = ?")`; miss → `ErrNotFound`. Updates by explicit map. Windows deleted + reinserted in the same transaction. `PublicBookingPage`: `PersonByHandle` → `First(&page, "person_id = ? AND code = ? AND published_at IS NOT NULL")` → open → windows ordered. |
| 215 | - [ ] **Step 6: Pass. `make ci`. Commit** `booking: pages, windows, bookings and their mail — the tables, sealed where they are content`. |
| 216 | |
| 217 | --- |
| 218 | |
| 219 | ### Task 2: `Zone()` made safe; availability parser; pane |
| 220 | |
| 221 | Spec §3, §5.1, §9. |
| 222 | |
| 223 | **Produces:** |
| 224 | ```go |
| 225 | func ParseAvailability(text string) (windows []Window, problems []string) |
| 226 | func MergeWindows(ws []Window) []Window |
| 227 | func DescribeWindows(ws []Window) []string |
| 228 | type BookingPaneView struct{ Lines, Problems []string; Empty bool } |
| 229 | func bookingPaneFor(text string) BookingPaneView |
| 230 | func IsCommonZone(name string) bool // zones.go |
| 231 | func ZoneCacheSizeForTest() int // testing.go |
| 232 | ``` |
| 233 | |
| 234 | - [ ] **Step 1: `zone_test.go`**: 100 goroutines resolve `Nowhere/TownN` and `Europe/Dublin`; run with `-race`; afterwards `ZoneCacheSizeForTest() <= len(CommonZones)+5`. |
| 235 | - [ ] **Step 2: `clock.go`**: `sync.Map`, successes only. `zones.go`: `IsCommonZone`. |
| 236 | - [ ] **Step 3: `avail_test.go`** — the reading table (every §3.1 example plus: `sun-tue 9-10`; `monday 9-12, monday 10-13` → one `09:00–13:00`; `monday 9-12, 12-1` → one; `monday 22:00-24:00` → `{Monday, 1320, 1440}`; `monday 0-1am`; `12:30-2:30` under `tuesdays` → `12:30–14:30`; `9pm-11` → `21:00–23:00`), the refusing table (`10pm-2`, `whenever`, leading `12-3` with no day, `25-26`, `9:60-10`, `13pm-2pm`, `24:00-1`, `9-9`, `9-5 in the office` quoting `in the office`; two bad clauses → two problems), and `DescribeWindows`. |
| 237 | - [ ] **Step 4: Implement** `avail.go` + `avail_describe.go` (a `_windows.go` suffix is a GOOS build constraint; Go would skip the file on Linux): |
| 238 | |
| 239 | ```go |
| 240 | var ( |
| 241 | reAvailSplit = regexp.MustCompile(`\s*[,;]\s*`) |
| 242 | reAvailGroup = regexp.MustCompile(`(?i)^\s*(?:every\s+)?(weekdays|weekends|every\s+day|daily|all\s+days)\b`) |
| 243 | reAvailDayRange = regexp.MustCompile(`(?i)^\s*(?:every\s+)?(` + weekdayAlt + `)s?` + rangeSep + `(` + weekdayAlt + `)s?\b`) |
| 244 | reAvailDay = regexp.MustCompile(`(?i)^\s*(?:every\s+)?(` + weekdayAlt + `)s?\b`) |
| 245 | reAvailRange = regexp.MustCompile(`(?i)\b(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*(?:[-–—]|to|until|till)\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\b`) |
| 246 | reAvailFiller = regexp.MustCompile(`(?i)\b(between|from|and|on|at)\b`) |
| 247 | ) |
| 248 | |
| 249 | // readClock validates RAW components; nothing is clamped. |
| 250 | // meridiem != "": hour 1..12, minute 0..59; 12am → 0, 12pm → 12. |
| 251 | // meridiem == "": hour 0..24 (24 only with minute 0 and only when atEnd), minute 0..59; |
| 252 | // if inferPM and 1 <= hour <= 7, hour += 12 — on the HOUR COMPONENT, whatever the minutes. |
| 253 | func readClock(hh, mm, meridiem string, inferPM, atEnd bool) (minutes int, ok bool) |
| 254 | ``` |
| 255 | Range rule: `sm, em := m[3], m[6]`; if exactly one is set, the other inherits it; `inferPM := sm == "" && em == ""`. `end <= start` → problem quoting `m[0]`. Every unconsumed non-filler token → problem quoting the clause. `MergeWindows` sorts by weekday then start and merges `next.StartMin <= cur.EndMin`. |
| 256 | |
| 257 | - [ ] **Step 5: `_bookingpane.html`** (as in revision-2 plan) into `fragments` and every page ParseFS. |
| 258 | - [ ] **Step 6: Pass. `make ci`. Commit** `booking: one parser for "mondays 12–3", and Zone() stops caching garbage`. |
| 259 | |
| 260 | --- |
| 261 | |
| 262 | ### Task 3: The range-query fix; slot arithmetic |
| 263 | |
| 264 | Spec §4, §4.1, §4.2 (day validation only; the bucket is Task 6). |
| 265 | |
| 266 | **Produces:** |
| 267 | ```go |
| 268 | type Slot struct{ StartUTC, EndUTC int64; Local time.Time } |
| 269 | type Busy struct{ StartUTC, EndUTC int64 } |
| 270 | type SlotRule struct{ DurationMin, BufferMin, NoticeMin, HorizonDays int; Zone string; Windows []Window } |
| 271 | func ComputeSlots(rule SlotRule, busy []Busy, now, from, to time.Time) ([]Slot, error) |
| 272 | func (s *Store) BusyFor(ctx, hostID int64, from, to int64, now time.Time, excludeEventID int64) ([]Busy, error) |
| 273 | func (s *Store) FreeSlots(ctx, page BookingPageView, host Person, now, from, to time.Time, excludeEventID int64) ([]Slot, error) |
| 274 | func ruleFor(page BookingPageView, host Person) SlotRule |
| 275 | ``` |
| 276 | |
| 277 | - [ ] **Step 1: The range regression first** — `occurrence_test.go`, `TestAMovedOccurrenceOfAFinishedSeriesStillShows`: create a daily series with `repeat_end` (check the form's field name in `event_form.html`) three days from now; move its LAST occurrence (`scope=this`) to ten days from now; `GET /day/<that day>` must show the chip. Run: FAIL (the override is dropped). |
| 278 | - [ ] **Step 2: Fix `store_range.go`**: after collecting `masterIDs` from masters, also add `*r.SeriesID` for every row with `r.SeriesID != nil` whose master is not already listed. Pass. |
| 279 | - [ ] **Step 3: `slots_test.go`** (helpers `dublin`, `mon = 2026-09-07 Dublin`, `mondayRule(dur, buffer)` with 12:00–15:00 Monday, `starts()`, `eq()`): |
| 280 | - step-by-duration → `12:00, 13:00, 14:00` |
| 281 | - busy 13:00–13:30 → `12:00, 14:00` |
| 282 | - buffer 10 → `14:00` |
| 283 | - busy 11:50–11:55 with `from = 12:00`, buffer 10 → `13:00, 14:00` |
| 284 | - notice floor: `NoticeMin: 0`, now = 11:55 → 12:00 is NOT offered (`MinNotice` is 10 min) |
| 285 | - notice 120, now 11:00, horizon 7, `to` = +14d → `Mon 13:00, Mon 14:00` only |
| 286 | - `to` bounds start: rule window Monday 23:00–24:00, 60-min, `to` = Tuesday 00:00 local → the 23:00 slot IS offered |
| 287 | - spring-forward 2026-03-29 with 00:00–04:00 / 60: no slot straddles 01:00; every offered start and end satisfies `ToUTC(FormatLocal(x)) == x.Unix()`; pin the list once observed |
| 288 | - fold 2026-10-25 with 00:00–04:00 / 60: same assertions; additionally assert `(EndUTC-StartUTC) == 3600` for every slot |
| 289 | - Tokyo host Monday 09:00 → LA Sunday |
| 290 | - [ ] **Step 4: Implement `slots.go`:** |
| 291 | |
| 292 | ```go |
| 293 | for m := w.StartMin; m+rule.DurationMin <= w.EndMin; m += rule.DurationMin { |
| 294 | start := time.Date(day.Year(), day.Month(), day.Day(), 0, m, 0, 0, loc) |
| 295 | end := start.Add(dur) |
| 296 | if !wallIs(start, day, m) || !wallIs(end, day, m+rule.DurationMin) { continue } |
| 297 | if !roundTrips(start, rule.Zone) || !roundTrips(end, rule.Zone) { continue } |
| 298 | if start.Before(earliest) || start.Before(from) || !start.Before(to) || end.After(latest) { continue } |
| 299 | if overlapsAny(start.Add(-pad).Unix(), end.Add(pad).Unix(), busy) { continue } |
| 300 | out = append(out, Slot{start.Unix(), end.Unix(), start}) |
| 301 | } |
| 302 | // wallIs: t is on `day`'s local date (or, when minutes == 1440, the next date at 00:00) and t.Hour()*60+t.Minute() == minutes%1440. |
| 303 | // roundTrips: u, err := ToUTC(FormatLocal(t, false), zone, false); err == nil && u == t.Unix(). |
| 304 | // earliest := now.Add(max(MinNotice, NoticeMin)); latest := now.In(loc).AddDate(0,0,HorizonDays). |
| 305 | ``` |
| 306 | `store_slots.go`: envelope ±1 day; for all-day, `evLoc := zoneOrUTC(e.TZ)`; `first := time.Unix(e.OccStartUTC,0).In(evLoc)`; `last := time.Unix(e.OccEndUTC-1,0).In(evLoc)`; for each date in `[first, last]` add `DayBounds(date, host.Zone)`. Lapsed holds: `SELECT event_id FROM bookings WHERE state='held' AND hold_expires_at <= ?` → skip set. |
| 307 | |
| 308 | - [ ] **Step 5: Pass; `make ci`; commit** `booking: free slots — the host's windows minus everything the host can see, and the range query stops losing moved occurrences`. |
| 309 | |
| 310 | --- |
| 311 | |
| 312 | ### Task 4: The state machine, the mail queue, the sweep queries, the host's authority |
| 313 | |
| 314 | Spec §5.2–5.7 (store), §2.5, §6 (queue), §7.1 (counts), §8 (queries), §5.5 (host authority). |
| 315 | |
| 316 | **Produces:** |
| 317 | |
| 318 | ```go |
| 319 | // store.go |
| 320 | type Store struct { g *gorm.DB; seal *seal.Sealer; logger *slog.Logger; holdMu sync.Mutex } |
| 321 | |
| 322 | // store_booking_flow.go |
| 323 | var ( |
| 324 | ErrSlotTaken = errors.New("calendar: slot taken") |
| 325 | ErrOverCap = errors.New("calendar: too many open holds") |
| 326 | ErrBookedEvent = ValidationError{"repeat", "This is a booked meeting; it cannot repeat."} |
| 327 | ) |
| 328 | type HoldInput struct { |
| 329 | Page BookingPageView |
| 330 | Host Person |
| 331 | StartUTC, EndUTC int64 |
| 332 | Name, Address string |
| 333 | Note, BookerZone string |
| 334 | IPFP string |
| 335 | } |
| 336 | func (s *Store) HoldBooking(ctx, in HoldInput, now time.Time) (Booking, error) // holdMu; caps; slot; tx incl. mail row; token returned via view |
| 337 | func (s *Store) ConfirmBooking(ctx, bookingID int64, now time.Time) (changed bool, err error) // guard: held, expires > now, now < event.start |
| 338 | func (s *Store) RescheduleBooking(ctx, bookingID, startUTC, endUTC int64, rev int, now time.Time) (changed bool, err error) // holdMu; ErrOverCap; ErrSlotTaken; rev mismatch → changed=false |
| 339 | func (s *Store) CancelBooking(ctx, bookingID int64, by string, now time.Time) (changed bool, err error) |
| 340 | func (s *Store) cancelBookingsForEvent(tx *gorm.DB, eventID int64, by string, now time.Time) error // used by UpdateEvent/DeleteEvent |
| 341 | |
| 342 | // store_booking_read.go |
| 343 | type BookingView struct { |
| 344 | Booking |
| 345 | Page BookingPageView |
| 346 | Host Person |
| 347 | HostName string |
| 348 | Event Event // the ROW, for Status/StartUTC/EndUTC/StartLocal/EndLocal/TZ/Sequence/DeletedAt only |
| 349 | EventUID string |
| 350 | Name, Address, Note string |
| 351 | Token string // opened; empty unless withToken |
| 352 | } |
| 353 | type HoldCounts struct{ OpenIP, OpenPage, OpenAddress, AttemptsIP, AttemptsAddress int } |
| 354 | func (s *Store) BookingByToken(ctx, token string) (BookingView, error) |
| 355 | func (s *Store) BookingByID(ctx, id int64, withToken bool) (BookingView, error) |
| 356 | func (s *Store) HoldCountsFor(ctx, pageID int64, ipFP, addressFP string, now time.Time) (HoldCounts, error) |
| 357 | func (s *Store) bookingIDForEvent(ctx, eventID int64) (int64, bool) |
| 358 | |
| 359 | // store_booking_mail.go |
| 360 | func queueBookingMail(tx *gorm.DB, bookingID int64, kind string, recipients []string, revision int, now time.Time) error |
| 361 | func (s *Store) ClaimBookingMail(ctx, now int64) (BookingMail, bool, error) // lease = claimLease; oldest due first |
| 362 | func (s *Store) MarkBookingMailSent(ctx, id int64, now int64) error // WHERE id=? AND sent_at IS NULL |
| 363 | func (s *Store) MarkBookingMailFailed(ctx, id int64, now int64) error // attempts+1; at MaxReminderAttempts sent_at = MailGaveUp |
| 364 | func (s *Store) NextBookingMailDue(ctx) time.Time |
| 365 | func (s *Store) UnsentBookingMailForTest(bookingID int64) []BookingMail |
| 366 | |
| 367 | // store_booking_sweep.go |
| 368 | func (s *Store) ExpireHolds(ctx, now time.Time, limit int) (int, error) |
| 369 | func (s *Store) ForgetOldIPs(ctx, now time.Time, limit int) (int, error) |
| 370 | func (s *Store) NextHoldExpiry(ctx) time.Time |
| 371 | func (s *Store) NextIPForget(ctx) time.Time |
| 372 | ``` |
| 373 | |
| 374 | - [ ] **Step 1: Tests** — `booking_flow_test.go` (helpers `hostWithPage`, `slotsFor`) and `booking_mail_test.go`: |
| 375 | - hold: tentative event titled `"Chat — Doc"`; `HoldExpiresAt == min(now+30m, start)` — test both branches with a slot 5 minutes out (notice 0 but `MinNotice` 10 → use 15 minutes out: expiry = start); one unsent `MailHold/ToBooker` row; token not on disk; `BookingByToken(token)` opens the same row and `BookingByID(id, true).Token == token`. |
| 376 | - 20 goroutines, one slot, one winner (`-race`). |
| 377 | - confirm: first `changed`; second not; event confirmed `Sequence == 1`; attendee; default reminder when host has one; one `MailConfirmed/ToHost` row at revision 1. |
| 378 | - confirm after `hold_expires_at` → no change; confirm when `now >= event.StartUTC` → no change (age the event via `DBForTest`). |
| 379 | - reschedule with correct `rev` → `changed`, sequence 2, `Reschedules 1`, two `MailRescheduled` rows at rev 2; the same call again with the OLD rev → `changed=false`, nothing else moved; back to the original slot works; sixth → `ErrOverCap`. |
| 380 | - cancel by booker: `changed`; event cancelled, sequence bumped; `ReminderQueue` rows gone; unsent `MailRescheduled` rows deleted; two `MailCancelled` rows; `CancelledBy == booker`; second → no change. |
| 381 | - host `UpdateEvent` with `Status: cancelled` → booking cancelled by host, one `MailCancelled/ToBooker`; host `DeleteEvent` → same plus sequence bumped on the deleted row; a `held` booking on a deleted event → `expired`. |
| 382 | - `UpdateEvent` with `RRule` on a booked event → `ErrBookedEvent`. |
| 383 | - mail queue: `ClaimBookingMail` twice in a row returns the same row only after `claimLease`; `MarkBookingMailSent` is conditional; `MarkBookingMailFailed` × `MaxReminderAttempts` → `SentAt == MailGaveUp`; `NextBookingMailDue` is zero when nothing is unsent. |
| 384 | - sweep: `ExpireHolds` → expired, event soft-deleted, queue rows gone, slot free; `NextHoldExpiry`; `ForgetOldIPs` at `ip_forget_at`; `NextIPForget`. |
| 385 | - `HoldCountsFor`: three holds one IP → `OpenIP 3, AttemptsIP 3`; expire → `OpenIP 0, AttemptsIP 3`. |
| 386 | - `NextDueAt` returns each of the four sources when it is earliest. |
| 387 | - reminder claim path: a queue row whose event is `cancelled` is dropped (`ClaimDueReminder` returns `ok=false` and deletes it). |
| 388 | - [ ] **Step 2: Run; failure.** |
| 389 | - [ ] **Step 3: Implement.** Sketches: |
| 390 | |
| 391 | ```go |
| 392 | func (s *Store) HoldBooking(ctx context.Context, in HoldInput, now time.Time) (Booking, error) { |
| 393 | s.holdMu.Lock() |
| 394 | defer s.holdMu.Unlock() |
| 395 | addrFP, _ := s.seal.Fingerprint(seal.PurposeAttendeeLookup, normalizeAddress(in.Address)) |
| 396 | c, err := s.HoldCountsFor(ctx, in.Page.ID, in.IPFP, addrFP, now) |
| 397 | if err != nil { return Booking{}, err } |
| 398 | if c.OpenIP >= 5 || c.OpenPage >= 20 || c.OpenAddress >= 2 || c.AttemptsIP >= 20 || c.AttemptsAddress >= 6 { return Booking{}, ErrOverCap } |
| 399 | loc, _ := Zone(in.Host.Zone) |
| 400 | d := time.Unix(in.StartUTC, 0).In(loc) |
| 401 | day := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, loc) |
| 402 | slots, err := s.FreeSlots(ctx, in.Page, in.Host, now, day, day.AddDate(0, 0, 1), 0) |
| 403 | if err != nil { return Booking{}, err } |
| 404 | if !hasStart(slots, in.StartUTC) { return Booking{}, ErrSlotTaken } |
| 405 | token := newToken() // 32 bytes hex, as MintFeed |
| 406 | var b Booking |
| 407 | err = s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| 408 | calID, err := s.defaultCalendarIDTx(tx, in.Host.ID) |
| 409 | e, err := s.buildEvent(in.Host.ID, EventInput{CalendarID: calID, Title: in.Page.Title + " — " + in.Name, Notes: in.Note, |
| 410 | StartLocal: FormatLocal(time.Unix(in.StartUTC,0).In(loc), false), EndLocal: FormatLocal(time.Unix(in.EndUTC,0).In(loc), false), |
| 411 | TZ: in.Host.Zone, Kind: KindMeeting, Status: StatusTentative}) |
| 412 | if err := tx.Create(&e).Error; err != nil { return err } |
| 413 | expires := now.Add(HoldTTL).Unix(); if in.StartUTC < expires { expires = in.StartUTC } |
| 414 | b = Booking{PageID: in.Page.ID, EventID: e.ID, TokenHash: HashFeedToken(token), TokenSealed: seal(token), State: BookingHeld, ..., HoldExpiresAt: expires, IPFP: &in.IPFP, IPForgetAt: now.Add(IPForgetAfter).Unix(), ...} |
| 415 | if err := tx.Create(&b).Error; err != nil { return err } |
| 416 | return queueBookingMail(tx, b.ID, MailHold, []string{ToBooker}, 0, now) |
| 417 | }) |
| 418 | return b, err |
| 419 | } |
| 420 | ``` |
| 421 | `ConfirmBooking`: in one transaction read the event row (`start_utc`), then `UPDATE bookings SET state='confirmed', confirmed_at=?, updated_at=? WHERE id=? AND state='held' AND hold_expires_at > ? AND ? < ?` (now, start); `RowsAffected == 0 → return false, nil`; `UPDATE events SET status='confirmed', sequence = sequence + 1, updated_at=? WHERE id=?`; re-read `sequence`; `SetAttendees` (make a `tx` variant or call after commit — say which); `queueBookingMail(tx, id, MailConfirmed, [ToHost], seq, now)`. After commit: `EnsureDefaultReminder(ctx, host, event)`. |
| 422 | |
| 423 | `RescheduleBooking`: `holdMu`; read view; `State != confirmed → false,nil`; `Reschedules >= MaxReschedules → ErrOverCap`; `FreeSlots(..., excludeEventID)` with `dur := Event.EndUTC - Event.StartUTC` — the rule's `DurationMin` for this call is `dur/60`; slot present else `ErrSlotTaken`; tx: `UPDATE events SET start_local, end_local, start_utc, end_utc, sequence = sequence + 1, updated_at WHERE id = ? AND sequence = ?` (rev) → 0 rows → `false, nil`; `UPDATE bookings SET reschedules = reschedules + 1, updated_at WHERE id = ? AND state = 'confirmed'`; `queueBookingMail(... MailRescheduled, [ToBooker, ToHost], rev+1 ...)`; after commit: `RescheduleReminder` for each `RemindersFor(host, eventID)`. |
| 424 | |
| 425 | `CancelBooking(by)`: tx: `UPDATE bookings SET state='cancelled', cancelled_at, cancelled_by=?, updated_at WHERE id=? AND state='confirmed'` → 0 → `false,nil`; `UPDATE events SET status='cancelled', sequence = sequence + 1`; `DELETE FROM reminder_queues WHERE reminder_id IN (SELECT id FROM reminders WHERE event_id = ?)`; `DELETE FROM booking_mails WHERE booking_id = ? AND kind = 'rescheduled' AND sent_at IS NULL`; queue `MailCancelled` to both (or booker only when `by == host`). |
| 426 | |
| 427 | `cancelBookingsForEvent(tx, eventID, by, now)`: the same as the confirmed branch for `state='confirmed'` bookings, and `UPDATE bookings SET state='expired' WHERE event_id=? AND state='held'`. Called from `UpdateEvent` when `in.Status == StatusCancelled && existing.Status != StatusCancelled`, and from `DeleteEvent` (which also does `sequence = sequence + 1`). `UpdateEvent`: `if in.RRule != "" { if _, booked := s.bookingIDForEvent(ctx, eventID); booked { return ErrBookedEvent } }`; change `"sequence": existing.Sequence + 1` to `gorm.Expr("sequence + 1")`. |
| 428 | |
| 429 | `store_drain.go` `ClaimDueReminder`: after loading `e`, `if e.Status == StatusCancelled { delete queue row; return DueReminder{}, false, nil }`. |
| 430 | |
| 431 | `NextDueAt`: min over four non-zero times. |
| 432 | |
| 433 | - [ ] **Step 4: `go test -race ./internal/calendartest/ -run 'Hold|Confirm|Reschedule|Cancel|Booked|Expire|Mail|Forget|NextDue|Claim' -count=1`; pass. `make ci`; commit** `booking: held → confirmed → cancelled as conditional updates, with mail as a queue the drain owns`. |
| 434 | |
| 435 | --- |
| 436 | |
| 437 | ### Task 5: Host settings UI |
| 438 | |
| 439 | Spec §2.1, §3.4, §7.4. Unchanged from revision 2 except: the publish button and its sentence (§7.4); byte limits on title/description in the form's server-side validation message ("Keep it under 100 bytes" is unfriendly — say "Keep it shorter" and rely on the validator); the list shows `Published`/`Not published`. |
| 440 | |
| 441 | **Routes (signed-in group):** `GET /settings/booking`, `GET /settings/booking/new`, `POST /settings/booking`, `GET /settings/booking/{id}`, `POST /settings/booking/{id}`, `POST /settings/booking/{id}/publish` (`publish=1|0`), `POST /settings/booking/parse`. |
| 442 | |
| 443 | - [ ] **Step 1: Tests** — `booking_settings_test.go`: page made and listed as not published with no URL; publish → URL `/book/paul/<8 chars>` and the §7.4 sentence present; unreadable sentence → 422 quoting it, nothing saved; pane fragment; another person → 404 on GET/POST/publish; taken handle → 422 "taken". |
| 444 | - [ ] **Step 2–3: Implement** as in the revision-2 plan (handlers, `bookingPageForm`, templates, `booking.js`, CSS). `settings.html` gains the `handle` field and a link to `/settings/booking`. `booking.js` also does the public page's zone preselect (`[cal-book-zone]` without `data-chosen`). |
| 445 | - [ ] **Step 4: Pass; `make ci`; commit** `booking: the host's side — a handle, pages, a pane, and a Publish button that says what it does`. |
| 446 | |
| 447 | --- |
| 448 | |
| 449 | ### Task 6: The public flow, mail, ICS, the timed sender, the token bucket |
| 450 | |
| 451 | Spec §4.2, §5, §6, §7.1–7.2, §2.7. |
| 452 | |
| 453 | **Routes (chi router, outside the session group, `/book/m/...` registered BEFORE `/book/{handle}/{code}`):** |
| 454 | ``` |
| 455 | GET /book/m/{token} held: confirm button; confirmed: manage; else: gone |
| 456 | POST /book/m/{token}/confirm |
| 457 | GET /book/m/{token}/calendar.ics |
| 458 | GET /book/m/{token}/reschedule ?day=&zone= (+ hidden rev) |
| 459 | POST /book/m/{token}/reschedule start, zone, rev |
| 460 | POST /book/m/{token}/cancel |
| 461 | GET /book/{handle}/{code} ?day=&zone= |
| 462 | POST /book/{handle}/{code} start, zone [, name, address, note] |
| 463 | ``` |
| 464 | |
| 465 | **Produces:** |
| 466 | ```go |
| 467 | // handlers_booking_util.go |
| 468 | func clientIP(r *http.Request) string |
| 469 | func (h *handlers) publicGate(next http.HandlerFunc) http.HandlerFunc // token bucket 60/min per ip_fp → 429; sets no-store + no-referrer; wraps every public route |
| 470 | func notFoundLikeTheRouter(w, r) |
| 471 | func zoneChoices(host Person) []string // CommonZones ∪ {host.Zone} |
| 472 | func pickZone(r *http.Request, host Person) (zone string, chosen bool) |
| 473 | type bucket struct{ mu sync.Mutex; tokens map[string]*tb } // 60 tokens, refill 1/s, prune idle keys past 5 min |
| 474 | |
| 475 | // mail_timed.go |
| 476 | var ErrMailTimeout = errors.New("calendar: mail send timed out") |
| 477 | type timedSender struct{ next mail.Sender; limit time.Duration } |
| 478 | func (t timedSender) Send(ctx, to, subject, body string) error // goroutine + select on time.After(limit); the request WAITS here |
| 479 | |
| 480 | // booking_mail.go |
| 481 | func (h *handlers) sendBookingMail(ctx context.Context, m BookingMail) error // loads BookingByID(withToken=true), renders by kind+recipient, sends via h.mailer (already wrapped), marks sent/failed |
| 482 | func (h *handlers) trySendNow(ctx context.Context, bookingID int64) // claims and sends every unsent row for the booking, once; errors only logged (scrubbed) |
| 483 | |
| 484 | // booking_ics.go |
| 485 | func WriteBookingICS(v BookingView) string // METHOD on VCALENDAR; one VEVENT; allowlisted fields only; no URL: |
| 486 | ``` |
| 487 | |
| 488 | `New` wraps the configured mailer: `h.mailer = timedSender{next: mailer, limit: 20 * time.Second}` (and the recorder stays outermost so tests still see bodies — check the order in `buildMailer`). |
| 489 | |
| 490 | - [ ] **Step 1: Tests** — `booking_http_test.go` (helpers: `publishedPage(t)` returning `app, mailer, path, host`; `strangerGet/strangerPost` with optional `RemoteAddr`; `reStart`, `reManage`, `reRev = regexp.MustCompile(`name="rev" value="(\d+)"`)`; extend `pickyMailer` with `to` matching and a `delivered []struct{To, Body string}`): |
| 491 | - two-step hold; one mail to booker; GET link → button, state still held, still one mail; POST confirm → manage, host mailed once; second POST → nothing. |
| 492 | - confirm after `event.start_utc` (age via SQL) → page says expired/too late, no change. |
| 493 | - ICS: `METHOD:REQUEST` on the VCALENDAR line; `SUMMARY:Quick chat`; `ATTENDEE;CN=Doc;PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:doc@tito.io`; `ORGANIZER`; `mailto:host@example.com`; `SEQUENCE:1`; no `URL:`; `ParseICS(strings.NewReader(body), "UTC")` yields one event. |
| 494 | - host edits never reach the booker (note, location, second attendee → absent from manage, ICS, cancelled mail). |
| 495 | - same slot twice → 409; wrong token → byte-identical 404; expired says expired. |
| 496 | - reschedule: GET has `rev`; POST with it → 2 mails, `SEQUENCE:2`; the same POST again → 200, no change, no mail; cancel → `METHOD:CANCEL`, 2 mails; again → nothing. |
| 497 | - host cancels via edit → booker mailed, page says cancelled by host; host deletes → same, ICS sequence higher. |
| 498 | - hold mail refused (`refuse = "bounce@example.com"`): 200 "Check your mail" with the lapse sentence; the slot is STILL held (a second stranger gets 409); the mail row is unsent with `attempts 1`. |
| 499 | - `no-store` and `no-referrer` on every public response. |
| 500 | `booking_abuse_test.go`: sixth open hold per IP 429; third per address 429; 21st attempt in 24h 429 with none open (use refused mails so none confirm; they still count); body > 16 KiB → 422; `?zone=Nowhere/Town` falls back to host zone; `?day=` past horizon → 404; 61 GETs in a minute from one IP → the 61st is 429 (use a test hook `ResetPublicGateForTest`). |
| 501 | `timedSender`: a `stallingMailer` that blocks on a channel → `Send` returns `ErrMailTimeout` within `limit+1s` (set `limit` small via a constructor). |
| 502 | - [ ] **Step 2: Run; failure.** |
| 503 | - [ ] **Step 3: Implement** per spec §5. Hold response: always 200 `book_sent`; when `trySendNow` reported an error for the hold row, `Data.Unsure = true` adds the lapse sentence. Manage page template receives only allowlisted fields (build a `publicBooking` struct with exactly: `Title, Description, StartUTC, EndUTC, Status, HostName, HostAddress, Name, Address, CancelledBy, Zone, HostZone, Rev` — never pass `BookingView` to a public template). |
| 504 | - [ ] **Step 4: Pass; `make ci`; commit** `booking: a stranger holds a slot, a button confirms it, and the ICS is one click away`. |
| 505 | |
| 506 | --- |
| 507 | |
| 508 | ### Task 7: Drain ordering, leak and log tests, browser passes |
| 509 | |
| 510 | Spec §8, §9. |
| 511 | |
| 512 | - [ ] **Step 1: Tests** — `booking_sweep_test.go`: `tick` expires and frees; `tick` sends owed mail (refuse then un-refuse); reminders still progress with 30 unsent booking-mail rows queued (`drainDeadline` halves — fake by making the mailer slow: a `slowMailer` sleeping 100 ms; assert a due reminder was sent in the same tick); `DrainOnRequest` does exactly one thing (one reminder OR one mail OR one expiry per GET); `ip_fp` nulled at `ip_forget_at`; `NextDue` reflects each source. Log test with a buffer logger: no address, no 64-hex token. Leak fixture in `TestTheDatabaseNeverHoldsAPlaintextTitle` (seven needles including the token). |
| 513 | - [ ] **Step 2: Implement** `drain`: `ExpireHolds(200)` → reminder loop until `now+deadline/2` → booking mail claim loop until `deadline` → `ForgetOldIPs(500)`. `DrainOnRequest`: reminder if claimable, else one booking mail, else one expiry. |
| 514 | - [ ] **Step 3: Browser** — `test/browser/booking.mjs` with subcommands `hold <origin> <path>` and `confirm <origin> <link>` (JS off, no cookie) and `pane <origin> <cookieName> <cookieValue>` (JS on). `TestBookingInARealBrowser` in `playwright_test.go` creates host/handle/page/publish via the store, runs `hold`, reads `LastMailForTest`, extracts the link, runs `confirm`, then `pane`. `Makefile`/`package.json` → `-run InARealBrowser`. |
| 515 | - [ ] **Step 4: `make ci` and `make browser` green; commit** `booking: holds expire on the tick, owed mail is retried, reminders keep their half, and a stranger with no script can book and confirm`. |
| 516 | |
| 517 | --- |
| 518 | |
| 519 | ### Task 8: AGENTS.md |
| 520 | |
| 521 | Spec §11, verbatim: rows B1, B2, B4, B5, B8, B11–B17; the Frontend, two Data, Ticks (rename + two bullets), two Time bullets; leak-list item 5. Commit `docs: the rules a booking page adds`. |
| 522 | |
| 523 | --- |
| 524 | |
| 525 | ## Self-review |
| 526 | |
| 527 | - **Spec coverage:** §2 → T1 (all columns, byte limits, `booking_mails`); §2.6 → T1 scoping + T4 terms; §2.7 → T6 `clientIP`; §3 → T2, T5; §4 → T3 (four-part check, `to` bounds start, notice floor, all-day via Occ, lapsed holds, ±1 day); §4.1 → T3 step 1–2; §4.2 → T3 (`day` validation lives in T6's handler; the one-query rule in T6) + T6 bucket; §5.1–5.2 → T6; §5.3–5.4 → T6 + T4 guards; §5.5 → T4 (host authority) + T6 (allowlist struct); §5.6–5.7 → T4, T6; §6 → T4 queue + T6 render/timed + T7 retry; §7 → T4 counts, T6 429s + bucket, T7 forget; §8 → T7; §9 → each Step 1; §11 → T8. |
| 528 | - **Type consistency:** `Window` once; `BookingView.Event` is the `Event` ROW (not `EventView`) so public code cannot reach opened notes by accident — the manage/ICS code builds `publicBooking` from allowlisted fields; `HoldInput.Page BookingPageView`; `MailHold…`, `ToBooker/ToHost`, `CancelledBy*` constants match the spec's column values; `RescheduleBooking` takes `rev int` and returns `changed`. |
| 529 | - **Placeholders:** none. DST expectations are pinned by observing once and recording, the repository's convention. |
| 530 | |
| 531 | |
| 532 | --- |
| 533 | |
| 534 | ## Revision 4–6 addenda (binding over the tasks above) |
| 535 | |
| 536 | Five review rounds after the plan was first written. Each item names the spec section that now governs. Where a task's text above disagrees with an addendum, the addendum wins; where an addendum disagrees with the spec, the spec wins. |
| 537 | |
| 538 | ### Landed state |
| 539 | |
| 540 | - Task 1 is on the branch (`6f76ba4`): `models_booking.go` has `Booking.TitleSealed`, `Booking.DescriptionSealed`, `BookingMail.ClaimID`, and `DefaultBufferMin/DefaultNoticeMin/DefaultHorizonDays`. The store does NOT merge windows — callers pass `MergeWindows(ParseAvailability(...))` output. `PersonByAddressForTest` runs `EnsurePersonSetup`. `ValidateBookingPageInput` does not default zero knobs. |
| 541 | - `Window` lives in `slots.go`. |
| 542 | |
| 543 | ### Task 3b — the remaining calendar fixes (spec §4.1, B23) |
| 544 | |
| 545 | Do these in `occurrences.go` / `store_range.go` with regressions in `occurrence_test.go` BEFORE Task 4 depends on `EventsInRangeStrict`: |
| 546 | 1. `daysBetween` → difference of CIVIL dates: build both ends as `time.Date(y, m, d, 0,0,0,0, time.UTC)` and divide by 24h. Test: weekly Dublin event 2026-03-28 09:00 → 2026-03-30 09:00 still occupies 30 March on its first and on a later occurrence. |
| 547 | 2. `rangeRows` master exclusion: `events.until_utc + (events.end_utc - events.start_utc) + 90000 >= ?`. Test: `UNTIL` the night before the fold, final occurrence spanning the fold day, day query on that day shows it. |
| 548 | 3. `ExpandComplete(...) ([]Occurrence, bool)` — the existing body, returning `false` when the 100,000 guard fired; `Expand` calls it and drops the flag. `EventsInRangeStrict(ctx, personID, from, to)` — same as `EventsInRange` but returns `ErrIncompleteExpansion` (a package var) if any master's expansion was incomplete. Test: a daily rule with `start_local` in 1700 (insert via `DBForTest`) → strict errs for a 2026 window, tolerant answers. |
| 549 | |
| 550 | ### Task 4 addenda (spec §2.4, §2.5, §5.4–5.7, §6, §8, B18, B22, B24) |
| 551 | |
| 552 | - **`BusyFor`** is `func (s *Store) BusyFor(ctx, host Person, from, to int64, skip map[int64]bool) ([]Busy, error)` (Task 3 agent's signature) and MUST use `EventsInRangeStrict`. `FreeSlots(ctx, page, host, now, from, to, excludeEventID)` builds `skip` = {excludeEventID} ∪ {event_id : bookings.state='held' AND hold_expires_at <= now} and calls `ComputeSlots(ruleFor(page, host), busy, now, from, to)`; a `DurationMin` override is needed for reschedule (the EVENT's duration), so give `FreeSlots` a `durationMin int` parameter (0 = the page's). |
| 553 | - **Hold** writes `TitleSealed`/`DescriptionSealed` on the booking from the page (B20). The event title is `page.Title + " — " + name`. |
| 554 | - **Reminder queue identity (B22):** additive migration adds `claim_id TEXT` (nullable) and `retry_at INTEGER` (nullable) to `reminder_queues` (`ReminderQueue.ClaimID *string`, `RetryAt *int64`; regenerate). `ClaimDueReminder`: eligibility `fired_at IS NULL AND due_at <= ? AND (retry_at IS NULL OR retry_at <= ?) AND (claimed_at IS NULL OR claimed_at <= ?)` with `<=`; the claim UPDATE sets `claimed_at` AND a fresh `claim_id` (16 random bytes hex) and the same predicate; `DueReminder` carries `ClaimID`. `MarkReminderSent(ctx, queueID, claimID, now)`, `FailReminder(ctx, queueID, claimID, occStart)`, new `DeferReminder(ctx, queueID, claimID, now)` (`retry_at = now+30s`, `claimed_at = NULL`, `claim_id = NULL`, attempts untouched), and `RescheduleAfterFiring(ctx, reminderID, queueID, claimID, firedStart)` are ALL `WHERE id = ? AND claim_id = ?` and check `RowsAffected`; mark + advance run in ONE transaction. `rescheduleAfter` (any path that rewrites a queue row) clears `claim_id`, `claimed_at`, `retry_at`. Never write `due_at` from a retry. `NextDueAt` reminders term: `MIN(MAX(due_at, COALESCE(retry_at,0), COALESCE(claimed_at + 300, 0)))` over `fired_at IS NULL`. |
| 555 | - **Reschedule (B24):** inside the transaction, after the event UPDATE, for each `Reminder` on the event: recompute the queue row from the NEW start (a `tx` variant of `rescheduleAfter` — factor the body to take `*gorm.DB`), clearing `claim_id/claimed_at/retry_at`. Nothing reminder-related runs after commit. |
| 556 | - **Booking mail claim:** `ClaimBookingMail` sets `claimed_at = now, claim_id = <random>, due_at = now + claimLease WHERE id = ? AND sent_at IS NULL AND due_at <= now` and returns the row with its `ClaimID`. `MarkBookingMailSent(ctx, id, claimID, now)`: `WHERE id=? AND claim_id=?`. `MarkBookingMailFailed(ctx, id, claimID)`: `attempts+1`, same predicate; at `MaxReminderAttempts` also `sent_at = MailGaveUp`. NEW `DeferBookingMail(ctx, id, claimID, now)`: `due_at = now+30s, claimed_at = NULL, claim_id = ''`, attempts untouched. `NextBookingMailDue` = `MIN(due_at) WHERE sent_at IS NULL`. |
| 557 | - **Supersession (B19):** cancel and expire delete ALL unsent `booking_mails` for the booking (not just `rescheduled`); reschedule deletes unsent `rescheduled` rows before queueing. |
| 558 | - **Host authority (B18):** `UpdateEvent` on a booked event (`bookingIDForEvent`): refuse if submitted `StartLocal`, `EndLocal`, `TZ`, `AllDay`, `Kind` or `RRule` differ from the stored row → `ValidationError{"start", "This is a booked meeting. Its time is changed by the person who booked it, or by cancelling and letting them rebook."}`; refuse `Status` other than confirmed/cancelled; refuse `Status == confirmed` when the booking is cancelled/expired → `ValidationError{"status", "This booking was cancelled. Make a new event instead."}`; refuse any update to a soft-deleted event. Replace `ErrBookedEvent` with these. `Status → cancelled` on a confirmed booking → `cancelBookingsForEvent(tx, eventID, CancelledByHost, now)`. |
| 559 | - **`cancelBookingsForEvent`** also deletes ALL unsent mail rows for the booking and queues `(cancelled, booker, seq)` only (the host did it). |
| 560 | - **`ExpireHolds`** deletes unsent mail rows for the expired booking too. |
| 561 | - Tests to ADD to Task 4's list: everything in spec §9 under Reminders, Mail queue (claim identity, defer keeps attempts 0, crash-after-claim leaves NextDueAt at lease end), Zone change, and the host-edit refusals (each of the six fields, reviving, deleted event, title-only succeeds). |
| 562 | |
| 563 | ### Task 5 addenda (spec §5.5 B25, §7.4) |
| 564 | |
| 565 | - `templates/event_form.html` + `readEventForm`: when the event is booked (`bookingIDForEvent`), render `start`, `end`, `all_day`, `kind`, `repeat` controls `disabled` with the B18 sentence beside them, AND emit hidden inputs `start`, `end`, `tz` (the EVENT's `TZ`), `all_day`, `kind`, `repeat` carrying the stored values. `readEventForm` uses `r.FormValue("tz")` for `TZ` when non-empty (only the booked form emits it), else `p.Zone`. Test: scriptless title-only edit succeeds, and still succeeds after the host changes their profile zone. |
| 566 | - The event page shows a booking badge ("Booked by {name}") and, when the booking is confirmed, the sentence "To move this meeting, cancel it and let {name} rebook." |
| 567 | - Callers pass `MergeWindows(...)` output to the store (Task 1 does not merge). |
| 568 | - Windows: the form's availability field is optional; an empty page offers nothing. |
| 569 | |
| 570 | ### Task 6 addenda (spec §4.2, §5.1–5.8, §6, §7.2) |
| 571 | |
| 572 | - **Public data:** templates receive `publicBooking` (spec §5.5) built from `BookingView`: `Title`/`Description` from the BOOKING's snapshot (`TitleSealed`/`DescriptionSealed`), never the page's or the event's. |
| 573 | - **Semaphore (§4.2):** `var slotSem = make(chan struct{}, 4)`; public handlers `select { case slotSem <- struct{}{}: defer release; default: 503 + Retry-After: 5 }` around `FreeSlots`. `ErrIncompleteExpansion` from `FreeSlots` → 503 + `Retry-After: 60`. |
| 574 | - **Gate (§7.2):** bucket capped at 10,000 keys, evict idle > 5 min on insert; full with no idle key → 429 + `Retry-After: 60` for a NEW key. |
| 575 | - **`boundedSender`** replaces `timedSender`: semaphore of 4 in flight (`ErrMailBusy` immediately when full), wait `min(20s, ctx deadline remaining)` → `ErrMailTimeout`. Wrap the configured sender in `New` so REMINDERS use it too (the recorder stays outermost). `sendBookingMail`: `ErrMailBusy` → `DeferBookingMail`; other error → `MarkBookingMailFailed`; nil → `MarkBookingMailSent`. `sendReminder` callers: `ErrMailBusy` → `DeferReminder`. |
| 576 | - **Hold response:** always 200 `book_sent`; if the immediate try errored, add the lapse sentence. NEVER `FailHold` — that function does not exist. |
| 577 | - **ICS (§5.8):** no `CN=` on ORGANIZER/ATTENDEE; `METHOD` from effective state (booking cancelled OR event cancelled OR event deleted → CANCEL); `held`/`expired` → 404; event row read including soft-deleted. `SUMMARY` = snapshot title. |
| 578 | - **Confirm guard** includes `now < events.start_utc` (read inside the transaction). |
| 579 | - **Reschedule POST** carries hidden `rev`; `RescheduleBooking(..., rev, ...)` writes `tz = host.Zone` with the four time columns (B21) and repairs reminder rows in the transaction (B24). |
| 580 | - Mail templates say the CURRENT state; the `confirmed`-to-host template opens with "This booking has since been {state}." when the booking is no longer confirmed at render time. |
| 581 | |
| 582 | ### Task 7 addenda (spec §8) |
| 583 | |
| 584 | - `drain` order: `ExpireHolds(200)` → `ForgetOldIPs(500)` → reminders until `deadline/2` → booking mail until `deadline` (each send bounded by remaining budget via ctx). `DrainOnRequest`: one expiry if due, else one reminder, else one booking mail; 5-second ctx. |
| 585 | - Tests: reminders still progress with 30 queued mail rows behind a slow sender; `DrainOnRequest` does exactly one thing; `NextDueAt` reflects `retry_at` and the lease for reminders. |
| 586 | |
| 587 | ### Task 8 addenda |
| 588 | |
| 589 | AGENTS.md changes are spec §11 as of revision 6 (rows B1, B2, B4, B5, B8, B11–B25; the Frontend, Data ×3, Recurrence ×2, Ticks ×3, Time ×2 bullets; leak-list item 5); plus the calendar spec's §7 debt line about `mail.SMTP` and its context. |
| 590 | |