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 (
4 "context"
5 "fmt"
6 "sort"
7 "strings"
8 "time"
9
10 "gorm.io/gorm"
11
12 "amadan.net/oficina/calendar/internal/calendar/seal"
13)
14
15// BookingPageInput is what a settings form may set on a page. An
16// explicit struct rather than the model: the columns it lacks —
17// person_id, code, published_at — are exactly the ones a hidden field
18// must not move.
19type BookingPageInput struct {
20 Title, Description, Availability string
21 DurationMin, BufferMin, NoticeMin, HorizonDays int
22}
23
24// BookingPageView is a page with its sealed columns opened and its
25// windows loaded. Handlers and templates see this; the sealed struct
26// never leaves the store.
27type BookingPageView struct {
28 BookingPage
29 Title, Description, Availability string
30 Windows []Window
31 Published bool
32}
33
34// ValidateBookingPageInput trims in place and reports the first problem.
35//
36// The text limits are BYTES, as the event validator's are. The windows
37// are validated separately by validateWindows because they arrive from
38// the parser, not the form.
39func ValidateBookingPageInput(in *BookingPageInput) error {
40 in.Title = strings.TrimSpace(in.Title)
41 in.Description = strings.TrimSpace(in.Description)
42 in.Availability = strings.TrimSpace(in.Availability)
43
44 if in.Title == "" {
45 return ValidationError{"title", "Give it a name."}
46 }
47 if len(in.Title) > MaxPageTitleBytes {
48 return ValidationError{"title", "Keep the name shorter."}
49 }
50 if len(in.Description) > MaxPageDescBytes {
51 return ValidationError{"description", "Keep the description shorter."}
52 }
53 if in.DurationMin < 5 || in.DurationMin > 480 {
54 return ValidationError{"duration_min", "Between 5 minutes and 8 hours."}
55 }
56 if in.BufferMin < 0 || in.BufferMin > 120 {
57 return ValidationError{"buffer_min", "Between 0 and 120 minutes."}
58 }
59 if in.NoticeMin < 0 || in.NoticeMin > 10080 {
60 return ValidationError{"notice_min", "Between 0 minutes and a week."}
61 }
62 if in.HorizonDays < 1 || in.HorizonDays > 90 {
63 return ValidationError{"horizon_days", "Between 1 and 90 days."}
64 }
65 return nil
66}
67
68// validateWindows refuses a window the slot arithmetic would loop or
69// misplace on, and returns them sorted by weekday then start. Merging
70// adjacent windows is the parser's job; the store trusts its caller
71// for that and defends only the invariants a row must hold.
72func validateWindows(ws []Window) ([]Window, error) {
73 out := make([]Window, 0, len(ws))
74 for _, w := range ws {
75 if w.Weekday < time.Sunday || w.Weekday > time.Saturday {
76 return nil, ValidationError{"availability", "A window names a day that does not exist."}
77 }
78 if w.StartMin < 0 || w.StartMin >= w.EndMin || w.EndMin > 1440 {
79 return nil, ValidationError{"availability", "A window ends before it starts."}
80 }
81 out = append(out, w)
82 }
83 sort.Slice(out, func(i, j int) bool {
84 if out[i].Weekday != out[j].Weekday {
85 return out[i].Weekday < out[j].Weekday
86 }
87 return out[i].StartMin < out[j].StartMin
88 })
89 return out, nil
90}
91
92// sealPage seals the three text columns of a page.
93func (s *Store) sealPage(in BookingPageInput) (title, desc, avail []byte, err error) {
94 if title, err = s.seal.SealString(seal.PurposeBookingTitle, in.Title); err != nil {
95 return nil, nil, nil, fmt.Errorf("calendar: seal page title: %w", err)
96 }
97 if desc, err = s.seal.SealString(seal.PurposeBookingDescription, in.Description); err != nil {
98 return nil, nil, nil, fmt.Errorf("calendar: seal page description: %w", err)
99 }
100 if avail, err = s.seal.SealString(seal.PurposeBookingAvailability, in.Availability); err != nil {
101 return nil, nil, nil, fmt.Errorf("calendar: seal page availability: %w", err)
102 }
103 return title, desc, avail, nil
104}
105
106// CreateBookingPage makes an unpublished page with a fresh code.
107//
108// The (person_id, code) index is unique, and a collision on eight
109// random characters is regenerated once before it is an error — the
110// spec's rule for the booking token, applied here for the same reason.
111func (s *Store) CreateBookingPage(ctx context.Context, personID int64, in BookingPageInput, windows []Window) (BookingPage, error) {
112 if err := ValidateBookingPageInput(&in); err != nil {
113 return BookingPage{}, err
114 }
115 ws, err := validateWindows(windows)
116 if err != nil {
117 return BookingPage{}, err
118 }
119 title, desc, avail, err := s.sealPage(in)
120 if err != nil {
121 return BookingPage{}, err
122 }
123 now := time.Now().UTC()
124 page := BookingPage{
125 PersonID: personID, TitleSealed: title, DescriptionSealed: desc,
126 DurationMin: in.DurationMin, BufferMin: in.BufferMin,
127 NoticeMin: in.NoticeMin, HorizonDays: in.HorizonDays,
128 AvailabilitySealed: avail, CreatedAt: now, UpdatedAt: now,
129 }
130 err = s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
131 for attempt := 0; ; attempt++ {
132 code, err := newPageCode()
133 if err != nil {
134 return err
135 }
136 page.Code = code
137 err = tx.Create(&page).Error
138 if err == nil {
139 break
140 }
141 if !isUniqueViolation(err) || attempt >= 1 {
142 return fmt.Errorf("calendar: create booking page: %w", err)
143 }
144 }
145 return insertWindows(tx, page.ID, ws)
146 })
147 if err != nil {
148 return BookingPage{}, err
149 }
150 return page, nil
151}
152
153// insertWindows writes a page's windows. The caller has already
154// deleted the old ones inside the same transaction, if there were any.
155func insertWindows(tx *gorm.DB, pageID int64, ws []Window) error {
156 for _, w := range ws {
157 row := BookingWindow{PageID: pageID, Weekday: int(w.Weekday), StartMin: w.StartMin, EndMin: w.EndMin}
158 if err := tx.Create(&row).Error; err != nil {
159 return fmt.Errorf("calendar: add booking window: %w", err)
160 }
161 }
162 return nil
163}
164
165// UpdateBookingPage rewrites a page's text, knobs and windows. The code
166// and the publication state are untouched: an edit to a page changes
167// what future bookings say, never where the page lives.
168//
169// Scoped by `id = ? AND person_id = ?`, and a miss is ErrNotFound —
170// a page that isn't yours doesn't exist.
171func (s *Store) UpdateBookingPage(ctx context.Context, personID, pageID int64, in BookingPageInput, windows []Window) error {
172 if err := ValidateBookingPageInput(&in); err != nil {
173 return err
174 }
175 ws, err := validateWindows(windows)
176 if err != nil {
177 return err
178 }
179 title, desc, avail, err := s.sealPage(in)
180 if err != nil {
181 return err
182 }
183 return s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
184 res := tx.Model(&BookingPage{}).Where("id = ? AND person_id = ?", pageID, personID).
185 Updates(map[string]any{
186 "title_sealed": title,
187 "description_sealed": desc,
188 "availability_text_sealed": avail,
189 "duration_min": in.DurationMin,
190 "buffer_min": in.BufferMin,
191 "notice_min": in.NoticeMin,
192 "horizon_days": in.HorizonDays,
193 "updated_at": time.Now().UTC(),
194 })
195 if res.Error != nil {
196 return fmt.Errorf("calendar: update booking page: %w", res.Error)
197 }
198 if res.RowsAffected == 0 {
199 return ErrNotFound
200 }
201 if err := tx.Where("page_id = ?", pageID).Delete(&BookingWindow{}).Error; err != nil {
202 return fmt.Errorf("calendar: clear booking windows: %w", err)
203 }
204 return insertWindows(tx, pageID, ws)
205 })
206}
207
208// PublishBookingPage sets or clears published_at. Explicit, with its
209// own button, because a published page is an oracle for when the host
210// is free (spec §7.4).
211func (s *Store) PublishBookingPage(ctx context.Context, personID, pageID int64, publish bool) error {
212 now := time.Now().UTC()
213 var at any // nil writes NULL
214 if publish {
215 at = now.Unix()
216 }
217 res := s.g.WithContext(ctx).Model(&BookingPage{}).
218 Where("id = ? AND person_id = ?", pageID, personID).
219 Updates(map[string]any{"published_at": at, "updated_at": now})
220 if res.Error != nil {
221 return fmt.Errorf("calendar: publish booking page: %w", res.Error)
222 }
223 if res.RowsAffected == 0 {
224 return ErrNotFound
225 }
226 return nil
227}
228