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// CancelBooking is the booker's cancel: confirmed → cancelled, the
14// event cancelled with a sequence bump, the reminder rows gone, every
15// unsent message forgotten, and the cancelled messages owed to both
16// (booking spec §5.6, §5.7). A second press affects zero rows.
17func (s *Store) CancelBooking(ctx context.Context, bookingID int64, by string, now time.Time) (changed bool, err error) {
18 err = s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
19 var b Booking
20 if err := tx.First(&b, bookingID).Error; err != nil {
21 return fmt.Errorf("calendar: read booking: %w", err)
22 }
23 n, err := cancelBookingOn(tx, b, by, now)
24 if err != nil {
25 return err
26 }
27 if n == 0 {
28 return nil
29 }
30 changed = true
31 if err := tx.Model(&Event{}).Where("id = ? AND deleted_at IS NULL", b.EventID).
32 Updates(map[string]any{"status": StatusCancelled, "sequence": gorm.Expr("sequence + 1"), "updated_at": now.UTC()}).Error; err != nil {
33 return fmt.Errorf("calendar: cancel booked event: %w", err)
34 }
35 return nil
36 })
37 return changed, err
38}
39
40// cancelBookingOn is the confirmed → cancelled row transition and its
41// side effects, on a transaction, without touching the event — the
42// caller owns the event (the booker's cancel sets it cancelled; the
43// host's own UpdateEvent/DeleteEvent already did). Returns rows moved.
44func cancelBookingOn(tx *gorm.DB, b Booking, by string, now time.Time) (int64, error) {
45 res := tx.Model(&Booking{}).
46 Where("id = ? AND state = ?", b.ID, BookingConfirmed).
47 Updates(map[string]any{"state": BookingCancelled, "cancelled_at": now.Unix(), "cancelled_by": by, "updated_at": now.UTC()})
48 if res.Error != nil {
49 return 0, fmt.Errorf("calendar: cancel booking: %w", res.Error)
50 }
51 if res.RowsAffected == 0 {
52 return 0, nil
53 }
54 if err := deleteReminderRowsFor(tx, b.EventID); err != nil {
55 return 0, err
56 }
57 if err := deleteUnsentMail(tx, b.ID); err != nil {
58 return 0, err
59 }
60 var e Event
61 if err := tx.First(&e, b.EventID).Error; err != nil {
62 return 0, fmt.Errorf("calendar: read cancelled event: %w", err)
63 }
64 // The revision the cancellation mail carries is the one the
65 // caller's event bump will produce.
66 to := []string{ToBooker}
67 if by == CancelledByBooker {
68 to = []string{ToBooker, ToHost}
69 }
70 if err := queueBookingMail(tx, b.ID, MailCancelled, to, e.Sequence+1, now); err != nil {
71 return 0, err
72 }
73 return 1, nil
74}
75
76// cancelBookingsForEvent is what the HOST's cancel or delete does to a
77// booking (booking spec §5.5): a confirmed booking is cancelled by the
78// host and the booker is told; a held one simply expires, and nobody is
79// told — there was nothing to tell them yet.
80func cancelBookingsForEvent(tx *gorm.DB, eventID int64, now time.Time) error {
81 b, ok, err := bookingFor(tx, eventID)
82 if err != nil || !ok {
83 return err
84 }
85 switch b.State {
86 case BookingConfirmed:
87 _, err := cancelBookingOn(tx, b, CancelledByHost, now)
88 return err
89 case BookingHeld:
90 res := tx.Model(&Booking{}).Where("id = ? AND state = ?", b.ID, BookingHeld).
91 Updates(map[string]any{"state": BookingExpired, "updated_at": now.UTC()})
92 if res.Error != nil {
93 return fmt.Errorf("calendar: expire held booking: %w", res.Error)
94 }
95 if res.RowsAffected == 0 {
96 return nil // confirmed under us; that transition owns the rest
97 }
98 if err := deleteReminderRowsFor(tx, eventID); err != nil {
99 return err
100 }
101 return deleteUnsentMail(tx, b.ID)
102 }
103 return nil
104}
105
106// openBookerAddress opens just the address, for the attendee row.
107func (s *Store) openBookerAddress(b Booking) (string, error) {
108 addr, err := s.seal.OpenString(seal.PurposeBookerAddress, b.BookerAddressSealed)
109 if err != nil {
110 return "", fmt.Errorf("calendar: open booker address (booking %d): %w", b.ID, err)
111 }
112 return addr, nil
113}
114
115// bookedEventRules is B18: the host's form may change an event's
116// title, notes, location and — within the booking's state — its
117// status; everything that schedules it belongs to the booking flow.
118// Compared against the STORED row inside UpdateEvent's transaction, so
119// a stale form that re-submits a time from before a public reschedule
120// is refused, and one that re-submits the current time passes.
121func bookedEventRules(existing Event, in EventInput, b Booking) error {
122 const moved = "This is a booked meeting. Its time is changed by the person who booked it, or by cancelling and letting them rebook."
123 switch {
124 case in.StartLocal != existing.StartLocal:
125 return ValidationError{"start", moved}
126 case in.EndLocal != existing.EndLocal:
127 return ValidationError{"end", moved}
128 case in.TZ != existing.TZ:
129 return ValidationError{"tz", moved}
130 case in.AllDay != existing.AllDay:
131 return ValidationError{"all_day", moved}
132 case in.Kind != existing.Kind:
133 return ValidationError{"kind", moved}
134 case in.RRule != existing.RRule:
135 return ValidationError{"repeat", "This is a booked meeting; it cannot repeat."}
136 }
137 switch b.State {
138 case BookingHeld:
139 if in.Status == StatusConfirmed {
140 return ValidationError{"status", "Only the person who booked this can confirm it."}
141 }
142 if in.Status != StatusTentative && in.Status != StatusCancelled {
143 return ValidationError{"status", "A held booking is tentative until the person who booked it confirms it."}
144 }
145 case BookingConfirmed:
146 if in.Status != StatusConfirmed && in.Status != StatusCancelled {
147 return ValidationError{"status", "A booked meeting is confirmed or cancelled."}
148 }
149 default: // cancelled, expired
150 if in.Status == StatusConfirmed {
151 return ValidationError{"status", "This booking was cancelled. Make a new event instead."}
152 }
153 if in.Status != StatusCancelled {
154 return ValidationError{"status", "This booking was cancelled."}
155 }
156 }
157 return nil
158}
159