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 "errors"
5 "net/http"
6 "strconv"
7 "strings"
8 "time"
9
10 "github.com/go-chi/chi/v5"
11)
12
13// The booker's own pages (booking spec §5.3–5.6, §5.8): confirm,
14// manage, the ICS, reschedule, cancel. Everything here is reached by a
15// token, and a wrong token is the router's own 404.
16
17// byToken resolves the URL's token or answers 404.
18func (h *handlers) byToken(w http.ResponseWriter, r *http.Request) (BookingView, string, bool) {
19 token := chi.URLParam(r, "token")
20 v, err := h.store.BookingByToken(r.Context(), token)
21 if NotFound(err) {
22 notFoundLikeTheRouter(w, r)
23 return v, "", false
24 }
25 if err != nil {
26 h.fail(w, r, "booking by token", err)
27 return v, "", false
28 }
29 return v, token, true
30}
31
32// renderState shows whatever the booking is now: the confirm button
33// for a live hold, the manage page for a confirmed one, or the plain
34// "expired"/"cancelled" page.
35func (h *handlers) renderState(w http.ResponseWriter, r *http.Request, v BookingView, token string) {
36 pb := publicOf(v, time.Now())
37 pb.Token = token
38 switch {
39 case pb.Held:
40 render(w, "book_hold", h.page(r, "Confirm your booking", pb))
41 case pb.Live:
42 render(w, "book_manage", h.page(r, pb.Title, pb))
43 default:
44 render(w, "book_gone", h.page(r, pb.Title, map[string]any{
45 "Title": pb.Title, "Zone": pb.Zone, "Path": h.pagePathOf(v),
46 "Why": goneWhy(pb), "Expired": pb.Expired, "Cancelled": pb.Cancelled, "CancelledBy": pb.CancelledBy,
47 "ExpiresAt": pb.ExpiresAt, "HostName": pb.HostName,
48 }))
49 }
50}
51
52func goneWhy(pb publicBooking) string {
53 switch {
54 case pb.Expired:
55 return "This hold expired at " + pb.ExpiresAt + ". The time went back on offer."
56 case pb.Cancelled && pb.CancelledBy == CancelledByHost:
57 return "This booking was cancelled by " + pb.HostName + "."
58 case pb.Cancelled:
59 return "You cancelled this booking."
60 }
61 return "This booking is no longer open."
62}
63
64// pagePathOf is the public page a booking came from, for the way back.
65func (h *handlers) pagePathOf(v BookingView) string {
66 if v.Host.Handle == nil {
67 return "/"
68 }
69 return "/book/" + *v.Host.Handle + "/" + v.Page.Code
70}
71
72// bookManage is GET /book/m/{token}. Nothing changes on a GET (B1).
73func (h *handlers) bookManage(w http.ResponseWriter, r *http.Request) {
74 v, token, ok := h.byToken(w, r)
75 if !ok {
76 return
77 }
78 h.renderState(w, r, v, token)
79}
80
81// bookConfirm is POST /book/m/{token}/confirm.
82func (h *handlers) bookConfirm(w http.ResponseWriter, r *http.Request) {
83 v, token, ok := h.byToken(w, r)
84 if !ok {
85 return
86 }
87 now := time.Now()
88 changed, err := h.store.ConfirmBooking(r.Context(), v.ID, now)
89 if err != nil {
90 h.fail(w, r, "confirm", err)
91 return
92 }
93 if changed {
94 h.trySendNow(r.Context(), v.ID, now)
95 }
96 v, err = h.store.BookingByID(r.Context(), v.ID, false)
97 if err != nil {
98 h.fail(w, r, "re-read booking", err)
99 return
100 }
101 h.renderState(w, r, v, token)
102}
103
104// bookICS is GET /book/m/{token}/calendar.ics — confirmed or cancelled
105// only; a hold has nothing to put in a calendar yet.
106func (h *handlers) bookICS(w http.ResponseWriter, r *http.Request) {
107 v, _, ok := h.byToken(w, r)
108 if !ok {
109 return
110 }
111 if v.State != BookingConfirmed && v.State != BookingCancelled {
112 notFoundLikeTheRouter(w, r)
113 return
114 }
115 pb := publicOf(v, time.Now())
116 body := WriteBookingICS(pb, v.EventUID, v.Event.UpdatedAt, v.Event)
117 w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
118 w.Header().Set("Content-Disposition", `attachment; filename="booking.ics"`)
119 w.Header().Set("Content-Length", strconv.Itoa(len(body)))
120 _, _ = w.Write([]byte(body))
121}
122
123// bookReschedulePicker is GET /book/m/{token}/reschedule: the page's
124// picker with the booking's own event out of busy and the EVENT's
125// duration, plus the revision the form must name.
126func (h *handlers) bookReschedulePicker(w http.ResponseWriter, r *http.Request) {
127 v, token, ok := h.byToken(w, r)
128 if !ok {
129 return
130 }
131 if !v.Live() {
132 h.renderState(w, r, v, token)
133 return
134 }
135 // A page the host has closed offers no times to move to. The
136 // booking itself stands; the store would refuse every slot anyway,
137 // so say so here rather than after a choice.
138 if !v.Page.Published {
139 render(w, "book_gone", h.page(r, v.Title, map[string]any{
140 "Title": v.Title, "Zone": v.BookerZone,
141 "Why": "This page is no longer taking bookings, so this one cannot be moved. It still stands as it is; you can cancel it from the link in your mail.",
142 }))
143 return
144 }
145 zone, chosen := pickZone(r, v.Host)
146 if !chosen {
147 zone = v.BookerZone
148 }
149 dur := int((v.Event.EndUTC - v.Event.StartUTC) / 60)
150 d, ok := h.picker(w, r, v.Page, v.Host, zone, strings.TrimSpace(r.URL.Query().Get("day")), v.EventID, dur)
151 if !ok {
152 return
153 }
154 // The booking's own snapshot, not the page's live text (B20).
155 d.Title, d.Description = v.Title, v.Description
156 d.Gone = r.URL.Query().Get("gone") == "1"
157 d.RescheduleToken, d.Rev, d.ZoneChosen = token, v.Event.Sequence, true
158 d.Path = "/book/m/" + token + "/reschedule"
159 render(w, "book_page", h.page(r, "Move "+v.Title, d))
160}
161
162// bookReschedule is POST /book/m/{token}/reschedule with start, zone, rev.
163func (h *handlers) bookReschedule(w http.ResponseWriter, r *http.Request) {
164 v, token, ok := h.byToken(w, r)
165 if !ok {
166 return
167 }
168 r.Body = http.MaxBytesReader(w, r.Body, 16<<10)
169 if err := r.ParseForm(); err != nil {
170 http.Error(w, "That form was too large.", http.StatusUnprocessableEntity)
171 return
172 }
173 start, okStart := parseUnix(r.PostFormValue("start"))
174 rev, _ := strconv.Atoi(r.PostFormValue("rev"))
175 if !okStart {
176 http.Redirect(w, r, "/book/m/"+token+"/reschedule", http.StatusSeeOther)
177 return
178 }
179 end := start + (v.Event.EndUTC - v.Event.StartUTC)
180 now := time.Now()
181 release, ok := acquireSlots()
182 if !ok {
183 busyNow(w)
184 return
185 }
186 changed, err := h.store.RescheduleBooking(r.Context(), v.ID, start, end, rev, now)
187 release()
188 switch {
189 case errors.Is(err, ErrSlotTaken):
190 http.Redirect(w, r, "/book/m/"+token+"/reschedule?gone=1", http.StatusSeeOther)
191 return
192 case errors.Is(err, ErrOverCap):
193 w.Header().Set("Retry-After", "86400")
194 http.Error(w, "This booking has been moved as many times as it can be. Cancel it and book again.", http.StatusTooManyRequests)
195 return
196 case errors.Is(err, ErrIncompleteExpansion):
197 w.Header().Set("Retry-After", "60")
198 http.Error(w, "This page cannot work out free time just now. Try again shortly.", http.StatusServiceUnavailable)
199 return
200 case err != nil:
201 h.fail(w, r, "reschedule", err)
202 return
203 }
204 if changed {
205 h.trySendNow(r.Context(), v.ID, now)
206 }
207 v, err = h.store.BookingByID(r.Context(), v.ID, false)
208 if err != nil {
209 h.fail(w, r, "re-read booking", err)
210 return
211 }
212 h.renderState(w, r, v, token)
213}
214
215// bookCancel is POST /book/m/{token}/cancel.
216func (h *handlers) bookCancel(w http.ResponseWriter, r *http.Request) {
217 v, token, ok := h.byToken(w, r)
218 if !ok {
219 return
220 }
221 now := time.Now()
222 changed, err := h.store.CancelBooking(r.Context(), v.ID, CancelledByBooker, now)
223 if err != nil {
224 h.fail(w, r, "cancel", err)
225 return
226 }
227 if changed {
228 h.trySendNow(r.Context(), v.ID, now)
229 }
230 v, err = h.store.BookingByID(r.Context(), v.ID, false)
231 if err != nil {
232 h.fail(w, r, "re-read booking", err)
233 return
234 }
235 h.renderState(w, r, v, token)
236}
237