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// EnsureDefaultReminder gives a newly created event the person's own
13// default warning, using the preference that matches what kind of thing
14// it is.
15//
16// A default rather than nothing, because a reminder somebody has to
17// remember to set is a reminder that exists for the events they were
18// already thinking about. A note gets none: a birthday does not need a
19// ten-minute warning, and the all-day default is the evening before.
20func (s *Store) EnsureDefaultReminder(ctx context.Context, p Person, e Event) error {
21 offset := p.DefaultReminderMin
22 if e.AllDay {
23 offset = p.DefaultAllDayMin
24 }
25 if offset == 0 {
26 return nil
27 }
28 r := Reminder{
29 EventID: e.ID,
30 PersonID: p.ID,
31 OffsetMin: offset,
32 Channel: "email",
33 CreatedAt: time.Now().UTC(),
34 }
35 if err := s.g.WithContext(ctx).Create(&r).Error; err != nil {
36 return fmt.Errorf("calendar: create reminder: %w", err)
37 }
38 return s.RescheduleReminder(ctx, r.ID)
39}
40
41// RemindersFor lists a person's own reminders on an event. Only their
42// own: a reminder is a fact about a person, and whether a colleague
43// wants a warning is not information the event's page should carry.
44func (s *Store) RemindersFor(ctx context.Context, personID, eventID int64) ([]Reminder, error) {
45 var out []Reminder
46 err := s.g.WithContext(ctx).
47 Where("event_id = ? AND person_id = ?", eventID, personID).
48 Order("offset_min DESC").
49 Find(&out).Error
50 if err != nil {
51 return nil, fmt.Errorf("calendar: list reminders: %w", err)
52 }
53 return out, nil
54}
55
56// AddReminder attaches a warning to an event the person can see.
57func (s *Store) AddReminder(ctx context.Context, personID, eventID, offsetMin int64) error {
58 if _, err := s.Event(ctx, personID, eventID); err != nil {
59 return err
60 }
61 r := Reminder{
62 EventID: eventID,
63 PersonID: personID,
64 OffsetMin: int(offsetMin),
65 Channel: "email",
66 CreatedAt: time.Now().UTC(),
67 }
68 if err := s.g.WithContext(ctx).Create(&r).Error; err != nil {
69 return fmt.Errorf("calendar: add reminder: %w", err)
70 }
71 return s.RescheduleReminder(ctx, r.ID)
72}
73
74// RemoveReminder deletes one of the person's own reminders, and the
75// queued row with it.
76func (s *Store) RemoveReminder(ctx context.Context, personID, reminderID int64) error {
77 return s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
78 res := tx.Where("id = ? AND person_id = ?", reminderID, personID).Delete(&Reminder{})
79 if res.Error != nil {
80 return fmt.Errorf("calendar: remove reminder: %w", res.Error)
81 }
82 if res.RowsAffected == 0 {
83 // Somebody else's reminder, or none. Not found, never
84 // forbidden.
85 return ErrNotFound
86 }
87 return tx.Where("reminder_id = ?", reminderID).Delete(&ReminderQueue{}).Error
88 })
89}
90
91// RescheduleReminder recomputes when a rule next fires and writes the
92// single pending queue row for it.
93//
94// One row per rule, always — never one per occurrence. A daily standup
95// that repeats forever has exactly one queued row at any moment, and
96// firing it computes the next. Materialising a series' reminders is how
97// a queue becomes unbounded.
98func (s *Store) RescheduleReminder(ctx context.Context, reminderID int64) error {
99 return s.rescheduleAfter(ctx, reminderID, 0)
100}
101
102// rescheduleAfter queues the first occurrence starting strictly after
103// `after`, or after now when `after` is zero.
104func (s *Store) rescheduleAfter(ctx context.Context, reminderID, after int64) error {
105 return s.rescheduleAfterTx(s.g.WithContext(ctx), reminderID, after)
106}
107
108// rescheduleAfterTx is rescheduleAfter on a caller's transaction, so a
109// reschedule of a booked meeting can rebuild its reminder rows in the
110// SAME transaction as the move (booking spec B24): a crash between the
111// two would otherwise leave a row that sends a reminder for a time the
112// meeting no longer has.
113//
114// Any rewrite of a queue row clears its claim and its retry: whoever
115// held it was announcing a time that is gone, and must find zero rows
116// when they try to complete.
117func (s *Store) rescheduleAfterTx(tx *gorm.DB, reminderID, after int64) error {
118 var rem Reminder
119 if err := tx.First(&rem, reminderID).Error; err != nil {
120 if errors.Is(err, gorm.ErrRecordNotFound) {
121 return nil
122 }
123 return fmt.Errorf("calendar: read reminder: %w", err)
124 }
125 var e Event
126 if err := tx.Where("id = ? AND deleted_at IS NULL AND status <> ?", rem.EventID, StatusCancelled).
127 First(&e).Error; err != nil {
128 if errors.Is(err, gorm.ErrRecordNotFound) {
129 // The event is gone, or cancelled; so is any reason to
130 // warn about it.
131 return tx.Where("reminder_id = ?", reminderID).Delete(&ReminderQueue{}).Error
132 }
133 return fmt.Errorf("calendar: read event for reminder: %w", err)
134 }
135
136 // The floor for the search: whichever is later of "now" and the
137 // occurrence just fired. Zero means nothing has fired, so now.
138 floor := time.Now().Unix()
139 if after > floor {
140 floor = after
141 }
142 occStart, occLocal, ok := s.nextOccurrenceAfter(e, floor, rem.OffsetMin)
143 if !ok {
144 // Nothing left to warn about: a finished series, or a single
145 // event already past. Drop the queue row rather than leave a
146 // due time in the past that every drain re-examines.
147 return tx.Where("reminder_id = ?", reminderID).Delete(&ReminderQueue{}).Error
148 }
149
150 q := ReminderQueue{
151 ReminderID: reminderID,
152 OccurrenceKey: fmt.Sprintf("%d/%s", e.ID, occLocal),
153 DueAt: occStart - int64(rem.OffsetMin)*60,
154 }
155 // Upsert on the unique reminder_id, so rescheduling is idempotent
156 // and two callers cannot leave two pending rows for one rule.
157 err := tx.
158 Where(ReminderQueue{ReminderID: reminderID}).
159 Assign(map[string]any{
160 "occurrence_key": q.OccurrenceKey,
161 "due_at": q.DueAt,
162 "claimed_at": nil,
163 "claim_id": nil,
164 "retry_at": nil,
165 "fired_at": nil,
166 "attempts": 0,
167 "late_by_sec": 0,
168 }).
169 FirstOrCreate(&q).Error
170 if err != nil {
171 return fmt.Errorf("calendar: queue reminder: %w", err)
172 }
173 return nil
174}
175
176// ExcludeOccurrence deletes one occurrence of a series by adding an
177// EXDATE, and reschedules any reminder that pointed at it.
178func (s *Store) ExcludeOccurrence(ctx context.Context, personID, eventID int64, local string) error {
179 e, err := s.Event(ctx, personID, eventID)
180 if err != nil {
181 return err
182 }
183 if !e.Writable() {
184 return ErrReadOnly
185 }
186 set := exdateSet(e.Exdates)
187 if set[local] {
188 return nil
189 }
190 next := e.Exdates
191 if next != "" {
192 next += ","
193 }
194 next += local
195 if err := s.g.WithContext(ctx).Model(&Event{}).Where("id = ?", eventID).
196 Updates(map[string]any{"exdates": next, "updated_at": time.Now().UTC()}).Error; err != nil {
197 return fmt.Errorf("calendar: exclude occurrence: %w", err)
198 }
199 return s.rescheduleAllFor(ctx, eventID)
200}
201
202// rescheduleAllFor recomputes every reminder on an event, after the
203// event's own times or rule changed.
204func (s *Store) rescheduleAllFor(ctx context.Context, eventID int64) error {
205 return s.rescheduleAllForTx(s.g.WithContext(ctx), eventID)
206}
207
208// rescheduleAllForTx enumerates the event's reminder RULES — not its
209// queue rows, because a reminder that already fired has none — and
210// upserts one queue row per rule from the event's current times
211// (booking spec B26).
212func (s *Store) rescheduleAllForTx(tx *gorm.DB, eventID int64) error {
213 var ids []int64
214 if err := tx.Model(&Reminder{}).
215 Where("event_id = ?", eventID).Pluck("id", &ids).Error; err != nil {
216 return fmt.Errorf("calendar: list reminders to reschedule: %w", err)
217 }
218 for _, id := range ids {
219 if err := s.rescheduleAfterTx(tx, id, 0); err != nil {
220 return err
221 }
222 }
223 return nil
224}
225
226// CountOccurrences reports how many times a series happens, for the
227// delete confirmation — "All 52 of them" is a warning that a bare
228// "Delete all" is not.
229//
230// forever is true for a series with no end, where no count exists and
231// the confirmation says so instead of inventing a number.
232func (s *Store) CountOccurrences(e EventView) (int, bool) {
233 if e.RRule == "" {
234 return 1, false
235 }
236 rule, err := ParseRule(e.RRule)
237 if err != nil {
238 return 0, true
239 }
240 if rule.Count > 0 {
241 return rule.Count, false
242 }
243 if !rule.HasUntil {
244 return 0, true
245 }
246 occs := Expand(e.StartLocal, e.EndLocal, e.TZ, e.AllDay, rule, e.Exdates,
247 e.StartUTC-1, untilBound(rule))
248 return len(occs), false
249}
250
251func untilBound(r Rule) int64 {
252 if r.HasUntil {
253 return r.Until.Unix() + 24*3600
254 }
255 return time.Now().AddDate(2, 0, 0).Unix()
256}
257