| 1 | package calendar |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "time" |
| 8 | |
| 9 | "gorm.io/gorm" |
| 10 | ) |
| 11 | |
| 12 | // The booking mail queue: one row per (booking, kind, recipient, |
| 13 | // revision), claimed with an identity and a lease, acknowledged |
| 14 | // conditionally on that identity — the reminder queue's own shape |
| 15 | // (booking spec §2.5, B13, B22). No body is stored; it renders at send |
| 16 | // time from the booking's current state. |
| 17 | |
| 18 | // queueBookingMail owes one message to each recipient, inside the |
| 19 | // caller's transaction, due now. The unique index makes a repeat of the |
| 20 | // same (booking, kind, recipient, revision) a no-op rather than a second |
| 21 | // message. |
| 22 | func queueBookingMail(tx *gorm.DB, bookingID int64, kind string, recipients []string, revision int, now time.Time) error { |
| 23 | for _, to := range recipients { |
| 24 | row := BookingMail{ |
| 25 | BookingID: bookingID, Kind: kind, Recipient: to, Revision: revision, |
| 26 | DueAt: now.Unix(), CreatedAt: now.UTC(), |
| 27 | } |
| 28 | if err := tx.Where(BookingMail{BookingID: bookingID, Kind: kind, Recipient: to, Revision: revision}). |
| 29 | FirstOrCreate(&row).Error; err != nil { |
| 30 | return fmt.Errorf("calendar: queue booking mail: %w", err) |
| 31 | } |
| 32 | } |
| 33 | return nil |
| 34 | } |
| 35 | |
| 36 | // deleteUnsentMail forgets every message a booking still owed. Called |
| 37 | // by the transitions that make those messages false — cancel, expire — |
| 38 | // so a booker does not get "confirmed" after "cancelled". A row already |
| 39 | // claimed and handed to the sender cannot be recalled; the spec says so. |
| 40 | func deleteUnsentMail(tx *gorm.DB, bookingID int64, kinds ...string) error { |
| 41 | q := tx.Where("booking_id = ? AND sent_at IS NULL", bookingID) |
| 42 | if len(kinds) > 0 { |
| 43 | q = q.Where("kind IN ?", kinds) |
| 44 | } |
| 45 | if err := q.Delete(&BookingMail{}).Error; err != nil { |
| 46 | return fmt.Errorf("calendar: forget owed mail: %w", err) |
| 47 | } |
| 48 | return nil |
| 49 | } |
| 50 | |
| 51 | // ClaimBookingMail takes the earliest claimable row: one transaction, |
| 52 | // a conditional UPDATE that writes a fresh claim id and advances due_at |
| 53 | // to the lease end, then a re-read of THAT row. For booking mail due_at |
| 54 | // means only "eligible from", so advancing it on claim is what makes a |
| 55 | // crash after a claim harmless — the row comes due again when the lease |
| 56 | // ends, and NextDueAt reads nothing but due_at. |
| 57 | func (s *Store) ClaimBookingMail(ctx context.Context, now int64) (BookingMail, bool, error) { |
| 58 | return s.claimBookingMail(ctx, now, 0) |
| 59 | } |
| 60 | |
| 61 | // ClaimBookingMailFor is the claim restricted to one booking: what a |
| 62 | // request tries right after its own transition, so a stranger's POST |
| 63 | // never waits through everybody else's queue. |
| 64 | func (s *Store) ClaimBookingMailFor(ctx context.Context, bookingID, now int64) (BookingMail, bool, error) { |
| 65 | return s.claimBookingMail(ctx, now, bookingID) |
| 66 | } |
| 67 | |
| 68 | func (s *Store) claimBookingMail(ctx context.Context, now, bookingID int64) (BookingMail, bool, error) { |
| 69 | var cand BookingMail |
| 70 | q := s.g.WithContext(ctx).Where("sent_at IS NULL AND due_at <= ?", now) |
| 71 | if bookingID != 0 { |
| 72 | q = q.Where("booking_id = ?", bookingID) |
| 73 | } |
| 74 | err := q.Order("due_at ASC, id ASC").First(&cand).Error |
| 75 | if errors.Is(err, gorm.ErrRecordNotFound) { |
| 76 | return BookingMail{}, false, nil |
| 77 | } |
| 78 | if err != nil { |
| 79 | return BookingMail{}, false, fmt.Errorf("calendar: find owed mail: %w", err) |
| 80 | } |
| 81 | claimID := newClaimID() |
| 82 | var row BookingMail |
| 83 | err = s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| 84 | res := tx.Model(&BookingMail{}). |
| 85 | Where("id = ? AND sent_at IS NULL AND due_at <= ?", cand.ID, now). |
| 86 | Updates(map[string]any{ |
| 87 | "claimed_at": now, "claim_id": claimID, |
| 88 | "due_at": now + int64(claimLease.Seconds()), |
| 89 | }) |
| 90 | if res.Error != nil { |
| 91 | return fmt.Errorf("calendar: claim booking mail: %w", res.Error) |
| 92 | } |
| 93 | if res.RowsAffected == 0 { |
| 94 | return errClaimLost |
| 95 | } |
| 96 | return tx.First(&row, cand.ID).Error |
| 97 | }) |
| 98 | if errors.Is(err, errClaimLost) { |
| 99 | return BookingMail{}, false, nil |
| 100 | } |
| 101 | if err != nil { |
| 102 | return BookingMail{}, false, err |
| 103 | } |
| 104 | return row, true, nil |
| 105 | } |
| 106 | |
| 107 | // MarkBookingMailSent acknowledges a send, conditional on the claim. |
| 108 | // ok is false when the claim was no longer ours; nothing depends on |
| 109 | // it, but the fact is reported rather than swallowed. |
| 110 | func (s *Store) MarkBookingMailSent(ctx context.Context, id int64, claimID string, now int64) (ok bool, err error) { |
| 111 | res := s.g.WithContext(ctx).Model(&BookingMail{}). |
| 112 | Where("id = ? AND claim_id = ? AND sent_at IS NULL", id, claimID). |
| 113 | Update("sent_at", now) |
| 114 | if res.Error != nil { |
| 115 | return false, fmt.Errorf("calendar: mark booking mail sent: %w", res.Error) |
| 116 | } |
| 117 | return res.RowsAffected == 1, nil |
| 118 | } |
| 119 | |
| 120 | // MarkBookingMailFailed counts a real send error. due_at already points |
| 121 | // at the lease end, so the retry time needs no writing. At |
| 122 | // MaxReminderAttempts the row is given up on: sent_at = MailGaveUp, and |
| 123 | // the caller logs a stage and a code, never an address. |
| 124 | func (s *Store) MarkBookingMailFailed(ctx context.Context, id int64, claimID string) (gaveUp bool, err error) { |
| 125 | var row BookingMail |
| 126 | if err := s.g.WithContext(ctx).First(&row, id).Error; err != nil { |
| 127 | return false, fmt.Errorf("calendar: read booking mail: %w", err) |
| 128 | } |
| 129 | attempts := row.Attempts + 1 |
| 130 | updates := map[string]any{"attempts": attempts} |
| 131 | if attempts >= MaxReminderAttempts { |
| 132 | updates["sent_at"] = MailGaveUp |
| 133 | gaveUp = true |
| 134 | } |
| 135 | res := s.g.WithContext(ctx).Model(&BookingMail{}). |
| 136 | Where("id = ? AND claim_id = ?", id, claimID).Updates(updates) |
| 137 | if res.Error != nil { |
| 138 | return false, fmt.Errorf("calendar: mark booking mail failed: %w", res.Error) |
| 139 | } |
| 140 | return gaveUp && res.RowsAffected == 1, nil |
| 141 | } |
| 142 | |
| 143 | // DeferBookingMail puts a row back without an attempt: the sender was |
| 144 | // busy and nothing was tried (B22). |
| 145 | func (s *Store) DeferBookingMail(ctx context.Context, id int64, claimID string, now int64) (ok bool, err error) { |
| 146 | res := s.g.WithContext(ctx).Model(&BookingMail{}). |
| 147 | Where("id = ? AND claim_id = ?", id, claimID). |
| 148 | Updates(map[string]any{"due_at": now + int64(deferFor.Seconds()), "claimed_at": nil, "claim_id": ""}) |
| 149 | if res.Error != nil { |
| 150 | return false, fmt.Errorf("calendar: defer booking mail: %w", res.Error) |
| 151 | } |
| 152 | return res.RowsAffected == 1, nil |
| 153 | } |
| 154 | |
| 155 | // NextBookingMailDue is the earliest owed row's due_at — always a |
| 156 | // claimable instant, because claims advance it. |
| 157 | func (s *Store) NextBookingMailDue(ctx context.Context) time.Time { |
| 158 | var at *int64 |
| 159 | err := s.g.WithContext(ctx).Raw(`SELECT MIN(due_at) FROM booking_mails WHERE sent_at IS NULL`).Scan(&at).Error |
| 160 | if err != nil || at == nil { |
| 161 | return time.Time{} |
| 162 | } |
| 163 | return time.Unix(*at, 0) |
| 164 | } |
| 165 | |
| 166 | // OwesMail says whether any of a booking's mail is still unsent — |
| 167 | // claimed, deferred or not yet tried. |
| 168 | func (s *Store) OwesMail(ctx context.Context, bookingID int64) (bool, error) { |
| 169 | var n int64 |
| 170 | err := s.g.WithContext(ctx).Model(&BookingMail{}). |
| 171 | Where("booking_id = ? AND sent_at IS NULL", bookingID).Count(&n).Error |
| 172 | if err != nil { |
| 173 | return true, fmt.Errorf("calendar: owed mail: %w", err) |
| 174 | } |
| 175 | return n > 0, nil |
| 176 | } |
| 177 | |
| 178 | // UnsentBookingMailForTest lists what a booking still owes. |
| 179 | func (s *Store) UnsentBookingMailForTest(bookingID int64) []BookingMail { |
| 180 | var rows []BookingMail |
| 181 | _ = s.g.Where("booking_id = ? AND sent_at IS NULL", bookingID).Order("id").Find(&rows).Error |
| 182 | return rows |
| 183 | } |
| 184 | |