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 "errors"
6 "fmt"
7 "time"
8
9 "gorm.io/gorm"
10)
11
12// The confirm and reschedule transitions (booking spec §5.4, §5.6,
13// §5.7). Each is a conditional UPDATE naming the state — and, for a
14// reschedule, the revision — it expects, checking RowsAffected. Zero
15// rows is "somebody moved it first": the caller re-reads and renders,
16// never retries.
17
18// ConfirmBooking moves held → confirmed. Guarded on the hold not having
19// lapsed AND the meeting not having started (B15); the second guard is
20// read from the event inside the transaction. The attendee row, the
21// sequence bump and the owed host mail commit with it; the host's own
22// default reminder is created after, as its own act — the booking is
23// correct without it.
24func (s *Store) ConfirmBooking(ctx context.Context, bookingID int64, now time.Time) (changed bool, err error) {
25 var b Booking
26 var e Event
27 err = s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
28 if err := tx.First(&b, bookingID).Error; err != nil {
29 return fmt.Errorf("calendar: read booking: %w", err)
30 }
31 if err := tx.First(&e, b.EventID).Error; err != nil {
32 return fmt.Errorf("calendar: read booked event: %w", err)
33 }
34 res := tx.Model(&Booking{}).
35 Where("id = ? AND state = ? AND hold_expires_at > ? AND ? < ?", bookingID, BookingHeld, now.Unix(), now.Unix(), e.StartUTC).
36 Updates(map[string]any{"state": BookingConfirmed, "confirmed_at": now.Unix(), "updated_at": now.UTC()})
37 if res.Error != nil {
38 return fmt.Errorf("calendar: confirm booking: %w", res.Error)
39 }
40 if res.RowsAffected == 0 {
41 return nil
42 }
43 changed = true
44 if err := tx.Model(&Event{}).Where("id = ?", e.ID).
45 Updates(map[string]any{"status": StatusConfirmed, "sequence": gorm.Expr("sequence + 1"), "updated_at": now.UTC()}).Error; err != nil {
46 return fmt.Errorf("calendar: confirm booked event: %w", err)
47 }
48 if err := tx.First(&e, e.ID).Error; err != nil {
49 return err
50 }
51 addr, err := s.openBookerAddress(b)
52 if err != nil {
53 return err
54 }
55 if err := s.setAttendeesOn(tx, e.ID, []string{addr}); err != nil {
56 return err
57 }
58 // An owed hold mail is now false: forgotten here, not merely
59 // skipped later.
60 if err := deleteUnsentMail(tx, bookingID, MailHold); err != nil {
61 return err
62 }
63 return queueBookingMail(tx, bookingID, MailConfirmed, []string{ToHost}, e.Sequence, now)
64 })
65 if err != nil || !changed {
66 return false, err
67 }
68 // The host's default reminder, after commit. Its own function, its
69 // own failure mode; a booking without a reminder is still a
70 // booking.
71 var host Person
72 if err := s.g.WithContext(ctx).Raw(`SELECT people.* FROM people JOIN booking_pages ON booking_pages.person_id = people.id WHERE booking_pages.id = ?`, b.PageID).Scan(&host).Error; err == nil && host.ID != 0 {
73 _ = s.EnsureDefaultReminder(ctx, host, e)
74 }
75 return true, nil
76}
77
78// RescheduleBooking moves a confirmed booking's event. Inside holdMu,
79// with the same write-lock-then-re-check shape as a hold (B12). The
80// event UPDATE names the revision the form saw, so a resubmitted form
81// affects zero rows (B11, r2 A10). All five time columns move together
82// with tz = the host's CURRENT zone, the zone the slot was computed in
83// (B21). Every reminder RULE on the event gets its queue row rebuilt in
84// the same transaction (B24, B26).
85func (s *Store) RescheduleBooking(ctx context.Context, bookingID, startUTC, endUTC int64, rev int, now time.Time) (changed bool, err error) {
86 s.holdMu.Lock()
87 defer s.holdMu.Unlock()
88
89 v, err := s.BookingByID(ctx, bookingID, false)
90 if err != nil {
91 return false, err
92 }
93 if !v.Live() {
94 return false, nil
95 }
96 if v.Reschedules >= MaxReschedules {
97 return false, ErrOverCap
98 }
99 dur := int((v.Event.EndUTC - v.Event.StartUTC) / 60)
100 if endUTC-startUTC != v.Event.EndUTC-v.Event.StartUTC {
101 return false, ErrSlotTaken // the form asked for a different length
102 }
103 loc, err := Zone(v.Host.Zone)
104 if err != nil {
105 return false, err
106 }
107 d := time.Unix(startUTC, 0).In(loc)
108 day := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, loc)
109 dayEnd := day.AddDate(0, 0, 1)
110
111 slots, err := s.FreeSlots(ctx, v.Page, v.Host, now, day, dayEnd, v.EventID, dur)
112 if err != nil {
113 return false, err
114 }
115 if !hasSlot(slots, startUTC, endUTC) {
116 return false, ErrSlotTaken
117 }
118 if s.afterSlotCheckForTest != nil {
119 s.afterSlotCheckForTest()
120 }
121
122 err = s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
123 if err := tx.Exec(`UPDATE booking_pages SET updated_at = updated_at WHERE id = ?`, v.PageID).Error; err != nil {
124 return fmt.Errorf("calendar: take write lock: %w", err)
125 }
126 // The page, its windows and the host as they are NOW, behind
127 // the lock: windows the host has since withdrawn cannot be
128 // moved into, and tz below is the zone the slot was computed
129 // in (B21), so compute it there. currentPageOn refuses an
130 // unpublished page, which is the host's way of closing it.
131 page, host, err := s.currentPageOn(tx, v.PageID)
132 if err != nil {
133 return err
134 }
135 v.Page, v.Host = page, host
136 if loc, err = Zone(v.Host.Zone); err != nil {
137 return err
138 }
139 again, err := s.freeSlotsOn(tx, v.Page, v.Host, now, day, dayEnd, v.EventID, dur)
140 if err != nil {
141 return err
142 }
143 if !hasSlot(again, startUTC, endUTC) {
144 return ErrSlotTaken
145 }
146 res := tx.Model(&Event{}).
147 Where("id = ? AND sequence = ? AND deleted_at IS NULL AND status = ?", v.EventID, rev, StatusConfirmed).
148 Updates(map[string]any{
149 "start_local": FormatLocal(time.Unix(startUTC, 0).In(loc), false),
150 "end_local": FormatLocal(time.Unix(endUTC, 0).In(loc), false),
151 "tz": v.Host.Zone,
152 "start_utc": startUTC,
153 "end_utc": endUTC,
154 "sequence": gorm.Expr("sequence + 1"),
155 "updated_at": now.UTC(),
156 })
157 if res.Error != nil {
158 return fmt.Errorf("calendar: move booked event: %w", res.Error)
159 }
160 if res.RowsAffected == 0 {
161 return nil
162 }
163 changed = true
164 cnt := tx.Model(&Booking{}).
165 Where("id = ? AND state = ?", bookingID, BookingConfirmed).
166 Updates(map[string]any{"reschedules": gorm.Expr("reschedules + 1"), "updated_at": now.UTC()})
167 if cnt.Error != nil {
168 return fmt.Errorf("calendar: count reschedule: %w", cnt.Error)
169 }
170 if cnt.RowsAffected == 0 {
171 return errClaimLost // cancelled under us; the event UPDATE above rolls back with this
172 }
173 if err := s.rescheduleAllForTx(tx, v.EventID); err != nil {
174 return err
175 }
176 if err := deleteUnsentMail(tx, bookingID, MailRescheduled); err != nil {
177 return err
178 }
179 return queueBookingMail(tx, bookingID, MailRescheduled, []string{ToBooker, ToHost}, rev+1, now)
180 })
181 if errors.Is(err, errClaimLost) {
182 return false, nil
183 }
184 return changed, err
185}
186