| 1 | package calendar |
| 2 | |
| 3 | import ( |
| 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. |
| 24 | func (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). |
| 85 | func (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 | |