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 "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.
14func (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.
62func (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.
100func (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.
160func (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}.
208func (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.
262func (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