| index 9715804..f2520b4 100644 |
| --- a/docs/superpowers/plans/2026-09-07-booking-plan.md |
| +++ b/docs/superpowers/plans/2026-09-07-booking-plan.md |
| @@ -4,32 +4,33 @@ |
| |
| **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. |
| |
| -**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 token; the existing range query is what makes a hold block a slot. Hold creation and rescheduling serialise on one process-wide mutex. Every state change is a conditional UPDATE that checks `RowsAffected`. Mail is an outbox column the existing drain retries. Public routes live beside `/feed/{token}.ics` — inside CSRF, outside the session group. |
| +**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. |
| |
| **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. |
| |
| -**Spec:** `docs/superpowers/specs/2026-09-07-booking-design.md` **revision 2**. Read it first, in full, including §12. 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. |
| +**Spec:** `docs/superpowers/specs/2026-09-07-booking-design.md` **revision 3**. Read it first, in full, including §12. 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. |
| |
| ## Global Constraints |
| |
| Copied from AGENTS.md and the spec. Every task inherits them. |
| |
| -- **The one rule.** Booker name, address, note, 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`, the page `code` and `ip_fp` are plaintext. Tokens are stored as `HashFeedToken(token)` only. |
| +- **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. |
| - **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. |
| -- **A row that isn't yours 404s, never 403s.** Unknown handle, code or token → the same 404 the router gives an unknown route (`http.NotFound` is NOT it — see Task 6 for `notFoundLikeTheRouter`). |
| +- **A row that isn't yours 404s, never 403s.** Unknown handle, code or token → the same body the router gives an unknown route. |
| - **Never bind a request body onto a model.** Explicit `Input` structs; explicit column allowlists in every `Updates`. |
| -- **Every booking transition is a conditional UPDATE on `state` that checks `RowsAffected == 1`.** Zero rows is "someone moved it first" — re-read and render, never retry. |
| -- **Hold and reschedule run inside `Store.holdMu`** (a `sync.Mutex` on `Store`), with the slot check inside the lock. |
| -- **Never `sessions.UserID`.** Use `CurrentPerson(r)`. |
| -- **Migrations are generated**: delete `internal/calendar/migrations/schema.sql`, add the model to `Models`, run `go run github.com/carlosframework/rastrillo/cmd/rastrillo migration generate`, then `... migration check`. Never edit a shipped migration. |
| -- **300-line cap on every `.go` and `.js` file** (`internal/guardstest`). Split by responsibility. |
| -- **`rst-*` attribute grammar**, never class spellings, except the seven class-only utilities. This app's components take `cal-`. Partials available from rastrillo: `field-text`, `field-textarea`, `field-select`, `field-check`, `form-foot`, `callout`, `page-header`, `detail-list`, `empty-state`, `badge` — see `settings.html` for the `dict` convention and the module cache `ui/partials/*.html` for exact keys (`field-textarea` takes `Name Label Value Rows Hint Error Required`). |
| -- **No affordance may require script.** `booking.js` is a debounced fetch plus a zone preselect and nothing else. |
| -- **Every range is half-open.** Use `DayBounds`, never `Add(24h)` for a day. |
| -- **Public zone input is bounded to `CommonZones`** (`zones.go`). |
| -- **Work inside the request.** No goroutines. |
| -- **`make ci` before every commit.** Run go with the sandbox off if the build cache is read-only. Commit trailer: `Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>`. |
| -- **Tests** live in `internal/calendartest` unless they need unexported names. Helpers there: `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 check `to` as well. |
| +- **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`. |
| +- **Hold and reschedule run inside `Store.holdMu`** (`sync.Mutex` on `Store`), with caps and the slot check inside the lock. |
| +- **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. |
| +- **Never `sessions.UserID`.** `CurrentPerson(r)`. |
| +- **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`. |
| +- **300-line cap on every `.go` and `.js`.** Split by responsibility. |
| +- **`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. |
| +- **No affordance may require script.** `booking.js` is a debounced fetch plus a zone preselect. |
| +- **Every range is half-open.** `DayBounds`, never `Add(24h)`. |
| +- **Public zone input** is `CommonZones` ∪ {host zone}. |
| +- **Work inside the request.** The one goroutine allowed is `timedSender`'s, and the request WAITS on it (§6). |
| +- **`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>`. |
| +- **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. |
| |
| --- |
| |
| @@ -37,42 +38,34 @@ Copied from AGENTS.md and the spec. Every task inherits them. |
| |
| | File | Responsibility | Task | |
| |---|---|---| |
| -| `internal/calendar/models_booking.go` | `BookingPage`, `BookingWindow`, `Booking`, `Window`, state + mail constants | 1 | |
| -| `internal/calendar/models.go` | `Person.Handle`; register models | 1 | |
| -| `internal/calendar/seal/seal.go` | purposes | 1 | |
| +| `internal/calendar/models_booking.go` | `BookingPage`, `BookingWindow`, `Booking`, `BookingMail`, `Window`, constants | 1 | |
| +| `internal/calendar/models.go`, `seal/seal.go`, `testing.go` | `Person.Handle`; register; purposes; test hooks | 1 | |
| | `internal/calendar/migrations/0005_*.sql`, `schema.sql` | generated | 1 | |
| -| `internal/calendar/store_booking_page.go` | handle, page CRUD, publish, public lookup | 1 | |
| -| `internal/calendar/testing.go` | `StoreForTest`, `PersonByAddressForTest` | 1 | |
| -| `internal/calendar/clock.go` | `Zone()` cache made safe, failures uncached | 2 | |
| -| `internal/calendar/avail.go`, `avail_windows.go` | parser; merge + describe | 2 | |
| -| `internal/calendar/templates/_bookingpane.html` | pane fragment | 2 | |
| -| `internal/calendar/slots.go` | `Slot`, `Busy`, `SlotRule`, `ComputeSlots` | 3 | |
| -| `internal/calendar/store_slots.go` | `BusyFor`, `FreeSlots` | 3 | |
| +| `internal/calendar/store_booking_page.go` | handle, page CRUD, publish, public lookup, ownership | 1 | |
| +| `internal/calendar/clock.go`, `zones.go` | `Zone()` safe; `IsCommonZone` | 2 | |
| +| `internal/calendar/avail.go`, `avail_windows.go`, `templates/_bookingpane.html` | parser; merge/describe; pane | 2 | |
| +| `internal/calendar/store_range.go` | override-of-finished-series fix | 3 | |
| +| `internal/calendar/slots.go`, `store_slots.go` | `ComputeSlots`; `BusyFor`; `FreeSlots` | 3 | |
| | `internal/calendar/store_booking_flow.go` | hold, confirm, reschedule, cancel | 4 | |
| -| `internal/calendar/store_booking_read.go` | `BookingByToken`, `BookingView`, `bookingByEvent` | 4 | |
| -| `internal/calendar/store_booking_sweep.go` | expire, owed mail, forget IPs, counts, `NextHoldExpiry` | 4 | |
| -| `internal/calendar/store_reminder_next.go` | `NextDueAt` widened | 4 | |
| -| `internal/calendar/store_event.go` | `UpdateEvent` refuses RRule on booked; `DeleteEvent` cancels booking | 4 | |
| -| `internal/calendar/handlers_booking_settings.go`, `handlers_booking_form.go` | host UI | 5 | |
| -| `internal/calendar/templates/booking_pages.html`, `booking_page_form.html`, `settings.html` | host UI | 5 | |
| -| `internal/calendar/static/booking.js` | pane fetch; zone preselect | 5 | |
| -| `internal/calendar/handlers_booking_public.go`, `handlers_booking_manage.go`, `handlers_booking_util.go` | public flow | 6 | |
| -| `internal/calendar/booking_ics.go`, `booking_mail.go`, `templates/mail/*.txt` | ICS + mail | 6 | |
| -| `internal/calendar/templates/book_page.html`, `book_sent.html`, `book_hold.html`, `book_manage.html`, `book_gone.html` | public UI | 6 | |
| -| `internal/calendar/handlers_jobs.go` | sweep steps in `drain`/`DrainOnRequest` | 7 | |
| +| `internal/calendar/store_booking_read.go` | `BookingByToken`, `BookingView`, counts | 4 | |
| +| `internal/calendar/store_booking_mail.go` | queue, claim, ack, `NextBookingMailDue` | 4 | |
| +| `internal/calendar/store_booking_sweep.go` | expire, forget IPs, `NextHoldExpiry`, `NextIPForget` | 4 | |
| +| `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 | |
| +| `internal/calendar/handlers_booking_settings.go`, `handlers_booking_form.go`, templates, `booking.js` | host UI | 5 | |
| +| `internal/calendar/handlers_booking_public.go`, `handlers_booking_manage.go`, `handlers_booking_util.go` | public flow; IP; bucket; 404 | 6 | |
| +| `internal/calendar/booking_ics.go`, `booking_mail.go`, `mail_timed.go`, `templates/mail/*.txt` | ICS; mail rendering; `timedSender` | 6 | |
| +| `internal/calendar/templates/book_*.html` | public UI | 6 | |
| +| `internal/calendar/handlers_jobs.go` | drain ordering; `DrainOnRequest` one thing | 7 | |
| | `internal/calendar/routes.go`, `render.go`, `templates/layout.html`, `static/calendar.css` | wiring | 5, 6 | |
| -| `internal/calendartest/booking_store_test.go`, `avail_test.go`, `slots_test.go`, `booking_flow_test.go`, `booking_http_test.go`, `booking_abuse_test.go`, `booking_sweep_test.go`, `zone_test.go` | tests | 1–7 | |
| -| `internal/calendartest/events_test.go` | leak fixture | 7 | |
| -| `test/browser/booking.mjs`, `internal/calendartest/playwright_test.go`, `Makefile`, `package.json` | browser passes | 7 | |
| +| 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 | |
| +| `test/browser/booking.mjs`, `playwright_test.go`, `Makefile`, `package.json` | browser | 7 | |
| | `AGENTS.md` | §11 | 8 | |
| |
| --- |
| |
| -### Task 1: Models, migration, purposes, page store |
| +### Task 1: Models, migration, purposes, page store, ownership |
| |
| -Spec §2.1–2.3, §7.3 (publish). |
| - |
| -**Files:** create `models_booking.go`, `store_booking_page.go`; modify `models.go`, `seal/seal.go`, `testing.go`; generate migration; test `booking_store_test.go`. |
| +Spec §2.1–2.6, §7.4 (publish). |
| |
| **Produces:** |
| |
| @@ -83,25 +76,33 @@ const ( |
| BookingConfirmed = "confirmed" |
| BookingCancelled = "cancelled" |
| BookingExpired = "expired" |
| - BookingFailed = "failed" |
| |
| - MailNone = "" |
| + CancelledByNobody = "" |
| + CancelledByBooker = "booker" |
| + CancelledByHost = "host" |
| + |
| MailHold = "hold" |
| MailConfirmed = "confirmed" |
| MailRescheduled = "rescheduled" |
| MailCancelled = "cancelled" |
| + ToBooker = "booker" |
| + ToHost = "host" |
| |
| HoldTTL = 30 * time.Minute |
| + MinNotice = 10 * time.Minute |
| MaxReschedules = 5 |
| IPForgetAfter = 24 * time.Hour |
| + MailGaveUp = int64(-1) // sent_at value meaning "stopped trying" |
| + |
| + MaxPageTitleBytes = 100 |
| + MaxPageDescBytes = 1000 |
| + MaxBookerNameRunes = 60 |
| + MaxBookerNameBytes = 80 |
| + MaxNoteRunes = 1000 |
| + MaxNoteBytes = 4000 |
| ) |
| |
| -// Window is a weekly availability window, the plain value BookingWindow rows are read into. |
| -type Window struct { |
| - Weekday time.Weekday |
| - StartMin int |
| - EndMin int |
| -} |
| +type Window struct{ Weekday time.Weekday; StartMin, EndMin int } |
| |
| type BookingPage struct { |
| ID int64 `gorm:"primaryKey"` |
| @@ -132,6 +133,7 @@ type Booking struct { |
| PageID int64 `gorm:"column:page_id;not null;index"` |
| EventID int64 `gorm:"column:event_id;not null;index"` |
| TokenHash string `gorm:"column:token_hash;not null;uniqueIndex"` |
| + TokenSealed []byte `gorm:"column:token_sealed"` |
| State string `gorm:"column:state;not null;index"` |
| BookerNameSealed []byte `gorm:"column:booker_name_sealed"` |
| BookerAddressSealed []byte `gorm:"column:booker_address_sealed"` |
| @@ -141,14 +143,27 @@ type Booking struct { |
| HoldExpiresAt int64 `gorm:"column:hold_expires_at;not null;index"` |
| ConfirmedAt *int64 `gorm:"column:confirmed_at"` |
| CancelledAt *int64 `gorm:"column:cancelled_at"` |
| - PendingMail string `gorm:"column:pending_mail;not null"` |
| - MailAttempts int `gorm:"column:mail_attempts;not null"` |
| + CancelledBy string `gorm:"column:cancelled_by;not null"` |
| IPFP *string `gorm:"column:ip_fp;index"` |
| + IPForgetAt int64 `gorm:"column:ip_forget_at;not null;index"` |
| Reschedules int `gorm:"column:reschedules;not null"` |
| CreatedAt time.Time `gorm:"column:created_at;not null"` |
| UpdatedAt time.Time `gorm:"column:updated_at;not null"` |
| } |
| |
| +type BookingMail struct { |
| + ID int64 `gorm:"primaryKey"` |
| + BookingID int64 `gorm:"column:booking_id;not null;uniqueIndex:idx_booking_mail_once"` |
| + Kind string `gorm:"column:kind;not null;uniqueIndex:idx_booking_mail_once"` |
| + Recipient string `gorm:"column:recipient;not null;uniqueIndex:idx_booking_mail_once"` |
| + Revision int `gorm:"column:revision;not null;uniqueIndex:idx_booking_mail_once"` |
| + DueAt int64 `gorm:"column:due_at;not null;index"` |
| + Attempts int `gorm:"column:attempts;not null"` |
| + ClaimedAt *int64 `gorm:"column:claimed_at"` |
| + SentAt *int64 `gorm:"column:sent_at"` |
| + CreatedAt time.Time `gorm:"column:created_at;not null"` |
| +} |
| + |
| // models.go, on Person after Zone: |
| Handle *string `gorm:"column:handle;uniqueIndex"` |
| |
| @@ -156,6 +171,7 @@ Handle *string `gorm:"column:handle;uniqueIndex"` |
| PurposeBookingTitle = "booking.title" |
| PurposeBookingDescription = "booking.description" |
| PurposeBookingAvailability = "booking.availability" |
| +PurposeBookingToken = "booking.token" |
| PurposeBookerName = "booking.booker.name" |
| PurposeBookerAddress = "booking.booker.address" |
| PurposeBookerNote = "booking.booker.note" |
| @@ -163,7 +179,7 @@ PurposeBookingIP = "booking.ip" |
| |
| // store_booking_page.go |
| type BookingPageInput struct { |
| - Title, Description, Availability string |
| + Title, Description, Availability string |
| DurationMin, BufferMin, NoticeMin, HorizonDays int |
| } |
| type BookingPageView struct { |
| @@ -174,248 +190,51 @@ type BookingPageView struct { |
| } |
| var ErrHandleTaken = errors.New("calendar: handle taken") |
| |
| -func ValidateHandle(h string) error // ^[a-z0-9-]{2,32}$, not in {m,new,static}; ValidationError{"handle", msg} |
| -func ValidateBookingPageInput(in *BookingPageInput) error // trims; title 1..120; description <= 1000; duration 5..480; buffer 0..120; notice 0..10080; horizon 1..90 |
| -func newPageCode() string // 8 chars of [a-z0-9] from crypto/rand |
| -func (s *Store) SaveHandle(ctx context.Context, personID int64, handle string) error |
| -func (s *Store) PersonByHandle(ctx context.Context, handle string) (Person, error) |
| -func (s *Store) CreateBookingPage(ctx context.Context, personID int64, in BookingPageInput, windows []Window) (BookingPage, error) |
| -func (s *Store) UpdateBookingPage(ctx context.Context, personID, pageID int64, in BookingPageInput, windows []Window) error |
| -func (s *Store) PublishBookingPage(ctx context.Context, personID, pageID int64, publish bool) error |
| -func (s *Store) BookingPages(ctx context.Context, personID int64) ([]BookingPageView, error) |
| -func (s *Store) BookingPage(ctx context.Context, personID, pageID int64) (BookingPageView, error) |
| -func (s *Store) PublicBookingPage(ctx context.Context, handle, code string) (BookingPageView, Person, error) // published only; ErrNotFound for every miss |
| +func ValidateHandle(h string) error // ^[a-z0-9-]{2,32}$ after lowercasing; not m/new/static |
| +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 |
| +func newPageCode() string // 8 × [a-z0-9], crypto/rand |
| +func (s *Store) SaveHandle(ctx, personID int64, handle string) error |
| +func (s *Store) PersonByHandle(ctx, handle string) (Person, error) |
| +func (s *Store) CreateBookingPage(ctx, personID int64, in BookingPageInput, windows []Window) (BookingPage, error) |
| +func (s *Store) UpdateBookingPage(ctx, personID, pageID int64, in BookingPageInput, windows []Window) error |
| +func (s *Store) PublishBookingPage(ctx, personID, pageID int64, publish bool) error |
| +func (s *Store) BookingPages(ctx, personID int64) ([]BookingPageView, error) |
| +func (s *Store) BookingPage(ctx, personID, pageID int64) (BookingPageView, error) |
| +func (s *Store) PublicBookingPage(ctx, handle, code string) (BookingPageView, Person, error) |
| |
| // testing.go |
| func (a *App) StoreForTest() *Store |
| func (a *App) PersonByAddressForTest(t TB, address string) Person |
| ``` |
| |
| -- [ ] **Step 1: Failing tests** — `internal/calendartest/booking_store_test.go`: |
| - |
| -```go |
| -package calendartest |
| - |
| -import ( |
| - "context" |
| - "testing" |
| - "time" |
| - |
| - calendar "amadan.net/oficina/calendar/internal/calendar" |
| -) |
| - |
| -func TestABookingPageRoundTripsSealedWithItsWindows(t *testing.T) { |
| - app, _ := signedIn(t, "host@example.com") |
| - ctx := context.Background() |
| - st := app.StoreForTest() |
| - host := app.PersonByAddressForTest(t, "host@example.com") |
| - if err := st.SaveHandle(ctx, host.ID, "paul"); err != nil { |
| - t.Fatalf("SaveHandle: %v", err) |
| - } |
| - in := calendar.BookingPageInput{Title: "Quick chat", Description: "Fifteen minutes.", |
| - Availability: "mondays 12-3", DurationMin: 15, BufferMin: 10, NoticeMin: 120, HorizonDays: 30} |
| - windows := []calendar.Window{{Weekday: time.Monday, StartMin: 720, EndMin: 900}} |
| - page, err := st.CreateBookingPage(ctx, host.ID, in, windows) |
| - if err != nil { |
| - t.Fatalf("CreateBookingPage: %v", err) |
| - } |
| - if len(page.Code) != 8 { |
| - t.Errorf("code %q, want eight generated characters", page.Code) |
| - } |
| - // Unpublished: invisible. |
| - if _, _, err := st.PublicBookingPage(ctx, "paul", page.Code); !calendar.NotFound(err) { |
| - t.Errorf("unpublished page: err = %v, want ErrNotFound", err) |
| - } |
| - if err := st.PublishBookingPage(ctx, host.ID, page.ID, true); err != nil { |
| - t.Fatal(err) |
| - } |
| - got, who, err := st.PublicBookingPage(ctx, "paul", page.Code) |
| - if err != nil { |
| - t.Fatalf("PublicBookingPage: %v", err) |
| - } |
| - if who.ID != host.ID || got.Title != "Quick chat" || got.Availability != "mondays 12-3" || |
| - len(got.Windows) != 1 || got.Windows[0].EndMin != 900 || !got.Published { |
| - t.Errorf("round trip lost something: %+v", got) |
| - } |
| - if _, err := app.DBForTest().Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil { |
| - t.Fatal(err) |
| - } |
| - for _, needle := range []string{"Quick chat", "Fifteen minutes.", "mondays 12-3"} { |
| - if found, where := app.GrepDatabaseForTest(t, needle); found { |
| - t.Errorf("%q is plaintext in %s", needle, where) |
| - } |
| - } |
| -} |
| - |
| -func TestUnknownHandleUnknownCodeAndUnpublishedAllVanishTheSameWay(t *testing.T) { |
| - app, _ := signedIn(t, "host@example.com") |
| - ctx := context.Background() |
| - st := app.StoreForTest() |
| - host := app.PersonByAddressForTest(t, "host@example.com") |
| - _ = st.SaveHandle(ctx, host.ID, "paul") |
| - page, _ := st.CreateBookingPage(ctx, host.ID, calendar.BookingPageInput{Title: "Chat", DurationMin: 30, HorizonDays: 30}, nil) |
| - _ = st.PublishBookingPage(ctx, host.ID, page.ID, true) |
| - _ = st.PublishBookingPage(ctx, host.ID, page.ID, false) |
| - for _, c := range [][2]string{{"paul", page.Code}, {"nobody", page.Code}, {"paul", "zzzzzzzz"}} { |
| - if _, _, err := st.PublicBookingPage(ctx, c[0], c[1]); !calendar.NotFound(err) { |
| - t.Errorf("%v: err = %v, want ErrNotFound", c, err) |
| - } |
| - } |
| -} |
| - |
| -func TestAHandleIsUniqueLowercaseAndNotReserved(t *testing.T) { |
| - app, _ := signedIn(t, "a@example.com") |
| - ctx := context.Background() |
| - st := app.StoreForTest() |
| - a := app.PersonByAddressForTest(t, "a@example.com") |
| - app.SignInForTest(t, "b@example.com") |
| - b := app.PersonByAddressForTest(t, "b@example.com") |
| - if err := st.SaveHandle(ctx, a.ID, "Paul"); err != nil { // lowercased on save |
| - t.Fatal(err) |
| - } |
| - if p, _ := st.PersonByHandle(ctx, "paul"); p.ID != a.ID { |
| - t.Error("handle was not lowercased or not saved") |
| - } |
| - if err := st.SaveHandle(ctx, b.ID, "paul"); err != calendar.ErrHandleTaken { |
| - t.Errorf("second claim: err = %v, want ErrHandleTaken", err) |
| - } |
| - for _, bad := range []string{"m", "new", "static", "a", "has space", "üñí"} { |
| - if err := calendar.ValidateHandle(bad); err == nil { |
| - t.Errorf("%q must be refused", bad) |
| - } |
| - } |
| - if err := st.SaveHandle(ctx, a.ID, ""); err != nil { |
| - t.Errorf("clearing a handle: %v", err) |
| - } |
| -} |
| - |
| -func TestAnotherPersonCannotEditOrPublishMyPage(t *testing.T) { |
| - app, _ := signedIn(t, "me@example.com") |
| - ctx := context.Background() |
| - st := app.StoreForTest() |
| - me := app.PersonByAddressForTest(t, "me@example.com") |
| - app.SignInForTest(t, "them@example.com") |
| - them := app.PersonByAddressForTest(t, "them@example.com") |
| - page, _ := st.CreateBookingPage(ctx, me.ID, calendar.BookingPageInput{Title: "Mine", DurationMin: 30, HorizonDays: 30}, nil) |
| - if err := st.PublishBookingPage(ctx, them.ID, page.ID, true); !calendar.NotFound(err) { |
| - t.Errorf("publish as another person: %v, want ErrNotFound", err) |
| - } |
| - if err := st.UpdateBookingPage(ctx, them.ID, page.ID, calendar.BookingPageInput{Title: "Theirs", DurationMin: 30, HorizonDays: 30}, nil); !calendar.NotFound(err) { |
| - t.Errorf("update as another person: %v, want ErrNotFound", err) |
| - } |
| - if _, err := st.BookingPage(ctx, them.ID, page.ID); !calendar.NotFound(err) { |
| - t.Errorf("read as another person: %v, want ErrNotFound", err) |
| - } |
| -} |
| -``` |
| - |
| -- [ ] **Step 2: Run** `go test ./internal/calendartest/ -run 'BookingPage|Handle|MyPage' -count=1` — expect compile errors. |
| -- [ ] **Step 3: Models, purposes, `Person.Handle`, `Models` registration, `testing.go` helpers.** `PersonByAddressForTest` does `First(&p, "address = ?", normalizeAddress(address))` and `t.Fatalf` on miss. |
| -- [ ] **Step 4: Generate the migration** (`rm schema.sql`; `migration generate`; `migration check`). Open the SQL: `handle` nullable + unique index; `ip_fp` nullable; `published_at` nullable. |
| -- [ ] **Step 5: Implement `store_booking_page.go`.** `SaveHandle` lowercases and trims, `""` writes NULL, `UNIQUE` error → `ErrHandleTaken`. `CreateBookingPage`: validate; seal three columns; `Code: newPageCode()`; page + windows in one `Transaction`; on a `UNIQUE` collision of the code (astronomically rare) regenerate once. `UpdateBookingPage`, `PublishBookingPage`, `BookingPage`: `Where("id = ? AND person_id = ?", ...)`; a miss is `ErrNotFound`; updates by explicit map (`title_sealed`, `description_sealed`, `duration_min`, `buffer_min`, `notice_min`, `horizon_days`, `availability_text_sealed`, `updated_at`); windows deleted and re-inserted in the same transaction. `PublishBookingPage` writes `published_at = now` or `NULL`. `PublicBookingPage`: `PersonByHandle` → `First(&page, "person_id = ? AND code = ? AND published_at IS NOT NULL")` → open → windows ordered by `weekday, start_min`. |
| -- [ ] **Step 6: Run; pass.** **Step 7: `make ci`; commit** `booking: pages, windows and bookings — the tables, sealed where they are content`. |
| +- [ ] **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). |
| +- [ ] **Step 2: Run; compile failure.** |
| +- [ ] **Step 3: Models, purposes, `Handle`, `Models` registration (all four new models), `testing.go` hooks.** |
| +- [ ] **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`. |
| +- [ ] **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. |
| +- [ ] **Step 6: Pass. `make ci`. Commit** `booking: pages, windows, bookings and their mail — the tables, sealed where they are content`. |
| |
| --- |
| |
| -### Task 2: `Zone()` made safe; the availability parser; the pane fragment |
| - |
| -Spec §3, §5.1 (bounded zone), §9 (`Zone()` test). |
| +### Task 2: `Zone()` made safe; availability parser; pane |
| |
| -**Files:** modify `clock.go:55-81`; create `avail.go`, `avail_windows.go`, `templates/_bookingpane.html`; modify `render.go` (`fragments` and every page's ParseFS); tests `zone_test.go`, `avail_test.go`. |
| - |
| -**Consumes:** `consumed`, `newConsumed`, `weekdayAlt`, `weekdays`, `rangeSep` from `quickadd*.go`. **Does NOT use** `reRange`, `clock`, `applyMeridiem` (they forgive; §3.1). |
| +Spec §3, §5.1, §9. |
| |
| **Produces:** |
| - |
| ```go |
| -func ParseAvailability(text string) (windows []Window, problems []string) // windows merged + sorted |
| -func MergeWindows(ws []Window) []Window // per weekday, overlapping/touching → one |
| -func DescribeWindows(ws []Window) []string // "Monday 12:00–15:00, 17:00–19:00" per weekday, Sun..Sat |
| +func ParseAvailability(text string) (windows []Window, problems []string) |
| +func MergeWindows(ws []Window) []Window |
| +func DescribeWindows(ws []Window) []string |
| type BookingPaneView struct{ Lines, Problems []string; Empty bool } |
| func bookingPaneFor(text string) BookingPaneView |
| -func IsCommonZone(name string) bool // zones.go |
| +func IsCommonZone(name string) bool // zones.go |
| +func ZoneCacheSizeForTest() int // testing.go |
| ``` |
| |
| -- [ ] **Step 1: `Zone()` test** — `internal/calendartest/zone_test.go`: |
| - |
| -```go |
| -func TestZoneIsSafeUnderConcurrentGarbage(t *testing.T) { |
| - var wg sync.WaitGroup |
| - for i := 0; i < 100; i++ { |
| - wg.Add(1) |
| - go func(i int) { |
| - defer wg.Done() |
| - _, _ = calendar.Zone(fmt.Sprintf("Nowhere/Town%d", i)) |
| - _, _ = calendar.Zone("Europe/Dublin") |
| - }(i) |
| - } |
| - wg.Wait() |
| - if n := calendar.ZoneCacheSizeForTest(); n > 40 { |
| - t.Errorf("cache holds %d entries after 100 garbage names: failures are being cached", n) |
| - } |
| - if !calendar.IsCommonZone("Europe/Dublin") || calendar.IsCommonZone("Nowhere/Town1") { |
| - t.Error("IsCommonZone is wrong") |
| - } |
| -} |
| -``` |
| -Run it with `-race`: `go test -race ./internal/calendartest/ -run ZoneIsSafe -count=1`. Expected: a data race report from the current map. |
| - |
| -- [ ] **Step 2: Fix `clock.go`**: replace the closure's map with a `sync.Map` (or `sync.RWMutex` + map); store ONLY successes; add `func ZoneCacheSizeForTest() int` in `testing.go` (range-count the sync.Map). Add `IsCommonZone` to `zones.go` (a `map[string]bool` built from `CommonZones` in `init`). |
| - |
| -- [ ] **Step 3: Parser tests** — `avail_test.go`: |
| - |
| -```go |
| -func w(d time.Weekday, from, to string) calendar.Window { return calendar.Window{Weekday: d, StartMin: mins(from), EndMin: mins(to)} } |
| -func mins(hhmm string) int { t, err := time.Parse("15:04", hhmm); if err != nil { panic(err) }; return t.Hour()*60 + t.Minute() } |
| -func allDays(from, to string) []calendar.Window { var out []calendar.Window; for d := time.Sunday; d <= time.Saturday; d++ { out = append(out, w(d, from, to)) }; return out } |
| - |
| -func TestAvailabilityReadsTheSentence(t *testing.T) { |
| - cases := []struct{ text string; want []calendar.Window }{ |
| - {"mondays between 12–3, tuesdays 12–2, 5-7", []calendar.Window{w(time.Monday, "12:00", "15:00"), w(time.Tuesday, "12:00", "14:00"), w(time.Tuesday, "17:00", "19:00")}}, |
| - {"mon-wed 9-5", []calendar.Window{w(time.Monday, "09:00", "17:00"), w(time.Tuesday, "09:00", "17:00"), w(time.Wednesday, "09:00", "17:00")}}, |
| - {"weekdays 10am to 4pm", []calendar.Window{w(time.Monday, "10:00", "16:00"), w(time.Tuesday, "10:00", "16:00"), w(time.Wednesday, "10:00", "16:00"), w(time.Thursday, "10:00", "16:00"), w(time.Friday, "10:00", "16:00")}}, |
| - {"every day 8–6", allDays("08:00", "18:00")}, |
| - {"saturday 10:30–13:00; sunday 14-16", []calendar.Window{w(time.Sunday, "14:00", "16:00"), w(time.Saturday, "10:30", "13:00")}}, |
| - {"weekends 10-12", []calendar.Window{w(time.Sunday, "10:00", "12:00"), w(time.Saturday, "10:00", "12:00")}}, |
| - {"Friday 9am-12pm, 1pm-3pm and 4-5", []calendar.Window{w(time.Friday, "09:00", "12:00"), w(time.Friday, "13:00", "15:00"), w(time.Friday, "16:00", "17:00")}}, |
| - {"tues 12 to 2", []calendar.Window{w(time.Tuesday, "12:00", "14:00")}}, |
| - {"every tuesday 12-2", []calendar.Window{w(time.Tuesday, "12:00", "14:00")}}, |
| - {"sun-tue 9-10", []calendar.Window{w(time.Sunday, "09:00", "10:00"), w(time.Monday, "09:00", "10:00"), w(time.Tuesday, "09:00", "10:00")}}, |
| - {"monday 9-12, monday 10-13", []calendar.Window{w(time.Monday, "09:00", "13:00")}}, // merged |
| - {"monday 9-12, 12-1", []calendar.Window{w(time.Monday, "09:00", "13:00")}}, // touching → merged |
| - {"monday 22:00-24:00", []calendar.Window{w(time.Monday, "22:00", "23:59")}}, // see note |
| - {"monday 0-1am", []calendar.Window{w(time.Monday, "00:00", "01:00")}}, |
| - } |
| - // ...loop as in Task 1's style; windows are sorted Sunday-first then by StartMin. |
| -} |
| -``` |
| -Note on `24:00`: `mins("24:00")` cannot parse, so express the expectation as `calendar.Window{Weekday: time.Monday, StartMin: 1320, EndMin: 1440}` for that case, not via `w(...)`. Remove the `"23:59"` placeholder above when writing the file. |
| - |
| -```go |
| -func TestAvailabilityRefusesWhatItCannotRead(t *testing.T) { |
| - cases := []struct{ text, quoted string }{ |
| - {"mondays 10pm-2", "10pm-2"}, // crosses midnight |
| - {"mondays 12-3, whenever", "whenever"}, |
| - {"12-3", "12-3"}, // no day, nothing to inherit |
| - {"mondays 25-26", "25-26"}, |
| - {"mondays 9:60-10", "9:60-10"}, |
| - {"mondays 24:00-1", "24:00-1"}, // 24:00 only at the END |
| - {"mondays 9-9", "9-9"}, // empty |
| - {"mondays 9-5 in the office", "in the office"}, |
| - } |
| - // each must produce >= 1 problem containing quoted |
| - _, problems := calendar.ParseAvailability("mondays whenever, tuesdays sometime") |
| - if len(problems) != 2 { t.Errorf("problems = %v, want two", problems) } |
| -} |
| - |
| -func TestDescribeWindowsGroupsByDay(t *testing.T) { |
| - ws, _ := calendar.ParseAvailability("tuesdays 12–2, 5-7, mondays 9-10") |
| - got := strings.Join(calendar.DescribeWindows(ws), "|") |
| - if got != "Monday 09:00–10:00|Tuesday 12:00–14:00, 17:00–19:00" { t.Errorf("got %q", got) } |
| -} |
| -``` |
| - |
| -- [ ] **Step 4: Implement.** `avail.go` (parser) and `avail_windows.go` (merge/describe) — keep each under 300 lines. |
| +- [ ] **Step 1: `zone_test.go`**: 100 goroutines resolve `Nowhere/TownN` and `Europe/Dublin`; run with `-race`; afterwards `ZoneCacheSizeForTest() <= len(CommonZones)+5`. |
| +- [ ] **Step 2: `clock.go`**: `sync.Map`, successes only. `zones.go`: `IsCommonZone`. |
| +- [ ] **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`. |
| +- [ ] **Step 4: Implement** `avail.go` + `avail_windows.go`: |
| |
| ```go |
| var ( |
| @@ -423,106 +242,84 @@ var ( |
| reAvailGroup = regexp.MustCompile(`(?i)^\s*(?:every\s+)?(weekdays|weekends|every\s+day|daily|all\s+days)\b`) |
| reAvailDayRange = regexp.MustCompile(`(?i)^\s*(?:every\s+)?(` + weekdayAlt + `)s?` + rangeSep + `(` + weekdayAlt + `)s?\b`) |
| reAvailDay = regexp.MustCompile(`(?i)^\s*(?:every\s+)?(` + weekdayAlt + `)s?\b`) |
| - // A time range with RAW components; nothing is clamped here. |
| - 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`) |
| - reAvailFiller = regexp.MustCompile(`(?i)\b(between|from|and|on|at)\b`) |
| + 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`) |
| + reAvailFiller = regexp.MustCompile(`(?i)\b(between|from|and|on|at)\b`) |
| ) |
| |
| -// readClock validates raw components. ok=false on hour>24, minute>59, |
| -// or 24 with a non-zero minute. 24:00 is returned as 1440 and the CALLER |
| -// decides whether the position allows it. |
| -func readClock(hh, mm, meridiem string, inferPM bool) (minutes int, ok bool) |
| -``` |
| -Meridiem rule: if either end has an explicit meridiem, the other end without one takes the same; if neither has one, `inferPM` applies to hours 1–7. `end <= start` → problem quoting the matched range text. `24:00` allowed only as end. Every problem: `fmt.Sprintf("Could not read %q", what)`. |
| - |
| -`MergeWindows`: sort by weekday then StartMin; merge when `next.StartMin <= cur.EndMin`. `DescribeWindows` uses `fmt.Sprintf("%02d:%02d–%02d:%02d")` and prints `24:00` as `24:00`. |
| - |
| -- [ ] **Step 5: The fragment.** `_bookingpane.html`: |
| -```html |
| -{{define "bookingpane"}}{{if .Empty}}<p cal-book-blank>Type when you are free — for example <q>mondays 12–3, tuesdays 12–2, 5–7</q>.</p>{{else}}{{with .Problems}}<ul cal-book-problems role="alert">{{range .}}<li>{{.}}</li>{{end}}</ul>{{end}}{{with .Lines}}<ul cal-book-lines>{{range .}}<li>{{.}}</li>{{end}}</ul>{{end}}{{end}}{{end}} |
| +// readClock validates RAW components; nothing is clamped. |
| +// meridiem != "": hour 1..12, minute 0..59; 12am → 0, 12pm → 12. |
| +// meridiem == "": hour 0..24 (24 only with minute 0 and only when atEnd), minute 0..59; |
| +// if inferPM and 1 <= hour <= 7, hour += 12 — on the HOUR COMPONENT, whatever the minutes. |
| +func readClock(hh, mm, meridiem string, inferPM, atEnd bool) (minutes int, ok bool) |
| ``` |
| -Add it to `fragments` and to the page ParseFS list in `render.go` beside `_quickpane.html`. |
| +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`. |
| |
| -- [ ] **Step 6: Run all; pass. `make ci`; commit** `booking: one parser for "mondays 12–3", and Zone() stops caching garbage`. |
| +- [ ] **Step 5: `_bookingpane.html`** (as in revision-2 plan) into `fragments` and every page ParseFS. |
| +- [ ] **Step 6: Pass. `make ci`. Commit** `booking: one parser for "mondays 12–3", and Zone() stops caching garbage`. |
| |
| --- |
| |
| -### Task 3: Slot arithmetic |
| - |
| -Spec §4. |
| +### Task 3: The range-query fix; slot arithmetic |
| |
| -**Files:** create `slots.go`, `store_slots.go`; test `slots_test.go`. |
| +Spec §4, §4.1, §4.2 (day validation only; the bucket is Task 6). |
| |
| **Produces:** |
| - |
| ```go |
| type Slot struct{ StartUTC, EndUTC int64; Local time.Time } |
| type Busy struct{ StartUTC, EndUTC int64 } |
| type SlotRule struct{ DurationMin, BufferMin, NoticeMin, HorizonDays int; Zone string; Windows []Window } |
| func ComputeSlots(rule SlotRule, busy []Busy, now, from, to time.Time) ([]Slot, error) |
| -func (s *Store) BusyFor(ctx context.Context, hostID, from, to int64, now time.Time, excludeEventID int64) ([]Busy, error) |
| -func (s *Store) FreeSlots(ctx context.Context, page BookingPageView, host Person, now, from, to time.Time, excludeEventID int64) ([]Slot, error) |
| +func (s *Store) BusyFor(ctx, hostID int64, from, to int64, now time.Time, excludeEventID int64) ([]Busy, error) |
| +func (s *Store) FreeSlots(ctx, page BookingPageView, host Person, now, from, to time.Time, excludeEventID int64) ([]Slot, error) |
| func ruleFor(page BookingPageView, host Person) SlotRule |
| ``` |
| |
| -- [ ] **Step 1: Tests** — `slots_test.go`. Use `dublin := mustZone("Europe/Dublin")`, `mon := time.Date(2026, 9, 7, 0, 0, 0, 0, dublin)` (a Monday, IST), `mondayRule(dur, buffer)` with a 12:00–15:00 Monday window, helpers `starts(slots) []string` formatting `Local` as `"Mon 15:04"`, and `eq`. |
| - |
| - - `TestSlotsStepByDurationInsideTheWindow`: 60-min → `12:00, 13:00, 14:00`. |
| - - `TestABusyBlockRemovesTheSlotItOverlaps`: busy 13:00–13:30 → `12:00, 14:00`. |
| - - `TestTheBufferPadsEverySlotBeforeTheOverlapTest`: same busy, buffer 10 → `14:00` only. |
| - - `TestABusyBlockJustBeforeTheRangeStillCountsWhenBuffered`: `from = mon 12:00`, busy 11:50–11:55, buffer 10 → `13:00, 14:00` (12:00 padded to 11:50 overlaps). |
| - - `TestNoticeAndHorizonAreTestedPerCandidate`: notice 120, now = mon 11:00, `to` = mon+14d, horizon 7 → `Mon 13:00, Mon 14:00` and nothing from the next Monday. |
| - - `TestSpringForwardSundayOffersOnlyWallClocksThatExist`: 29 March 2026, window 00:00–04:00, 60-min → the local labels Go produces once 01:00 is dropped; pin `slots[i].StartUTC` to `time.Date(...UTC).Unix()` values. **Write the test to print what it got, run it once against the implementation, then pin the printed numbers as the expectation with a comment explaining each** — that is the `clock_test.go` convention. |
| - - `TestFoldSundayKeepsTheOccurrenceToUTCWouldStore`: 25 October 2026, window 00:00–04:00. Every offered start must satisfy `calendar.ToUTC(calendar.FormatLocal(slot.Local, false), "Europe/Dublin", false) == slot.StartUTC`. Assert that for each slot, and pin the count. |
| - - `TestATokyoHostIsBookedOnTheBookersSundayButTheHostsMonday`: rule zone `Asia/Tokyo`, Monday 09:00–10:00 window → one slot; `time.Unix(slot.StartUTC,0).In(LA).Weekday() == time.Sunday`. |
| - |
| -- [ ] **Step 2: Run; confirm failure.** |
| -- [ ] **Step 3: Implement `slots.go`:** |
| +- [ ] **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). |
| +- [ ] **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. |
| +- [ ] **Step 3: `slots_test.go`** (helpers `dublin`, `mon = 2026-09-07 Dublin`, `mondayRule(dur, buffer)` with 12:00–15:00 Monday, `starts()`, `eq()`): |
| + - step-by-duration → `12:00, 13:00, 14:00` |
| + - busy 13:00–13:30 → `12:00, 14:00` |
| + - buffer 10 → `14:00` |
| + - busy 11:50–11:55 with `from = 12:00`, buffer 10 → `13:00, 14:00` |
| + - notice floor: `NoticeMin: 0`, now = 11:55 → 12:00 is NOT offered (`MinNotice` is 10 min) |
| + - notice 120, now 11:00, horizon 7, `to` = +14d → `Mon 13:00, Mon 14:00` only |
| + - `to` bounds start: rule window Monday 23:00–24:00, 60-min, `to` = Tuesday 00:00 local → the 23:00 slot IS offered |
| + - 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 |
| + - fold 2026-10-25 with 00:00–04:00 / 60: same assertions; additionally assert `(EndUTC-StartUTC) == 3600` for every slot |
| + - Tokyo host Monday 09:00 → LA Sunday |
| +- [ ] **Step 4: Implement `slots.go`:** |
| |
| ```go |
| -func ComputeSlots(rule SlotRule, busy []Busy, now, from, to time.Time) ([]Slot, error) { |
| - loc, err := Zone(rule.Zone) |
| - if err != nil { return nil, err } |
| - earliest := now.Add(time.Duration(rule.NoticeMin) * time.Minute) |
| - latest := now.In(loc).AddDate(0, 0, rule.HorizonDays) |
| - dur := time.Duration(rule.DurationMin) * time.Minute |
| - pad := time.Duration(rule.BufferMin) * time.Minute |
| - byDay := map[time.Weekday][]Window{} |
| - for _, w := range rule.Windows { byDay[w.Weekday] = append(byDay[w.Weekday], w) } |
| - var out []Slot |
| - f := from.In(loc) |
| - for day := time.Date(f.Year(), f.Month(), f.Day(), 0, 0, 0, 0, loc); day.Before(to); day = day.AddDate(0, 0, 1) { |
| - for _, w := range byDay[day.Weekday()] { |
| - for m := w.StartMin; m+rule.DurationMin <= w.EndMin; m += rule.DurationMin { |
| - start := time.Date(day.Year(), day.Month(), day.Day(), 0, m, 0, 0, loc) |
| - // Round-trip rule (§4.3): what is offered must be what the record can hold. |
| - if start.Hour()*60+start.Minute() != m { continue } |
| - if utc, err := ToUTC(FormatLocal(start, false), rule.Zone, false); err != nil || utc != start.Unix() { continue } |
| - end := start.Add(dur) |
| - if start.Before(earliest) || start.Before(from) || end.After(to) || end.After(latest) { continue } |
| - if overlapsAny(start.Add(-pad).Unix(), end.Add(pad).Unix(), busy) { continue } |
| - out = append(out, Slot{StartUTC: start.Unix(), EndUTC: end.Unix(), Local: start}) |
| - } |
| - } |
| - } |
| - return out, nil |
| +for m := w.StartMin; m+rule.DurationMin <= w.EndMin; m += rule.DurationMin { |
| + start := time.Date(day.Year(), day.Month(), day.Day(), 0, m, 0, 0, loc) |
| + end := start.Add(dur) |
| + if !wallIs(start, day, m) || !wallIs(end, day, m+rule.DurationMin) { continue } |
| + if !roundTrips(start, rule.Zone) || !roundTrips(end, rule.Zone) { continue } |
| + if start.Before(earliest) || start.Before(from) || !start.Before(to) || end.After(latest) { continue } |
| + if overlapsAny(start.Add(-pad).Unix(), end.Add(pad).Unix(), busy) { continue } |
| + out = append(out, Slot{start.Unix(), end.Unix(), start}) |
| } |
| +// 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. |
| +// roundTrips: u, err := ToUTC(FormatLocal(t, false), zone, false); err == nil && u == t.Unix(). |
| +// earliest := now.Add(max(MinNotice, NoticeMin)); latest := now.In(loc).AddDate(0,0,HorizonDays). |
| ``` |
| -`store_slots.go` `BusyFor`: `EventsInRange(ctx, hostID, from-86400, to+86400)`; skip `!Busy(e.Kind)`, `e.Status == StatusCancelled`, `e.ID == excludeEventID`; for `e.AllDay` derive `[DayBounds(startDate, host.Zone), DayBounds(endDate-1, host.Zone).end)` — i.e. parse `StartLocal`/`EndLocal` as dates and take the host-zone bounds of each day in `[start, end)`; for tentative events, exclude those whose booking is `held` with `hold_expires_at <= now` (one query up front: `SELECT event_id FROM bookings WHERE state='held' AND hold_expires_at <= ?` into a set). `ruleFor` copies the page's four knobs, `host.Zone`, `page.Windows`. |
| +`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. |
| |
| -- [ ] **Step 4: Run; pin the DST numbers; pass. `make ci`; commit** `booking: free slots — the host's windows minus everything the host can see`. |
| +- [ ] **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`. |
| |
| --- |
| |
| -### Task 4: The state machine, the outbox, the sweep queries, and the host's authority |
| - |
| -Spec §5.2–5.7 (store half), §6 (outbox columns), §7.1 (counts), §8 (queries). |
| +### Task 4: The state machine, the mail queue, the sweep queries, the host's authority |
| |
| -**Files:** create `store_booking_flow.go`, `store_booking_read.go`, `store_booking_sweep.go`; modify `store_reminder_next.go`, `store_event.go` (`UpdateEvent`, `DeleteEvent`), `store.go` (add `holdMu sync.Mutex` to `Store`); test `booking_flow_test.go`. |
| +Spec §5.2–5.7 (store), §2.5, §6 (queue), §7.1 (counts), §8 (queries), §5.5 (host authority). |
| |
| **Produces:** |
| |
| ```go |
| +// store.go |
| +type Store struct { g *gorm.DB; seal *seal.Sealer; logger *slog.Logger; holdMu sync.Mutex } |
| + |
| +// store_booking_flow.go |
| var ( |
| ErrSlotTaken = errors.New("calendar: slot taken") |
| ErrOverCap = errors.New("calendar: too many open holds") |
| @@ -536,219 +333,197 @@ type HoldInput struct { |
| Note, BookerZone string |
| IPFP string |
| } |
| +func (s *Store) HoldBooking(ctx, in HoldInput, now time.Time) (Booking, error) // holdMu; caps; slot; tx incl. mail row; token returned via view |
| +func (s *Store) ConfirmBooking(ctx, bookingID int64, now time.Time) (changed bool, err error) // guard: held, expires > now, now < event.start |
| +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 |
| +func (s *Store) CancelBooking(ctx, bookingID int64, by string, now time.Time) (changed bool, err error) |
| +func (s *Store) cancelBookingsForEvent(tx *gorm.DB, eventID int64, by string, now time.Time) error // used by UpdateEvent/DeleteEvent |
| + |
| +// store_booking_read.go |
| type BookingView struct { |
| Booking |
| Page BookingPageView |
| Host Person |
| - Event EventView // Status/StartUTC/EndUTC/Sequence/DeletedAt are what public code reads; NEVER Notes/Location/Attendees |
| + HostName string |
| + Event Event // the ROW, for Status/StartUTC/EndUTC/StartLocal/EndLocal/TZ/Sequence/DeletedAt only |
| + EventUID string |
| Name, Address, Note string |
| - HostName string // opened NameSealed, or the handle |
| + Token string // opened; empty unless withToken |
| } |
| type HoldCounts struct{ OpenIP, OpenPage, OpenAddress, AttemptsIP, AttemptsAddress int } |
| - |
| -func (s *Store) HoldBooking(ctx context.Context, in HoldInput, now time.Time) (token string, b Booking, err error) // holdMu; caps; slot; tx |
| -func (s *Store) FailHold(ctx context.Context, bookingID int64, now time.Time) error // held→failed, event soft-deleted |
| -func (s *Store) ConfirmBooking(ctx context.Context, bookingID int64, now time.Time) (changed bool, err error) |
| -func (s *Store) RescheduleBooking(ctx context.Context, bookingID, startUTC, endUTC int64, now time.Time) error // holdMu |
| -func (s *Store) CancelBooking(ctx context.Context, bookingID int64, now time.Time) (changed bool, err error) |
| -func (s *Store) BookingByToken(ctx context.Context, token string) (BookingView, error) |
| -func (s *Store) bookingIDForEvent(ctx context.Context, eventID int64) (int64, bool) |
| -func (s *Store) HoldCountsFor(ctx context.Context, pageID int64, ipFP, addressFP string, now time.Time) (HoldCounts, error) |
| -func (s *Store) ExpireHolds(ctx context.Context, now time.Time, limit int) (int, error) |
| -func (s *Store) ForgetOldIPs(ctx context.Context, now time.Time, limit int) (int, error) |
| -func (s *Store) OwedMail(ctx context.Context, limit int) ([]BookingView, error) |
| -func (s *Store) MarkMailSent(ctx context.Context, bookingID int64) error |
| -func (s *Store) MarkMailFailed(ctx context.Context, bookingID int64) error // attempts+1; at MaxReminderAttempts clear the marker |
| -func (s *Store) NextHoldExpiry(ctx context.Context) time.Time |
| -func (s *Store) MailIsOwed(ctx context.Context) bool |
| +func (s *Store) BookingByToken(ctx, token string) (BookingView, error) |
| +func (s *Store) BookingByID(ctx, id int64, withToken bool) (BookingView, error) |
| +func (s *Store) HoldCountsFor(ctx, pageID int64, ipFP, addressFP string, now time.Time) (HoldCounts, error) |
| +func (s *Store) bookingIDForEvent(ctx, eventID int64) (int64, bool) |
| + |
| +// store_booking_mail.go |
| +func queueBookingMail(tx *gorm.DB, bookingID int64, kind string, recipients []string, revision int, now time.Time) error |
| +func (s *Store) ClaimBookingMail(ctx, now int64) (BookingMail, bool, error) // lease = claimLease; oldest due first |
| +func (s *Store) MarkBookingMailSent(ctx, id int64, now int64) error // WHERE id=? AND sent_at IS NULL |
| +func (s *Store) MarkBookingMailFailed(ctx, id int64, now int64) error // attempts+1; at MaxReminderAttempts sent_at = MailGaveUp |
| +func (s *Store) NextBookingMailDue(ctx) time.Time |
| +func (s *Store) UnsentBookingMailForTest(bookingID int64) []BookingMail |
| + |
| +// store_booking_sweep.go |
| +func (s *Store) ExpireHolds(ctx, now time.Time, limit int) (int, error) |
| +func (s *Store) ForgetOldIPs(ctx, now time.Time, limit int) (int, error) |
| +func (s *Store) NextHoldExpiry(ctx) time.Time |
| +func (s *Store) NextIPForget(ctx) time.Time |
| ``` |
| |
| -- [ ] **Step 1: Tests** — `booking_flow_test.go`. Helpers: `hostWithPage(t) (*calendar.App, calendar.Person, calendar.BookingPageView)` (handle `paul`, page `every day 9-5`, 30 min, buffer 0, notice 0, horizon 30, published) and `firstSlots(t, app, page, host, now, n) []calendar.Slot`. Tests: |
| - |
| - - `TestAHoldIsATentativeEventTitledForTheBooker`: title `"Chat — Doc"`, status tentative, `PendingMail == MailHold`, token not on disk (grep). |
| - - `TestTwentyStrangersOneSlotOneWinner`: 20 goroutines call `HoldBooking` for the same slot; count `err == nil` (must be 1) and `ErrSlotTaken` (19). Run under `-race`. |
| - - `TestConfirmIsAConditionalUpdate`: first confirm `changed=true`; second `changed=false`; state confirmed; event confirmed with `Sequence == 1`; attendee recorded; `RemindersFor(host, event)` has one row (the default) when `host.DefaultReminderMin > 0` — set it via `SaveSettings` first; `PendingMail == MailConfirmed`. |
| - - `TestConfirmAfterExpiryChangesNothing`: `now + HoldTTL + 1m` → `changed=false`, state still `held` (the SWEEP moves it, not confirm). |
| - - `TestRescheduleMovesBumpsAndCanReturnToItsOwnSlot`: sequence goes 1→2; `Reschedules == 1`; moving back to the original slot succeeds (own event excluded); a sixth reschedule returns `ErrOverCap`. |
| - - `TestCancelIsConditional`: first `changed=true` with event cancelled and `Sequence` bumped and `ReminderQueue` rows for the event gone; second `changed=false`. |
| - - `TestHostDeleteCancelsTheBookingAndOwesMail`: `DeleteEvent(host, eventID)` → booking `cancelled`, `PendingMail == MailCancelled`. |
| - - `TestABookedEventCannotBecomeASeries`: `UpdateEvent` with `RRule: "FREQ=WEEKLY"` on the event → `ErrBookedEvent`. |
| - - `TestExpireHoldsFreesTheSlotAndNextHoldExpiryIsRight`. |
| - - `TestHoldCountsIncludeFailedRowsForAttempts`: three holds from one `IPFP`, `FailHold` on all three → `OpenIP == 0`, `AttemptsIP == 3`. |
| - - `TestOwedMailAndMarks`: after confirm, `OwedMail` returns the row; `MarkMailFailed` × `MaxReminderAttempts` clears the marker; `MailIsOwed` flips accordingly. |
| - - `TestForgetOldIPs`: age `created_at` via `DBForTest().Exec`; `ForgetOldIPs` nulls `ip_fp`. |
| - - `TestNextDueAtIsTheEarliestOfThree`: no reminders + a hold → hold expiry; owed mail → `<= now`. |
| - |
| -- [ ] **Step 2: Run; confirm failure.** |
| -- [ ] **Step 3: Implement.** |
| - |
| -`HoldBooking`: |
| +- [ ] **Step 1: Tests** — `booking_flow_test.go` (helpers `hostWithPage`, `slotsFor`) and `booking_mail_test.go`: |
| + - 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`. |
| + - 20 goroutines, one slot, one winner (`-race`). |
| + - confirm: first `changed`; second not; event confirmed `Sequence == 1`; attendee; default reminder when host has one; one `MailConfirmed/ToHost` row at revision 1. |
| + - confirm after `hold_expires_at` → no change; confirm when `now >= event.StartUTC` → no change (age the event via `DBForTest`). |
| + - 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`. |
| + - cancel by booker: `changed`; event cancelled, sequence bumped; `ReminderQueue` rows gone; unsent `MailRescheduled` rows deleted; two `MailCancelled` rows; `CancelledBy == booker`; second → no change. |
| + - 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`. |
| + - `UpdateEvent` with `RRule` on a booked event → `ErrBookedEvent`. |
| + - 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. |
| + - sweep: `ExpireHolds` → expired, event soft-deleted, queue rows gone, slot free; `NextHoldExpiry`; `ForgetOldIPs` at `ip_forget_at`; `NextIPForget`. |
| + - `HoldCountsFor`: three holds one IP → `OpenIP 3, AttemptsIP 3`; expire → `OpenIP 0, AttemptsIP 3`. |
| + - `NextDueAt` returns each of the four sources when it is earliest. |
| + - reminder claim path: a queue row whose event is `cancelled` is dropped (`ClaimDueReminder` returns `ok=false` and deletes it). |
| +- [ ] **Step 2: Run; failure.** |
| +- [ ] **Step 3: Implement.** Sketches: |
| + |
| ```go |
| -s.holdMu.Lock(); defer s.holdMu.Unlock() |
| -counts, err := s.HoldCountsFor(ctx, in.Page.ID, in.IPFP, addrFP, now) |
| -if counts.OpenIP >= 5 || counts.OpenPage >= 20 || counts.OpenAddress >= 2 || counts.AttemptsIP >= 20 || counts.AttemptsAddress >= 6 { return "", Booking{}, ErrOverCap } |
| -day := time.Unix(in.StartUTC, 0).In(hostLoc); dayStart := midnight(day); slots, err := s.FreeSlots(ctx, in.Page, in.Host, now, dayStart, dayStart.AddDate(0,0,1), 0) |
| -if !containsStart(slots, in.StartUTC) { return "", Booking{}, ErrSlotTaken } |
| -// then the transaction: buildEvent(...StatusTentative...) + tx.Create(&e); token; seal; tx.Create(&b) |
| +func (s *Store) HoldBooking(ctx context.Context, in HoldInput, now time.Time) (Booking, error) { |
| + s.holdMu.Lock() |
| + defer s.holdMu.Unlock() |
| + addrFP, _ := s.seal.Fingerprint(seal.PurposeAttendeeLookup, normalizeAddress(in.Address)) |
| + c, err := s.HoldCountsFor(ctx, in.Page.ID, in.IPFP, addrFP, now) |
| + if err != nil { return Booking{}, err } |
| + if c.OpenIP >= 5 || c.OpenPage >= 20 || c.OpenAddress >= 2 || c.AttemptsIP >= 20 || c.AttemptsAddress >= 6 { return Booking{}, ErrOverCap } |
| + loc, _ := Zone(in.Host.Zone) |
| + d := time.Unix(in.StartUTC, 0).In(loc) |
| + day := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, loc) |
| + slots, err := s.FreeSlots(ctx, in.Page, in.Host, now, day, day.AddDate(0, 0, 1), 0) |
| + if err != nil { return Booking{}, err } |
| + if !hasStart(slots, in.StartUTC) { return Booking{}, ErrSlotTaken } |
| + token := newToken() // 32 bytes hex, as MintFeed |
| + var b Booking |
| + err = s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| + calID, err := s.defaultCalendarIDTx(tx, in.Host.ID) |
| + e, err := s.buildEvent(in.Host.ID, EventInput{CalendarID: calID, Title: in.Page.Title + " — " + in.Name, Notes: in.Note, |
| + StartLocal: FormatLocal(time.Unix(in.StartUTC,0).In(loc), false), EndLocal: FormatLocal(time.Unix(in.EndUTC,0).In(loc), false), |
| + TZ: in.Host.Zone, Kind: KindMeeting, Status: StatusTentative}) |
| + if err := tx.Create(&e).Error; err != nil { return err } |
| + expires := now.Add(HoldTTL).Unix(); if in.StartUTC < expires { expires = in.StartUTC } |
| + 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(), ...} |
| + if err := tx.Create(&b).Error; err != nil { return err } |
| + return queueBookingMail(tx, b.ID, MailHold, []string{ToBooker}, 0, now) |
| + }) |
| + return b, err |
| +} |
| ``` |
| -`ConfirmBooking`: one transaction — `res := tx.Model(&Booking{}).Where("id = ? AND state = ? AND hold_expires_at > ?", id, BookingHeld, now.Unix()).Updates(map[string]any{"state": BookingConfirmed, "confirmed_at": now.Unix(), "pending_mail": MailConfirmed, "updated_at": now})`; `if res.RowsAffected == 0 { return false, nil }`; then event `status`, `sequence = sequence + 1` (use `gorm.Expr("sequence + 1")`), `updated_at`; `SetAttendees`; `EnsureDefaultReminder(ctx, host, event)` — NOTE it uses `s.g`, not `tx`; either make it accept a `*gorm.DB` or call it right after the transaction commits and accept the tiny window (say which in a comment; the reminder is the host's convenience, not the booking's correctness). |
| +`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)`. |
| |
| -`RescheduleBooking`: `holdMu`; read booking (must be `confirmed`, `Reschedules < MaxReschedules` else `ErrOverCap`); `FreeSlots(..., excludeEventID = b.EventID)`; transaction: event `start_local`, `end_local`, `start_utc`, `end_utc`, `sequence+1`, `updated_at`; booking conditional update `WHERE id=? AND state='confirmed' AND reschedules = ?` setting `reschedules+1`, `pending_mail`; then `RescheduleReminder` for each `RemindersFor(host, eventID)` (again outside `tx` if needed). |
| +`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)`. |
| |
| -`CancelBooking`: conditional `WHERE id=? AND state='confirmed'` → `cancelled`, `cancelled_at`, `pending_mail`; event `status=cancelled`, `sequence+1`; `DELETE FROM reminder_queues WHERE reminder_id IN (SELECT id FROM reminders WHERE event_id = ?)`. |
| +`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`). |
| |
| -`store_event.go`: in `UpdateEvent`, after validation: `if in.RRule != "" { if _, booked := s.bookingIDForEvent(ctx, eventID); booked { return ErrBookedEvent } }`. In `DeleteEvent`'s transaction: `tx.Model(&Booking{}).Where("event_id = ? AND state = ?", eventID, BookingConfirmed).Updates(map[string]any{"state": BookingCancelled, "cancelled_at": now.Unix(), "pending_mail": MailCancelled, "updated_at": now})`; also `held` → `expired` (a deleted hold is a dead hold). |
| +`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")`. |
| |
| -`NextDueAt`: reminder due → `min` with `NextHoldExpiry` → if `MailIsOwed` return `time.Now()`. |
| +`store_drain.go` `ClaimDueReminder`: after loading `e`, `if e.Status == StatusCancelled { delete queue row; return DueReminder{}, false, nil }`. |
| |
| -- [ ] **Step 4: `go test -race ./internal/calendartest/ -run 'Hold|Confirm|Reschedule|Cancel|Booked|Expire|Owed|Forget|NextDueAt' -count=1`; pass. `make ci`; commit** `booking: held → confirmed → cancelled as conditional updates, and mail as an outbox the drain owns`. |
| +`NextDueAt`: min over four non-zero times. |
| + |
| +- [ ] **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`. |
| |
| --- |
| |
| ### Task 5: Host settings UI |
| |
| -Spec §2.1, §3.4, §7.3. |
| - |
| -**Files:** create `handlers_booking_settings.go` (list, publish, parse), `handlers_booking_form.go` (new/edit/create/update), `templates/booking_pages.html`, `booking_page_form.html`, `static/booking.js`; modify `settings.html`, `handlers_misc.go` (`saveSettings` + `settings` read `handle`), `routes.go`, `render.go` (`pageNames` + ParseFS), `layout.html` (`booking.js` after `quickadd.js`), `calendar.css`; test `booking_settings_test.go`. |
| - |
| -**Routes (signed-in group):** |
| -``` |
| -GET /settings/booking list |
| -GET /settings/booking/new form |
| -POST /settings/booking create → 303 /settings/booking?saved=1 |
| -GET /settings/booking/{id} edit form; 404 if not mine |
| -POST /settings/booking/{id} update → 303 |
| -POST /settings/booking/{id}/publish publish=1|0 → 303 |
| -POST /settings/booking/parse fragment |
| -``` |
| - |
| -- [ ] **Step 1: Tests** — `booking_settings_test.go`: |
| - - `TestAHostMakesAPageAndSeesItsPublicURLOnlyOncePublished`: save settings with `handle=paul`; create page; list shows title and windows but says "Not published" and shows no `/book/` URL; POST publish=1; list shows `/book/paul/<code>` and the publish sentence ("Anyone with this link can see when you are free"). Extract the code with `regexp.MustCompile(`/book/paul/([a-z0-9]{8})`)`. |
| - - `TestAPageWithAnUnreadableSentenceIsNotSaved`: 422, quotes the clause, list has no such page. |
| - - `TestThePaneFragmentDescribesTheWindows`: `fri 9-5` → `Friday 09:00–17:00`, no `<html`. |
| - - `TestAnotherPersonsBookingPageIs404`: GET and POST `/settings/booking/{id}` as another person → 404; POST publish → 404. |
| - - `TestATakenHandleIsRefusedOnSettings`: second person posts `handle=paul` → 422 containing "taken". |
| - |
| -- [ ] **Step 2: Run; confirm.** |
| -- [ ] **Step 3: Implement.** `saveSettings`: after `SaveSettings` succeeds, `SaveHandle`; `ErrHandleTaken` → 422 with "That handle is taken."; `ValidationError` → its message. `settings.html`: a `field-text` for `handle` (value `{{with .Data.Person.Handle}}{{.}}{{end}}`), hint "Lowercase letters, digits and dashes. Changing it changes every booking link you have shared.", and a link "Booking pages →" to `/settings/booking`. |
| - |
| -`bookingPageForm` struct: `ID int64; Title, Description, Availability, DurationMin, BufferMin, NoticeMin, HorizonDays string; Pane BookingPaneView; Error string`. `readBookingPageForm(r) (BookingPageInput, bookingPageForm)` with defaults `30/10/120/30` when blank. Create/update: `ws, problems := ParseAvailability(in.Availability)`; problems → 422 with `Pane{Lines, Problems}`; `ValidationError` → 422 with `Error`; else store → 303. |
| - |
| -`booking_pages.html`: page-header "Booking pages" with a "New page" `rst-btn="primary"` link; when the person has no handle, a `callout` (warning): "Choose a handle in Settings before publishing — it is the first part of every booking link." Each page: title, duration, `DescribeWindows` lines, state badge (Published / Not published), the URL when published (`h.origin + "/book/" + handle + "/" + code`, as a `<code>` and a copyable link), Edit link, and a publish form whose button reads "Publish" or "Unpublish" with the §7.3 sentence above it as `<p cal-book-caveat>`. |
| - |
| -`booking_page_form.html`: `field-text` title, `field-textarea` description, `field-textarea` availability with `cal-book-text` (wrap in `<div cal-book-form>` so the JS can find root), then `<div id="cal-book-pane" cal-book-pane aria-live="polite">{{template "bookingpane" .Data.Pane}}</div>`, `field-select` duration (5,10,15,20,30,45,60,90,120), `field-text` buffer/notice/horizon with `inputmode=numeric`, `form-foot` Save / Cancel → `/settings/booking`. |
| +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`. |
| |
| -`booking.js` (clone `quickadd.js`; < 120 lines): `setupPane(root)` for `[cal-book-form]` → `/settings/booking/parse`; `setupZone()` — if `[cal-book-zone]` exists and has no `data-chosen`, set its value to `Intl.DateTimeFormat().resolvedOptions().timeZone` when that value is one of its options. |
| +**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`. |
| |
| -CSS: `[cal-book-pane]`, `[cal-book-lines]`, `[cal-book-problems]`, `[cal-book-caveat]`, `[cal-book-slots]`, `[cal-book-slot]` using existing `--rst-*` tokens already referenced in `calendar.css` (the guard test fails on a token that is not declared). |
| - |
| -- [ ] **Step 4: Run; pass. `make ci`; commit** `booking: the host's side — a handle, pages, a pane, and a Publish button that says what it does`. |
| +- [ ] **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". |
| +- [ ] **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`). |
| +- [ ] **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`. |
| |
| --- |
| |
| -### Task 6: The public flow, mail and ICS |
| - |
| -Spec §5, §6, §7.1 (429), §2.5. |
| +### Task 6: The public flow, mail, ICS, the timed sender, the token bucket |
| |
| -**Files:** create `handlers_booking_public.go` (page GET/POST), `handlers_booking_manage.go` (manage GET, confirm, reschedule, cancel), `handlers_booking_util.go` (`clientIP`, `notFoundLikeTheRouter`, `publicHeaders`, zone pick), `booking_ics.go`, `booking_mail.go`, `templates/mail/{hold,confirmed_host,rescheduled,cancelled}.txt`, `templates/book_page.html`, `book_hold.html`, `book_sent.html`, `book_manage.html`, `book_gone.html`; modify `routes.go`, `render.go`, `calendar.css`; test `booking_http_test.go`, `booking_abuse_test.go`. |
| +Spec §4.2, §5, §6, §7.1–7.2, §2.7. |
| |
| -**Routes (chi router, outside the session group, registered BEFORE `/book/{handle}/{code}`):** |
| +**Routes (chi router, outside the session group, `/book/m/...` registered BEFORE `/book/{handle}/{code}`):** |
| ``` |
| -GET /book/m/{token} state page (held: confirm button; confirmed: manage) |
| +GET /book/m/{token} held: confirm button; confirmed: manage; else: gone |
| POST /book/m/{token}/confirm |
| GET /book/m/{token}/calendar.ics |
| -GET /book/m/{token}/reschedule ?day=&zone= |
| -POST /book/m/{token}/reschedule start, zone |
| +GET /book/m/{token}/reschedule ?day=&zone= (+ hidden rev) |
| +POST /book/m/{token}/reschedule start, zone, rev |
| POST /book/m/{token}/cancel |
| GET /book/{handle}/{code} ?day=&zone= |
| POST /book/{handle}/{code} start, zone [, name, address, note] |
| ``` |
| |
| -**Utilities:** |
| +**Produces:** |
| ```go |
| -func clientIP(r *http.Request) string // last XFF element, else RemoteAddr host; net.ParseIP(...).String() or "unknown" |
| -func notFoundLikeTheRouter(w http.ResponseWriter, r *http.Request) // ErrorPage(w, r, 404, "") — confirm this is what chi's NotFound is wired to in routes.go/main.go; if the router's 404 is plain http.NotFound, use that instead. The test compares bodies. |
| -func publicHeaders(w http.ResponseWriter) // Cache-Control: no-store; Referrer-Policy: no-referrer |
| -func pickZone(r *http.Request, fallback string) (zone string, chosen bool) // IsCommonZone(r.FormValue("zone")) else fallback |
| +// handlers_booking_util.go |
| +func clientIP(r *http.Request) string |
| +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 |
| +func notFoundLikeTheRouter(w, r) |
| +func zoneChoices(host Person) []string // CommonZones ∪ {host.Zone} |
| +func pickZone(r *http.Request, host Person) (zone string, chosen bool) |
| +type bucket struct{ mu sync.Mutex; tokens map[string]*tb } // 60 tokens, refill 1/s, prune idle keys past 5 min |
| + |
| +// mail_timed.go |
| +var ErrMailTimeout = errors.New("calendar: mail send timed out") |
| +type timedSender struct{ next mail.Sender; limit time.Duration } |
| +func (t timedSender) Send(ctx, to, subject, body string) error // goroutine + select on time.After(limit); the request WAITS here |
| + |
| +// booking_mail.go |
| +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 |
| +func (h *handlers) trySendNow(ctx context.Context, bookingID int64) // claims and sends every unsent row for the booking, once; errors only logged (scrubbed) |
| + |
| +// booking_ics.go |
| +func WriteBookingICS(v BookingView) string // METHOD on VCALENDAR; one VEVENT; allowlisted fields only; no URL: |
| ``` |
| |
| -**Mail:** `//go:embed templates/mail/*.txt` → `text/template`. `func (h *handlers) sendBookingMail(ctx, v BookingView, token string) error` switches on `v.PendingMail`; returns the first error. Each template receives `struct{ Title, Description, HostName, HostAddress, Name, Address, Note, WhenBooker, WhenHost, ExpiryBooker, ManageURL, EventURL string; Who string }`. `hold.txt` body per spec §6 (the "Somebody — we hope you —" wording). `ManageURL` is only known at hold time (the token is plaintext once); for later mails the booker gets the manage URL ONLY if the handler has the token in hand (confirm/reschedule/cancel handlers do, from the path); the DRAIN retry does not, so the rescheduled/cancelled templates say "the link in your first mail" when `ManageURL` is empty. Wrap `Send` in `context.WithTimeout(ctx, 20*time.Second)` with the comment the spec asks for. |
| - |
| -**ICS:** `func WriteBookingICS(v BookingView) string` — `BEGIN:VCALENDAR / VERSION / PRODID / CALSCALE / METHOD:REQUEST|CANCEL` then one VEVENT: `UID` (event's opened UID: extend `BookingByToken` to open `UIDSealed` with `PurposeEventUID`), `DTSTAMP`, `SEQUENCE:{v.Event.Sequence}`, `DTSTART;TZID=host zone`, `DTEND`, `SUMMARY:{page title}`, `DESCRIPTION:{page description}` (only if non-empty), `ORGANIZER;CN={HostName}:mailto:{host address}`, `ATTENDEE;CN={Name};PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:{booker}`, `STATUS:CONFIRMED|CANCELLED`. **No `URL:`. No event notes. No location. No other attendees.** Reuse `line`, `escapeText`, `icsDateTime`, `utcStamp` from `ics_export.go`. |
| - |
| -- [ ] **Step 1: Tests** — `booking_http_test.go`. Helpers: `publishedPage(t) (*calendar.App, *pickyMailer, string /*path*/, calendar.Person)`; `strangerGet/strangerPost` (RequestForTest with nil session; for POSTs also set `req.RemoteAddr` when a test needs distinct IPs); `reStart = regexp.MustCompile(`name="start" value="(\d+)"`)`; `reManage = regexp.MustCompile(`/book/m/([0-9a-f]{64})`)`; extend `pickyMailer` so `refuse` also matches `to`. |
| - |
| - - `TestAStrangerHoldsAndTheLinkOnlyOffersAButton`: GET page with `?day=` → slot forms; POST slot (no name) → 200 with details form carrying `start` hidden; POST with name/address/note → 200 "Check your mail" echoing the address; exactly one mail, to the booker, containing `/book/m/`; GET the link → 200 with `Confirm this booking` and a form posting to `…/confirm`; **state still held, mails still 1**; POST confirm → 200 manage with "Reschedule"; mails now 2, the second to the host, containing "Doc"; POST confirm again → mails still 2. |
| - - `TestTheICSIsARequestWithNoManagementLink`: after confirm, GET `…/calendar.ics`: `text/calendar`, `METHOD:REQUEST`, `SUMMARY:Quick chat`, `ATTENDEE;CN=Doc;PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:doc@tito.io`, `ORGANIZER`, `mailto:host@example.com`, `SEQUENCE:1`, and `!strings.Contains(body, "URL:")`. Parse it with the app's importer (find the parse entry point in `ics_import.go`/`ics_parse.go` — `ParseICS` or similar — and assert one event). |
| - - `TestTheHostsLaterEditsNeverReachTheBooker`: host `UpdateEvent` adding `Notes: "private"`, `Location: "Room 9"`, then `SetAttendees(event, [doc, "boss@example.com"])`; GET manage, GET ICS, and the cancelled mail body after a booker cancel must contain none of `private`, `Room 9`, `boss@example.com`. |
| - - `TestTheSameSlotCannotBeHeldTwice` → 409 "just gone". |
| - - `TestAWrongTokenIsTheSame404AsAnUnknownRoute`: bodies byte-identical. |
| - - `TestAnExpiredHoldSaysSoOnlyToTheRightToken`: age `hold_expires_at`; GET link → 200 containing "expired"; GET wrong token → 404. |
| - - `TestRescheduleAndCancelTellBothParties`: reschedule → 2 mails, ICS `SEQUENCE:2`; cancel → 2 more, `METHOD:CANCEL`; second cancel → no more mail. |
| - - `TestHostDeletingTheEventTellsTheBooker`: host `POST /events/{id}/delete` (or `DeleteEvent`) → `tick` → the booker got a cancelled mail; GET manage says cancelled. |
| - - `TestAHoldWhoseMailIsRefusedIsFailedAndFreesTheSlot`: `mailer.refuse = "bounce@example.com"` → 422 "could not send"; the slot is offered again; the row exists with state `failed` (query `DBForTest`). |
| - - `TestPublicResponsesAreNoStoreNoReferrer`. |
| - |
| - `booking_abuse_test.go`: |
| - - `TestTheSixthOpenHoldFromOneIPIs429` (six different slots, same `RemoteAddr`, distinct addresses). |
| - - `TestTheThirdOpenHoldForOneAddressIs429`. |
| - - `TestAttemptsCountFailedRows`: refuse the address; 6 failed holds from one address → the 7th is 429 even though none is open. |
| - - `TestABodyOverTheLimitIs422` (a 20 KiB note). |
| - - `TestAZoneOutsideCommonZonesFallsBackToTheHosts` (`?zone=Nowhere/Town` renders the host's zone selected). |
| - |
| -- [ ] **Step 2: Run; confirm failure.** |
| -- [ ] **Step 3: Implement.** Handler shapes: |
| - |
| -`bookPage` GET: `PublicBookingPage` → 404; `publicHeaders`; `zone, chosen := pickZone(r, host.Zone)`; `FreeSlots` over `[now, now+horizon]` once; group by `time.Unix(s.StartUTC,0).In(bookerLoc).Format(LayoutDate)`; if `?day=` matches a group, render that day's slots as forms; else render the day list as links. Template data: `Page, HostName, Zone, Zones []string (CommonZones), Chosen bool, Days []struct{Date, Label string; N int}, Day string, Slots []struct{StartUTC int64; Label string}, Picked *struct{StartUTC int64; Label string}, Form struct{Name, Address, Note, Error string}`. |
| - |
| -`bookPost` POST: `r.Body = http.MaxBytesReader(w, r.Body, 16<<10)`; `ParseForm` error → 422; `start` int; if `name == ""` → re-render the page with `Picked` set (verify `start` is currently free; if not → 409 "just gone" with the day's slots). Else validate name/address/note; `HoldBooking(HoldInput{..., IPFP: fp(clientIP(r))}, now)`: `ErrOverCap` → 429 `book_gone` with the §7 sentence; `ErrSlotTaken` → 409; else `sendBookingMail(hold)`: error → `FailHold` + 422 "We could not send to that address"; success → `MarkMailSent`; render `book_sent` (address, expiry in booker zone). |
| - |
| -`bookManage` GET: `BookingByToken` → 404; `publicHeaders`; switch state / expiry / `Event.Status == cancelled || Event.DeletedAt != nil` → `book_hold` (button) | `book_gone` (expired/cancelled/failed) | `book_manage`. |
| - |
| -`bookConfirm` POST: `ConfirmBooking` → `changed` → `sendBookingMail(confirmed)` → `MarkMailSent`/`MarkMailFailed`; then render as `bookManage` does. Zero rows → just render. |
| - |
| -`bookReschedule` GET/POST: picker with `excludeEventID`; POST → `RescheduleBooking` (`ErrSlotTaken` 409, `ErrOverCap` 429) → mail both → render manage. |
| - |
| -`bookCancel` POST: `CancelBooking` → if changed, mail both → render. |
| - |
| -`bookICS`: state `confirmed` or `cancelled` only, else 404; headers `text/calendar; charset=utf-8`, `Content-Disposition: attachment; filename="booking.ics"`, plus `publicHeaders`. |
| - |
| -Templates render through `render(w, name, h.page(r, title, data))`; the layout hides the nav for a stranger. Add the five names to `pageNames`. |
| - |
| -- [ ] **Step 4: Run; pass. `make ci`; commit** `booking: a stranger holds a slot, a button confirms it, and the ICS is one click away`. |
| +`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`). |
| + |
| +- [ ] **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}`): |
| + - 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. |
| + - confirm after `event.start_utc` (age via SQL) → page says expired/too late, no change. |
| + - 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. |
| + - host edits never reach the booker (note, location, second attendee → absent from manage, ICS, cancelled mail). |
| + - same slot twice → 409; wrong token → byte-identical 404; expired says expired. |
| + - 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. |
| + - host cancels via edit → booker mailed, page says cancelled by host; host deletes → same, ICS sequence higher. |
| + - 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`. |
| + - `no-store` and `no-referrer` on every public response. |
| + `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`). |
| + `timedSender`: a `stallingMailer` that blocks on a channel → `Send` returns `ErrMailTimeout` within `limit+1s` (set `limit` small via a constructor). |
| +- [ ] **Step 2: Run; failure.** |
| +- [ ] **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). |
| +- [ ] **Step 4: Pass; `make ci`; commit** `booking: a stranger holds a slot, a button confirms it, and the ICS is one click away`. |
| |
| --- |
| |
| -### Task 7: Sweep in the drain, leak test, log test, browser passes |
| +### Task 7: Drain ordering, leak and log tests, browser passes |
| |
| Spec §8, §9. |
| |
| -**Files:** modify `handlers_jobs.go` (`drain` and `DrainOnRequest`), `events_test.go`; create `booking_sweep_test.go`, `test/browser/booking.mjs`; modify `playwright_test.go`, `Makefile`, `package.json`. |
| - |
| -- [ ] **Step 1: Tests** — `booking_sweep_test.go`: |
| - - `TestTheTickExpiresHoldsFreesTheSlotAndWakesForThem`: hold; age `hold_expires_at`; `app.NextDue().Before(now)`; `tick` 204; slot offered again; the held event is gone from `/events/{id}` (404 as the host). |
| - - `TestTheTickRetriesOwedMail`: confirm with the host address refused → `pending_mail='confirmed'`; un-refuse; `tick` → host mail delivered; marker cleared; `NextDue` no longer `<= now`. |
| - - `TestAPageViewAlsoSweeps`: a signed-in GET `/today` after an expiry (DrainOnRequest) expires it. |
| - - `TestIPFingerprintsAreForgottenAfterADay`. |
| - - `TestNoAddressOrTokenReachesTheLog`: build the app with `Logger: slog.New(slog.NewTextHandler(&buf, nil))` (add a `newAppWithMailerAndLogger` helper); refuse the hold mail; assert `!bytes.Contains(buf, address)` and no 64-hex run. |
| - - Extend `TestTheDatabaseNeverHoldsAPlaintextTitle` with a booking fixture: page title `"Interrogation about the acquisition"`, description `"Bring nothing"`, availability `"mondays 12-3"`, booker `"Dermot Lacey"`, `"dermot.lacey@elsewhere.example"`, note `"The folder"`, and the token; checkpoint; grep all seven. |
| - |
| -- [ ] **Step 2: Implement the drain steps** at the top of `drain` AND in `DrainOnRequest` (bounded 200/20/500), each logging only counts with a stage. Mail retries call `sendBookingMail(v, "")` — no token — and `MarkMailSent`/`MarkMailFailed`. |
| - |
| -- [ ] **Step 3: Browser** — `test/browser/booking.mjs` taking `origin cookieName cookieValue publicPath`. Pass A (JS off, no cookie): open `publicPath`, click the first day link, click the first slot button, fill `name`/`address`, submit, assert "Check your mail". Then the Go side reads `app.LastMailForTest()`, extracts the link, and node pass B (JS off) opens it, asserts "Confirm this booking", presses the button, asserts "Reschedule". Pass C (JS on, signed-in cookie): `/settings/booking/new`, type `tues 12-2`, wait for `[cal-book-pane]` to contain `Tuesday 12:00–14:00`. Because pass B needs the mail from pass A, split the node script into two invocations (`booking.mjs hold …` and `booking.mjs confirm … <link>`) driven from a new `TestBookingInARealBrowser` in `playwright_test.go`; the Go test creates the host/handle/page/publish through the store first. Update `Makefile` and `package.json` to `-run 'InARealBrowser'`. |
| - |
| -- [ ] **Step 4: `make ci` and `make browser` green; commit** `booking: holds expire on the tick, owed mail is retried, and a stranger with no script can book and confirm`. |
| +- [ ] **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). |
| +- [ ] **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. |
| +- [ ] **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`. |
| +- [ ] **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`. |
| |
| --- |
| |
| ### Task 8: AGENTS.md |
| |
| -Spec §11, verbatim. Rows B1, B2, B4, B5, B8, B11, B12, B13 in Settled decisions ("Booking spec §1, 2026-09-07"); the Frontend booker exception; the two Data bullets; rename Reminders → Ticks with the `NextDue` bullet; leak-list item 5; the Time bullet on `Zone()`. Commit `docs: the rules a booking page adds`. |
| +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`. |
| |
| --- |
| |
| ## Self-review |
| |
| -- **Spec coverage:** §2 → T1 (+ `ip_fp`, `pending_mail`, `reschedules` columns); §2.5 → T6 `clientIP`; §3 → T2, T5; §4 → T3 (round-trip rule, all-day via DayBounds, lapsed holds excluded, ±1 day envelope, per-candidate filters); §5.1–5.2 → T6 (two-step form, limits, caps 429, 409); §5.3–5.4 → T6 (GET shows button; POST confirms; idempotent by RowsAffected); §5.5 → T4 (`UpdateEvent`/`DeleteEvent`) + T6 (allowlist + test); §5.6–5.7 → T4, T6; §6 → T6 + T7 (retry); §7 → T4 counts, T6 429s, T7 forget; §8 → T7; §9 → each task's Step 1; §11 → T8. |
| -- **Type consistency:** `Window` in `models_booking.go`; `BookingPageView.Windows []Window`; `HoldInput.Page BookingPageView`; `BookingView.Event EventView` and public code reads only `Status/StartUTC/EndUTC/Sequence/DeletedAt/UIDSealedOpen` from it; `MailHold…MailCancelled` constants match `pending_mail` values in the spec; `HoldCounts` field names match the five caps. |
| -- **Placeholders:** the `"23:59"` line in Task 2's table is flagged for removal in the note beneath it; the DST expectations are pinned by running once and recording, which is the repository's stated convention for DST numbers. |
| +- **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. |
| +- **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`. |
| +- **Placeholders:** none. DST expectations are pinned by observing once and recording, the repository's convention. |