| 1 | package calendar |
| 2 | |
| 3 | import ( |
| 4 | "net/http" |
| 5 | "strconv" |
| 6 | "strings" |
| 7 | "time" |
| 8 | |
| 9 | "github.com/go-chi/chi/v5" |
| 10 | ) |
| 11 | |
| 12 | // newEvent renders the create form, prefilled from the query so that a |
| 13 | // click on an empty slot arrives with its time already set. |
| 14 | func (h *handlers) newEvent(w http.ResponseWriter, r *http.Request) { |
| 15 | p, _ := CurrentPerson(r) |
| 16 | cals, err := h.store.Calendars(r.Context(), p.ID) |
| 17 | if err != nil { |
| 18 | h.fail(w, r, "list calendars", err) |
| 19 | return |
| 20 | } |
| 21 | q := r.URL.Query() |
| 22 | |
| 23 | f := eventForm{ |
| 24 | Action: "/events", |
| 25 | Title: "New event", |
| 26 | Heading: "New event", |
| 27 | Submit: "Create", |
| 28 | Calendars: writable(cals), |
| 29 | TZ: p.Zone, |
| 30 | Kind: KindMeeting, |
| 31 | Status: StatusConfirmed, |
| 32 | Repeat: "none", |
| 33 | } |
| 34 | f.CalendarID, _ = strconv.ParseInt(q.Get("calendar"), 10, 64) |
| 35 | if f.CalendarID == 0 { |
| 36 | f.CalendarID, _ = h.store.DefaultCalendarID(r.Context(), p.ID) |
| 37 | } |
| 38 | f.StartLocal = q.Get("start") |
| 39 | f.EndLocal = q.Get("end") |
| 40 | if f.StartLocal == "" { |
| 41 | // No slot was clicked: offer the next half hour, which is what |
| 42 | // somebody creating an event from the toolbar usually means. |
| 43 | loc, err := Zone(p.Zone) |
| 44 | if err != nil { |
| 45 | loc = time.UTC |
| 46 | } |
| 47 | next := time.Now().In(loc).Truncate(30 * time.Minute).Add(30 * time.Minute) |
| 48 | f.StartLocal = next.Format(LayoutLocal) |
| 49 | f.EndLocal = next.Add(time.Hour).Format(LayoutLocal) |
| 50 | } |
| 51 | if d := q.Get("date"); d != "" { |
| 52 | f.AllDay = true |
| 53 | f.StartLocal = d |
| 54 | if t, err := ParseDate(d, p.Zone); err == nil { |
| 55 | f.EndLocal = t.AddDate(0, 0, 1).Format(LayoutDate) |
| 56 | } |
| 57 | } |
| 58 | render(w, "event_form", h.page(r, f.Title, f)) |
| 59 | } |
| 60 | |
| 61 | // createEvent handles POST /events. |
| 62 | func (h *handlers) createEvent(w http.ResponseWriter, r *http.Request) { |
| 63 | p, _ := CurrentPerson(r) |
| 64 | in, f, ok := h.readEventForm(r, p) |
| 65 | if !ok { |
| 66 | f.Action, f.Title, f.Heading, f.Submit = "/events", "New event", "New event", "Create" |
| 67 | cals, _ := h.store.Calendars(r.Context(), p.ID) |
| 68 | f.Calendars = writable(cals) |
| 69 | w.WriteHeader(http.StatusUnprocessableEntity) |
| 70 | render(w, "event_form", h.page(r, f.Title, f)) |
| 71 | return |
| 72 | } |
| 73 | e, err := h.store.CreateEvent(r.Context(), p.ID, in) |
| 74 | switch { |
| 75 | case NotFound(err): |
| 76 | h.errorPage(w, r, http.StatusNotFound, "") |
| 77 | return |
| 78 | case err != nil: |
| 79 | if ve, isVE := Invalid(err); isVE { |
| 80 | f.Error, f.ErrorField = ve.Message, ve.Field |
| 81 | cals, _ := h.store.Calendars(r.Context(), p.ID) |
| 82 | f.Action, f.Title, f.Heading, f.Submit = "/events", "New event", "New event", "Create" |
| 83 | f.Calendars = writable(cals) |
| 84 | w.WriteHeader(http.StatusUnprocessableEntity) |
| 85 | render(w, "event_form", h.page(r, f.Title, f)) |
| 86 | return |
| 87 | } |
| 88 | h.fail(w, r, "create event", err) |
| 89 | return |
| 90 | } |
| 91 | // A default reminder for the person who created it, using their own |
| 92 | // preference for the kind of thing it is. |
| 93 | if err := h.store.EnsureDefaultReminder(r.Context(), p, e); err != nil { |
| 94 | h.logger.Error("calendar: default reminder", "err", err) |
| 95 | } |
| 96 | http.Redirect(w, r, "/events/"+strconv.FormatInt(e.ID, 10), http.StatusSeeOther) |
| 97 | } |
| 98 | |
| 99 | // showEvent renders one event, or one occurrence of a series. |
| 100 | func (h *handlers) showEvent(w http.ResponseWriter, r *http.Request) { |
| 101 | p, _ := CurrentPerson(r) |
| 102 | id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) |
| 103 | if err != nil { |
| 104 | h.errorPage(w, r, http.StatusNotFound, "") |
| 105 | return |
| 106 | } |
| 107 | e, err := h.store.Event(r.Context(), p.ID, id) |
| 108 | if NotFound(err) { |
| 109 | h.errorPage(w, r, http.StatusNotFound, "") |
| 110 | return |
| 111 | } |
| 112 | if err != nil { |
| 113 | h.fail(w, r, "read event", err) |
| 114 | return |
| 115 | } |
| 116 | |
| 117 | // An occurrence URL carries which one, so "edit this one" knows |
| 118 | // what it is editing. |
| 119 | occurrence := "" |
| 120 | if raw := chi.URLParam(r, "occ"); raw != "" { |
| 121 | local, ok := expandCompact(raw) |
| 122 | if !ok { |
| 123 | h.errorPage(w, r, http.StatusNotFound, "") |
| 124 | return |
| 125 | } |
| 126 | occurrence = local |
| 127 | } |
| 128 | |
| 129 | reminders, err := h.store.RemindersFor(r.Context(), p.ID, e.ID) |
| 130 | if err != nil { |
| 131 | h.fail(w, r, "read reminders", err) |
| 132 | return |
| 133 | } |
| 134 | |
| 135 | // Scoped already: Event above 404s a row that is not this |
| 136 | // person's, so the attendee read needs no grant join of its own. |
| 137 | attendees, err := h.store.AttendeesFor(r.Context(), e.ID) |
| 138 | if err != nil { |
| 139 | h.fail(w, r, "read attendees", err) |
| 140 | return |
| 141 | } |
| 142 | |
| 143 | bookedBy, booked := h.store.BookingSummaryFor(r.Context(), e.ID) |
| 144 | data := map[string]any{ |
| 145 | "Booked": booked, |
| 146 | "BookedBy": bookedBy, |
| 147 | "Event": e, |
| 148 | "Chip": chipFor(e, p.Zone, time.Now().Unix()), |
| 149 | "Occurrence": occurrence, |
| 150 | "Reminders": reminders, |
| 151 | "Attendees": attendees, |
| 152 | "Zone": p.Zone, |
| 153 | "RuleText": DescribeRule(e.RRule), |
| 154 | "When": spanWords(e.StartUTC, e.EndUTC, p.Zone), |
| 155 | } |
| 156 | render(w, "event", h.page(r, e.Title, data)) |
| 157 | } |
| 158 | |
| 159 | // editEvent renders the edit form. |
| 160 | func (h *handlers) editEvent(w http.ResponseWriter, r *http.Request) { |
| 161 | p, _ := CurrentPerson(r) |
| 162 | id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) |
| 163 | if err != nil { |
| 164 | h.errorPage(w, r, http.StatusNotFound, "") |
| 165 | return |
| 166 | } |
| 167 | e, err := h.store.Event(r.Context(), p.ID, id) |
| 168 | if NotFound(err) { |
| 169 | h.errorPage(w, r, http.StatusNotFound, "") |
| 170 | return |
| 171 | } |
| 172 | if err != nil { |
| 173 | h.fail(w, r, "read event", err) |
| 174 | return |
| 175 | } |
| 176 | cals, err := h.store.Calendars(r.Context(), p.ID) |
| 177 | if err != nil { |
| 178 | h.fail(w, r, "list calendars", err) |
| 179 | return |
| 180 | } |
| 181 | bookedBy, booked := h.store.BookingSummaryFor(r.Context(), e.ID) |
| 182 | render(w, "event_form", h.page(r, "Edit", eventForm{ |
| 183 | Booked: booked, |
| 184 | BookedBy: bookedBy, |
| 185 | Action: "/events/" + strconv.FormatInt(e.ID, 10), |
| 186 | Title: "Edit", |
| 187 | Heading: "Edit event", |
| 188 | Submit: "Save", |
| 189 | Calendars: writable(cals), |
| 190 | EventID: e.ID, |
| 191 | CalendarID: e.CalendarID, |
| 192 | EventTitle: e.Title, |
| 193 | Location: e.Location, |
| 194 | Notes: e.Notes, |
| 195 | AllDay: e.AllDay, |
| 196 | StartLocal: e.StartLocal, |
| 197 | EndLocal: e.EndLocal, |
| 198 | TZ: e.TZ, |
| 199 | Kind: e.Kind, |
| 200 | Status: e.Status, |
| 201 | Repeat: RepeatFromRule(e.RRule), |
| 202 | Series: e.RRule != "", |
| 203 | Occurrence: r.URL.Query().Get("occurrence"), |
| 204 | })) |
| 205 | } |
| 206 | |
| 207 | // updateEvent handles POST /events/{id}. |
| 208 | func (h *handlers) updateEvent(w http.ResponseWriter, r *http.Request) { |
| 209 | p, _ := CurrentPerson(r) |
| 210 | id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) |
| 211 | if err != nil { |
| 212 | h.errorPage(w, r, http.StatusNotFound, "") |
| 213 | return |
| 214 | } |
| 215 | in, f, ok := h.readEventForm(r, p) |
| 216 | if !ok { |
| 217 | h.rerenderEdit(w, r, p, id, f) |
| 218 | return |
| 219 | } |
| 220 | // Which occurrences the edit applies to. Only meaningful on a |
| 221 | // series, and "this one" is the default — an edit that silently |
| 222 | // changed every occurrence would be the most expensive default in |
| 223 | // the app. |
| 224 | switch occ := strings.TrimSpace(r.FormValue("occurrence")); r.FormValue("scope") { |
| 225 | case ScopeThis: |
| 226 | if occ != "" { |
| 227 | err = h.store.UpdateOccurrence(r.Context(), p.ID, id, occ, in) |
| 228 | break |
| 229 | } |
| 230 | err = h.store.UpdateEvent(r.Context(), p.ID, id, in) |
| 231 | case ScopeFollowing: |
| 232 | if occ != "" { |
| 233 | err = h.store.SplitSeries(r.Context(), p.ID, id, occ, in) |
| 234 | break |
| 235 | } |
| 236 | err = h.store.UpdateEvent(r.Context(), p.ID, id, in) |
| 237 | default: |
| 238 | err = h.store.UpdateEvent(r.Context(), p.ID, id, in) |
| 239 | } |
| 240 | if ve, isVE := Invalid(err); isVE { |
| 241 | // The store refused — a booked event's locked field, or a |
| 242 | // status the booking's state does not allow. Back to the form |
| 243 | // with the message beside the field. |
| 244 | f.Error, f.ErrorField = ve.Message, ve.Field |
| 245 | h.rerenderEdit(w, r, p, id, f) |
| 246 | return |
| 247 | } |
| 248 | switch { |
| 249 | case NotFound(err): |
| 250 | h.errorPage(w, r, http.StatusNotFound, "") |
| 251 | case err == ErrReadOnly: |
| 252 | h.errorPage(w, r, http.StatusForbidden, "") |
| 253 | case err != nil: |
| 254 | h.fail(w, r, "update event", err) |
| 255 | default: |
| 256 | http.Redirect(w, r, "/events/"+strconv.FormatInt(id, 10), http.StatusSeeOther) |
| 257 | } |
| 258 | } |
| 259 | |
| 260 | // rerenderEdit puts the edit form back with what was typed and why it |
| 261 | // was refused, at 422. |
| 262 | func (h *handlers) rerenderEdit(w http.ResponseWriter, r *http.Request, p Person, id int64, f eventForm) { |
| 263 | f.Action = "/events/" + strconv.FormatInt(id, 10) |
| 264 | f.Title, f.Heading, f.Submit = "Edit", "Edit event", "Save" |
| 265 | f.EventID = id |
| 266 | cals, _ := h.store.Calendars(r.Context(), p.ID) |
| 267 | f.Calendars = writable(cals) |
| 268 | f.BookedBy, f.Booked = h.store.BookingSummaryFor(r.Context(), id) |
| 269 | w.WriteHeader(http.StatusUnprocessableEntity) |
| 270 | render(w, "event_form", h.page(r, f.Title, f)) |
| 271 | } |
| 272 | |