oficina / calendar Public

booking: pages, windows, bookings and their mail — the tables, sealed where they are content

Task 1 of the booking plan, against spec revision 4.

Four new tables: booking_pages (the host's "book time with me" page,
title/description/availability sealed, code generated and plaintext),
booking_windows (parsed weekly windows, shape-of-time, plaintext),
bookings (one held/confirmed/cancelled/expired slot, with the token
both hashed and sealed, the page text snapshotted at hold time per B20,
and every booker field sealed), and booking_mails (the queue table,
one row per booking/kind/recipient/revision with a claim_id per B13).
people gains a nullable unique handle.

The migration is generated (0005_alter_people.sql), not hand-written.

The page store: every settings read and write is WHERE id = ? AND
person_id = ?, and a miss is ErrNotFound. PublicBookingPage is the
one unscoped reader and requires published_at IS NOT NULL; an unknown
handle, an unknown code and an unpublished page are indistinguishable
from outside. Updates take an explicit column map, so the code and the
publication state cannot be moved by a form. Text limits are BYTES.

Tests: a page round-trips sealed with its windows and none of its text
is in the raw file; the three misses vanish the same way; a handle is
lowercased, unique, not a reserved segment, and cleared by an empty
save; another person cannot read, edit or publish a page; the limits
are bytes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 6f76ba42d0e7f0f9defed1b081f876827917d1c3 parent ab6a3a7
12 files changed, +1127 −1
  • internal/calendar/migrations/0005_alter_people.sql +36 −0
  • internal/calendar/migrations/schema.sql +35 −1
  • internal/calendar/models.go +12 −0
  • internal/calendar/models_booking.go +187 −0
  • internal/calendar/seal/seal.go +13 −0
  • internal/calendar/store_booking_handle.go +140 −0
  • internal/calendar/store_booking_page.go +227 −0
  • internal/calendar/store_booking_page_read.go +127 −0
  • internal/calendar/testing.go +24 −0
  • internal/calendartest/booking_handle_test.go +64 −0
  • internal/calendartest/booking_store_test.go +261 −0
  • internal/calendartest/migrate_test.go +1 −0
diff --git a/internal/calendar/migrations/0005_alter_people.sql b/internal/calendar/migrations/0005_alter_people.sql
new file mode 100644
index 0000000..8781699
--- /dev/null
+++ b/internal/calendar/migrations/0005_alter_people.sql
@@ -0,0 +1,36 @@
+ALTER TABLE `people` ADD `handle` text;
+
+CREATE UNIQUE INDEX `idx_people_handle` ON `people`(`handle`);
+
+CREATE TABLE `booking_pages` (`id` integer PRIMARY KEY AUTOINCREMENT,`person_id` integer NOT NULL,`code` text NOT NULL,`title_sealed` blob,`description_sealed` blob,`duration_min` integer NOT NULL,`buffer_min` integer NOT NULL,`notice_min` integer NOT NULL,`horizon_days` integer NOT NULL,`availability_text_sealed` blob,`published_at` integer,`created_at` datetime NOT NULL,`updated_at` datetime NOT NULL);
+
+CREATE UNIQUE INDEX `idx_booking_page_code` ON `booking_pages`(`person_id`,`code`);
+
+CREATE TABLE `booking_windows` (`id` integer PRIMARY KEY AUTOINCREMENT,`page_id` integer NOT NULL,`weekday` integer NOT NULL,`start_min` integer NOT NULL,`end_min` integer NOT NULL);
+
+CREATE INDEX `idx_booking_windows_page_id` ON `booking_windows`(`page_id`);
+
+CREATE TABLE `bookings` (`id` integer PRIMARY KEY AUTOINCREMENT,`page_id` integer NOT NULL,`event_id` integer NOT NULL,`token_hash` text NOT NULL,`token_sealed` blob,`state` text NOT NULL,`title_sealed` blob,`description_sealed` blob,`booker_name_sealed` blob,`booker_address_sealed` blob,`booker_address_fp` text NOT NULL,`note_sealed` blob,`booker_zone` text NOT NULL,`hold_expires_at` integer NOT NULL,`confirmed_at` integer,`cancelled_at` integer,`cancelled_by` text NOT NULL,`ip_fp` text,`ip_forget_at` integer NOT NULL,`reschedules` integer NOT NULL,`created_at` datetime NOT NULL,`updated_at` datetime NOT NULL);
+
+CREATE INDEX `idx_bookings_ip_forget_at` ON `bookings`(`ip_forget_at`);
+
+CREATE INDEX `idx_bookings_ip_fp` ON `bookings`(`ip_fp`);
+
+CREATE INDEX `idx_bookings_hold_expires_at` ON `bookings`(`hold_expires_at`);
+
+CREATE INDEX `idx_bookings_booker_address_fp` ON `bookings`(`booker_address_fp`);
+
+CREATE INDEX `idx_bookings_state` ON `bookings`(`state`);
+
+CREATE UNIQUE INDEX `idx_bookings_token_hash` ON `bookings`(`token_hash`);
+
+CREATE INDEX `idx_bookings_event_id` ON `bookings`(`event_id`);
+
+CREATE INDEX `idx_bookings_page_id` ON `bookings`(`page_id`);
+
+CREATE TABLE `booking_mails` (`id` integer PRIMARY KEY AUTOINCREMENT,`booking_id` integer NOT NULL,`kind` text NOT NULL,`recipient` text NOT NULL,`revision` integer NOT NULL,`due_at` integer NOT NULL,`attempts` integer NOT NULL,`claimed_at` integer,`claim_id` text NOT NULL,`sent_at` integer,`created_at` datetime NOT NULL);
+
+CREATE INDEX `idx_booking_mails_due_at` ON `booking_mails`(`due_at`);
+
+CREATE UNIQUE INDEX `idx_booking_mail_once` ON `booking_mails`(`booking_id`,`kind`,`recipient`,`revision`);
+
diff --git a/internal/calendar/migrations/schema.sql b/internal/calendar/migrations/schema.sql
index b9cf2a5..0e4d3ba 100644
--- a/internal/calendar/migrations/schema.sql
+++ b/internal/calendar/migrations/schema.sql
@@ -3,6 +3,14 @@
CREATE TABLE `attendees` (`id` integer PRIMARY KEY AUTOINCREMENT,`event_id` integer NOT NULL,`address_sealed` blob,`address_fp` text NOT NULL,`created_at` datetime NOT NULL);
+CREATE TABLE `booking_mails` (`id` integer PRIMARY KEY AUTOINCREMENT,`booking_id` integer NOT NULL,`kind` text NOT NULL,`recipient` text NOT NULL,`revision` integer NOT NULL,`due_at` integer NOT NULL,`attempts` integer NOT NULL,`claimed_at` integer,`claim_id` text NOT NULL,`sent_at` integer,`created_at` datetime NOT NULL);
+
+CREATE TABLE `booking_pages` (`id` integer PRIMARY KEY AUTOINCREMENT,`person_id` integer NOT NULL,`code` text NOT NULL,`title_sealed` blob,`description_sealed` blob,`duration_min` integer NOT NULL,`buffer_min` integer NOT NULL,`notice_min` integer NOT NULL,`horizon_days` integer NOT NULL,`availability_text_sealed` blob,`published_at` integer,`created_at` datetime NOT NULL,`updated_at` datetime NOT NULL);
+
+CREATE TABLE `booking_windows` (`id` integer PRIMARY KEY AUTOINCREMENT,`page_id` integer NOT NULL,`weekday` integer NOT NULL,`start_min` integer NOT NULL,`end_min` integer NOT NULL);
+
+CREATE TABLE `bookings` (`id` integer PRIMARY KEY AUTOINCREMENT,`page_id` integer NOT NULL,`event_id` integer NOT NULL,`token_hash` text NOT NULL,`token_sealed` blob,`state` text NOT NULL,`title_sealed` blob,`description_sealed` blob,`booker_name_sealed` blob,`booker_address_sealed` blob,`booker_address_fp` text NOT NULL,`note_sealed` blob,`booker_zone` text NOT NULL,`hold_expires_at` integer NOT NULL,`confirmed_at` integer,`cancelled_at` integer,`cancelled_by` text NOT NULL,`ip_fp` text,`ip_forget_at` integer NOT NULL,`reschedules` integer NOT NULL,`created_at` datetime NOT NULL,`updated_at` datetime NOT NULL);
+
CREATE TABLE `calendars` (`id` integer PRIMARY KEY AUTOINCREMENT,`name_sealed` blob,`kind` text NOT NULL,`owner_id` integer NOT NULL,`hue` integer NOT NULL,`created_at` datetime NOT NULL,`archived_at` datetime);
CREATE TABLE `events` (`id` integer PRIMARY KEY AUTOINCREMENT,`calendar_id` integer NOT NULL,`uid_hash` text NOT NULL,`uid_sealed` blob,`series_id` integer,`recurrence_id_local` text NOT NULL,`title_sealed` blob,`location_sealed` blob,`notes_sealed` blob,`all_day` numeric NOT NULL,`start_local` text NOT NULL,`end_local` text NOT NULL,`tz` text NOT NULL,`start_utc` integer NOT NULL,`end_utc` integer NOT NULL,`rrule` text NOT NULL,`rrule_foreign` numeric NOT NULL,`until_utc` integer,`exdates` text NOT NULL,`kind` text NOT NULL,`status` text NOT NULL,`sequence` integer NOT NULL,`created_by` integer NOT NULL,`created_at` datetime NOT NULL,`updated_at` datetime NOT NULL,`deleted_at` datetime);
@@ -13,7 +21,7 @@ CREATE TABLE `grants` (`id` integer PRIMARY KEY AUTOINCREMENT,`calendar_id` inte
CREATE TABLE `meta` (`key` text,`value` text NOT NULL,PRIMARY KEY (`key`));
-CREATE TABLE `people` (`id` integer PRIMARY KEY AUTOINCREMENT,`address` text NOT NULL,`zone` text NOT NULL,`created_at` datetime NOT NULL,`updated_at` datetime NOT NULL, `name_sealed` blob, `week_start` integer NOT NULL, `default_reminder_min` integer NOT NULL, `default_all_day_min` integer NOT NULL);
+CREATE TABLE `people` (`id` integer PRIMARY KEY AUTOINCREMENT,`address` text NOT NULL,`zone` text NOT NULL,`created_at` datetime NOT NULL,`updated_at` datetime NOT NULL, `name_sealed` blob, `week_start` integer NOT NULL, `default_reminder_min` integer NOT NULL, `default_all_day_min` integer NOT NULL, `handle` text);
CREATE TABLE `reminder_queues` (`id` integer PRIMARY KEY AUTOINCREMENT,`reminder_id` integer NOT NULL,`occurrence_key` text NOT NULL,`due_at` integer NOT NULL,`claimed_at` integer,`fired_at` integer,`attempts` integer NOT NULL,`late_by_sec` integer NOT NULL);
@@ -23,6 +31,30 @@ CREATE INDEX `idx_attendees_address_fp` ON `attendees`(`address_fp`);
CREATE INDEX `idx_attendees_event_id` ON `attendees`(`event_id`);
+CREATE UNIQUE INDEX `idx_booking_mail_once` ON `booking_mails`(`booking_id`,`kind`,`recipient`,`revision`);
+
+CREATE INDEX `idx_booking_mails_due_at` ON `booking_mails`(`due_at`);
+
+CREATE UNIQUE INDEX `idx_booking_page_code` ON `booking_pages`(`person_id`,`code`);
+
+CREATE INDEX `idx_booking_windows_page_id` ON `booking_windows`(`page_id`);
+
+CREATE INDEX `idx_bookings_booker_address_fp` ON `bookings`(`booker_address_fp`);
+
+CREATE INDEX `idx_bookings_event_id` ON `bookings`(`event_id`);
+
+CREATE INDEX `idx_bookings_hold_expires_at` ON `bookings`(`hold_expires_at`);
+
+CREATE INDEX `idx_bookings_ip_forget_at` ON `bookings`(`ip_forget_at`);
+
+CREATE INDEX `idx_bookings_ip_fp` ON `bookings`(`ip_fp`);
+
+CREATE INDEX `idx_bookings_page_id` ON `bookings`(`page_id`);
+
+CREATE INDEX `idx_bookings_state` ON `bookings`(`state`);
+
+CREATE UNIQUE INDEX `idx_bookings_token_hash` ON `bookings`(`token_hash`);
+
CREATE INDEX `idx_calendars_archived_at` ON `calendars`(`archived_at`);
CREATE INDEX `idx_calendars_owner_id` ON `calendars`(`owner_id`);
@@ -51,6 +83,8 @@ CREATE UNIQUE INDEX `idx_grant_cal_person` ON `grants`(`calendar_id`,`person_id`
CREATE UNIQUE INDEX `idx_people_address` ON `people`(`address`);
+CREATE UNIQUE INDEX `idx_people_handle` ON `people`(`handle`);
+
CREATE INDEX `idx_reminder_queues_due_at` ON `reminder_queues`(`due_at`);
CREATE INDEX `idx_reminder_queues_occurrence_key` ON `reminder_queues`(`occurrence_key`);
diff --git a/internal/calendar/models.go b/internal/calendar/models.go
index 4210abd..3a15ad9 100644
--- a/internal/calendar/models.go
+++ b/internal/calendar/models.go
@@ -17,6 +17,10 @@ var Models = []any{
&ReminderQueue{},
&Feed{},
&Meta{},
+ &BookingPage{},
+ &BookingWindow{},
+ &Booking{},
+ &BookingMail{},
}
// Person is an address that has signed in here, and the row everything
@@ -56,6 +60,14 @@ type Person struct {
// because somebody opened it on holiday is a calendar that lies.
Zone string `gorm:"column:zone;not null"`
+ // Handle is the plaintext name in this person's public booking
+ // URLs, /book/{handle}/{code}: [a-z0-9-]{2,32}, lowercased on save,
+ // NULL until they choose one. Plaintext because it is in a URL the
+ // person hands to strangers, and unique because two people cannot
+ // share a URL prefix. Nullable so the unique index admits any
+ // number of people who never set one.
+ Handle *string `gorm:"column:handle;uniqueIndex"`
+
// WeekStart is 1 for Monday (the default), 7 for Sunday, 6 for
// Saturday — time.Weekday's numbering with Sunday as 7 rather than
// 0, so "unset" is distinguishable from "Sunday".
diff --git a/internal/calendar/models_booking.go b/internal/calendar/models_booking.go
new file mode 100644
index 0000000..198f4ca
--- /dev/null
+++ b/internal/calendar/models_booking.go
@@ -0,0 +1,187 @@
+package calendar
+
+import "time"
+
+// The booking tables. Spec: docs/superpowers/specs/2026-09-07-booking-design.md §2.
+//
+// What is sealed and what is not follows the one rule's falsifiable
+// form. A page's title, description and availability sentence are
+// content; a booker's name, address and note are content; the
+// management token is a live capability. All of those are sealed.
+// Windows, states, codes, revisions and instants are the shape of the
+// team's time, which the rule already gives up.
+
+// Booking states and who cancelled — the column values, spelled once.
+const (
+ BookingHeld = "held"
+ BookingConfirmed = "confirmed"
+ BookingCancelled = "cancelled"
+ BookingExpired = "expired"
+
+ CancelledByNobody = ""
+ CancelledByBooker = "booker"
+ CancelledByHost = "host"
+)
+
+// Mail kinds and recipients, the other half of a booking_mails key.
+const (
+ MailHold = "hold"
+ MailConfirmed = "confirmed"
+ MailRescheduled = "rescheduled"
+ MailCancelled = "cancelled"
+ ToBooker = "booker"
+ ToHost = "host"
+)
+
+// The knobs. HoldTTL and MinNotice are B15: a hold lasts thirty minutes
+// or until the meeting would have started, whichever is first, and a
+// page's notice_min has a floor of ten minutes at slot time.
+const (
+ HoldTTL = 30 * time.Minute
+ MinNotice = 10 * time.Minute
+ MaxReschedules = 5
+ IPForgetAfter = 24 * time.Hour
+
+ // MailGaveUp is the sent_at value meaning "stopped trying": the
+ // drain reached MaxReminderAttempts and will not claim this row
+ // again. Not NULL, so NextDueAt does not keep advertising it.
+ MailGaveUp = int64(-1)
+)
+
+// Byte limits, because the event validator's limits are bytes (r2 B6)
+// and two units for "how long is a title" is a bug waiting for a
+// multibyte name. The booker's fields carry a rune limit too, for a
+// message a person can understand, and a byte limit, for the row.
+const (
+ MaxPageTitleBytes = 100
+ MaxPageDescBytes = 1000
+ MaxBookerNameRunes = 60
+ MaxBookerNameBytes = 80
+ MaxNoteRunes = 1000
+ MaxNoteBytes = 4000
+)
+
+// What a new page's knobs read before the host touches them. Form
+// defaults, not validator defaults: a buffer or notice of zero is a
+// legitimate choice, so the validator cannot tell "unset" from "none".
+const (
+ DefaultBufferMin = 10
+ DefaultNoticeMin = 120
+ DefaultHorizonDays = 30
+)
+
+// BookingPage is one "book time with me" page a person owns. Public at
+// /book/{handle}/{code} once published.
+type BookingPage struct {
+ ID int64 `gorm:"primaryKey"`
+ PersonID int64 `gorm:"column:person_id;not null;uniqueIndex:idx_booking_page_code"`
+
+ // Code is eight of [a-z0-9], generated, immutable and plaintext.
+ // Not a secret — it is in a URL the host hands out — and distinct
+ // from the management token a booker gets. It is generated rather
+ // than chosen because a chosen slug is prose joined to events (B4).
+ Code string `gorm:"column:code;not null;uniqueIndex:idx_booking_page_code"`
+
+ TitleSealed []byte `gorm:"column:title_sealed"`
+ DescriptionSealed []byte `gorm:"column:description_sealed"`
+
+ DurationMin int `gorm:"column:duration_min;not null"`
+ BufferMin int `gorm:"column:buffer_min;not null"`
+ NoticeMin int `gorm:"column:notice_min;not null"`
+ HorizonDays int `gorm:"column:horizon_days;not null"`
+
+ // AvailabilitySealed is the sentence the host typed. The parsed
+ // windows are rows of their own; the sentence is kept so the form
+ // can show it back exactly as written.
+ AvailabilitySealed []byte `gorm:"column:availability_text_sealed"`
+
+ // PublishedAt is NULL while unpublished, and an unpublished page
+ // 404s to the public. Publishing is a deliberate step with its own
+ // button because a published page is an oracle for when the host is
+ // free (spec §7.4).
+ PublishedAt *int64 `gorm:"column:published_at"`
+
+ CreatedAt time.Time `gorm:"column:created_at;not null"`
+ UpdatedAt time.Time `gorm:"column:updated_at;not null"`
+}
+
+// BookingWindow is one parsed weekly window of a page, plaintext:
+// weekday 0 (Sunday) to 6, and minutes from local midnight with
+// 0 <= start_min < end_min <= 1440. Rewritten wholesale on every save.
+type BookingWindow struct {
+ ID int64 `gorm:"primaryKey"`
+ PageID int64 `gorm:"column:page_id;not null;index"`
+ Weekday int `gorm:"column:weekday;not null"`
+ StartMin int `gorm:"column:start_min;not null"`
+ EndMin int `gorm:"column:end_min;not null"`
+}
+
+// Booking is one held, confirmed, cancelled or expired slot. The times
+// live on the event it points at; this row is the booker's side.
+type Booking struct {
+ ID int64 `gorm:"primaryKey"`
+ PageID int64 `gorm:"column:page_id;not null;index"`
+ EventID int64 `gorm:"column:event_id;not null;index"`
+
+ // TokenHash is the lookup for the management URL, hashed like a
+ // feed token; TokenSealed is the same token under its own purpose,
+ // opened only to render a mail (B14). A hash alone could not
+ // rebuild the link after a restart.
+ TokenHash string `gorm:"column:token_hash;not null;uniqueIndex"`
+ TokenSealed []byte `gorm:"column:token_sealed"`
+
+ State string `gorm:"column:state;not null;index"`
+
+ // The page's title and description AS THEY WERE at hold time (B20).
+ // Every public surface reads these, never the page's live text, so
+ // an edit to the page cannot change an ICS without a new sequence.
+ TitleSealed []byte `gorm:"column:title_sealed"`
+ DescriptionSealed []byte `gorm:"column:description_sealed"`
+
+ BookerNameSealed []byte `gorm:"column:booker_name_sealed"`
+ BookerAddressSealed []byte `gorm:"column:booker_address_sealed"`
+ // BookerAddressFP is the KEYED fingerprint, as for attendees.
+ BookerAddressFP string `gorm:"column:booker_address_fp;not null;index"`
+ NoteSealed []byte `gorm:"column:note_sealed"`
+
+ // BookerZone is one of CommonZones or the host's own zone. Shape of
+ // time, plaintext.
+ BookerZone string `gorm:"column:booker_zone;not null"`
+
+ HoldExpiresAt int64 `gorm:"column:hold_expires_at;not null;index"`
+ ConfirmedAt *int64 `gorm:"column:confirmed_at"`
+ CancelledAt *int64 `gorm:"column:cancelled_at"`
+ CancelledBy string `gorm:"column:cancelled_by;not null"`
+
+ // IPFP is Fingerprint(PurposeBookingIP, ip), for the abuse caps, and
+ // is nulled by the sweep at IPForgetAt.
+ 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"`
+}
+
+// BookingMail is one owed message: a queue row per (booking, kind,
+// recipient, revision), claimed with a lease and an identity and
+// acknowledged conditionally on that identity (B13). No content is
+// stored; bodies render at send time from the booking's current state.
+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 carries the retry time explicitly, so NextDueAt never
+ // advertises work nobody may claim yet.
+ DueAt int64 `gorm:"column:due_at;not null;index"`
+ Attempts int `gorm:"column:attempts;not null"`
+ ClaimedAt *int64 `gorm:"column:claimed_at"`
+ ClaimID string `gorm:"column:claim_id;not null"`
+ SentAt *int64 `gorm:"column:sent_at"`
+
+ CreatedAt time.Time `gorm:"column:created_at;not null"`
+}
diff --git a/internal/calendar/seal/seal.go b/internal/calendar/seal/seal.go
index 8de0424..d0eb1b6 100644
--- a/internal/calendar/seal/seal.go
+++ b/internal/calendar/seal/seal.go
@@ -50,6 +50,19 @@ const (
// lookup column.
PurposeAttendeeAddress = "attendee.address"
PurposeAttendeeLookup = "attendee.lookup"
+
+ // The booking purposes. A page's text and availability sentence,
+ // the booking's snapshot of that text, the management token, and
+ // everything a booker typed. PurposeBookingIP is a Fingerprint
+ // purpose: the client IP is never stored, only its keyed digest.
+ PurposeBookingTitle = "booking.title"
+ PurposeBookingDescription = "booking.description"
+ PurposeBookingAvailability = "booking.availability"
+ PurposeBookingToken = "booking.token"
+ PurposeBookerName = "booking.booker.name"
+ PurposeBookerAddress = "booking.booker.address"
+ PurposeBookerNote = "booking.booker.note"
+ PurposeBookingIP = "booking.ip"
)
func sha256New() hash.Hash { return sha256.New() }
diff --git a/internal/calendar/store_booking_handle.go b/internal/calendar/store_booking_handle.go
new file mode 100644
index 0000000..9c3b621
--- /dev/null
+++ b/internal/calendar/store_booking_handle.go
@@ -0,0 +1,140 @@
+package calendar
+
+import (
+ "context"
+ "crypto/rand"
+ "errors"
+ "fmt"
+ "regexp"
+ "strings"
+ "time"
+
+ "gorm.io/gorm"
+)
+
+// ErrHandleTaken is another person already holding the handle. Its own
+// error rather than a ValidationError because the form's message for
+// it is different ("taken", not "not allowed") and because a race
+// between two people choosing the same handle at once surfaces here
+// from the unique index, not from a check the handler could run first.
+var ErrHandleTaken = errors.New("calendar: handle taken")
+
+// reHandle is the handle grammar after lowercasing: the characters a
+// URL path segment can carry without escaping, at a length somebody
+// can read out loud.
+var reHandle = regexp.MustCompile(`^[a-z0-9-]{2,32}$`)
+
+// reservedHandles are path segments under /book/ that are not people.
+// /book/m/{token} is the management page; the others are refused so a
+// later route can take them without stranding somebody's URL.
+var reservedHandles = map[string]bool{"m": true, "new": true, "static": true}
+
+// normalizeHandle is the spelling that is stored and compared.
+func normalizeHandle(h string) string {
+ return strings.ToLower(strings.TrimSpace(h))
+}
+
+// ValidateHandle reports why h cannot be a handle, after lowercasing.
+// An empty handle is not valid here; SaveHandle treats it as "clear".
+func ValidateHandle(h string) error {
+ h = normalizeHandle(h)
+ if !reHandle.MatchString(h) {
+ return ValidationError{"handle", "Use 2 to 32 of a-z, 0-9 and dashes."}
+ }
+ if reservedHandles[h] {
+ return ValidationError{"handle", "That one is taken by the app itself. Pick another."}
+ }
+ return nil
+}
+
+// SaveHandle sets a person's handle, lowercased, or clears it when
+// handle is empty.
+//
+// The unique index is the arbiter of "taken", not a read-then-write:
+// two people saving the same handle at the same moment would both read
+// "free". The pre-check below is only so the common case gives a clean
+// answer without relying on the driver's error text; the index catches
+// the race.
+func (s *Store) SaveHandle(ctx context.Context, personID int64, handle string) error {
+ h := normalizeHandle(handle)
+ now := time.Now().UTC()
+ if h == "" {
+ err := s.g.WithContext(ctx).Model(&Person{}).Where("id = ?", personID).
+ Updates(map[string]any{"handle": nil, "updated_at": now}).Error
+ if err != nil {
+ return fmt.Errorf("calendar: clear handle: %w", err)
+ }
+ return nil
+ }
+ if err := ValidateHandle(h); err != nil {
+ return err
+ }
+ if p, err := s.PersonByHandle(ctx, h); err == nil && p.ID != personID {
+ return ErrHandleTaken
+ } else if err != nil && !NotFound(err) {
+ return err
+ }
+ err := s.g.WithContext(ctx).Model(&Person{}).Where("id = ?", personID).
+ Updates(map[string]any{"handle": h, "updated_at": now}).Error
+ if err != nil {
+ if isUniqueViolation(err) {
+ return ErrHandleTaken
+ }
+ return fmt.Errorf("calendar: save handle: %w", err)
+ }
+ return nil
+}
+
+// isUniqueViolation recognises the SQLite driver's constraint error.
+// By text, because the cgo-free driver does not expose a typed code
+// through database/sql and GORM's translation is not switched on here.
+func isUniqueViolation(err error) bool {
+ return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
+}
+
+// PersonByHandle resolves a public URL's first segment. A miss is
+// ErrNotFound, the same answer an unknown code or an unpublished page
+// gives, so the three cannot be told apart from outside.
+func (s *Store) PersonByHandle(ctx context.Context, handle string) (Person, error) {
+ h := normalizeHandle(handle)
+ if h == "" {
+ return Person{}, ErrNotFound
+ }
+ var p Person
+ err := s.g.WithContext(ctx).Where("handle = ?", h).First(&p).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return Person{}, ErrNotFound
+ }
+ if err != nil {
+ return Person{}, fmt.Errorf("calendar: person by handle: %w", err)
+ }
+ return p, nil
+}
+
+// pageCodeAlphabet is what a page code is spelled from: lowercase and
+// digits, so the URL survives being read aloud or typed on a phone.
+const pageCodeAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
+
+// newPageCode is eight characters of pageCodeAlphabet from crypto/rand.
+//
+// Rejection sampling rather than a bare modulo: 256 is not a multiple
+// of 36, so `b % 36` would favour the first four letters. The code is
+// not a secret, but a biased generator is a bug that would look fine
+// forever, and the fix costs a few extra bytes of randomness.
+func newPageCode() (string, error) {
+ const n = 8
+ const limit = 252 // the largest multiple of 36 that fits a byte
+ out := make([]byte, 0, n)
+ buf := make([]byte, 2*n)
+ for len(out) < n {
+ if _, err := rand.Read(buf); err != nil {
+ return "", fmt.Errorf("calendar: no randomness for a page code: %w", err)
+ }
+ for _, b := range buf {
+ if b < limit && len(out) < n {
+ out = append(out, pageCodeAlphabet[int(b)%len(pageCodeAlphabet)])
+ }
+ }
+ }
+ return string(out), nil
+}
diff --git a/internal/calendar/store_booking_page.go b/internal/calendar/store_booking_page.go
new file mode 100644
index 0000000..93f0df2
--- /dev/null
+++ b/internal/calendar/store_booking_page.go
@@ -0,0 +1,227 @@
+package calendar
+
+import (
+ "context"
+ "fmt"
+ "sort"
+ "strings"
+ "time"
+
+ "gorm.io/gorm"
+
+ "amadan.net/oficina/calendar/internal/calendar/seal"
+)
+
+// BookingPageInput is what a settings form may set on a page. An
+// explicit struct rather than the model: the columns it lacks —
+// person_id, code, published_at — are exactly the ones a hidden field
+// must not move.
+type BookingPageInput struct {
+ Title, Description, Availability string
+ DurationMin, BufferMin, NoticeMin, HorizonDays int
+}
+
+// BookingPageView is a page with its sealed columns opened and its
+// windows loaded. Handlers and templates see this; the sealed struct
+// never leaves the store.
+type BookingPageView struct {
+ BookingPage
+ Title, Description, Availability string
+ Windows []Window
+ Published bool
+}
+
+// ValidateBookingPageInput trims in place and reports the first problem.
+//
+// The text limits are BYTES, as the event validator's are. The windows
+// are validated separately by validateWindows because they arrive from
+// the parser, not the form.
+func ValidateBookingPageInput(in *BookingPageInput) error {
+ in.Title = strings.TrimSpace(in.Title)
+ in.Description = strings.TrimSpace(in.Description)
+ in.Availability = strings.TrimSpace(in.Availability)
+
+ if in.Title == "" {
+ return ValidationError{"title", "Give it a name."}
+ }
+ if len(in.Title) > MaxPageTitleBytes {
+ return ValidationError{"title", "Keep the name shorter."}
+ }
+ if len(in.Description) > MaxPageDescBytes {
+ return ValidationError{"description", "Keep the description shorter."}
+ }
+ if in.DurationMin < 5 || in.DurationMin > 480 {
+ return ValidationError{"duration_min", "Between 5 minutes and 8 hours."}
+ }
+ if in.BufferMin < 0 || in.BufferMin > 120 {
+ return ValidationError{"buffer_min", "Between 0 and 120 minutes."}
+ }
+ if in.NoticeMin < 0 || in.NoticeMin > 10080 {
+ return ValidationError{"notice_min", "Between 0 minutes and a week."}
+ }
+ if in.HorizonDays < 1 || in.HorizonDays > 90 {
+ return ValidationError{"horizon_days", "Between 1 and 90 days."}
+ }
+ return nil
+}
+
+// validateWindows refuses a window the slot arithmetic would loop or
+// misplace on, and returns them sorted by weekday then start. Merging
+// adjacent windows is the parser's job; the store trusts its caller
+// for that and defends only the invariants a row must hold.
+func validateWindows(ws []Window) ([]Window, error) {
+ out := make([]Window, 0, len(ws))
+ for _, w := range ws {
+ if w.Weekday < time.Sunday || w.Weekday > time.Saturday {
+ return nil, ValidationError{"availability", "A window names a day that does not exist."}
+ }
+ if w.StartMin < 0 || w.StartMin >= w.EndMin || w.EndMin > 1440 {
+ return nil, ValidationError{"availability", "A window ends before it starts."}
+ }
+ out = append(out, w)
+ }
+ sort.Slice(out, func(i, j int) bool {
+ if out[i].Weekday != out[j].Weekday {
+ return out[i].Weekday < out[j].Weekday
+ }
+ return out[i].StartMin < out[j].StartMin
+ })
+ return out, nil
+}
+
+// sealPage seals the three text columns of a page.
+func (s *Store) sealPage(in BookingPageInput) (title, desc, avail []byte, err error) {
+ if title, err = s.seal.SealString(seal.PurposeBookingTitle, in.Title); err != nil {
+ return nil, nil, nil, fmt.Errorf("calendar: seal page title: %w", err)
+ }
+ if desc, err = s.seal.SealString(seal.PurposeBookingDescription, in.Description); err != nil {
+ return nil, nil, nil, fmt.Errorf("calendar: seal page description: %w", err)
+ }
+ if avail, err = s.seal.SealString(seal.PurposeBookingAvailability, in.Availability); err != nil {
+ return nil, nil, nil, fmt.Errorf("calendar: seal page availability: %w", err)
+ }
+ return title, desc, avail, nil
+}
+
+// CreateBookingPage makes an unpublished page with a fresh code.
+//
+// The (person_id, code) index is unique, and a collision on eight
+// random characters is regenerated once before it is an error — the
+// spec's rule for the booking token, applied here for the same reason.
+func (s *Store) CreateBookingPage(ctx context.Context, personID int64, in BookingPageInput, windows []Window) (BookingPage, error) {
+ if err := ValidateBookingPageInput(&in); err != nil {
+ return BookingPage{}, err
+ }
+ ws, err := validateWindows(windows)
+ if err != nil {
+ return BookingPage{}, err
+ }
+ title, desc, avail, err := s.sealPage(in)
+ if err != nil {
+ return BookingPage{}, err
+ }
+ now := time.Now().UTC()
+ page := BookingPage{
+ PersonID: personID, TitleSealed: title, DescriptionSealed: desc,
+ DurationMin: in.DurationMin, BufferMin: in.BufferMin,
+ NoticeMin: in.NoticeMin, HorizonDays: in.HorizonDays,
+ AvailabilitySealed: avail, CreatedAt: now, UpdatedAt: now,
+ }
+ err = s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ for attempt := 0; ; attempt++ {
+ code, err := newPageCode()
+ if err != nil {
+ return err
+ }
+ page.Code = code
+ err = tx.Create(&page).Error
+ if err == nil {
+ break
+ }
+ if !isUniqueViolation(err) || attempt >= 1 {
+ return fmt.Errorf("calendar: create booking page: %w", err)
+ }
+ }
+ return insertWindows(tx, page.ID, ws)
+ })
+ if err != nil {
+ return BookingPage{}, err
+ }
+ return page, nil
+}
+
+// insertWindows writes a page's windows. The caller has already
+// deleted the old ones inside the same transaction, if there were any.
+func insertWindows(tx *gorm.DB, pageID int64, ws []Window) error {
+ for _, w := range ws {
+ row := BookingWindow{PageID: pageID, Weekday: int(w.Weekday), StartMin: w.StartMin, EndMin: w.EndMin}
+ if err := tx.Create(&row).Error; err != nil {
+ return fmt.Errorf("calendar: add booking window: %w", err)
+ }
+ }
+ return nil
+}
+
+// UpdateBookingPage rewrites a page's text, knobs and windows. The code
+// and the publication state are untouched: an edit to a page changes
+// what future bookings say, never where the page lives.
+//
+// Scoped by `id = ? AND person_id = ?`, and a miss is ErrNotFound —
+// a page that isn't yours doesn't exist.
+func (s *Store) UpdateBookingPage(ctx context.Context, personID, pageID int64, in BookingPageInput, windows []Window) error {
+ if err := ValidateBookingPageInput(&in); err != nil {
+ return err
+ }
+ ws, err := validateWindows(windows)
+ if err != nil {
+ return err
+ }
+ title, desc, avail, err := s.sealPage(in)
+ if err != nil {
+ return err
+ }
+ return s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ res := tx.Model(&BookingPage{}).Where("id = ? AND person_id = ?", pageID, personID).
+ Updates(map[string]any{
+ "title_sealed": title,
+ "description_sealed": desc,
+ "availability_text_sealed": avail,
+ "duration_min": in.DurationMin,
+ "buffer_min": in.BufferMin,
+ "notice_min": in.NoticeMin,
+ "horizon_days": in.HorizonDays,
+ "updated_at": time.Now().UTC(),
+ })
+ if res.Error != nil {
+ return fmt.Errorf("calendar: update booking page: %w", res.Error)
+ }
+ if res.RowsAffected == 0 {
+ return ErrNotFound
+ }
+ if err := tx.Where("page_id = ?", pageID).Delete(&BookingWindow{}).Error; err != nil {
+ return fmt.Errorf("calendar: clear booking windows: %w", err)
+ }
+ return insertWindows(tx, pageID, ws)
+ })
+}
+
+// PublishBookingPage sets or clears published_at. Explicit, with its
+// own button, because a published page is an oracle for when the host
+// is free (spec §7.4).
+func (s *Store) PublishBookingPage(ctx context.Context, personID, pageID int64, publish bool) error {
+ now := time.Now().UTC()
+ var at any // nil writes NULL
+ if publish {
+ at = now.Unix()
+ }
+ res := s.g.WithContext(ctx).Model(&BookingPage{}).
+ Where("id = ? AND person_id = ?", pageID, personID).
+ Updates(map[string]any{"published_at": at, "updated_at": now})
+ if res.Error != nil {
+ return fmt.Errorf("calendar: publish booking page: %w", res.Error)
+ }
+ if res.RowsAffected == 0 {
+ return ErrNotFound
+ }
+ return nil
+}
diff --git a/internal/calendar/store_booking_page_read.go b/internal/calendar/store_booking_page_read.go
new file mode 100644
index 0000000..36cf378
--- /dev/null
+++ b/internal/calendar/store_booking_page_read.go
@@ -0,0 +1,127 @@
+package calendar
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "gorm.io/gorm"
+
+ "amadan.net/oficina/calendar/internal/calendar/seal"
+)
+
+// The readers of booking pages. The writers are in store_booking_page.go;
+// the handle and the code are in store_booking_handle.go.
+
+// BookingPages lists a person's own pages, oldest first.
+func (s *Store) BookingPages(ctx context.Context, personID int64) ([]BookingPageView, error) {
+ var rows []BookingPage
+ if err := s.g.WithContext(ctx).Where("person_id = ?", personID).Order("id").Find(&rows).Error; err != nil {
+ return nil, fmt.Errorf("calendar: list booking pages: %w", err)
+ }
+ ids := make([]int64, len(rows))
+ for i, r := range rows {
+ ids[i] = r.ID
+ }
+ windows, err := s.windowsFor(ctx, ids)
+ if err != nil {
+ return nil, err
+ }
+ out := make([]BookingPageView, 0, len(rows))
+ for _, r := range rows {
+ v, err := s.openPage(r, windows[r.ID])
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, v)
+ }
+ return out, nil
+}
+
+// BookingPage reads one of a person's own pages, scoped.
+func (s *Store) BookingPage(ctx context.Context, personID, pageID int64) (BookingPageView, error) {
+ var page BookingPage
+ err := s.g.WithContext(ctx).Where("id = ? AND person_id = ?", pageID, personID).First(&page).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return BookingPageView{}, ErrNotFound
+ }
+ if err != nil {
+ return BookingPageView{}, fmt.Errorf("calendar: read booking page: %w", err)
+ }
+ return s.viewOf(ctx, page)
+}
+
+// PublicBookingPage is the ONE unscoped reader: a stranger's URL, made
+// of a handle and a code. It requires the page to be published, and
+// every miss — no such handle, no such code, not published — is the
+// same ErrNotFound, so nothing can be enumerated by telling them apart.
+func (s *Store) PublicBookingPage(ctx context.Context, handle, code string) (BookingPageView, Person, error) {
+ host, err := s.PersonByHandle(ctx, handle)
+ if err != nil {
+ return BookingPageView{}, Person{}, err
+ }
+ var page BookingPage
+ err = s.g.WithContext(ctx).
+ Where("person_id = ? AND code = ? AND published_at IS NOT NULL", host.ID, code).
+ First(&page).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return BookingPageView{}, Person{}, ErrNotFound
+ }
+ if err != nil {
+ return BookingPageView{}, Person{}, fmt.Errorf("calendar: read public booking page: %w", err)
+ }
+ v, err := s.viewOf(ctx, page)
+ if err != nil {
+ return BookingPageView{}, Person{}, err
+ }
+ return v, host, nil
+}
+
+func (s *Store) viewOf(ctx context.Context, page BookingPage) (BookingPageView, error) {
+ windows, err := s.windowsFor(ctx, []int64{page.ID})
+ if err != nil {
+ return BookingPageView{}, err
+ }
+ return s.openPage(page, windows[page.ID])
+}
+
+// windowsFor loads the windows of several pages in one query, ordered
+// as the slot arithmetic wants them.
+func (s *Store) windowsFor(ctx context.Context, pageIDs []int64) (map[int64][]Window, error) {
+ out := map[int64][]Window{}
+ if len(pageIDs) == 0 {
+ return out, nil
+ }
+ var rows []BookingWindow
+ err := s.g.WithContext(ctx).Where("page_id IN ?", pageIDs).
+ Order("page_id, weekday, start_min").Find(&rows).Error
+ if err != nil {
+ return nil, fmt.Errorf("calendar: read booking windows: %w", err)
+ }
+ for _, r := range rows {
+ out[r.PageID] = append(out[r.PageID], Window{Weekday: time.Weekday(r.Weekday), StartMin: r.StartMin, EndMin: r.EndMin})
+ }
+ return out, nil
+}
+
+// openPage opens the sealed columns. No text in the error: it is the
+// thing we failed to decrypt.
+func (s *Store) openPage(p BookingPage, windows []Window) (BookingPageView, error) {
+ title, err := s.seal.OpenString(seal.PurposeBookingTitle, p.TitleSealed)
+ if err != nil {
+ return BookingPageView{}, fmt.Errorf("calendar: open page title (id %d): %w", p.ID, err)
+ }
+ desc, err := s.seal.OpenString(seal.PurposeBookingDescription, p.DescriptionSealed)
+ if err != nil {
+ return BookingPageView{}, fmt.Errorf("calendar: open page description (id %d): %w", p.ID, err)
+ }
+ avail, err := s.seal.OpenString(seal.PurposeBookingAvailability, p.AvailabilitySealed)
+ if err != nil {
+ return BookingPageView{}, fmt.Errorf("calendar: open page availability (id %d): %w", p.ID, err)
+ }
+ return BookingPageView{
+ BookingPage: p, Title: title, Description: desc, Availability: avail,
+ Windows: windows, Published: p.PublishedAt != nil,
+ }, nil
+}
diff --git a/internal/calendar/testing.go b/internal/calendar/testing.go
index e6d8a65..89f84d7 100644
--- a/internal/calendar/testing.go
+++ b/internal/calendar/testing.go
@@ -2,6 +2,7 @@ package calendar
import (
"bytes"
+ "context"
"database/sql"
"fmt"
"io"
@@ -162,3 +163,26 @@ func (a *App) LastMailForTest() (string, error) {
}
return a.mailRecorder.last(), nil
}
+
+// StoreForTest is the app's store, for tests that exercise a store
+// function directly rather than through a page — the booking store,
+// whose handlers arrive in a later task than its tables.
+func (a *App) StoreForTest() *Store { return a.store }
+
+// PersonByAddressForTest returns the person row for an address that has
+// signed in, creating it as the first signed-in page view would.
+//
+// It goes through EnsurePersonSetup rather than a bare read because a
+// sign-in alone mints a session and nothing else: the person row, the
+// team grant and the personal calendar are written by requirePerson on
+// the first page view. A test that has signed in but not yet asked for
+// a page would otherwise find no row, and a booking host needs the
+// personal calendar the setup creates.
+func (a *App) PersonByAddressForTest(t TB, address string) Person {
+ t.Helper()
+ p, err := a.store.EnsurePersonSetup(context.Background(), address)
+ if err != nil {
+ t.Fatalf("calendar: PersonByAddressForTest: %v", err)
+ }
+ return p
+}
diff --git a/internal/calendartest/booking_handle_test.go b/internal/calendartest/booking_handle_test.go
new file mode 100644
index 0000000..8cabef0
--- /dev/null
+++ b/internal/calendartest/booking_handle_test.go
@@ -0,0 +1,64 @@
+package calendartest
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "testing"
+
+ calendar "amadan.net/oficina/calendar/internal/calendar"
+)
+
+// A handle is lowercased on save, held by one person at a time, never a
+// reserved path segment, and cleared by saving nothing.
+func TestAHandleIsUniqueLowercaseAndNotReserved(t *testing.T) {
+ app := newApp(t)
+ paul := aHost(t, app, "paul@example.com")
+ other := aHost(t, app, "other@example.com")
+ store := app.StoreForTest()
+ ctx := context.Background()
+
+ if err := store.SaveHandle(ctx, paul.ID, "Paul"); err != nil {
+ t.Fatalf("SaveHandle(Paul): %v", err)
+ }
+ p, err := store.PersonByHandle(ctx, "paul")
+ if err != nil || p.ID != paul.ID {
+ t.Fatalf("PersonByHandle(paul) = %d, %v; want %d", p.ID, err, paul.ID)
+ }
+ if p.Handle == nil || *p.Handle != "paul" {
+ t.Errorf("saved handle = %v, want paul", p.Handle)
+ }
+ // The same handle again for the same person is not a conflict.
+ if err := store.SaveHandle(ctx, paul.ID, "paul"); err != nil {
+ t.Errorf("re-saving my own handle: %v", err)
+ }
+ for _, spelling := range []string{"paul", "PAUL"} {
+ if err := store.SaveHandle(ctx, other.ID, spelling); !errors.Is(err, calendar.ErrHandleTaken) {
+ t.Errorf("SaveHandle(other, %q) = %v, want ErrHandleTaken", spelling, err)
+ }
+ }
+
+ for _, bad := range []string{"m", "new", "static", "a", "has space", "üñí", strings.Repeat("x", 33)} {
+ err := store.SaveHandle(ctx, other.ID, bad)
+ if _, ok := calendar.Invalid(err); !ok {
+ t.Errorf("SaveHandle(%q) = %v, want a ValidationError", bad, err)
+ }
+ if err := calendar.ValidateHandle(bad); err == nil {
+ t.Errorf("ValidateHandle(%q) accepted it", bad)
+ }
+ }
+ if err := calendar.ValidateHandle("Paul-2"); err != nil {
+ t.Errorf("ValidateHandle(Paul-2): %v", err)
+ }
+
+ // Clearing frees the name for somebody else.
+ if err := store.SaveHandle(ctx, paul.ID, ""); err != nil {
+ t.Fatalf("SaveHandle(\"\"): %v", err)
+ }
+ if _, err := store.PersonByHandle(ctx, "paul"); !calendar.NotFound(err) {
+ t.Errorf("a cleared handle still resolves: %v", err)
+ }
+ if err := store.SaveHandle(ctx, other.ID, "paul"); err != nil {
+ t.Errorf("taking a freed handle: %v", err)
+ }
+}
diff --git a/internal/calendartest/booking_store_test.go b/internal/calendartest/booking_store_test.go
new file mode 100644
index 0000000..9cf2c49
--- /dev/null
+++ b/internal/calendartest/booking_store_test.go
@@ -0,0 +1,261 @@
+package calendartest
+
+import (
+ "context"
+ "regexp"
+ "strings"
+ "testing"
+ "time"
+
+ calendar "amadan.net/oficina/calendar/internal/calendar"
+)
+
+// aHost is a person who has signed in and can own booking pages.
+func aHost(t *testing.T, app *calendar.App, address string) calendar.Person {
+ t.Helper()
+ app.SignInForTest(t, address)
+ return app.PersonByAddressForTest(t, address)
+}
+
+var mondayLunch = []calendar.Window{
+ {Weekday: time.Monday, StartMin: 12 * 60, EndMin: 15 * 60},
+ {Weekday: time.Tuesday, StartMin: 12 * 60, EndMin: 14 * 60},
+}
+
+func pageInput() calendar.BookingPageInput {
+ return calendar.BookingPageInput{
+ Title: "Quick chat about the acquisition",
+ Description: "Twenty minutes, no slides, bring the unmarked folder.",
+ Availability: "mondays 12-3, tuesdays 12-2",
+ DurationMin: 20,
+ BufferMin: 10,
+ NoticeMin: 120,
+ HorizonDays: 30,
+ }
+}
+
+// A page is made, read back with its text opened and its windows in
+// order, hidden from the public until it is published, and its text is
+// nowhere in the raw file.
+func TestABookingPageRoundTripsSealedWithItsWindows(t *testing.T) {
+ app := newApp(t)
+ host := aHost(t, app, "paul@example.com")
+ store := app.StoreForTest()
+ ctx := context.Background()
+
+ if err := store.SaveHandle(ctx, host.ID, "paul"); err != nil {
+ t.Fatalf("SaveHandle: %v", err)
+ }
+ in := pageInput()
+ page, err := store.CreateBookingPage(ctx, host.ID, in, mondayLunch)
+ if err != nil {
+ t.Fatalf("CreateBookingPage: %v", err)
+ }
+ if !regexp.MustCompile(`^[a-z0-9]{8}$`).MatchString(page.Code) {
+ t.Errorf("code %q is not eight of [a-z0-9]", page.Code)
+ }
+ if page.PersonID != host.ID {
+ t.Errorf("page owned by %d, want %d", page.PersonID, host.ID)
+ }
+
+ // Unpublished: the owner sees it, the public does not.
+ mine, err := store.BookingPage(ctx, host.ID, page.ID)
+ if err != nil {
+ t.Fatalf("BookingPage: %v", err)
+ }
+ if mine.Title != in.Title || mine.Description != in.Description || mine.Availability != in.Availability {
+ t.Errorf("opened text differs: %+v", mine)
+ }
+ if mine.Published {
+ t.Error("a new page reports Published")
+ }
+ if len(mine.Windows) != 2 || mine.Windows[0] != mondayLunch[0] || mine.Windows[1] != mondayLunch[1] {
+ t.Errorf("windows = %+v, want %+v", mine.Windows, mondayLunch)
+ }
+ if _, _, err := store.PublicBookingPage(ctx, "paul", page.Code); !calendar.NotFound(err) {
+ t.Errorf("unpublished page is public: err = %v", err)
+ }
+
+ if err := store.PublishBookingPage(ctx, host.ID, page.ID, true); err != nil {
+ t.Fatalf("PublishBookingPage: %v", err)
+ }
+ pub, who, err := store.PublicBookingPage(ctx, "paul", page.Code)
+ if err != nil {
+ t.Fatalf("PublicBookingPage after publish: %v", err)
+ }
+ if !pub.Published || pub.Title != in.Title || who.ID != host.ID {
+ t.Errorf("public view = %+v host %d, want published, titled, host %d", pub, who.ID, host.ID)
+ }
+ if len(pub.Windows) != 2 {
+ t.Errorf("public windows = %+v", pub.Windows)
+ }
+
+ // An update replaces the text and the windows wholesale.
+ in.Title = "Longer chat"
+ if err := store.UpdateBookingPage(ctx, host.ID, page.ID, in, mondayLunch[:1]); err != nil {
+ t.Fatalf("UpdateBookingPage: %v", err)
+ }
+ list, err := store.BookingPages(ctx, host.ID)
+ if err != nil {
+ t.Fatalf("BookingPages: %v", err)
+ }
+ if len(list) != 1 || list[0].Title != "Longer chat" || len(list[0].Windows) != 1 {
+ t.Errorf("after update: %+v", list)
+ }
+ if list[0].Code != page.Code {
+ t.Errorf("an update changed the code from %q to %q", page.Code, list[0].Code)
+ }
+
+ if _, err := app.DBForTest().Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
+ t.Fatalf("checkpoint: %v", err)
+ }
+ for _, needle := range []string{
+ "Quick chat about the acquisition", "Longer chat",
+ "unmarked folder", "mondays 12-3",
+ } {
+ if found, where := app.GrepDatabaseForTest(t, needle); found {
+ t.Errorf("%q appears in plaintext in %s", needle, where)
+ }
+ }
+}
+
+// Whatever is wrong — the handle, the code, or publication — the answer
+// is one error, so nothing can be enumerated by telling them apart.
+func TestUnknownHandleUnknownCodeAndUnpublishedAllVanishTheSameWay(t *testing.T) {
+ app := newApp(t)
+ host := aHost(t, app, "paul@example.com")
+ store := app.StoreForTest()
+ ctx := context.Background()
+
+ if err := store.SaveHandle(ctx, host.ID, "paul"); err != nil {
+ t.Fatalf("SaveHandle: %v", err)
+ }
+ page, err := store.CreateBookingPage(ctx, host.ID, pageInput(), mondayLunch)
+ if err != nil {
+ t.Fatalf("CreateBookingPage: %v", err)
+ }
+ if err := store.PublishBookingPage(ctx, host.ID, page.ID, true); err != nil {
+ t.Fatalf("publish: %v", err)
+ }
+ if _, _, err := store.PublicBookingPage(ctx, "paul", page.Code); err != nil {
+ t.Fatalf("the published page should be public: %v", err)
+ }
+
+ if _, _, err := store.PublicBookingPage(ctx, "nobody", page.Code); !calendar.NotFound(err) {
+ t.Errorf("unknown handle: err = %v, want ErrNotFound", err)
+ }
+ if _, _, err := store.PublicBookingPage(ctx, "paul", "zzzzzzzz"); !calendar.NotFound(err) {
+ t.Errorf("unknown code: err = %v, want ErrNotFound", err)
+ }
+ if err := store.PublishBookingPage(ctx, host.ID, page.ID, false); err != nil {
+ t.Fatalf("unpublish: %v", err)
+ }
+ if _, _, err := store.PublicBookingPage(ctx, "paul", page.Code); !calendar.NotFound(err) {
+ t.Errorf("unpublished: err = %v, want ErrNotFound", err)
+ }
+ if _, err := store.PersonByHandle(ctx, "nobody"); !calendar.NotFound(err) {
+ t.Errorf("PersonByHandle(nobody): err = %v, want ErrNotFound", err)
+ }
+}
+
+// Every settings read or write is scoped to the owner, and a miss is
+// ErrNotFound rather than a different error for "exists but not yours".
+func TestAnotherPersonCannotReadEditOrPublishMyPage(t *testing.T) {
+ app := newApp(t)
+ paul := aHost(t, app, "paul@example.com")
+ other := aHost(t, app, "other@example.com")
+ store := app.StoreForTest()
+ ctx := context.Background()
+
+ page, err := store.CreateBookingPage(ctx, paul.ID, pageInput(), mondayLunch)
+ if err != nil {
+ t.Fatalf("CreateBookingPage: %v", err)
+ }
+
+ if _, err := store.BookingPage(ctx, other.ID, page.ID); !calendar.NotFound(err) {
+ t.Errorf("read as another person: err = %v, want ErrNotFound", err)
+ }
+ if err := store.UpdateBookingPage(ctx, other.ID, page.ID, pageInput(), mondayLunch); !calendar.NotFound(err) {
+ t.Errorf("update as another person: err = %v, want ErrNotFound", err)
+ }
+ if err := store.PublishBookingPage(ctx, other.ID, page.ID, true); !calendar.NotFound(err) {
+ t.Errorf("publish as another person: err = %v, want ErrNotFound", err)
+ }
+ list, err := store.BookingPages(ctx, other.ID)
+ if err != nil || len(list) != 0 {
+ t.Errorf("another person's list = %+v, %v; want empty", list, err)
+ }
+
+ // None of that touched the page.
+ mine, err := store.BookingPage(ctx, paul.ID, page.ID)
+ if err != nil {
+ t.Fatalf("my own read: %v", err)
+ }
+ if mine.Published {
+ t.Error("another person's publish took effect")
+ }
+ if len(mine.Windows) != 2 {
+ t.Errorf("another person's update touched the windows: %+v", mine.Windows)
+ }
+}
+
+// The limits are bytes, because the event validator's are bytes and two
+// different units for "how long is a title" is a bug waiting for a
+// multibyte name.
+func TestPageLimitsAreBytes(t *testing.T) {
+ app := newApp(t)
+ paul := aHost(t, app, "paul@example.com")
+ store := app.StoreForTest()
+ ctx := context.Background()
+
+ euro := strings.Repeat("€", 33) // 99 bytes, 33 runes
+ over := pageInput()
+ over.Title = euro + "ab" // 101 bytes, 35 runes
+ if _, err := store.CreateBookingPage(ctx, paul.ID, over, mondayLunch); err == nil {
+ t.Error("a 101-byte title was accepted")
+ } else if v, ok := calendar.Invalid(err); !ok || v.Field != "title" {
+ t.Errorf("a 101-byte title: err = %v, want a title ValidationError", err)
+ }
+
+ fits := pageInput()
+ fits.Title = euro + "a" // 100 bytes
+ if _, err := store.CreateBookingPage(ctx, paul.ID, fits, mondayLunch); err != nil {
+ t.Errorf("a 100-byte title was refused: %v", err)
+ }
+
+ longDesc := pageInput()
+ longDesc.Description = strings.Repeat("€", 333) + "ab" // 1001 bytes
+ if err := calendar.ValidateBookingPageInput(&longDesc); err == nil {
+ t.Error("a 1001-byte description was accepted")
+ }
+ longDesc.Description = strings.Repeat("€", 333) + "a" // 1000 bytes
+ if err := calendar.ValidateBookingPageInput(&longDesc); err != nil {
+ t.Errorf("a 1000-byte description was refused: %v", err)
+ }
+
+ // The knobs have ranges too.
+ for _, tc := range []struct {
+ name string
+ mut func(*calendar.BookingPageInput)
+ }{
+ {"empty title", func(in *calendar.BookingPageInput) { in.Title = " " }},
+ {"duration 4", func(in *calendar.BookingPageInput) { in.DurationMin = 4 }},
+ {"duration 481", func(in *calendar.BookingPageInput) { in.DurationMin = 481 }},
+ {"buffer 121", func(in *calendar.BookingPageInput) { in.BufferMin = 121 }},
+ {"buffer -1", func(in *calendar.BookingPageInput) { in.BufferMin = -1 }},
+ {"notice 10081", func(in *calendar.BookingPageInput) { in.NoticeMin = 10081 }},
+ {"horizon 0", func(in *calendar.BookingPageInput) { in.HorizonDays = 0 }},
+ {"horizon 91", func(in *calendar.BookingPageInput) { in.HorizonDays = 91 }},
+ } {
+ in := pageInput()
+ tc.mut(&in)
+ if err := calendar.ValidateBookingPageInput(&in); err == nil {
+ t.Errorf("%s was accepted", tc.name)
+ }
+ }
+ if err := calendar.ValidateBookingPageInput(&calendar.BookingPageInput{
+ Title: "Edges", DurationMin: 480, BufferMin: 120, NoticeMin: 10080, HorizonDays: 90,
+ }); err != nil {
+ t.Errorf("the upper edges were refused: %v", err)
+ }
+}
diff --git a/internal/calendartest/migrate_test.go b/internal/calendartest/migrate_test.go
index c8a86ab..6a1fd9e 100644
--- a/internal/calendartest/migrate_test.go
+++ b/internal/calendartest/migrate_test.go
@@ -37,6 +37,7 @@ func TestSchemaAppliesAndHoldsARow(t *testing.T) {
for _, table := range []string{
"people", "calendars", "grants", "events",
"reminders", "reminder_queues", "feeds", "meta",
+ "booking_pages", "booking_windows", "bookings", "booking_mails",
} {
var n int
// A count is enough: it fails if the table is absent, and