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
8 "gorm.io/gorm"
9
10 "amadan.net/oficina/calendar/internal/calendar/seal"
11)
12
13// BookingView is a booking with its sealed columns opened and the rows
14// around it loaded. Event is the ROW, deliberately not an EventView:
15// public code reads its status, times, sequence and deletion, and must
16// never reach the host's notes, location or attendees, so those are
17// not opened here at all (booking spec §5.5).
18type BookingView struct {
19 Booking
20 Page BookingPageView
21 Host Person
22 HostName string // opened NameSealed, or the handle
23 Event Event
24 EventUID string
25
26 // Title and Description are the booking's own snapshot (B20).
27 Title, Description string
28 Name, Address, Note string
29
30 // Token is the opened management token: set only by BookingByID
31 // with withToken, and only to render a mail.
32 Token string
33}
34
35// BookingByToken is the public reader. A wrong token is ErrNotFound,
36// like a wrong feed token — never "expired", never 403.
37func (s *Store) BookingByToken(ctx context.Context, token string) (BookingView, error) {
38 var b Booking
39 err := s.g.WithContext(ctx).Where("token_hash = ?", HashFeedToken(token)).First(&b).Error
40 if errors.Is(err, gorm.ErrRecordNotFound) {
41 return BookingView{}, ErrNotFound
42 }
43 if err != nil {
44 return BookingView{}, fmt.Errorf("calendar: read booking by token: %w", err)
45 }
46 return s.openBooking(ctx, b, false)
47}
48
49// BookingByID is the internal reader, for the drain and the handlers
50// that already hold a row. withToken opens the management token, which
51// only a mail renderer needs.
52func (s *Store) BookingByID(ctx context.Context, id int64, withToken bool) (BookingView, error) {
53 var b Booking
54 err := s.g.WithContext(ctx).First(&b, id).Error
55 if errors.Is(err, gorm.ErrRecordNotFound) {
56 return BookingView{}, ErrNotFound
57 }
58 if err != nil {
59 return BookingView{}, fmt.Errorf("calendar: read booking: %w", err)
60 }
61 return s.openBooking(ctx, b, withToken)
62}
63
64func (s *Store) openBooking(ctx context.Context, b Booking, withToken bool) (BookingView, error) {
65 v := BookingView{Booking: b}
66
67 var page BookingPage
68 if err := s.g.WithContext(ctx).First(&page, b.PageID).Error; err != nil {
69 return v, fmt.Errorf("calendar: read booking's page: %w", err)
70 }
71 pv, err := s.viewOf(ctx, page)
72 if err != nil {
73 return v, err
74 }
75 v.Page = pv
76
77 if err := s.g.WithContext(ctx).First(&v.Host, page.PersonID).Error; err != nil {
78 return v, fmt.Errorf("calendar: read booking's host: %w", err)
79 }
80 if name, err := s.seal.OpenString(seal.PurposeMemberName, v.Host.NameSealed); err == nil && name != "" {
81 v.HostName = name
82 } else if v.Host.Handle != nil {
83 v.HostName = *v.Host.Handle
84 }
85
86 // The event row INCLUDING a soft-deleted one: a host-deleted booking
87 // still exports its CANCEL (spec §5.8).
88 if err := s.g.WithContext(ctx).Unscoped().First(&v.Event, b.EventID).Error; err != nil {
89 return v, fmt.Errorf("calendar: read booking's event: %w", err)
90 }
91 if uid, err := s.seal.OpenString(seal.PurposeEventUID, v.Event.UIDSealed); err == nil {
92 v.EventUID = uid
93 }
94
95 open := func(purpose string, ct []byte, what string) (string, error) {
96 out, err := s.seal.OpenString(purpose, ct)
97 if err != nil {
98 return "", fmt.Errorf("calendar: open booking %s (id %d): %w", what, b.ID, err)
99 }
100 return out, nil
101 }
102 if v.Title, err = open(seal.PurposeBookingTitle, b.TitleSealed, "title"); err != nil {
103 return v, err
104 }
105 if v.Description, err = open(seal.PurposeBookingDescription, b.DescriptionSealed, "description"); err != nil {
106 return v, err
107 }
108 if v.Name, err = open(seal.PurposeBookerName, b.BookerNameSealed, "name"); err != nil {
109 return v, err
110 }
111 if v.Address, err = open(seal.PurposeBookerAddress, b.BookerAddressSealed, "address"); err != nil {
112 return v, err
113 }
114 if v.Note, err = open(seal.PurposeBookerNote, b.NoteSealed, "note"); err != nil {
115 return v, err
116 }
117 if withToken {
118 if v.Token, err = open(seal.PurposeBookingToken, b.TokenSealed, "token"); err != nil {
119 return v, err
120 }
121 }
122 return v, nil
123}
124
125// bookingFor finds the booking an event belongs to, if any. Used by the
126// host's own edits to learn the event is booked (B18).
127func bookingFor(tx *gorm.DB, eventID int64) (Booking, bool, error) {
128 var b Booking
129 err := tx.Where("event_id = ?", eventID).First(&b).Error
130 if errors.Is(err, gorm.ErrRecordNotFound) {
131 return Booking{}, false, nil
132 }
133 if err != nil {
134 return Booking{}, false, fmt.Errorf("calendar: booking for event: %w", err)
135 }
136 return b, true, nil
137}
138
139// bookingIDForEvent is bookingFor without the row, for callers that
140// only need the fact.
141func (s *Store) bookingIDForEvent(ctx context.Context, eventID int64) (int64, bool) {
142 b, ok, err := bookingFor(s.g.WithContext(ctx), eventID)
143 if err != nil || !ok {
144 return 0, false
145 }
146 return b.ID, true
147}
148
149// Effective state of a booking, read from the booking AND its event:
150// a host who cancelled or deleted the event has cancelled the booking
151// whether or not the transition recorded it (spec §5.3).
152func (v BookingView) Cancelled() bool {
153 return v.State == BookingCancelled || v.Event.Status == StatusCancelled || v.Event.DeletedAt != nil
154}
155
156// Live reports a confirmed booking whose event still stands.
157func (v BookingView) Live() bool {
158 return v.State == BookingConfirmed && !v.Cancelled()
159}
160
161// BookingSummaryFor is what the host's own event page and form need to
162// know: that the event is booked, and by whom. The name, nothing else.
163func (s *Store) BookingSummaryFor(ctx context.Context, eventID int64) (name string, booked bool) {
164 b, ok, err := bookingFor(s.g.WithContext(ctx), eventID)
165 if err != nil || !ok {
166 return "", false
167 }
168 if n, err := s.seal.OpenString(seal.PurposeBookerName, b.BookerNameSealed); err == nil {
169 name = n
170 }
171 return name, true
172}
173