| 1 | package calendar |
| 2 | |
| 3 | import ( |
| 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. |
| 18 | func (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. |
| 35 | func (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 | |
| 52 | func 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. |
| 65 | func (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). |
| 73 | func (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. |
| 82 | func (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. |
| 106 | func (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. |
| 126 | func (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. |
| 163 | func (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. |
| 216 | func (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 | |