| 1 | package calendar |
| 2 | |
| 3 | import "time" |
| 4 | |
| 5 | // The booking tables. Spec: docs/superpowers/specs/2026-09-07-booking-design.md §2. |
| 6 | // |
| 7 | // What is sealed and what is not follows the one rule's falsifiable |
| 8 | // form. A page's title, description and availability sentence are |
| 9 | // content; a booker's name, address and note are content; the |
| 10 | // management token is a live capability. All of those are sealed. |
| 11 | // Windows, states, codes, revisions and instants are the shape of the |
| 12 | // team's time, which the rule already gives up. |
| 13 | |
| 14 | // Booking states and who cancelled — the column values, spelled once. |
| 15 | const ( |
| 16 | BookingHeld = "held" |
| 17 | BookingConfirmed = "confirmed" |
| 18 | BookingCancelled = "cancelled" |
| 19 | BookingExpired = "expired" |
| 20 | |
| 21 | CancelledByNobody = "" |
| 22 | CancelledByBooker = "booker" |
| 23 | CancelledByHost = "host" |
| 24 | ) |
| 25 | |
| 26 | // Mail kinds and recipients, the other half of a booking_mails key. |
| 27 | const ( |
| 28 | MailHold = "hold" |
| 29 | MailConfirmed = "confirmed" |
| 30 | MailRescheduled = "rescheduled" |
| 31 | MailCancelled = "cancelled" |
| 32 | ToBooker = "booker" |
| 33 | ToHost = "host" |
| 34 | ) |
| 35 | |
| 36 | // The knobs. HoldTTL and MinNotice are B15: a hold lasts thirty minutes |
| 37 | // or until the meeting would have started, whichever is first, and a |
| 38 | // page's notice_min has a floor of ten minutes at slot time. |
| 39 | const ( |
| 40 | HoldTTL = 30 * time.Minute |
| 41 | MinNotice = 10 * time.Minute |
| 42 | MaxReschedules = 5 |
| 43 | IPForgetAfter = 24 * time.Hour |
| 44 | |
| 45 | // MailGaveUp is the sent_at value meaning "stopped trying": the |
| 46 | // drain reached MaxReminderAttempts and will not claim this row |
| 47 | // again. Not NULL, so NextDueAt does not keep advertising it. |
| 48 | MailGaveUp = int64(-1) |
| 49 | ) |
| 50 | |
| 51 | // Byte limits, because the event validator's limits are bytes (r2 B6) |
| 52 | // and two units for "how long is a title" is a bug waiting for a |
| 53 | // multibyte name. The booker's fields carry a rune limit too, for a |
| 54 | // message a person can understand, and a byte limit, for the row. |
| 55 | const ( |
| 56 | MaxPageTitleBytes = 100 |
| 57 | MaxPageDescBytes = 1000 |
| 58 | MaxBookerNameRunes = 60 |
| 59 | MaxBookerNameBytes = 80 |
| 60 | MaxNoteRunes = 1000 |
| 61 | MaxNoteBytes = 4000 |
| 62 | ) |
| 63 | |
| 64 | // What a new page's knobs read before the host touches them. Form |
| 65 | // defaults, not validator defaults: a buffer or notice of zero is a |
| 66 | // legitimate choice, so the validator cannot tell "unset" from "none". |
| 67 | const ( |
| 68 | DefaultBufferMin = 10 |
| 69 | DefaultNoticeMin = 120 |
| 70 | DefaultHorizonDays = 30 |
| 71 | ) |
| 72 | |
| 73 | // BookingPage is one "book time with me" page a person owns. Public at |
| 74 | // /book/{handle}/{code} once published. |
| 75 | type BookingPage struct { |
| 76 | ID int64 `gorm:"primaryKey"` |
| 77 | PersonID int64 `gorm:"column:person_id;not null;uniqueIndex:idx_booking_page_code"` |
| 78 | |
| 79 | // Code is eight of [a-z0-9], generated, immutable and plaintext. |
| 80 | // Not a secret — it is in a URL the host hands out — and distinct |
| 81 | // from the management token a booker gets. It is generated rather |
| 82 | // than chosen because a chosen slug is prose joined to events (B4). |
| 83 | Code string `gorm:"column:code;not null;uniqueIndex:idx_booking_page_code"` |
| 84 | |
| 85 | TitleSealed []byte `gorm:"column:title_sealed"` |
| 86 | DescriptionSealed []byte `gorm:"column:description_sealed"` |
| 87 | |
| 88 | DurationMin int `gorm:"column:duration_min;not null"` |
| 89 | BufferMin int `gorm:"column:buffer_min;not null"` |
| 90 | NoticeMin int `gorm:"column:notice_min;not null"` |
| 91 | HorizonDays int `gorm:"column:horizon_days;not null"` |
| 92 | |
| 93 | // AvailabilitySealed is the sentence the host typed. The parsed |
| 94 | // windows are rows of their own; the sentence is kept so the form |
| 95 | // can show it back exactly as written. |
| 96 | AvailabilitySealed []byte `gorm:"column:availability_text_sealed"` |
| 97 | |
| 98 | // PublishedAt is NULL while unpublished, and an unpublished page |
| 99 | // 404s to the public. Publishing is a deliberate step with its own |
| 100 | // button because a published page is an oracle for when the host is |
| 101 | // free (spec §7.4). |
| 102 | PublishedAt *int64 `gorm:"column:published_at"` |
| 103 | |
| 104 | CreatedAt time.Time `gorm:"column:created_at;not null"` |
| 105 | UpdatedAt time.Time `gorm:"column:updated_at;not null"` |
| 106 | } |
| 107 | |
| 108 | // BookingWindow is one parsed weekly window of a page, plaintext: |
| 109 | // weekday 0 (Sunday) to 6, and minutes from local midnight with |
| 110 | // 0 <= start_min < end_min <= 1440. Rewritten wholesale on every save. |
| 111 | type BookingWindow struct { |
| 112 | ID int64 `gorm:"primaryKey"` |
| 113 | PageID int64 `gorm:"column:page_id;not null;index"` |
| 114 | Weekday int `gorm:"column:weekday;not null"` |
| 115 | StartMin int `gorm:"column:start_min;not null"` |
| 116 | EndMin int `gorm:"column:end_min;not null"` |
| 117 | } |
| 118 | |
| 119 | // Booking is one held, confirmed, cancelled or expired slot. The times |
| 120 | // live on the event it points at; this row is the booker's side. |
| 121 | type Booking struct { |
| 122 | ID int64 `gorm:"primaryKey"` |
| 123 | PageID int64 `gorm:"column:page_id;not null;index"` |
| 124 | EventID int64 `gorm:"column:event_id;not null;index"` |
| 125 | |
| 126 | // TokenHash is the lookup for the management URL, hashed like a |
| 127 | // feed token; TokenSealed is the same token under its own purpose, |
| 128 | // opened only to render a mail (B14). A hash alone could not |
| 129 | // rebuild the link after a restart. |
| 130 | TokenHash string `gorm:"column:token_hash;not null;uniqueIndex"` |
| 131 | TokenSealed []byte `gorm:"column:token_sealed"` |
| 132 | |
| 133 | State string `gorm:"column:state;not null;index"` |
| 134 | |
| 135 | // The page's title and description AS THEY WERE at hold time (B20). |
| 136 | // Every public surface reads these, never the page's live text, so |
| 137 | // an edit to the page cannot change an ICS without a new sequence. |
| 138 | TitleSealed []byte `gorm:"column:title_sealed"` |
| 139 | DescriptionSealed []byte `gorm:"column:description_sealed"` |
| 140 | |
| 141 | BookerNameSealed []byte `gorm:"column:booker_name_sealed"` |
| 142 | BookerAddressSealed []byte `gorm:"column:booker_address_sealed"` |
| 143 | // BookerAddressFP is the KEYED fingerprint, as for attendees. |
| 144 | BookerAddressFP string `gorm:"column:booker_address_fp;not null;index"` |
| 145 | NoteSealed []byte `gorm:"column:note_sealed"` |
| 146 | |
| 147 | // BookerZone is one of CommonZones or the host's own zone. Shape of |
| 148 | // time, plaintext. |
| 149 | BookerZone string `gorm:"column:booker_zone;not null"` |
| 150 | |
| 151 | HoldExpiresAt int64 `gorm:"column:hold_expires_at;not null;index"` |
| 152 | ConfirmedAt *int64 `gorm:"column:confirmed_at"` |
| 153 | CancelledAt *int64 `gorm:"column:cancelled_at"` |
| 154 | CancelledBy string `gorm:"column:cancelled_by;not null"` |
| 155 | |
| 156 | // IPFP is Fingerprint(PurposeBookingIP, ip), for the abuse caps, and |
| 157 | // is nulled by the sweep at IPForgetAt. |
| 158 | IPFP *string `gorm:"column:ip_fp;index"` |
| 159 | IPForgetAt int64 `gorm:"column:ip_forget_at;not null;index"` |
| 160 | |
| 161 | Reschedules int `gorm:"column:reschedules;not null"` |
| 162 | |
| 163 | CreatedAt time.Time `gorm:"column:created_at;not null"` |
| 164 | UpdatedAt time.Time `gorm:"column:updated_at;not null"` |
| 165 | } |
| 166 | |
| 167 | // BookingMail is one owed message: a queue row per (booking, kind, |
| 168 | // recipient, revision), claimed with a lease and an identity and |
| 169 | // acknowledged conditionally on that identity (B13). No content is |
| 170 | // stored; bodies render at send time from the booking's current state. |
| 171 | type BookingMail struct { |
| 172 | ID int64 `gorm:"primaryKey"` |
| 173 | BookingID int64 `gorm:"column:booking_id;not null;uniqueIndex:idx_booking_mail_once"` |
| 174 | Kind string `gorm:"column:kind;not null;uniqueIndex:idx_booking_mail_once"` |
| 175 | Recipient string `gorm:"column:recipient;not null;uniqueIndex:idx_booking_mail_once"` |
| 176 | Revision int `gorm:"column:revision;not null;uniqueIndex:idx_booking_mail_once"` |
| 177 | |
| 178 | // DueAt carries the retry time explicitly, so NextDueAt never |
| 179 | // advertises work nobody may claim yet. |
| 180 | DueAt int64 `gorm:"column:due_at;not null;index"` |
| 181 | Attempts int `gorm:"column:attempts;not null"` |
| 182 | ClaimedAt *int64 `gorm:"column:claimed_at"` |
| 183 | ClaimID string `gorm:"column:claim_id;not null"` |
| 184 | SentAt *int64 `gorm:"column:sent_at"` |
| 185 | |
| 186 | CreatedAt time.Time `gorm:"column:created_at;not null"` |
| 187 | } |
| 188 | |