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 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.
22func 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.
40func 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.
57func (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.
64func (s *Store) ClaimBookingMailFor(ctx context.Context, bookingID, now int64) (BookingMail, bool, error) {
65 return s.claimBookingMail(ctx, now, bookingID)
66}
67
68func (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.
110func (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.
124func (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).
145func (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.
157func (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.
168func (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.
179func (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