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>
12 files changed,
+1127
−1
internal/calendar/migrations/0005_alter_people.sql+36 −0internal/calendar/migrations/schema.sql+35 −1internal/calendar/models.go+12 −0internal/calendar/models_booking.go+187 −0internal/calendar/seal/seal.go+13 −0internal/calendar/store_booking_handle.go+140 −0internal/calendar/store_booking_page.go+227 −0internal/calendar/store_booking_page_read.go+127 −0internal/calendar/testing.go+24 −0internal/calendartest/booking_handle_test.go+64 −0internal/calendartest/booking_store_test.go+261 −0internal/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 |