oficina / calendar Public

Clone
git clone https://amadan.net/oficina/calendar

Plain git — no account needed to clone.

Download

Download this file

1package calendar
2
3import "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.
15const (
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.
27const (
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.
39const (
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.
55const (
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".
67const (
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.
75type 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.
111type 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.
121type 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.
171type 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