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 "fmt"
6 "time"
7
8 "gorm.io/gorm"
9
10 "amadan.net/oficina/calendar/internal/calendar/seal"
11)
12
13// The two writes to an existing event, split from store_event.go so
14// neither file passes the 300-line cap. Both enforce the booking flow's
15// authority over a booked event (booking spec B18).
16
17// UpdateEvent replaces the editable fields of an existing event.
18func (s *Store) UpdateEvent(ctx context.Context, personID, eventID int64, in EventInput) error {
19 existing, err := s.Event(ctx, personID, eventID)
20 if err != nil {
21 return err
22 }
23 if !existing.Writable() {
24 return ErrReadOnly
25 }
26 if err := ValidateEventInput(&in); err != nil {
27 return err
28 }
29 // A BOOKED event's scheduling belongs to the booking flow (booking
30 // spec B18): the comparison against the stored row happens inside
31 // the transaction below, so a stale form cannot slip a time past it.
32 return s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
33 return s.updateEventOn(tx, existing.Event, in, time.Now().UTC())
34 })
35}
36
37func (s *Store) updateEventOn(tx *gorm.DB, existing Event, in EventInput, now time.Time) error {
38 var stored Event
39 if err := tx.Where("id = ? AND deleted_at IS NULL", existing.ID).First(&stored).Error; err != nil {
40 return ErrNotFound
41 }
42 booking, booked, err := bookingFor(tx, stored.ID)
43 if err != nil {
44 return err
45 }
46 if booked {
47 if err := bookedEventRules(stored, in, booking); err != nil {
48 return err
49 }
50 }
51 startUTC, err := ToUTC(in.StartLocal, in.TZ, in.AllDay)
52 if err != nil {
53 return err
54 }
55 endUTC, err := ToUTC(in.EndLocal, in.TZ, in.AllDay)
56 if err != nil {
57 return err
58 }
59 title, err := s.seal.SealString(seal.PurposeEventTitle, in.Title)
60 if err != nil {
61 return fmt.Errorf("calendar: seal title: %w", err)
62 }
63 loc, err := s.seal.SealString(seal.PurposeEventLocation, in.Location)
64 if err != nil {
65 return fmt.Errorf("calendar: seal location: %w", err)
66 }
67 notes, err := s.seal.SealString(seal.PurposeEventNotes, in.Notes)
68 if err != nil {
69 return fmt.Errorf("calendar: seal notes: %w", err)
70 }
71
72 // An explicit column allowlist, never a struct update: the fields
73 // absent from this list — calendar_id, uid, created_by, series_id —
74 // are exactly the ones a hidden form field must not be able to
75 // move.
76 //
77 // A note on the sequence: the bump is an expression, not a value
78 // read earlier. Two writers reading 3 and both writing 4 lose a
79 // revision the ICS relies on (B11).
80 updates := map[string]any{
81 "title_sealed": title,
82 "location_sealed": loc,
83 "notes_sealed": notes,
84 "all_day": in.AllDay,
85 "start_local": in.StartLocal,
86 "end_local": in.EndLocal,
87 "tz": in.TZ,
88 "start_utc": startUTC,
89 "end_utc": endUTC,
90 "rrule": in.RRule,
91 "exdates": in.Exdates,
92 "kind": in.Kind,
93 "status": in.Status,
94 // SEQUENCE is what tells a re-importing client that this is a
95 // newer version of an event it already has.
96 "sequence": gorm.Expr("sequence + 1"),
97 "updated_at": now,
98 }
99 if until, ok := RuleUntilUTC(in.RRule, in.StartLocal, in.TZ, in.AllDay); ok {
100 updates["until_utc"] = until
101 } else {
102 updates["until_utc"] = nil
103 }
104 // The host cancelling a booked meeting cancels the booking and
105 // tells the booker (booking spec §5.5). BEFORE the event's own
106 // bump, so the cancellation mail names the revision that bump
107 // produces — every cancellation entrance queues seq+1 and then
108 // bumps once.
109 if booked && in.Status == StatusCancelled && stored.Status != StatusCancelled {
110 if err := cancelBookingsForEvent(tx, stored.ID, now); err != nil {
111 return err
112 }
113 }
114 if err := tx.Model(&Event{}).
115 Where("id = ?", stored.ID).Updates(updates).Error; err != nil {
116 return fmt.Errorf("calendar: update event: %w", err)
117 }
118 return s.rescheduleAllForTx(tx, stored.ID)
119}
120
121// DeleteEvent soft-deletes an event and, when it is a series master,
122// its overrides with it — an override of a series that no longer exists
123// is a row nothing can render and nothing can reach.
124func (s *Store) DeleteEvent(ctx context.Context, personID, eventID int64) error {
125 existing, err := s.Event(ctx, personID, eventID)
126 if err != nil {
127 return err
128 }
129 if !existing.Writable() {
130 return ErrReadOnly
131 }
132 now := time.Now().UTC()
133 return s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
134 // A booked event's deletion is a cancellation the booker is
135 // told about, and its CANCEL ICS must outrank the REQUEST, so
136 // the soft-deleted row takes one more revision (booking spec
137 // §5.5).
138 updates := map[string]any{"deleted_at": now, "updated_at": now, "sequence": gorm.Expr("sequence + 1")}
139 if _, booked, err := bookingFor(tx, eventID); err != nil {
140 return err
141 } else if booked {
142 if err := cancelBookingsForEvent(tx, eventID, now); err != nil {
143 return err
144 }
145 }
146 if err := tx.Model(&Event{}).Where("id = ?", eventID).
147 Updates(updates).Error; err != nil {
148 return fmt.Errorf("calendar: delete event: %w", err)
149 }
150 if err := tx.Model(&Event{}).Where("series_id = ?", eventID).
151 Update("deleted_at", now).Error; err != nil {
152 return fmt.Errorf("calendar: delete overrides: %w", err)
153 }
154 return nil
155 })
156}
157