diff --git a/features/admin/handlers.go b/features/admin/handlers.go index dede624..0f5934e 100644 --- a/features/admin/handlers.go +++ b/features/admin/handlers.go @@ -12,6 +12,7 @@ import ( "fmt" "log/slog" "net/http" + "net/url" "strconv" "strings" "time" @@ -257,6 +258,7 @@ func (h *Handlers) AdminEventEditPage(w http.ResponseWriter, r *http.Request) { StartTime: ev.StartTime.Format("2006-01-02T15:04"), EndTime: ev.EndTime.Format("2006-01-02T15:04"), ExpectedAttendees: ev.ExpectedAttendees, + Links: toEventLinks(ev.Links), QRToken: token, } @@ -266,8 +268,23 @@ func (h *Handlers) AdminEventEditPage(w http.ResponseWriter, r *http.Request) { } } -// AdminEventEditSave handles POST /admin/events/{token}/edit. +// toEventLinks maps stored event links to the view type. +func toEventLinks(links []event.Link) []pages.EventLink { + out := make([]pages.EventLink, 0, len(links)) + for _, l := range links { + out = append(out, pages.EventLink{Label: l.Label, URL: l.URL}) + } + return out +} + +// AdminEventEditSave handles POST /admin/events/{token}/edit. It rewrites both +// the organizer's PDS record and the local cache so the two stay in sync. func (h *Handlers) AdminEventEditSave(w http.ResponseWriter, r *http.Request) { + _, viewerSess, ok := h.Auth.RequireSession(w, r) + if !ok { + return + } + token := chi.URLParam(r, "token") ev, err := event.LookupByQRToken(r.Context(), h.DB, token) if err != nil { @@ -285,12 +302,16 @@ func (h *Handlers) AdminEventEditSave(w http.ResponseWriter, r *http.Request) { return } - if err := event.UpdateLocal(r.Context(), h.DB, ev.URI, event.UpdateInput{ + // Geofence isn't editable in the form, so carry the existing value through + // to avoid dropping it when the PDS record is rewritten. + if err := event.Update(r.Context(), viewerSess, h.DB, ev.URI, event.UpdateInput{ Name: in.Name, StartTime: in.StartTime, EndTime: in.EndTime, Location: in.Location, ExpectedAttendees: in.ExpectedAttendees, + Geofence: ev.Geofence, + Links: in.Links, }); err != nil { slog.Error("admin event edit save", "err", err) formView.Error = "failed to save: " + err.Error() @@ -492,6 +513,23 @@ func parseEventForm(r *http.Request) (event.CreateInput, pages.AdminEventFormVie ExpectedAttendees: in.ExpectedAttendees, } + // Parse up to MaxEventLinks {label,url} rows. A row needs a URL to be + // kept (the label is optional and falls back to the host at render); + // fully-empty rows are dropped. URLs must be http(s). + for i := 0; i < event.MaxEventLinks; i++ { + label := strings.TrimSpace(r.FormValue("link_label_" + strconv.Itoa(i))) + raw := strings.TrimSpace(r.FormValue("link_url_" + strconv.Itoa(i))) + if raw == "" { + continue + } + u, perr := url.Parse(raw) + if perr != nil || (u.Scheme != "http" && u.Scheme != "https") { + return in, view, "links must be http or https URLs" + } + in.Links = append(in.Links, event.Link{Label: label, URL: raw}) + view.Links = append(view.Links, pages.EventLink{Label: label, URL: raw}) + } + if in.Name == "" { return in, view, "event name is required" } diff --git a/features/admin/handlers_test.go b/features/admin/handlers_test.go new file mode 100644 index 0000000..45635df --- /dev/null +++ b/features/admin/handlers_test.go @@ -0,0 +1,66 @@ +package admin + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +func formRequest(values url.Values) *http.Request { + req := httptest.NewRequest("POST", "/admin/events", strings.NewReader(values.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + return req +} + +func baseEventForm() url.Values { + v := url.Values{} + v.Set("name", "CascadiaJS") + v.Set("start_time", "2026-05-31T09:00") + v.Set("end_time", "2026-05-31T17:00") + return v +} + +func TestParseEventForm_ParsesLinks(t *testing.T) { + v := baseEventForm() + v.Set("link_label_0", "Schedule") + v.Set("link_url_0", "https://example.com/s") + v.Set("link_label_1", "") // empty label, has URL -> kept (host fallback at render) + v.Set("link_url_1", "https://example.com/map") + v.Set("link_label_2", "Ghost") // label only, no URL -> dropped + v.Set("link_url_2", "") + + in, _, errMsg := parseEventForm(formRequest(v)) + if errMsg != "" { + t.Fatalf("unexpected error: %s", errMsg) + } + if len(in.Links) != 2 { + t.Fatalf("got %d links, want 2: %+v", len(in.Links), in.Links) + } + if in.Links[0].Label != "Schedule" || in.Links[0].URL != "https://example.com/s" { + t.Errorf("link[0] = %+v", in.Links[0]) + } + if in.Links[1].URL != "https://example.com/map" { + t.Errorf("link[1] = %+v", in.Links[1]) + } +} + +func TestParseEventForm_RejectsNonHTTPLink(t *testing.T) { + v := baseEventForm() + v.Set("link_url_0", "javascript:alert(1)") + _, _, errMsg := parseEventForm(formRequest(v)) + if errMsg == "" { + t.Fatal("expected error for non-http link, got none") + } +} + +func TestParseEventForm_NoLinks(t *testing.T) { + in, _, errMsg := parseEventForm(formRequest(baseEventForm())) + if errMsg != "" { + t.Fatalf("unexpected error: %s", errMsg) + } + if len(in.Links) != 0 { + t.Errorf("expected no links, got %+v", in.Links) + } +} diff --git a/features/admin/pages/events.templ b/features/admin/pages/events.templ index a267e64..f3d7757 100644 --- a/features/admin/pages/events.templ +++ b/features/admin/pages/events.templ @@ -1,6 +1,10 @@ package pages -import "atmoquest/features/common/layouts" +import ( + "strconv" + + "atmoquest/features/common/layouts" +) // AdminEventsView is the data for /admin/events. type AdminEventsView struct { @@ -91,11 +95,19 @@ type AdminEventFormView struct { StartTime string // datetime-local "2006-01-02T15:04" EndTime string ExpectedAttendees int + Links []EventLink Error string // Edit mode fields (empty for create). QRToken string // non-empty = editing } +// EventLink is a {label, url} pair shown in the admin event form's links +// editor. Mirrors event.Link for the view layer. +type EventLink struct { + Label string + URL string +} + templ AdminEventNew(v AdminEventFormView) { @layouts.Base("admin · new event", "Create a new atmo.quest event.") { @adminShell("events", "") { @@ -130,6 +142,7 @@ templ AdminEventNew(v AdminEventFormView) { + @eventLinksFieldset(v.Links)
- changes are saved to the local database only. the PDS record is not modified. + changes are written to your quest.atmo.event PDS record and the local cache.
} } } +// eventLinksFieldset renders the "links (up to 5)" editor, mirroring the +// profile links editor. Always renders MaxEventLinks rows so an organizer can +// add new links; populated rows are pre-filled from v.Links. +templ eventLinksFieldset(links []EventLink) { + +} + +// eventLinkRow renders a single label+URL row of the links editor. +templ eventLinkRow(idx int, l EventLink) { +creating an event writes a quest.atmo.event record to your PDS. you'll be taken to the badge designer next.
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\">") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = eventLinksFieldset(v.Links).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "creating an event writes a quest.atmo.event record to your PDS. you'll be taken to the badge designer next.
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -488,108 +508,116 @@ func AdminEventEdit(v AdminEventFormView) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var45 string - templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(v.EventURI) + var templ_7745c5c3_Var54 string + templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(v.EventURI) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 266, Col: 25} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 324, Col: 25} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if v.Design.Signature != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "● signed (key ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "
● signed (key ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var46 string - templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(v.Design.SigningKeyID) + var templ_7745c5c3_Var55 string + templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(v.Design.SigningKeyID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 270, Col: 70} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 328, Col: 70} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, ")
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, ")") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "public scan URL: ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var56 string
- templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(v.QRPublicURL)
+ var templ_7745c5c3_Var65 string
+ templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs(v.QRPublicURL)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 307, Col: 93}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 365, Col: 93}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "
saving signs this design with the server's admin key (ed25519). tampering with the row in the database will fail signature verification on next read.
no attendees to show.
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "no attendees to show.
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "🕐 busiest hour: ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, " 🕐 busiest hour: ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var41 string
- templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(v.Summary.PeakHour)
+ var templ_7745c5c3_Var43 string
+ templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(v.Summary.PeakHour)
if templ_7745c5c3_Err != nil {
- return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 333, Col: 52}
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 353, Col: 52}
}
- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "
event statistics will appear here once the event is finalized by an admin.
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "event statistics will appear here once the event is finalized by an admin.
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "▸ tap to flip
+ @layouts.CameraScanButton() + if v.DisplayName != "" {