diff --git a/features/admin/handlers.go b/features/admin/handlers.go index ec22fc1..c341054 100644 --- a/features/admin/handlers.go +++ b/features/admin/handlers.go @@ -27,6 +27,7 @@ import ( "atmoquest/internal/checkin" "atmoquest/internal/connection" "atmoquest/internal/event" + "atmoquest/internal/profile" "atmoquest/internal/users" ) @@ -241,6 +242,67 @@ func (h *Handlers) AdminEventCreate(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/events/"+qr+"/badge", http.StatusSeeOther) } +// AdminEventEditPage renders the edit form for an existing event. +func (h *Handlers) AdminEventEditPage(w http.ResponseWriter, r *http.Request) { + token := chi.URLParam(r, "token") + ev, err := event.LookupByQRToken(r.Context(), h.DB, token) + if err != nil { + http.NotFound(w, r) + return + } + + view := pages.AdminEventFormView{ + Name: ev.Name, + Location: ev.Location, + StartTime: ev.StartTime.Format("2006-01-02T15:04"), + EndTime: ev.EndTime.Format("2006-01-02T15:04"), + ExpectedAttendees: ev.ExpectedAttendees, + QRToken: token, + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := pages.AdminEventEdit(view).Render(r.Context(), w); err != nil { + slog.Error("render admin event edit", "err", err) + } +} + +// AdminEventEditSave handles POST /admin/events/{token}/edit. +func (h *Handlers) AdminEventEditSave(w http.ResponseWriter, r *http.Request) { + token := chi.URLParam(r, "token") + ev, err := event.LookupByQRToken(r.Context(), h.DB, token) + if err != nil { + http.NotFound(w, r) + return + } + + in, formView, perr := parseEventForm(r) + formView.QRToken = token + if perr != "" { + formView.Error = perr + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusBadRequest) + _ = pages.AdminEventEdit(formView).Render(r.Context(), w) + return + } + + if err := event.UpdateLocal(r.Context(), h.DB, ev.URI, event.UpdateInput{ + Name: in.Name, + StartTime: in.StartTime, + EndTime: in.EndTime, + Location: in.Location, + ExpectedAttendees: in.ExpectedAttendees, + }); err != nil { + slog.Error("admin event edit save", "err", err) + formView.Error = "failed to save: " + err.Error() + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusInternalServerError) + _ = pages.AdminEventEdit(formView).Render(r.Context(), w) + return + } + + http.Redirect(w, r, "/admin/events", http.StatusSeeOther) +} + // AdminEventBadge renders the badge designer for an event, pre-loaded with // any existing design. func (h *Handlers) AdminEventBadge(w http.ResponseWriter, r *http.Request) { @@ -591,6 +653,19 @@ func (h *Handlers) AdminEventFinalize(w http.ResponseWriter, r *http.Request) { } } + // Compute and cache event summary statistics. + profileFetcher := func(ctx context.Context, pds string, did syntax.DID) []string { + q, err := profile.FetchQuest(ctx, pds, did) + if err != nil || q == nil { + return nil + } + return q.Interests + } + pdsLookupFn := func(did syntax.DID) string { return lookupPDSForDIDAdmin(ctx, h.DB, did) } + if err := event.ComputeAndCacheSummary(ctx, h.DB, ev.URI, attendeeDIDs, ev, pdsLookupFn, profileFetcher); err != nil { + slog.Warn("finalize: compute summary", "err", err) + } + slog.Info("admin event finalized", "uri", ev.URI, "name", ev.Name, "attendees", len(attendeeDIDs), "leaderboard_entries", len(leaderboard)) http.Redirect(w, r, "/admin/events", http.StatusSeeOther) diff --git a/features/admin/pages/events.templ b/features/admin/pages/events.templ index 1d2dd1e..c879f07 100644 --- a/features/admin/pages/events.templ +++ b/features/admin/pages/events.templ @@ -36,16 +36,20 @@ templ AdminEvents(v AdminEventsView) { name location - start → end + dates expected - QR url - + actions for _, ev := range v.Events { - { ev.Name } + + { ev.Name } +
+ { ev.QRPublicURL } +
+ if ev.Location != "" { { ev.Location } @@ -53,18 +57,20 @@ templ AdminEvents(v AdminEventsView) { — } - { ev.StartTime }  →  { ev.EndTime } + { ev.StartTime }
→ { ev.EndTime } { intStr(ev.ExpectedAttendees) } - { ev.QRPublicURL } - - QR ↓ - badge → -
- -
-
- -
+ +
+ edit + QR ↓ + badge +
+ +
+
+ +
+
} @@ -85,6 +91,8 @@ type AdminEventFormView struct { EndTime string ExpectedAttendees int Error string + // Edit mode fields (empty for create). + QRToken string // non-empty = editing } templ AdminEventNew(v AdminEventFormView) { @@ -134,6 +142,52 @@ templ AdminEventNew(v AdminEventFormView) { } } +templ AdminEventEdit(v AdminEventFormView) { + @layouts.Base("admin · edit event", "Edit an event.") { + @adminShell("events", "") { +

edit event

+ + if v.Error != "" { + + } + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + cancel +
+

+ changes are saved to the local database only. the PDS record is not modified. +

+
+ } + } +} + // AdminEventBadgeView feeds the badge designer page. type AdminEventBadgeView struct { EventURI string diff --git a/features/admin/pages/events_templ.go b/features/admin/pages/events_templ.go index 5992073..91a8127 100644 --- a/features/admin/pages/events_templ.go +++ b/features/admin/pages/events_templ.go @@ -83,7 +83,7 @@ func AdminEvents(v AdminEventsView) templ.Component { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
namelocationstart → endexpectedQR url
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -95,155 +95,168 @@ func AdminEvents(v AdminEventsView) templ.Component { var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(ev.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 48, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 48, Col: 27} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" style=\"display:contents\" onsubmit=\"return confirm('Award end-of-event badges?')\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
namelocationdatesexpectedactions
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" style=\"color:var(--lavender)\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var6 string - templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(ev.StartTime) + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(ev.QRPublicURL) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 56, Col: 48} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 50, Col: 113} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "  →  ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var7 string - templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(ev.EndTime) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 56, Col: 79} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err + if ev.Location != "" { + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(ev.Location) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 55, Col: 24} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "—") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var8 string - templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(intStr(ev.ExpectedAttendees)) + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(ev.StartTime) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 57, Col: 55} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 60, Col: 48} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "→ ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var9 templ.SafeURL - templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(ev.QRPublicURL)) + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(ev.EndTime) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 58, Col: 66} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 60, Col: 71} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\">") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var10 string - templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(ev.QRPublicURL) + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(intStr(ev.ExpectedAttendees)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 58, Col: 85} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 61, Col: 55} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -273,6 +286,8 @@ type AdminEventFormView struct { EndTime string ExpectedAttendees int Error string + // Edit mode fields (empty for create). + QRToken string // non-empty = editing } func AdminEventNew(v AdminEventFormView) templ.Component { @@ -291,12 +306,12 @@ func AdminEventNew(v AdminEventFormView) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var15 := templ.GetChildren(ctx) - if templ_7745c5c3_Var15 == nil { - templ_7745c5c3_Var15 = templ.NopComponent + templ_7745c5c3_Var16 := templ.GetChildren(ctx) + if templ_7745c5c3_Var16 == nil { + templ_7745c5c3_Var16 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Var16 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_Var17 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { @@ -308,7 +323,7 @@ func AdminEventNew(v AdminEventFormView) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var17 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_Var18 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { @@ -320,107 +335,273 @@ func AdminEventNew(v AdminEventFormView) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "

create event

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "

create event

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if v.Error != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
heads up: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
heads up: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var18 string - templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(v.Error) + var templ_7745c5c3_Var19 string + templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(v.Error) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 97, Col: 63} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 105, Col: 63} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
cancel

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, 26, "\">
cancel

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 + } + return nil + }) + templ_7745c5c3_Err = adminShell("events", "").Render(templ.WithChildren(ctx, templ_7745c5c3_Var18), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = layouts.Base("admin · new event", "Create a new atmo.quest event.").Render(templ.WithChildren(ctx, templ_7745c5c3_Var17), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func AdminEventEdit(v AdminEventFormView) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var25 := templ.GetChildren(ctx) + if templ_7745c5c3_Var25 == nil { + templ_7745c5c3_Var25 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var26 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var27 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "

edit event

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if v.Error != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
heads up: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var28 string + templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(v.Error) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 152, Col: 63} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
cancel

changes are saved to the local database only. the PDS record is not modified.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) - templ_7745c5c3_Err = adminShell("events", "").Render(templ.WithChildren(ctx, templ_7745c5c3_Var17), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = adminShell("events", "").Render(templ.WithChildren(ctx, templ_7745c5c3_Var27), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) - templ_7745c5c3_Err = layouts.Base("admin · new event", "Create a new atmo.quest event.").Render(templ.WithChildren(ctx, templ_7745c5c3_Var16), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = layouts.Base("admin · edit event", "Edit an event.").Render(templ.WithChildren(ctx, templ_7745c5c3_Var26), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -481,12 +662,12 @@ func AdminEventBadge(v AdminEventBadgeView) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var24 := templ.GetChildren(ctx) - if templ_7745c5c3_Var24 == nil { - templ_7745c5c3_Var24 = templ.NopComponent + templ_7745c5c3_Var35 := templ.GetChildren(ctx) + if templ_7745c5c3_Var35 == nil { + templ_7745c5c3_Var35 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Var25 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_Var36 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { @@ -498,7 +679,7 @@ func AdminEventBadge(v AdminEventBadgeView) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var26 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_Var37 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { @@ -510,140 +691,140 @@ func AdminEventBadge(v AdminEventBadgeView) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "

badge designer — ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "

badge designer — ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var27 string - templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(v.EventName) + var templ_7745c5c3_Var38 string + templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(v.EventName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 177, Col: 67} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 231, Col: 67} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(v.EventBadges) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
Event badges — click to design
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "
Event badges — click to design
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, eb := range v.EventBadges { - var templ_7745c5c3_Var28 = []any{"settings-row", templ.KV("active-badge-type", eb.Slug == v.SelectedBadgeType)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var28...) + var templ_7745c5c3_Var39 = []any{"settings-row", templ.KV("active-badge-type", eb.Slug == v.SelectedBadgeType)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var39...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if eb.Designed { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "◆") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "◆") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "◇") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "◇") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var31 string - templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(eb.Name) + var templ_7745c5c3_Var42 string + templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(eb.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 193, Col: 50} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 247, Col: 50} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var32 string - templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(eb.Description) + var templ_7745c5c3_Var43 string + templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(eb.Description) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 194, Col: 56} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 248, Col: 56} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) + _, 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, 37, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if eb.Designed { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var33 string - templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(eb.Label) + var templ_7745c5c3_Var44 string + templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(eb.Label) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 197, Col: 57} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 251, Col: 57} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "
not designed
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "
not designed
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -651,138 +832,138 @@ func AdminEventBadge(v AdminEventBadgeView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var34 string - templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(v.EventURI) + var templ_7745c5c3_Var45 string + templ_7745c5c3_Var45, 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: 211, Col: 25} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 265, Col: 25} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if v.Design.Signature != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "

● signed (key ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "

● signed (key ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var35 string - templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(v.Design.SigningKeyID) + var templ_7745c5c3_Var46 string + templ_7745c5c3_Var46, 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: 215, Col: 70} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 269, Col: 70} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, ")

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, ")

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, s := range v.Shapes { if s == v.Design.Shape { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -798,71 +979,71 @@ func AdminEventBadge(v AdminEventBadgeView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "
shown when someone views their badge
← all events

public scan URL: shown when someone views their badge

← all events

public scan URL: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var45 string - templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(v.QRPublicURL) + var templ_7745c5c3_Var56 string + templ_7745c5c3_Var56, 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: 252, Col: 93} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/admin/pages/events.templ`, Line: 306, Col: 93} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "
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.

") + 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.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) - templ_7745c5c3_Err = adminShell("events", "").Render(templ.WithChildren(ctx, templ_7745c5c3_Var26), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = adminShell("events", "").Render(templ.WithChildren(ctx, templ_7745c5c3_Var37), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) - templ_7745c5c3_Err = layouts.Base("admin · badge designer", "Design an event badge.").Render(templ.WithChildren(ctx, templ_7745c5c3_Var25), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = layouts.Base("admin · badge designer", "Design an event badge.").Render(templ.WithChildren(ctx, templ_7745c5c3_Var36), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -887,128 +1068,128 @@ func colorSwatchField(name, label string, options []string, selected string) tem }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var46 := templ.GetChildren(ctx) - if templ_7745c5c3_Var46 == nil { - templ_7745c5c3_Var46 = templ.NopComponent + templ_7745c5c3_Var57 := templ.GetChildren(ctx) + if templ_7745c5c3_Var57 == nil { + templ_7745c5c3_Var57 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/features/admin/routes.go b/features/admin/routes.go index 5fc2d3d..3af4d9b 100644 --- a/features/admin/routes.go +++ b/features/admin/routes.go @@ -31,6 +31,8 @@ func SetupRoutes(router chi.Router, conn *sql.DB, authH *auth.Handlers, signer * r.Get("/events", h.AdminEvents) r.Get("/events/new", h.AdminEventNew) r.Post("/events", h.AdminEventCreate) + r.Get("/events/{token}/edit", h.AdminEventEditPage) + r.Post("/events/{token}/edit", h.AdminEventEditSave) r.Get("/events/{token}/badge", h.AdminEventBadge) r.Post("/events/{token}/badge", h.AdminEventBadgeSave) r.Get("/badge-preview.svg", h.BadgePreviewSVG) diff --git a/features/api/api.go b/features/api/api.go new file mode 100644 index 0000000..6ec8109 --- /dev/null +++ b/features/api/api.go @@ -0,0 +1,53 @@ +// Package api implements the JSON API layer at /api/v1/ for the mobile app. +// +// All handlers return JSON and accept Bearer token auth (or session cookie). +// This package mirrors the same business logic as the HTML handlers but exposes +// it through a structured JSON contract. +package api + +import ( + "database/sql" + "encoding/json" + "log/slog" + "net/http" + + "atmoquest/features/auth" + "atmoquest/internal/apitoken" + "atmoquest/internal/connection" + "atmoquest/internal/push" +) + +// Handlers holds the shared dependencies for all API endpoints. +type Handlers struct { + DB *sql.DB + Auth *auth.Handlers + ConnQueue *connection.Queue + Tokens *apitoken.Generator + Push *push.Sender +} + +// NewHandlers wires the API feature. +func NewHandlers(conn *sql.DB, authH *auth.Handlers, connQueue *connection.Queue, tokens *apitoken.Generator, pusher *push.Sender) *Handlers { + return &Handlers{DB: conn, Auth: authH, ConnQueue: connQueue, Tokens: tokens, Push: pusher} +} + +// --- JSON helpers ----------------------------------------------------------- + +// writeJSON encodes v as JSON with the given status code. +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(v); err != nil { + slog.Warn("api: encode response", "err", err) + } +} + +// writeError writes a JSON error response. +func writeError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} + +// decodeBody reads a JSON request body, capped at 64 KB. +func decodeBody(r *http.Request, v any) error { + return json.NewDecoder(http.MaxBytesReader(nil, r.Body, 64*1024)).Decode(v) +} diff --git a/features/api/auth.go b/features/api/auth.go new file mode 100644 index 0000000..4f0cf59 --- /dev/null +++ b/features/api/auth.go @@ -0,0 +1,118 @@ +package api + +import ( + "log/slog" + "net/http" + "strings" + + "atmoquest/internal/connection" + "atmoquest/internal/users" +) + +// sessionResponse is the JSON shape for GET /api/v1/auth/session. +type sessionResponse struct { + DID string `json:"did"` + Handle string `json:"handle"` + IsAdmin bool `json:"isAdmin"` +} + +// GetSession returns the current authenticated user's session info. +// GET /api/v1/auth/session +func (h *Handlers) GetSession(w http.ResponseWriter, r *http.Request) { + did, ok := h.requireAuth(w, r) + if !ok { + return + } + + handle := h.resolveHandle(r, did.String()) + + isAdmin, _ := users.IsAdmin(r.Context(), h.DB, did) + + writeJSON(w, http.StatusOK, sessionResponse{ + DID: did.String(), + Handle: handle, + IsAdmin: isAdmin, + }) +} + +// OAuthLogin starts the OAuth flow for mobile clients. +// POST /api/v1/oauth/login +// +// Request: {"handle": "alice.bsky.social"} +// Response: {"authUrl": "https://...", "state": "..."} +func (h *Handlers) OAuthLogin(w http.ResponseWriter, r *http.Request) { + var body struct { + Handle string `json:"handle"` + } + if err := decodeBody(r, &body); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON") + return + } + + identifier := strings.TrimSpace(body.Handle) + if identifier == "" { + writeError(w, http.StatusBadRequest, "handle is required") + return + } + + redirectURL, err := h.Auth.OAuth.StartAuthFlow(r.Context(), identifier) + if err != nil { + slog.Warn("api: oauth start", + "identifier", identifier, + "err", err, + ) + writeError(w, http.StatusBadRequest, "couldn't start sign-in") + return + } + + writeJSON(w, http.StatusOK, map[string]string{ + "authUrl": redirectURL, + }) +} + +// OAuthCallback completes the OAuth flow for mobile clients. +// GET /api/v1/oauth/callback?code=...&state=... +// +// Response: {"token": "...", "did": "...", "handle": "..."} +func (h *Handlers) OAuthCallback(w http.ResponseWriter, r *http.Request) { + sessData, err := h.Auth.OAuth.ProcessCallback(r.Context(), r.URL.Query()) + if err != nil { + slog.Warn("api: oauth callback", "err", err) + writeError(w, http.StatusBadRequest, "sign-in failed") + return + } + + // Issue a Bearer token for the mobile app. + token := h.Tokens.Issue(sessData.AccountDID.String(), sessData.SessionID) + + // Best-effort: record login in users table. + go h.Auth.RecordUserLogin(sessData) + + // Best-effort: drain pending connections. + if h.ConnQueue != nil { + sess, err := h.Auth.OAuth.ResumeSession(r.Context(), sessData.AccountDID, sessData.SessionID) + if err == nil { + res, err := connection.Drain(r.Context(), h.ConnQueue, sess, sess.Data.HostURL, slog.Default(), nil) + if err != nil { + slog.Warn("api: connect drain", "did", sessData.AccountDID.String(), "err", err) + } else if res.Written > 0 || res.Skipped > 0 { + slog.Info("api: connect drain", "did", sessData.AccountDID.String(), "written", res.Written, "skipped", res.Skipped) + } + } + } + + handle := h.resolveHandle(r, sessData.AccountDID.String()) + + writeJSON(w, http.StatusOK, map[string]string{ + "token": token, + "did": sessData.AccountDID.String(), + "handle": handle, + }) +} + +// Logout revokes the current session. +// POST /api/v1/oauth/logout +func (h *Handlers) Logout(w http.ResponseWriter, r *http.Request) { + h.Auth.Sessions.Clear(w, r) + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} diff --git a/features/api/badges.go b/features/api/badges.go new file mode 100644 index 0000000..b59d8b8 --- /dev/null +++ b/features/api/badges.go @@ -0,0 +1,83 @@ +package api + +import ( + "net/http" + + "atmoquest/internal/badge" +) + +// badgeResponse is the JSON shape for a badge. +type badgeResponse struct { + Name string `json:"name"` + EarnedAt string `json:"earnedAt"` +} + +// ListBadges returns all badges earned by the authenticated user. +// GET /api/v1/badges +func (h *Handlers) ListBadges(w http.ResponseWriter, r *http.Request) { + did, ok := h.requireAuth(w, r) + if !ok { + return + } + + rows, err := h.DB.QueryContext(r.Context(), ` + SELECT d.name, e.earned_at + FROM earned_badges e + JOIN badge_definitions d ON d.id = e.badge_id + WHERE e.did = ? + ORDER BY e.earned_at DESC + `, did.String()) + if err != nil { + writeJSON(w, http.StatusOK, []badgeResponse{}) + return + } + defer rows.Close() + + resp := make([]badgeResponse, 0) + for rows.Next() { + var b badgeResponse + if rows.Scan(&b.Name, &b.EarnedAt) == nil { + resp = append(resp, b) + } + } + + writeJSON(w, http.StatusOK, resp) +} + +// RecentBadges returns badges earned in the last 60 seconds. +// GET /api/v1/badges/recent +func (h *Handlers) RecentBadges(w http.ResponseWriter, r *http.Request) { + did, ok := h.requireAuth(w, r) + if !ok { + return + } + + rows, err := h.DB.QueryContext(r.Context(), ` + SELECT d.name FROM earned_badges e + JOIN badge_definitions d ON d.id = e.badge_id + WHERE e.did = ? AND e.earned_at >= datetime('now', '-60 seconds') + ORDER BY e.earned_at DESC + `, did.String()) + if err != nil { + writeJSON(w, http.StatusOK, map[string]any{"badges": []string{}}) + return + } + defer rows.Close() + + var names []string + for rows.Next() { + var name string + if rows.Scan(&name) == nil { + names = append(names, name) + } + } + if names == nil { + names = []string{} + } + + w.Header().Set("Cache-Control", "no-cache") + writeJSON(w, http.StatusOK, map[string]any{"badges": names}) +} + +// Ensure badge package is reachable (used in future device/push handlers). +var _ = badge.Design{} diff --git a/features/api/connections.go b/features/api/connections.go new file mode 100644 index 0000000..31e54dd --- /dev/null +++ b/features/api/connections.go @@ -0,0 +1,213 @@ +package api + +import ( + "log/slog" + "net/http" + "net/url" + "time" + + "github.com/bluesky-social/indigo/atproto/syntax" + + "atmoquest/internal/connection" + "atmoquest/internal/notes" +) + +// connectionResponse is one item in the connections list. +type connectionResponse struct { + DID string `json:"did"` + Handle string `json:"handle"` + DisplayName string `json:"displayName"` + ConnectedAt string `json:"connectedAt"` +} + +// connectionNotesResponse is the response for notes endpoints. +type connectionNotesResponse struct { + Notes string `json:"notes"` + FollowUp bool `json:"followUp"` + UpdatedAt string `json:"updatedAt"` +} + +// ListConnections returns all connections for the authenticated user. +// GET /api/v1/connections +func (h *Handlers) ListConnections(w http.ResponseWriter, r *http.Request) { + did, ok := h.requireAuth(w, r) + if !ok { + return + } + + pds := h.lookupPDS(r, did) + entries, err := connection.List(r.Context(), pds, did) + if err != nil { + slog.Warn("api: list connections", "did", did, "err", err) + writeJSON(w, http.StatusOK, []connectionResponse{}) + return + } + + resp := make([]connectionResponse, 0, len(entries)) + for _, e := range entries { + resp = append(resp, connectionResponse{ + DID: e.With.String(), + Handle: h.resolveHandle(r, e.With.String()), + DisplayName: "", + ConnectedAt: e.ConnectedAt.Format(time.RFC3339), + }) + } + + writeJSON(w, http.StatusOK, resp) +} + +// CreateConnection writes a reciprocal connection record. +// POST /api/v1/connections/{did} +func (h *Handlers) CreateConnection(w http.ResponseWriter, r *http.Request) { + did, sess, ok := h.requireSession(w, r) + if !ok { + return + } + + targetStr, _ := url.PathUnescape(r.PathValue("did")) + targetDID, err := syntax.ParseDID(targetStr) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid target DID") + return + } + + if targetDID == did { + writeError(w, http.StatusBadRequest, "cannot connect with yourself") + return + } + + rec := connection.Record{ + With: targetDID, + EventURI: r.URL.Query().Get("event"), + } + + uri, _, err := connection.Put(r.Context(), sess, rec) + if err != nil { + slog.Warn("api: create connection", "from", did, "to", targetDID, "err", err) + writeError(w, http.StatusInternalServerError, "failed to create connection") + return + } + + // Enqueue reciprocal write. + if err := h.ConnQueue.Enqueue(r.Context(), targetDID, did, rec.EventURI); err != nil { + slog.Warn("api: enqueue reciprocal", "target", targetDID, "err", err) + } + + // Notify the target user of the new connection. + h.notifyNewConnection(targetDID.String(), h.resolveHandle(r, did.String())) + + writeJSON(w, http.StatusCreated, map[string]string{"uri": uri}) +} + +// FlushConnections batch-writes queued connections. +// POST /api/v1/connections/flush +func (h *Handlers) FlushConnections(w http.ResponseWriter, r *http.Request) { + did, sess, ok := h.requireSession(w, r) + if !ok { + return + } + + var body struct { + Targets []string `json:"targets"` + } + if err := decodeBody(r, &body); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON") + return + } + + written, skipped, errs := 0, 0, 0 + pds := sess.Data.HostURL + for _, t := range body.Targets { + target, err := syntax.ParseDID(t) + if err != nil { + skipped++ + continue + } + if connection.HasConnection(r.Context(), pds, did, target, "") { + skipped++ + continue + } + rec := connection.Record{With: target} + if _, _, err := connection.Put(r.Context(), sess, rec); err != nil { + slog.Warn("api: flush connection", "target", target, "err", err) + errs++ + continue + } + if err := h.ConnQueue.Enqueue(r.Context(), target, did, ""); err != nil { + slog.Warn("api: enqueue reciprocal flush", "target", target, "err", err) + } + written++ + } + + writeJSON(w, http.StatusOK, map[string]int{ + "written": written, + "skipped": skipped, + "errors": errs, + }) +} + +// GetConnectionNotes returns private notes for a connection. +// GET /api/v1/connections/{did}/notes +func (h *Handlers) GetConnectionNotes(w http.ResponseWriter, r *http.Request) { + did, ok := h.requireAuth(w, r) + if !ok { + return + } + + targetStr, _ := url.PathUnescape(r.PathValue("did")) + if _, err := syntax.ParseDID(targetStr); err != nil { + writeError(w, http.StatusBadRequest, "invalid DID") + return + } + + note, err := notes.Get(r.Context(), h.DB, did.String(), targetStr) + if err != nil { + // No notes yet — return empty. + writeJSON(w, http.StatusOK, connectionNotesResponse{}) + return + } + + writeJSON(w, http.StatusOK, connectionNotesResponse{ + Notes: note.Notes, + FollowUp: note.FollowUp, + }) +} + +// SaveConnectionNotes saves private notes for a connection. +// PUT /api/v1/connections/{did}/notes +func (h *Handlers) SaveConnectionNotes(w http.ResponseWriter, r *http.Request) { + did, ok := h.requireAuth(w, r) + if !ok { + return + } + + targetStr, _ := url.PathUnescape(r.PathValue("did")) + if _, err := syntax.ParseDID(targetStr); err != nil { + writeError(w, http.StatusBadRequest, "invalid DID") + return + } + + var body struct { + Notes string `json:"notes"` + FollowUp bool `json:"followUp"` + } + if err := decodeBody(r, &body); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON") + return + } + + if len([]rune(body.Notes)) > 2000 { + body.Notes = string([]rune(body.Notes)[:2000]) + } + + if err := notes.Put(r.Context(), h.DB, did.String(), targetStr, notes.Note{ + Notes: body.Notes, + FollowUp: body.FollowUp, + }); err != nil { + slog.Warn("api: save notes", "viewer", did, "target", targetStr, "err", err) + writeError(w, http.StatusInternalServerError, "failed to save") + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} diff --git a/features/api/devices.go b/features/api/devices.go new file mode 100644 index 0000000..5f4c5c2 --- /dev/null +++ b/features/api/devices.go @@ -0,0 +1,77 @@ +package api + +import ( + "log/slog" + "net/http" +) + +// deviceTokenRequest is the JSON body for device registration. +type deviceTokenRequest struct { + Token string `json:"token"` + Platform string `json:"platform"` // "ios" or "android" +} + +// RegisterDevice saves a push notification token for the authenticated user. +// POST /api/v1/devices +func (h *Handlers) RegisterDevice(w http.ResponseWriter, r *http.Request) { + did, ok := h.requireAuth(w, r) + if !ok { + return + } + + var body deviceTokenRequest + if err := decodeBody(r, &body); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON") + return + } + + if body.Token == "" { + writeError(w, http.StatusBadRequest, "token is required") + return + } + if body.Platform != "ios" && body.Platform != "android" { + writeError(w, http.StatusBadRequest, "platform must be 'ios' or 'android'") + return + } + + _, err := h.DB.ExecContext(r.Context(), ` + INSERT INTO device_tokens (did, push_token, platform) + VALUES (?, ?, ?) + ON CONFLICT (did, push_token) DO UPDATE SET + platform = excluded.platform, + updated_at = CURRENT_TIMESTAMP + `, did.String(), body.Token, body.Platform) + if err != nil { + slog.Warn("api: register device", "did", did, "err", err) + writeError(w, http.StatusInternalServerError, "failed to register device") + return + } + + writeJSON(w, http.StatusCreated, map[string]string{"status": "registered"}) +} + +// UnregisterDevice removes a push notification token. +// DELETE /api/v1/devices/{token} +func (h *Handlers) UnregisterDevice(w http.ResponseWriter, r *http.Request) { + did, ok := h.requireAuth(w, r) + if !ok { + return + } + + token := r.PathValue("token") + if token == "" { + writeError(w, http.StatusBadRequest, "missing token") + return + } + + _, err := h.DB.ExecContext(r.Context(), ` + DELETE FROM device_tokens WHERE did = ? AND push_token = ? + `, did.String(), token) + if err != nil { + slog.Warn("api: unregister device", "did", did, "err", err) + writeError(w, http.StatusInternalServerError, "failed to unregister") + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "removed"}) +} diff --git a/features/api/events.go b/features/api/events.go new file mode 100644 index 0000000..e2c6e73 --- /dev/null +++ b/features/api/events.go @@ -0,0 +1,230 @@ +package api + +import ( + "log/slog" + "net/http" + "time" + + "github.com/bluesky-social/indigo/atproto/syntax" + + "atmoquest/internal/checkin" + "atmoquest/internal/event" +) + +// eventResponse is one item in the events list. +type eventResponse struct { + Token string `json:"token"` + Name string `json:"name"` + Description string `json:"description"` + StartDate string `json:"startDate"` + EndDate string `json:"endDate"` + Location string `json:"location"` + Lat float64 `json:"lat,omitempty"` + Lng float64 `json:"lng,omitempty"` + Radius float64 `json:"radius,omitempty"` + CheckinCount int `json:"checkinCount"` +} + +// eventDetailResponse is the full event detail. +type eventDetailResponse struct { + eventResponse + Attendees []eventAttendee `json:"attendees"` + Stats eventStats `json:"stats"` + IsCheckedIn bool `json:"isCheckedIn"` +} + +type eventAttendee struct { + DID string `json:"did"` + Handle string `json:"handle"` + DisplayName string `json:"displayName"` +} + +type eventStats struct { + UniqueConnectors int `json:"uniqueConnectors"` + TotalCheckins int `json:"totalCheckins"` + AttendeeCount int `json:"attendeeCount"` +} + +// ListEvents returns all events. +// GET /api/v1/events +func (h *Handlers) ListEvents(w http.ResponseWriter, r *http.Request) { + events, err := event.ListAll(r.Context(), h.DB) + if err != nil { + slog.Warn("api: list events", "err", err) + writeJSON(w, http.StatusOK, []eventResponse{}) + return + } + + resp := make([]eventResponse, 0, len(events)) + for _, ev := range events { + token := event.QRTokenForURI(r.Context(), h.DB, ev.URI) + er := eventResponse{ + Token: token, + Name: ev.Name, + StartDate: ev.StartTime.Format(time.RFC3339), + EndDate: ev.EndTime.Format(time.RFC3339), + Location: ev.Location, + } + if ev.Geofence != nil { + er.Lat = ev.Geofence.Lat + er.Lng = ev.Geofence.Lng + er.Radius = float64(ev.Geofence.RadiusMeters) + } + resp = append(resp, er) + } + + writeJSON(w, http.StatusOK, resp) +} + +// GetEvent returns full event detail. +// GET /api/v1/events/{token} +func (h *Handlers) GetEvent(w http.ResponseWriter, r *http.Request) { + token := r.PathValue("token") + if token == "" { + writeError(w, http.StatusBadRequest, "missing event token") + return + } + + ev, err := event.LookupByQRToken(r.Context(), h.DB, token) + if err != nil { + writeError(w, http.StatusNotFound, "event not found") + return + } + + stats, _ := event.GetStats(r.Context(), h.DB, ev.URI) + + attendeeDIDs, _ := checkin.ListAttendeesForEvent(r.Context(), h.DB, ev.URI) + attendees := make([]eventAttendee, 0, len(attendeeDIDs)) + for _, d := range attendeeDIDs { + attendees = append(attendees, eventAttendee{ + DID: d, + Handle: h.resolveHandle(r, d), + }) + } + + // Check if the current user is checked in (optional — no auth required for viewing). + isCheckedIn := false + didStr, _ := h.Auth.Sessions.Get(r) + if didStr != "" { + if did, err := syntax.ParseDID(didStr); err == nil { + _, isCheckedIn, _ = checkin.Current(r.Context(), h.DB, did) + } + } + + er := eventResponse{ + Token: token, + Name: ev.Name, + StartDate: ev.StartTime.Format(time.RFC3339), + EndDate: ev.EndTime.Format(time.RFC3339), + Location: ev.Location, + } + if ev.Geofence != nil { + er.Lat = ev.Geofence.Lat + er.Lng = ev.Geofence.Lng + er.Radius = float64(ev.Geofence.RadiusMeters) + } + + writeJSON(w, http.StatusOK, eventDetailResponse{ + eventResponse: er, + Attendees: attendees, + Stats: eventStats{ + UniqueConnectors: stats.UniqueConnectors, + TotalCheckins: stats.TotalCheckins, + AttendeeCount: len(attendeeDIDs), + }, + IsCheckedIn: isCheckedIn, + }) +} + +// GetEventStats returns just the live stats for an event (polling endpoint). +// GET /api/v1/events/{token}/stats +func (h *Handlers) GetEventStats(w http.ResponseWriter, r *http.Request) { + token := r.PathValue("token") + ev, err := event.LookupByQRToken(r.Context(), h.DB, token) + if err != nil { + writeError(w, http.StatusNotFound, "event not found") + return + } + + stats, _ := event.GetStats(r.Context(), h.DB, ev.URI) + attendeeDIDs, _ := checkin.ListAttendeesForEvent(r.Context(), h.DB, ev.URI) + + w.Header().Set("Cache-Control", "no-cache") + writeJSON(w, http.StatusOK, eventStats{ + UniqueConnectors: stats.UniqueConnectors, + TotalCheckins: stats.TotalCheckins, + AttendeeCount: len(attendeeDIDs), + }) +} + +// Checkin checks the authenticated user into an event. +// POST /api/v1/events/{token}/checkin +func (h *Handlers) Checkin(w http.ResponseWriter, r *http.Request) { + did, sess, ok := h.requireSession(w, r) + if !ok { + return + } + + token := r.PathValue("token") + ev, err := event.LookupByQRToken(r.Context(), h.DB, token) + if err != nil { + writeError(w, http.StatusNotFound, "event not found") + return + } + + if !ev.IsOngoing(time.Now()) { + writeError(w, http.StatusBadRequest, "event is not currently active") + return + } + + recordURI, err := checkin.Put(r.Context(), sess, h.DB, ev.URI, time.Now()) + if err != nil { + slog.Warn("api: checkin", "did", did, "event", ev.URI, "err", err) + writeError(w, http.StatusInternalServerError, "failed to check in") + return + } + + writeJSON(w, http.StatusCreated, map[string]string{"uri": recordURI}) +} + +// FlushEvents batch-processes queued event tokens. +// POST /api/v1/events/flush +func (h *Handlers) FlushEvents(w http.ResponseWriter, r *http.Request) { + did, sess, ok := h.requireSession(w, r) + if !ok { + return + } + + var body struct { + Tokens []string `json:"tokens"` + } + if err := decodeBody(r, &body); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON") + return + } + + written, skipped, errs := 0, 0, 0 + for _, token := range body.Tokens { + ev, err := event.LookupByQRToken(r.Context(), h.DB, token) + if err != nil { + skipped++ + continue + } + if !ev.IsOngoing(time.Now()) { + skipped++ + continue + } + if _, err := checkin.Put(r.Context(), sess, h.DB, ev.URI, time.Now()); err != nil { + slog.Warn("api: flush event", "did", did, "token", token, "err", err) + errs++ + continue + } + written++ + } + + writeJSON(w, http.StatusOK, map[string]int{ + "written": written, + "skipped": skipped, + "errors": errs, + }) +} diff --git a/features/api/helpers.go b/features/api/helpers.go new file mode 100644 index 0000000..c65e3f0 --- /dev/null +++ b/features/api/helpers.go @@ -0,0 +1,8 @@ +package api + +import "github.com/bluesky-social/indigo/atproto/syntax" + +// parseDID validates and parses a DID string. +func parseDID(s string) (syntax.DID, error) { + return syntax.ParseDID(s) +} diff --git a/features/api/middleware.go b/features/api/middleware.go new file mode 100644 index 0000000..09d4641 --- /dev/null +++ b/features/api/middleware.go @@ -0,0 +1,128 @@ +package api + +import ( + "net/http" + "strings" + + "github.com/bluesky-social/indigo/atproto/auth/oauth" + "github.com/bluesky-social/indigo/atproto/identity" + "github.com/bluesky-social/indigo/atproto/syntax" +) + +// requireAuth extracts the authenticated DID from the request. +// Supports both cookie-based sessions (web) and Bearer token auth (mobile). +// Returns 401 JSON error if not authenticated. +func (h *Handlers) requireAuth(w http.ResponseWriter, r *http.Request) (syntax.DID, bool) { + // Try cookie-based session first (web browser). + didStr, _ := h.Auth.Sessions.Get(r) + + // Fall back to Bearer token (mobile app). + if didStr == "" { + if authHeader := r.Header.Get("Authorization"); strings.HasPrefix(authHeader, "Bearer ") { + token := strings.TrimPrefix(authHeader, "Bearer ") + if d, _, err := h.Tokens.Validate(token); err == nil { + didStr = d + } + } + } + + if didStr == "" { + writeError(w, http.StatusUnauthorized, "not authenticated") + return "", false + } + did, err := syntax.ParseDID(didStr) + if err != nil { + writeError(w, http.StatusUnauthorized, "invalid session") + return "", false + } + return did, true +} + +// requireSession extracts both the DID and the full OAuth session. +// Needed for handlers that must talk to the user's PDS. +// Supports both cookie and Bearer token auth. +// Returns 401 JSON error if not authenticated. +func (h *Handlers) requireSession(w http.ResponseWriter, r *http.Request) (syntax.DID, *oauth.ClientSession, bool) { + // Try cookie-based session first. + did, sess, err := h.Auth.ResumeSession(r) + if err == nil { + return did, sess, true + } + + // Fall back to Bearer token — extract DID + session ID and resume. + if authHeader := r.Header.Get("Authorization"); strings.HasPrefix(authHeader, "Bearer ") { + token := strings.TrimPrefix(authHeader, "Bearer ") + if d, sid, err := h.Tokens.Validate(token); err == nil { + parsedDID, err := syntax.ParseDID(d) + if err == nil { + sess, err := h.Auth.OAuth.ResumeSession(r.Context(), parsedDID, sid) + if err == nil { + return parsedDID, sess, true + } + } + } + } + + writeError(w, http.StatusUnauthorized, "not authenticated") + return "", nil, false +} + +// JSONMiddleware sets common headers for all API responses and handles CORS +// for web-based clients (Expo web dev server runs on a different origin). +func JSONMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + + // CORS: allow requests from any origin (the mobile app and Expo web + // dev server both need cross-origin access). + origin := r.Header.Get("Origin") + if origin != "" { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Max-Age", "86400") + } + + // Handle preflight. + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusNoContent) + return + } + + next.ServeHTTP(w, r) + }) +} + +// lookupPDS resolves a user's PDS host from cached OAuth session data. +func (h *Handlers) lookupPDS(r *http.Request, did syntax.DID) string { + if h.DB == nil { + return "https://bsky.social" + } + var host string + err := h.DB.QueryRowContext(r.Context(), ` + SELECT data ->> 'host_url' FROM oauth_sessions + WHERE did = ? ORDER BY updated_at DESC LIMIT 1 + `, did.String()).Scan(&host) + if err == nil && host != "" { + return host + } + return "https://bsky.social" +} + +// resolveHandle returns the best-known handle for a DID (from users table or +// passed-through as the DID string). +func (h *Handlers) resolveHandle(r *http.Request, did string) string { + var handle string + _ = h.DB.QueryRowContext(r.Context(), `SELECT handle FROM users WHERE did = ?`, did).Scan(&handle) + if handle != "" { + return handle + } + // Check if identity can resolve it. + dir := identity.DefaultDirectory() + ident, err := dir.LookupDID(r.Context(), syntax.DID(did)) + if err == nil && ident.Handle != "" { + return ident.Handle.String() + } + return did +} diff --git a/features/api/notifications.go b/features/api/notifications.go new file mode 100644 index 0000000..ac62ffb --- /dev/null +++ b/features/api/notifications.go @@ -0,0 +1,41 @@ +package api + +import ( + "context" + + "atmoquest/internal/push" +) + +// notifyBadgeEarned sends a push notification when a user earns a badge. +func (h *Handlers) notifyBadgeEarned(did, badgeName string) { + if h.Push == nil { + return + } + go h.Push.SendToUser(context.Background(), did, push.Notification{ + Title: "badge earned!", + Body: "you earned the " + badgeName + " badge", + Data: map[string]string{ + "route": "/badges", + "type": "badge_earned", + }, + }) +} + +// notifyNewConnection sends a push notification when someone connects with a user. +func (h *Handlers) notifyNewConnection(targetDID, fromHandle string) { + if h.Push == nil { + return + } + body := "someone connected with you" + if fromHandle != "" { + body = "@" + fromHandle + " connected with you" + } + go h.Push.SendToUser(context.Background(), targetDID, push.Notification{ + Title: "new connection", + Body: body, + Data: map[string]string{ + "route": "/connections", + "type": "new_connection", + }, + }) +} diff --git a/features/api/profile.go b/features/api/profile.go new file mode 100644 index 0000000..0074d98 --- /dev/null +++ b/features/api/profile.go @@ -0,0 +1,169 @@ +package api + +import ( + "errors" + "log/slog" + "net/http" + + "atmoquest/internal/profile" +) + +// profileResponse is the JSON shape for profile endpoints. +type profileResponse struct { + DID string `json:"did"` + Handle string `json:"handle"` + DisplayName string `json:"displayName"` + Bio string `json:"bio"` + Interests []string `json:"interests"` + Links []profileLinkPayload `json:"links"` + Location string `json:"location"` + IsHiring bool `json:"isHiring"` + IsLooking bool `json:"isLooking"` +} + +type profileLinkPayload struct { + Label string `json:"label"` + URL string `json:"url"` +} + +// GetProfile returns the authenticated user's profile. +// GET /api/v1/profile +func (h *Handlers) GetProfile(w http.ResponseWriter, r *http.Request) { + did, sess, ok := h.requireSession(w, r) + if !ok { + return + } + pds := sess.Data.HostURL + + bsky, err := profile.FetchBluesky(r.Context(), pds, did) + if err != nil && !errors.Is(err, profile.ErrNotFound) { + slog.Warn("api: fetch bsky profile", "did", did, "err", err) + } + + quest, err := profile.FetchQuest(r.Context(), pds, did) + if err != nil && !errors.Is(err, profile.ErrNotFound) { + slog.Warn("api: fetch quest profile", "did", did, "err", err) + } + + resp := buildProfileResponse(did.String(), h.resolveHandle(r, did.String()), bsky, quest) + writeJSON(w, http.StatusOK, resp) +} + +// GetUser returns another user's public profile. +// GET /api/v1/users/{did} +func (h *Handlers) GetUser(w http.ResponseWriter, r *http.Request) { + _, ok := h.requireAuth(w, r) + if !ok { + return + } + + targetDIDStr := r.PathValue("did") + if targetDIDStr == "" { + writeError(w, http.StatusBadRequest, "missing did parameter") + return + } + + targetDID, err := parseDID(targetDIDStr) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid DID") + return + } + + pds := h.lookupPDS(r, targetDID) + + bsky, err := profile.FetchBluesky(r.Context(), pds, targetDID) + if err != nil && !errors.Is(err, profile.ErrNotFound) { + slog.Warn("api: fetch user bsky", "target", targetDID, "err", err) + } + + quest, err := profile.FetchQuest(r.Context(), pds, targetDID) + if err != nil && !errors.Is(err, profile.ErrNotFound) { + slog.Warn("api: fetch user quest", "target", targetDID, "err", err) + } + + resp := buildProfileResponse(targetDID.String(), h.resolveHandle(r, targetDID.String()), bsky, quest) + writeJSON(w, http.StatusOK, resp) +} + +// UpdateProfile updates the authenticated user's quest.atmo.profile record. +// PUT /api/v1/profile +func (h *Handlers) UpdateProfile(w http.ResponseWriter, r *http.Request) { + did, sess, ok := h.requireSession(w, r) + if !ok { + return + } + + var body struct { + DisplayName string `json:"displayName"` + Bio string `json:"bio"` + Location string `json:"location"` + IsHiring bool `json:"isHiring"` + IsLooking bool `json:"isLooking"` + Interests []string `json:"interests"` + Links []profileLinkPayload `json:"links"` + } + if err := decodeBody(r, &body); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON") + return + } + + // Build the quest profile record. + hiring := body.IsHiring + looking := body.IsLooking + rec := profile.QuestRecord{ + Bio: body.Bio, + Location: body.Location, + Hiring: &hiring, + Looking: &looking, + } + for _, i := range body.Interests { + rec.Interests = append(rec.Interests, i) + } + for _, l := range body.Links { + rec.Links = append(rec.Links, profile.Link{Label: l.Label, URL: l.URL}) + } + + if _, err := profile.PutQuest(r.Context(), sess, did, rec); err != nil { + slog.Warn("api: update profile", "did", did, "err", err) + writeError(w, http.StatusInternalServerError, "failed to update profile") + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +// buildProfileResponse creates a profileResponse from fetched PDS data. +func buildProfileResponse(didStr, handle string, bsky *profile.BlueskyRecord, quest *profile.QuestRecord) profileResponse { + resp := profileResponse{ + DID: didStr, + Handle: handle, + Interests: []string{}, + Links: []profileLinkPayload{}, + } + + if bsky != nil { + resp.DisplayName = bsky.DisplayName + resp.Bio = bsky.Description + } + + if quest != nil { + if quest.Bio != "" { + resp.Bio = quest.Bio + } + resp.Location = quest.Location + if quest.Hiring != nil { + resp.IsHiring = *quest.Hiring + } + if quest.Looking != nil { + resp.IsLooking = *quest.Looking + } + for _, i := range quest.Interests { + resp.Interests = append(resp.Interests, i) + } + for _, l := range quest.Links { + resp.Links = append(resp.Links, profileLinkPayload{Label: l.Label, URL: l.URL}) + } + } + + return resp +} diff --git a/features/api/routes.go b/features/api/routes.go new file mode 100644 index 0000000..2f27e79 --- /dev/null +++ b/features/api/routes.go @@ -0,0 +1,63 @@ +package api + +import ( + "database/sql" + + "github.com/go-chi/chi/v5" + + "atmoquest/features/auth" + "atmoquest/internal/apitoken" + "atmoquest/internal/connection" + "atmoquest/internal/push" +) + +// SetupRoutes registers the /api/v1/ routes. +// +// All endpoints return JSON. Authentication is via session cookie (web) or +// Bearer token (mobile). +func SetupRoutes(router chi.Router, conn *sql.DB, authH *auth.Handlers, connQueue *connection.Queue, tokens *apitoken.Generator, pusher *push.Sender) { + h := NewHandlers(conn, authH, connQueue, tokens, pusher) + + router.Route("/api/v1", func(r chi.Router) { + r.Use(JSONMiddleware) + + // Auth + r.Get("/auth/session", h.GetSession) + r.Post("/oauth/login", h.OAuthLogin) + r.Get("/oauth/callback", h.OAuthCallback) + r.Post("/oauth/logout", h.Logout) + + // Profile + r.Get("/profile", h.GetProfile) + r.Put("/profile", h.UpdateProfile) + r.Get("/users/{did}", h.GetUser) + + // Connections + r.Get("/connections", h.ListConnections) + r.Post("/connections/{did}", h.CreateConnection) + r.Post("/connections/flush", h.FlushConnections) + r.Get("/connections/{did}/notes", h.GetConnectionNotes) + r.Put("/connections/{did}/notes", h.SaveConnectionNotes) + + // Events + r.Get("/events", h.ListEvents) + r.Get("/events/{token}", h.GetEvent) + r.Get("/events/{token}/stats", h.GetEventStats) + r.Post("/events/{token}/checkin", h.Checkin) + r.Post("/events/flush", h.FlushEvents) + + // Badges + r.Get("/badges", h.ListBadges) + r.Get("/badges/recent", h.RecentBadges) + + // Settings + r.Get("/settings", h.GetSettings) + r.Put("/settings/privacy", h.UpdatePrivacy) + r.Get("/settings/notes", h.ListNotes) + r.Delete("/settings/account", h.DeleteAccount) + + // Devices (push notifications) + r.Post("/devices", h.RegisterDevice) + r.Delete("/devices/{token}", h.UnregisterDevice) + }) +} diff --git a/features/api/settings.go b/features/api/settings.go new file mode 100644 index 0000000..a0eeeb1 --- /dev/null +++ b/features/api/settings.go @@ -0,0 +1,137 @@ +package api + +import ( + "log/slog" + "net/http" + + "atmoquest/internal/notes" + "atmoquest/internal/users" +) + +// settingsResponse is the JSON shape for the settings endpoint. +type settingsResponse struct { + DID string `json:"did"` + Handle string `json:"handle"` + ShowInAttendees bool `json:"showInAttendees"` + ShowInLeaderboard bool `json:"showInLeaderboard"` +} + +// GetSettings returns the user's privacy settings. +// GET /api/v1/settings +func (h *Handlers) GetSettings(w http.ResponseWriter, r *http.Request) { + did, ok := h.requireAuth(w, r) + if !ok { + return + } + + privacy, err := users.GetPrivacy(r.Context(), h.DB, did) + if err != nil { + slog.Warn("api: get privacy", "did", did, "err", err) + } + + writeJSON(w, http.StatusOK, settingsResponse{ + DID: did.String(), + Handle: h.resolveHandle(r, did.String()), + ShowInAttendees: !privacy.HideFromAttendees, + ShowInLeaderboard: !privacy.HideFromLeaderboard, + }) +} + +// UpdatePrivacy toggles a privacy setting. +// PUT /api/v1/settings/privacy +func (h *Handlers) UpdatePrivacy(w http.ResponseWriter, r *http.Request) { + did, ok := h.requireAuth(w, r) + if !ok { + return + } + + var body struct { + ShowInAttendees *bool `json:"showInAttendees"` + ShowInLeaderboard *bool `json:"showInLeaderboard"` + } + if err := decodeBody(r, &body); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON") + return + } + + if body.ShowInAttendees != nil { + if err := users.SetPrivacy(r.Context(), h.DB, did, "hide_from_attendees", !*body.ShowInAttendees); err != nil { + slog.Warn("api: set attendee privacy", "did", did, "err", err) + writeError(w, http.StatusInternalServerError, "failed to update") + return + } + } + if body.ShowInLeaderboard != nil { + if err := users.SetPrivacy(r.Context(), h.DB, did, "hide_from_leaderboard", !*body.ShowInLeaderboard); err != nil { + slog.Warn("api: set leaderboard privacy", "did", did, "err", err) + writeError(w, http.StatusInternalServerError, "failed to update") + return + } + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} + +// noteListItem matches the NoteRow type expected by the mobile app. +type noteListItem struct { + TargetDID string `json:"targetDID"` + Handle string `json:"handle"` + DisplayName string `json:"displayName"` + Notes string `json:"notes"` + FollowUp bool `json:"followUp"` + UpdatedAt string `json:"updatedAt"` +} + +// ListNotes returns all local notes for the authenticated user. +// GET /api/v1/settings/notes +func (h *Handlers) ListNotes(w http.ResponseWriter, r *http.Request) { + did, ok := h.requireAuth(w, r) + if !ok { + return + } + + rows, err := notes.ListAll(r.Context(), h.DB, did.String()) + if err != nil { + slog.Warn("api: list notes", "did", did, "err", err) + writeJSON(w, http.StatusOK, []noteListItem{}) + return + } + + resp := make([]noteListItem, 0, len(rows)) + for _, row := range rows { + resp = append(resp, noteListItem{ + TargetDID: row.TargetDID, + Handle: h.resolveHandle(r, row.TargetDID), + DisplayName: "", + Notes: row.Notes, + FollowUp: row.FollowUp, + UpdatedAt: row.UpdatedAt, + }) + } + + writeJSON(w, http.StatusOK, resp) +} + +// DeleteAccount deletes all local data for the authenticated user. +// DELETE /api/v1/settings/account +func (h *Handlers) DeleteAccount(w http.ResponseWriter, r *http.Request) { + did, ok := h.requireAuth(w, r) + if !ok { + return + } + + // Delete connection notes. + if _, err := notes.DeleteAll(r.Context(), h.DB, did.String()); err != nil { + slog.Warn("api: delete notes", "did", did, "err", err) + } + + // Delete user record. + if _, err := h.DB.ExecContext(r.Context(), `DELETE FROM users WHERE did = ?`, did.String()); err != nil { + slog.Warn("api: delete user", "did", did, "err", err) + } + + // Clear session. + h.Auth.Sessions.Clear(w, r) + + writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) +} diff --git a/features/auth/handlers.go b/features/auth/handlers.go index cf28346..57c9c36 100644 --- a/features/auth/handlers.go +++ b/features/auth/handlers.go @@ -117,7 +117,7 @@ func (h *Handlers) OAuthCallback(w http.ResponseWriter, r *http.Request) { // Best-effort: touch the users table so the admin UI knows about this DID. // Enrichment (handle resolution, Bluesky profile fetch) lands when the // profile + admin features are ported. - go h.recordUserLogin(sessData) + go h.RecordUserLogin(sessData) // Best-effort: flush any reciprocal connection writes queued while this // user was offline. Runs synchronously so the user lands on /profile with @@ -187,7 +187,7 @@ func (h *Handlers) OAuthJWKS(w http.ResponseWriter, _ *http.Request) { // Handle resolution (via an atproto identity directory) is deferred until // the admin step; the users.Touch COALESCE preserves any previously-stored // handle across enrichment-less re-logins. -func (h *Handlers) recordUserLogin(sessData *oauth.ClientSessionData) { +func (h *Handlers) RecordUserLogin(sessData *oauth.ClientSessionData) { if h.DB == nil || sessData == nil { return } diff --git a/features/connections/handlers.go b/features/connections/handlers.go index 7305cc4..e504e4b 100644 --- a/features/connections/handlers.go +++ b/features/connections/handlers.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "github.com/bluesky-social/indigo/atproto/identity" "github.com/bluesky-social/indigo/atproto/syntax" "github.com/go-chi/chi/v5" @@ -27,13 +28,14 @@ import ( // Handlers holds the dependencies for the connections feature. type Handlers struct { - DB *sql.DB - Auth *auth.Handlers + DB *sql.DB + Auth *auth.Handlers + Directory identity.Directory } // NewHandlers wires the connections feature. func NewHandlers(conn *sql.DB, authH *auth.Handlers) *Handlers { - return &Handlers{DB: conn, Auth: authH} + return &Handlers{DB: conn, Auth: authH, Directory: identity.DefaultDirectory()} } // List renders the full connections list page. @@ -99,13 +101,29 @@ func (h *Handlers) List(w http.ResponseWriter, r *http.Request) { bsky, err := profile.FetchBluesky(r.Context(), targetPDS, entry.With) if err == nil && bsky != nil { item.DisplayName = bsky.DisplayName - item.Handle = "" // handle resolution is expensive; DID is sufficient for v1 if bsky.Avatar != nil { item.AvatarURL = profile.AvatarURL(targetPDS, entry.With, bsky.Avatar.CID()) } item.Bio = bsky.Description } + // Resolve handle from local users table, then identity directory as fallback. + if h.DB != nil { + var handle string + _ = h.DB.QueryRowContext(r.Context(), `SELECT handle FROM users WHERE did = ?`, entry.With.String()).Scan(&handle) + if handle != "" && handle != "handle.invalid" { + item.Handle = handle + } + } + if item.Handle == "" && h.Directory != nil { + if ident, err := h.Directory.LookupDID(r.Context(), entry.With); err == nil && ident != nil { + hStr := ident.Handle.String() + if hStr != "" && hStr != "handle.invalid" { + item.Handle = hStr + } + } + } + // Apply search filter (after enrichment so we can match on display name). if query != "" { q := strings.ToLower(query) diff --git a/features/connections/pages/connections.templ b/features/connections/pages/connections.templ index ffcd636..854b5d2 100644 --- a/features/connections/pages/connections.templ +++ b/features/connections/pages/connections.templ @@ -136,13 +136,15 @@ templ connectionRow(item ConnectionItem) {
if item.DisplayName != "" { { item.DisplayName } + if item.Handle != "" { + { "@" + item.Handle } + } + } else if item.Handle != "" { + { "@" + item.Handle } } else { { shortDID(item.DID) } }
- if item.Handle != "" { -
{ "@" + item.Handle }
- }
{ item.ConnectedAt } if item.EventName != "" { diff --git a/features/connections/pages/connections_templ.go b/features/connections/pages/connections_templ.go index 6d98da4..06690c4 100644 --- a/features/connections/pages/connections_templ.go +++ b/features/connections/pages/connections_templ.go @@ -391,50 +391,60 @@ func connectionRow(item ConnectionItem) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - } else { - var templ_7745c5c3_Var20 string - templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(shortDID(item.DID)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 140, Col: 25} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if item.Handle != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err + if item.Handle != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var20 string + templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs("@" + item.Handle) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 140, Col: 58} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } + } else if item.Handle != "" { var templ_7745c5c3_Var21 string templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs("@" + item.Handle) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 144, Col: 48} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 143, Col: 24} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") + } else { + var templ_7745c5c3_Var22 string + templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(shortDID(item.DID)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 145, Col: 25} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var22 string - templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(item.ConnectedAt) + var templ_7745c5c3_Var23 string + templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(item.ConnectedAt) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 147, Col: 28} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 149, Col: 28} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -447,12 +457,12 @@ func connectionRow(item ConnectionItem) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var23 string - templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(item.EventName) + var templ_7745c5c3_Var24 string + templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(item.EventName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 149, Col: 45} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 151, Col: 45} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/features/events/handlers.go b/features/events/handlers.go index 3595765..1a883e6 100644 --- a/features/events/handlers.go +++ b/features/events/handlers.go @@ -20,6 +20,7 @@ import ( "atmoquest/internal/connection" "atmoquest/internal/event" "atmoquest/internal/interest" + "atmoquest/internal/notes" "atmoquest/internal/profile" "atmoquest/internal/users" ) @@ -440,6 +441,50 @@ func (h *Handlers) Detail(w http.ResponseWriter, r *http.Request) { StatsUnlocked: statsUnlocked, } + // For past events, load the cached summary + viewer's personal stats. + if status == "complete" { + // Viewer's connection count at this event. + for _, c := range viewerConns { + if c.EventURI == ev.URI { + view.ViewerConnections++ + } + } + + // Follow-up items from the viewer's notes. + if allNotes, err := notes.ListAll(r.Context(), h.DB, viewerDID.String()); err == nil { + for _, n := range allNotes { + if n.FollowUp { + handle := h.resolveHandle(r, syntax.DID(n.TargetDID)) + view.FollowUps = append(view.FollowUps, pages.FollowUpItem{ + DID: n.TargetDID, + Handle: handle, + Notes: n.Notes, + }) + } + } + } + + // Cached event summary (computed at finalization). + if summary, err := event.GetSummary(r.Context(), h.DB, ev.URI); err == nil && summary != nil { + view.Summary = &pages.EventSummary{ + TotalConnections: summary.TotalConnections, + TotalCheckins: summary.TotalCheckins, + NewSignups: summary.NewSignups, + PeakHour: summary.PeakHour, + } + for _, h := range summary.ConnectionsByHour { + view.Summary.ConnectionsByHour = append(view.Summary.ConnectionsByHour, pages.HourlyCount{ + Hour: h.Hour, Count: h.Count, + }) + } + for _, ic := range summary.InterestDistribution { + view.Summary.InterestDistribution = append(view.Summary.InterestDistribution, pages.InterestCount{ + Interest: ic.Interest, Count: ic.Count, + }) + } + } + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := pages.EventDetail(view).Render(r.Context(), w); err != nil { slog.Error("render event detail", "err", err) diff --git a/features/events/pages/detail.templ b/features/events/pages/detail.templ index ff06d1b..bd128f5 100644 --- a/features/events/pages/detail.templ +++ b/features/events/pages/detail.templ @@ -30,6 +30,41 @@ type EventDetailView struct { InterestMatches []InterestMatch InterestUnlocked bool // true when UniqueConnectors >= 40% of TotalAttendees StatsUnlocked bool // true when UniqueConnectors >= 80% of TotalAttendees + // Past-event summary (nil if not yet computed or event is still live). + Summary *EventSummary + // Personal stats for the viewer at this event. + ViewerConnections int // how many connections the viewer made at this event + FollowUps []FollowUpItem +} + +// EventSummary is the cached post-event statistics. +type EventSummary struct { + TotalConnections int + TotalCheckins int + NewSignups int + PeakHour string + ConnectionsByHour []HourlyCount + InterestDistribution []InterestCount +} + +// HourlyCount is connections in a specific hour slot. +type HourlyCount struct { + Hour string + Count int +} + +// InterestCount is an interest and its attendee count. +type InterestCount struct { + Interest string + Count int +} + +// FollowUpItem is a connection the viewer flagged for follow-up. +type FollowUpItem struct { + DID string + DisplayName string + Handle string + Notes string } // Attendee is one person checked into the event. @@ -103,6 +138,11 @@ templ EventDetail(v EventDetailView) { @eventDetailProgress(v.ActualCheckins, v.TotalAttendees) + // ── Post-event summary (shown at top for completed events) ── + if v.Status == "complete" { + @eventSummarySection(v) + } +
if v.LeaderboardUnlocked {
@@ -215,6 +255,7 @@ templ EventDetail(v EventDetailView) {

no attendees to show.

}
+
@@ -229,6 +270,98 @@ templ EventDetail(v EventDetailView) { } } +templ eventSummarySection(v EventDetailView) { +
+ + +
+
+
{ itoa(v.ViewerConnections) }
+
connections you made
+
+
+ + if len(v.FollowUps) > 0 { + + + } + + if v.Summary != nil && v.StatsUnlocked { + + +
+
+
{ itoa(v.Summary.TotalConnections) }
+
total connections
+
+
+
{ itoa(v.Summary.TotalCheckins) }
+
attendees checked in
+
+
+
{ itoa(v.Summary.NewSignups) }
+
new app signups
+
+
+ + if v.Summary.PeakHour != "" { +

+ 🕐 busiest hour: { v.Summary.PeakHour } +

+ } + + if len(v.Summary.ConnectionsByHour) > 0 { + +
+ for _, h := range v.Summary.ConnectionsByHour { +
+ { h.Hour } +
+ { itoa(h.Count) } +
+
+ } +
+ } + + if len(v.Summary.InterestDistribution) > 0 { + +
+ for _, ic := range v.Summary.InterestDistribution { +
+ { ic.Interest } +
+ { itoa(ic.Count) } +
+
+ } +
+ } + } else if v.Summary == nil { +

+ event statistics will appear here once the event is finalized by an admin. +

+ } +
+} + templ attendeeCard(a Attendee, token string) { if a.AvatarURL != "" { @@ -356,6 +489,37 @@ func attendeesPageURL(token, filter string, page int) string { return url } +func maxHourlyCount(hours []HourlyCount) int { + max := 1 + for _, h := range hours { + if h.Count > max { + max = h.Count + } + } + return max +} + +func maxInterestCount(interests []InterestCount) int { + max := 1 + for _, ic := range interests { + if ic.Count > max { + max = ic.Count + } + } + return max +} + +func barPct(count, max int) string { + if max <= 0 { + return "0" + } + pct := float64(count) / float64(max) * 100.0 + if pct < 5 { + pct = 5 // min width so the label is readable + } + return fmt.Sprintf("%.0f", pct) +} + // connectPct returns the fill percentage: connectors / totalAttendees * 100. func connectPct(connectors, total int) string { if total == 0 { diff --git a/features/events/pages/detail_templ.go b/features/events/pages/detail_templ.go index 01e4b58..0d56511 100644 --- a/features/events/pages/detail_templ.go +++ b/features/events/pages/detail_templ.go @@ -38,6 +38,41 @@ type EventDetailView struct { InterestMatches []InterestMatch InterestUnlocked bool // true when UniqueConnectors >= 40% of TotalAttendees StatsUnlocked bool // true when UniqueConnectors >= 80% of TotalAttendees + // Past-event summary (nil if not yet computed or event is still live). + Summary *EventSummary + // Personal stats for the viewer at this event. + ViewerConnections int // how many connections the viewer made at this event + FollowUps []FollowUpItem +} + +// EventSummary is the cached post-event statistics. +type EventSummary struct { + TotalConnections int + TotalCheckins int + NewSignups int + PeakHour string + ConnectionsByHour []HourlyCount + InterestDistribution []InterestCount +} + +// HourlyCount is connections in a specific hour slot. +type HourlyCount struct { + Hour string + Count int +} + +// InterestCount is an interest and its attendee count. +type InterestCount struct { + Interest string + Count int +} + +// FollowUpItem is a connection the viewer flagged for follow-up. +type FollowUpItem struct { + DID string + DisplayName string + Handle string + Notes string } // Attendee is one person checked into the event. @@ -118,7 +153,7 @@ func EventDetail(v EventDetailView) templ.Component { var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs("@") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 77, Col: 56} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 112, Col: 56} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -131,7 +166,7 @@ func EventDetail(v EventDetailView) templ.Component { var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(v.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 78, Col: 46} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 113, Col: 46} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -144,7 +179,7 @@ func EventDetail(v EventDetailView) templ.Component { var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(sseAttr(v.Token)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 81, Col: 61} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 116, Col: 61} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5) if templ_7745c5c3_Err != nil { @@ -162,7 +197,7 @@ func EventDetail(v EventDetailView) templ.Component { var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(v.Status) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 85, Col: 37} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 120, Col: 37} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -180,7 +215,7 @@ func EventDetail(v EventDetailView) templ.Component { var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(v.Status) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 87, Col: 48} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 122, Col: 48} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -198,7 +233,7 @@ func EventDetail(v EventDetailView) templ.Component { var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(v.Status) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 89, Col: 48} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 124, Col: 48} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -217,7 +252,7 @@ func EventDetail(v EventDetailView) templ.Component { var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(v.DayNumber)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 92, Col: 34} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 127, Col: 34} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { @@ -230,7 +265,7 @@ func EventDetail(v EventDetailView) templ.Component { var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(v.TotalDays)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 92, Col: 59} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 127, Col: 59} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { @@ -244,7 +279,7 @@ func EventDetail(v EventDetailView) templ.Component { var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(v.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 95, Col: 46} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 130, Col: 46} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { @@ -258,7 +293,7 @@ func EventDetail(v EventDetailView) templ.Component { var templ_7745c5c3_Var12 string templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(v.Location) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 98, Col: 20} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 133, Col: 20} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { @@ -272,7 +307,7 @@ func EventDetail(v EventDetailView) templ.Component { var templ_7745c5c3_Var13 string templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(v.StartTime) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 100, Col: 20} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 135, Col: 20} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { @@ -285,7 +320,7 @@ func EventDetail(v EventDetailView) templ.Component { var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(v.EndTime) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 100, Col: 38} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 135, Col: 38} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { @@ -299,6 +334,12 @@ func EventDetail(v EventDetailView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } + if v.Status == "complete" { + templ_7745c5c3_Err = eventSummarySection(v).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err @@ -316,7 +357,7 @@ func EventDetail(v EventDetailView) templ.Component { var templ_7745c5c3_Var15 string templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(remaining(milestone20(v.TotalAttendees), v.UniqueConnectors)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 121, Col: 96} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 161, Col: 96} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) if templ_7745c5c3_Err != nil { @@ -340,7 +381,7 @@ func EventDetail(v EventDetailView) templ.Component { var templ_7745c5c3_Var16 string templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(remaining(milestone40(v.TotalAttendees), v.UniqueConnectors)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 140, Col: 96} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 180, Col: 96} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { @@ -364,7 +405,7 @@ func EventDetail(v EventDetailView) templ.Component { var templ_7745c5c3_Var17 string templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(remaining(milestone80(v.TotalAttendees), v.UniqueConnectors)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 159, Col: 96} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 199, Col: 96} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) if templ_7745c5c3_Err != nil { @@ -427,7 +468,7 @@ func EventDetail(v EventDetailView) templ.Component { var templ_7745c5c3_Var19 templ.SafeURL templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/events/" + v.Token)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 192, Col: 53} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 232, Col: 53} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) if templ_7745c5c3_Err != nil { @@ -462,7 +503,7 @@ func EventDetail(v EventDetailView) templ.Component { var templ_7745c5c3_Var22 templ.SafeURL templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/events/" + v.Token + "?filter=mine")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 193, Col: 70} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 233, Col: 70} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) if templ_7745c5c3_Err != nil { @@ -497,7 +538,7 @@ func EventDetail(v EventDetailView) templ.Component { var templ_7745c5c3_Var25 templ.SafeURL templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/events/" + v.Token + "?filter=unmet")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 194, Col: 71} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 234, Col: 71} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) if templ_7745c5c3_Err != nil { @@ -548,7 +589,7 @@ func EventDetail(v EventDetailView) templ.Component { var templ_7745c5c3_Var27 templ.SafeURL templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(attendeesPageURL(v.Token, v.Filter, v.Page-1))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 206, Col: 80} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 246, Col: 80} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) if templ_7745c5c3_Err != nil { @@ -566,7 +607,7 @@ func EventDetail(v EventDetailView) templ.Component { var templ_7745c5c3_Var28 string templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(v.Page)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 208, Col: 64} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 248, Col: 64} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) if templ_7745c5c3_Err != nil { @@ -579,7 +620,7 @@ func EventDetail(v EventDetailView) templ.Component { var templ_7745c5c3_Var29 string templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(v.TotalPages)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 208, Col: 90} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 248, Col: 90} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) if templ_7745c5c3_Err != nil { @@ -597,7 +638,7 @@ func EventDetail(v EventDetailView) templ.Component { var templ_7745c5c3_Var30 templ.SafeURL templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(attendeesPageURL(v.Token, v.Filter, v.Page+1))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 210, Col: 80} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 250, Col: 80} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) if templ_7745c5c3_Err != nil { @@ -637,7 +678,7 @@ func EventDetail(v EventDetailView) templ.Component { }) } -func attendeeCard(a Attendee, token string) templ.Component { +func eventSummarySection(v EventDetailView) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -658,145 +699,465 @@ func attendeeCard(a Attendee, token string) templ.Component { templ_7745c5c3_Var31 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var32 templ.SafeURL - templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/connections/" + a.DID)) + var templ_7745c5c3_Var32 string + templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(v.ViewerConnections)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 233, Col: 49} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 279, Col: 61} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\" class=\"attendee-card\" title=\"") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "
connections you made
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var33 string - templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(a.DisplayName) + if len(v.FollowUps) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, f := range v.FollowUps { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if f.DisplayName != "" { + var templ_7745c5c3_Var34 string + templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(f.DisplayName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 291, Col: 23} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if f.Handle != "" { + var templ_7745c5c3_Var35 string + templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs("@" + f.Handle) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 293, Col: 24} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + var templ_7745c5c3_Var36 string + templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(shortDID(f.DID)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 295, Col: 25} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if f.Notes != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var37 string + templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(f.Notes) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 299, Col: 51} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if v.Summary != nil && v.StatsUnlocked { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var38 string + templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(v.Summary.TotalConnections)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 311, Col: 69} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var38)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "
total connections
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var39 string + templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(v.Summary.TotalCheckins)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 315, Col: 66} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "
attendees checked in
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var40 string + templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(v.Summary.NewSignups)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 319, Col: 63} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "
new app signups
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if v.Summary.PeakHour != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "

🕐 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) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 326, Col: 52} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(v.Summary.ConnectionsByHour) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, h := range v.Summary.ConnectionsByHour { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var42 string + templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(h.Hour) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 335, Col: 45} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var44 string + templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(h.Count)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 337, Col: 51} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(v.Summary.InterestDistribution) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, ic := range v.Summary.InterestDistribution { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var45 string + templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(ic.Interest) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 349, Col: 50} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var47 string + templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(ic.Count)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 351, Col: 52} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + } else if v.Summary == nil { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "

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, 88, "
") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 233, Col: 95} + return templ_7745c5c3_Err } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33) + return nil + }) +} + +func attendeeCard(a Attendee, token string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var48 := templ.GetChildren(ctx) + if templ_7745c5c3_Var48 == nil { + templ_7745c5c3_Var48 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "\" class=\"attendee-card\" title=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var50 string + templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.ResolveAttributeValue(a.DisplayName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 366, Col: 95} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var50) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if a.AvatarURL != "" { - var templ_7745c5c3_Var34 = []any{"attendee-avatar", templ.KV("connected", a.Connected), templ.KV("not-connected", !a.Connected)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var34...) + var templ_7745c5c3_Var51 = []any{"attendee-avatar", templ.KV("connected", a.Connected), templ.KV("not-connected", !a.Connected)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var51...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "\"") ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "\" loading=\"lazy\"> ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - var templ_7745c5c3_Var38 = []any{"attendee-avatar leaderboard-avatar-empty", templ.KV("connected", a.Connected), templ.KV("not-connected", !a.Connected)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var38...) + var templ_7745c5c3_Var55 = []any{"attendee-avatar leaderboard-avatar-empty", templ.KV("connected", a.Connected), templ.KV("not-connected", !a.Connected)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var55...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "
?
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "\">?
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if a.DisplayName != "" { - var templ_7745c5c3_Var40 string - templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(a.DisplayName) + var templ_7745c5c3_Var57 string + templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(a.DisplayName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 246, Col: 19} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 379, Col: 19} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else if a.Handle != "" { - var templ_7745c5c3_Var41 string - templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs("@" + a.Handle) + var templ_7745c5c3_Var58 string + templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs("@" + a.Handle) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 248, Col: 20} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 381, Col: 20} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - var templ_7745c5c3_Var42 string - templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(shortDID(a.DID)) + var templ_7745c5c3_Var59 string + templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(shortDID(a.DID)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 250, Col: 21} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 383, Col: 21} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -820,172 +1181,172 @@ func leaderboardRow(row LeaderboardRow) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var43 := templ.GetChildren(ctx) - if templ_7745c5c3_Var43 == nil { - templ_7745c5c3_Var43 = templ.NopComponent + templ_7745c5c3_Var60 := templ.GetChildren(ctx) + if templ_7745c5c3_Var60 == nil { + templ_7745c5c3_Var60 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var44 = []any{"leaderboard-rank", templ.KV("top-3", row.Rank <= 3)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var44...) + var templ_7745c5c3_Var61 = []any{"leaderboard-rank", templ.KV("top-3", row.Rank <= 3)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var61...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var46 string - templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(row.Rank)) + var templ_7745c5c3_Var63 string + templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(row.Rank)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 258, Col: 87} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 391, Col: 87} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if row.AvatarURL != "" { - var templ_7745c5c3_Var47 = []any{"leaderboard-avatar", templ.KV("not-connected", !row.Connected)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var47...) + var templ_7745c5c3_Var64 = []any{"leaderboard-avatar", templ.KV("not-connected", !row.Connected)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var64...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\"") ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "\" loading=\"lazy\"> ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - var templ_7745c5c3_Var51 = []any{"leaderboard-avatar-empty", templ.KV("not-connected", !row.Connected)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var51...) + var templ_7745c5c3_Var68 = []any{"leaderboard-avatar-empty", templ.KV("not-connected", !row.Connected)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var68...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "
?
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "\">?
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if row.DisplayName != "" { - var templ_7745c5c3_Var53 string - templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(row.DisplayName) + var templ_7745c5c3_Var70 string + templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinStringErrs(row.DisplayName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 266, Col: 21} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 399, Col: 21} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else if row.Handle != "" { - var templ_7745c5c3_Var54 string - templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs("@" + row.Handle) + var templ_7745c5c3_Var71 string + templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.JoinStringErrs("@" + row.Handle) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 268, Col: 22} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 401, Col: 22} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - var templ_7745c5c3_Var55 string - templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(shortDID(row.DID)) + var templ_7745c5c3_Var72 string + templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinStringErrs(shortDID(row.DID)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 270, Col: 23} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 403, Col: 23} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var56 string - templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(row.ConnectionCount)) + var templ_7745c5c3_Var73 string + templ_7745c5c3_Var73, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(row.ConnectionCount)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 273, Col: 61} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 406, Col: 61} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var73)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, " connections
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, " connections") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1009,157 +1370,157 @@ func interestMatchCard(m InterestMatch) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var57 := templ.GetChildren(ctx) - if templ_7745c5c3_Var57 == nil { - templ_7745c5c3_Var57 = templ.NopComponent + templ_7745c5c3_Var74 := templ.GetChildren(ctx) + if templ_7745c5c3_Var74 == nil { + templ_7745c5c3_Var74 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 113, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if m.AvatarURL != "" { - var templ_7745c5c3_Var58 = []any{"interest-match-avatar", templ.KV("not-connected", !m.Connected)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var58...) + var templ_7745c5c3_Var75 = []any{"interest-match-avatar", templ.KV("not-connected", !m.Connected)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var75...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "\"") ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 117, "\" loading=\"lazy\"> ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - var templ_7745c5c3_Var62 = []any{"interest-match-avatar leaderboard-avatar-empty", templ.KV("not-connected", !m.Connected)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var62...) + var templ_7745c5c3_Var79 = []any{"interest-match-avatar leaderboard-avatar-empty", templ.KV("not-connected", !m.Connected)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var79...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "
?
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 119, "\">?
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 120, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if m.DisplayName != "" { - var templ_7745c5c3_Var64 string - templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinStringErrs(m.DisplayName) + var templ_7745c5c3_Var81 string + templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.JoinStringErrs(m.DisplayName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 287, Col: 20} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 420, Col: 20} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var81)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else if m.Handle != "" { - var templ_7745c5c3_Var65 string - templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs("@" + m.Handle) + var templ_7745c5c3_Var82 string + templ_7745c5c3_Var82, templ_7745c5c3_Err = templ.JoinStringErrs("@" + m.Handle) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 289, Col: 21} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 422, Col: 21} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var82)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - var templ_7745c5c3_Var66 string - templ_7745c5c3_Var66, templ_7745c5c3_Err = templ.JoinStringErrs(shortDID(m.DID)) + var templ_7745c5c3_Var83 string + templ_7745c5c3_Var83, templ_7745c5c3_Err = templ.JoinStringErrs(shortDID(m.DID)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 291, Col: 22} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 424, Col: 22} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var66)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var83)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 121, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if m.Connected { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "✓ connected") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 122, "✓ connected") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 123, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, interest := range m.SharedInterests { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 124, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var67 string - templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.JoinStringErrs(interest) + var templ_7745c5c3_Var84 string + templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.JoinStringErrs(interest) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 300, Col: 55} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 433, Col: 55} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var67)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var84)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 125, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1183,232 +1544,232 @@ func eventDetailProgress(checkins int, totalAttendees int) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var68 := templ.GetChildren(ctx) - if templ_7745c5c3_Var68 == nil { - templ_7745c5c3_Var68 = templ.NopComponent + templ_7745c5c3_Var85 := templ.GetChildren(ctx) + if templ_7745c5c3_Var85 == nil { + templ_7745c5c3_Var85 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "
collective progress
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "\">
collective progress
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if checkins > totalAttendees && totalAttendees > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 129, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var70 string - templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(checkins)) + var templ_7745c5c3_Var87 string + templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(checkins)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 311, Col: 48} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 444, Col: 48} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var87)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, " attendees
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 130, " attendees
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 131, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var71 string - templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(checkins)) + var templ_7745c5c3_Var88 string + templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(checkins)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 313, Col: 48} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 446, Col: 48} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var88)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "/") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 132, "/") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var72 string - templ_7745c5c3_Var72, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(totalAttendees)) + var templ_7745c5c3_Var89 string + templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(totalAttendees)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 313, Col: 90} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 446, Col: 90} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var72)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var89)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, " attendees
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 133, " attendees
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 135, "\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var74 = []any{"progress-tick", templ.KV("unlocked", true)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var74...) + var templ_7745c5c3_Var91 = []any{"progress-tick", templ.KV("unlocked", true)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var91...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "
0
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 137, "\">0
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var76 = []any{"progress-tick", templ.KV("unlocked", checkins >= milestone20(totalAttendees)), templ.KV("next", checkins < milestone20(totalAttendees))} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var76...) + var templ_7745c5c3_Var93 = []any{"progress-tick", templ.KV("unlocked", checkins >= milestone20(totalAttendees)), templ.KV("next", checkins < milestone20(totalAttendees))} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var93...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 139, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var78 string - templ_7745c5c3_Var78, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(milestone20(totalAttendees))) + var templ_7745c5c3_Var95 string + templ_7745c5c3_Var95, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(milestone20(totalAttendees))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 322, Col: 39} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 455, Col: 39} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var78)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var95)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "
leaderboard
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 140, "
leaderboard
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var79 = []any{"progress-tick", templ.KV("unlocked", checkins >= milestone40(totalAttendees)), templ.KV("next", checkins >= milestone20(totalAttendees) && checkins < milestone40(totalAttendees))} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var79...) + var templ_7745c5c3_Var96 = []any{"progress-tick", templ.KV("unlocked", checkins >= milestone40(totalAttendees)), templ.KV("next", checkins >= milestone20(totalAttendees) && checkins < milestone40(totalAttendees))} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var96...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 142, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var81 string - templ_7745c5c3_Var81, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(milestone40(totalAttendees))) + var templ_7745c5c3_Var98 string + templ_7745c5c3_Var98, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(milestone40(totalAttendees))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 325, Col: 39} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 458, Col: 39} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var81)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var98)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "
matching
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 143, "
matching
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var82 = []any{"progress-tick", templ.KV("unlocked", checkins >= milestone80(totalAttendees)), templ.KV("next", checkins >= milestone40(totalAttendees) && checkins < milestone80(totalAttendees))} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var82...) + var templ_7745c5c3_Var99 = []any{"progress-tick", templ.KV("unlocked", checkins >= milestone80(totalAttendees)), templ.KV("next", checkins >= milestone40(totalAttendees) && checkins < milestone80(totalAttendees))} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var99...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 112, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 145, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var84 string - templ_7745c5c3_Var84, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(milestone80(totalAttendees))) + var templ_7745c5c3_Var101 string + templ_7745c5c3_Var101, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(milestone80(totalAttendees))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 328, Col: 39} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 461, Col: 39} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var84)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var101)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 114, "
stats
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "
stats
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var85 string - templ_7745c5c3_Var85, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(totalAttendees)) + var templ_7745c5c3_Var102 string + templ_7745c5c3_Var102, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(totalAttendees)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 330, Col: 52} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 463, Col: 52} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var85)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var102)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 115, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 147, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1440,6 +1801,37 @@ func attendeesPageURL(token, filter string, page int) string { return url } +func maxHourlyCount(hours []HourlyCount) int { + max := 1 + for _, h := range hours { + if h.Count > max { + max = h.Count + } + } + return max +} + +func maxInterestCount(interests []InterestCount) int { + max := 1 + for _, ic := range interests { + if ic.Count > max { + max = ic.Count + } + } + return max +} + +func barPct(count, max int) string { + if max <= 0 { + return "0" + } + pct := float64(count) / float64(max) * 100.0 + if pct < 5 { + pct = 5 // min width so the label is readable + } + return fmt.Sprintf("%.0f", pct) +} + // connectPct returns the fill percentage: connectors / totalAttendees * 100. func connectPct(connectors, total int) string { if total == 0 { diff --git a/internal/apitoken/apitoken.go b/internal/apitoken/apitoken.go new file mode 100644 index 0000000..eb28762 --- /dev/null +++ b/internal/apitoken/apitoken.go @@ -0,0 +1,85 @@ +// Package apitoken implements signed opaque Bearer tokens for the mobile API. +// +// Tokens are HMAC-SHA256 signed strings containing the user's DID and OAuth +// session ID. They are validated statelessly (no DB lookup needed) using the +// same session secret that signs cookies. +// +// Token format: base64url(did + ":" + sid + ":" + hmac_hex) +package apitoken + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "strings" +) + +var ( + ErrInvalidToken = errors.New("apitoken: invalid token") + ErrExpired = errors.New("apitoken: token expired") +) + +// Generator creates and validates Bearer tokens. +type Generator struct { + secret []byte +} + +// New creates a Generator using the given secret key. +func New(secret string) *Generator { + return &Generator{secret: []byte(secret)} +} + +// NewRandom creates a Generator with a random 32-byte key (for testing/dev). +func NewRandom() (*Generator, error) { + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + return nil, err + } + return &Generator{secret: key}, nil +} + +// Issue creates a signed token for the given DID and OAuth session ID. +func (g *Generator) Issue(did, sessionID string) string { + payload := did + "|" + sessionID + mac := g.sign(payload) + raw := payload + "|" + mac + return base64.RawURLEncoding.EncodeToString([]byte(raw)) +} + +// Validate checks a token and returns the DID and session ID. +func (g *Generator) Validate(token string) (did, sessionID string, err error) { + raw, err := base64.RawURLEncoding.DecodeString(token) + if err != nil { + return "", "", ErrInvalidToken + } + + parts := strings.SplitN(string(raw), "|", 3) + if len(parts) != 3 { + return "", "", ErrInvalidToken + } + + did = parts[0] + sessionID = parts[1] + mac := parts[2] + + if did == "" || sessionID == "" { + return "", "", ErrInvalidToken + } + + expected := g.sign(did + "|" + sessionID) + if !hmac.Equal([]byte(mac), []byte(expected)) { + return "", "", ErrInvalidToken + } + + return did, sessionID, nil +} + +// sign computes HMAC-SHA256 of the payload and returns it as a hex string. +func (g *Generator) sign(payload string) string { + h := hmac.New(sha256.New, g.secret) + h.Write([]byte(payload)) + return hex.EncodeToString(h.Sum(nil)) +} diff --git a/internal/apitoken/apitoken_test.go b/internal/apitoken/apitoken_test.go new file mode 100644 index 0000000..dc081c4 --- /dev/null +++ b/internal/apitoken/apitoken_test.go @@ -0,0 +1,54 @@ +package apitoken +package apitoken + +import "testing" + +func TestRoundTrip(t *testing.T) { + g := New("test-secret-key-for-hmac-signing") + + did := "did:plc:abc123" + sid := "sess-456" + + token := g.Issue(did, sid) + if token == "" { + t.Fatal("Issue returned empty token") + } + + gotDID, gotSID, err := g.Validate(token) + if err != nil { + t.Fatalf("Validate: %v", err) + } + if gotDID != did { + t.Errorf("DID: got %q, want %q", gotDID, did) + } + if gotSID != sid { + t.Errorf("SID: got %q, want %q", gotSID, sid) + } +} + +func TestInvalidToken(t *testing.T) { + g := New("test-secret") + + tests := []string{ + "", + "not-base64!@#$", + "bm90LWVub3VnaC1wYXJ0cw", // "not-enough-parts" + } + for _, tok := range tests { + _, _, err := g.Validate(tok) + if err == nil { + t.Errorf("Validate(%q): expected error, got nil", tok) + } + } +} + +func TestWrongSecret(t *testing.T) { + g1 := New("secret-one") + g2 := New("secret-two") + + token := g1.Issue("did:plc:abc", "sess-1") + _, _, err := g2.Validate(token) + if err != ErrInvalidToken { + t.Errorf("expected ErrInvalidToken, got %v", err) + } +} diff --git a/internal/badge/award_test.go b/internal/badge/award_test.go new file mode 100644 index 0000000..0e53b0b --- /dev/null +++ b/internal/badge/award_test.go @@ -0,0 +1,446 @@ +package badge + +import ( + "context" + "database/sql" + "path/filepath" + "testing" + + "atmoquest/internal/admincrypto" + atdb "atmoquest/internal/db" +) + +// --- Test helpers --- + +func newAwardTestDB(t *testing.T) (context.Context, *sql.DB) { + t.Helper() + dir := t.TempDir() + dsn := "file:" + filepath.Join(dir, "award.db") + "?_pragma=foreign_keys(ON)" + conn, err := atdb.Open(dsn) + if err != nil { + t.Fatalf("db open: %v", err) + } + if err := atdb.Migrate(conn); err != nil { + t.Fatalf("migrate: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + return context.Background(), conn +} + +// --- New seeded definitions from migrations --- + +func TestSeededNewBadgeDefinitions(t *testing.T) { + ctx, db := newAwardTestDB(t) + defs, err := ListDefinitions(ctx, db) + if err != nil { + t.Fatalf("ListDefinitions: %v", err) + } + + slugs := make(map[string]bool, len(defs)) + for _, d := range defs { + slugs[d.Slug] = true + } + + // Check migration 010 badges. + for _, expected := range []string{ + "connections-10", "connections-300", "connections-400", "connections-500", + "extra-curious", "besties", + "event-leaderboard-1st", "event-leaderboard-2nd", "event-leaderboard-3rd", + "event-exceeded", + } { + if !slugs[expected] { + t.Errorf("missing seeded badge definition: %q", expected) + } + } + + // Check migration 012 badge. + if !slugs["event-host"] { + t.Error("missing seeded badge definition: event-host") + } +} + +func TestGetDefinitionByTrigger_NewTypes(t *testing.T) { + ctx, db := newAwardTestDB(t) + + triggers := []string{ + "connections-10", "connections-300", "extra-curious", "besties", + "event-leaderboard-1st", "event-exceeded", "event-host", + } + for _, trigger := range triggers { + d, err := GetDefinitionByTrigger(ctx, db, trigger) + if err != nil { + t.Errorf("GetDefinitionByTrigger(%q): %v", trigger, err) + continue + } + if d.Trigger != trigger { + t.Errorf("Trigger = %q; want %q", d.Trigger, trigger) + } + } +} + +// --- RecordEarned with message --- + +func TestRecordEarned_WithMessage(t *testing.T) { + ctx, db := newAwardTestDB(t) + + def, err := GetDefinitionBySlug(ctx, db, "first-connect") + if err != nil { + t.Fatalf("GetDefinitionBySlug: %v", err) + } + + did := "did:plc:testuser1" + msg := "Welcome to CascadiaJS!" + err = RecordEarned(ctx, db, did, def.ID, "", msg) + if err != nil { + t.Fatalf("RecordEarned: %v", err) + } + + earned, err := ListEarned(ctx, db, did) + if err != nil { + t.Fatalf("ListEarned: %v", err) + } + if len(earned) != 1 { + t.Fatalf("ListEarned returned %d; want 1", len(earned)) + } + if earned[0].Message != msg { + t.Errorf("Message = %q; want %q", earned[0].Message, msg) + } + if earned[0].Slug != "first-connect" { + t.Errorf("Slug = %q; want first-connect", earned[0].Slug) + } +} + +func TestRecordEarned_WithoutMessage(t *testing.T) { + ctx, db := newAwardTestDB(t) + + def, err := GetDefinitionBySlug(ctx, db, "event-attendee") + if err != nil { + t.Fatalf("GetDefinitionBySlug: %v", err) + } + + did := "did:plc:testuser2" + err = RecordEarned(ctx, db, did, def.ID, "at://some/uri") + if err != nil { + t.Fatalf("RecordEarned: %v", err) + } + + earned, err := ListEarned(ctx, db, did) + if err != nil { + t.Fatalf("ListEarned: %v", err) + } + if len(earned) != 1 { + t.Fatalf("ListEarned returned %d; want 1", len(earned)) + } + if earned[0].Message != "" { + t.Errorf("Message = %q; want empty", earned[0].Message) + } +} + +// --- HasEarned --- + +func TestHasEarned_AfterRecordWithMessage(t *testing.T) { + ctx, db := newAwardTestDB(t) + + def, err := GetDefinitionBySlug(ctx, db, "first-connect") + if err != nil { + t.Fatal(err) + } + + did := "did:plc:has-earned-test" + if HasEarned(ctx, db, did, def.ID) { + t.Error("HasEarned = true before recording; want false") + } + + _ = RecordEarned(ctx, db, did, def.ID, "", "test message") + + if !HasEarned(ctx, db, did, def.ID) { + t.Error("HasEarned = false after recording; want true") + } +} + +// --- DrainPending --- + +func TestDrainPending_NilSession(t *testing.T) { + ctx, db := newAwardTestDB(t) + res := DrainPending(ctx, nil, db) + if res.Written != 0 || res.Skipped != 0 { + t.Errorf("DrainPending(nil session) = {Written:%d, Skipped:%d}; want zeros", res.Written, res.Skipped) + } +} + +func TestDrainPending_NilDB(t *testing.T) { + res := DrainPending(context.Background(), nil, nil) + if res.Written != 0 { + t.Errorf("DrainPending(nil db) Written = %d; want 0", res.Written) + } +} + +func TestDrainPending_FindsPendingBadges(t *testing.T) { + ctx, db := newAwardTestDB(t) + + // Record a badge locally without a PDS record_uri. + def, err := GetDefinitionBySlug(ctx, db, "extra-curious") + if err != nil { + t.Fatal(err) + } + + did := "did:plc:drain-test" + _ = RecordEarned(ctx, db, did, def.ID, "") // empty record_uri = pending + + // Verify it shows up as pending. + var count int + err = db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM earned_badges + WHERE did = ? AND (record_uri = '' OR record_uri IS NULL) + `, did).Scan(&count) + if err != nil { + t.Fatal(err) + } + if count != 1 { + t.Errorf("pending count = %d; want 1", count) + } +} + +// --- ListForEvent --- + +func TestListForEvent_Empty(t *testing.T) { + ctx, db := newAwardTestDB(t) + + m, err := ListForEvent(ctx, db, "at://nonexistent/event") + if err != nil { + t.Fatalf("ListForEvent: %v", err) + } + if len(m) != 0 { + t.Errorf("ListForEvent returned %d entries; want 0", len(m)) + } +} + +func TestListForEvent_MultipleBadgeTypes(t *testing.T) { + ctx, db := newAwardTestDB(t) + + eventURI := "at://did:plc:org/quest.atmo.event/multi-badge" + // Seed event for FK. + _, _ = db.ExecContext(ctx, ` + INSERT INTO events (uri, name, start_time, end_time, location, organizer_did, cached_at) + VALUES (?, 'Multi Badge Event', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'Test', 'did:plc:org', CURRENT_TIMESTAMP) + `, eventURI) + + // Insert two badge types for same event. + _, _ = db.ExecContext(ctx, ` + INSERT INTO event_badges (event_uri, badge_type, shape, primary_color, accent_color, ribbon_color, label, message_template, signature, signing_key_id) + VALUES (?, 'event-attendee', 'shield', '#fab387', '#1e1e2e', '#f38ba8', 'Attendee', 'Welcome!', 'sig1', 'v1') + `, eventURI) + _, _ = db.ExecContext(ctx, ` + INSERT INTO event_badges (event_uri, badge_type, shape, primary_color, accent_color, ribbon_color, label, message_template, signature, signing_key_id) + VALUES (?, 'event-leaderboard-1st', 'star', '#FFD700', '#1e1e2e', '#f38ba8', '1st', 'Champion!', 'sig2', 'v1') + `, eventURI) + + m, err := ListForEvent(ctx, db, eventURI) + if err != nil { + t.Fatalf("ListForEvent: %v", err) + } + if len(m) != 2 { + t.Fatalf("ListForEvent returned %d; want 2", len(m)) + } + + att, ok := m["event-attendee"] + if !ok { + t.Fatal("missing event-attendee design") + } + if att.Label != "Attendee" { + t.Errorf("event-attendee Label = %q; want 'Attendee'", att.Label) + } + if att.MessageTemplate != "Welcome!" { + t.Errorf("event-attendee MessageTemplate = %q; want 'Welcome!'", att.MessageTemplate) + } + + lb, ok := m["event-leaderboard-1st"] + if !ok { + t.Fatal("missing event-leaderboard-1st design") + } + if lb.Label != "1st" { + t.Errorf("event-leaderboard-1st Label = %q; want '1st'", lb.Label) + } +} + +// --- CountConnectionsForDID --- + +func TestCountConnectionsForDID_Empty(t *testing.T) { + ctx, db := newAwardTestDB(t) + count := CountConnectionsForDID(ctx, db, "did:plc:nobody") + if count != 0 { + t.Errorf("CountConnectionsForDID = %d; want 0", count) + } +} + +func TestCountConnectionsForDID_WithNotes(t *testing.T) { + ctx, db := newAwardTestDB(t) + + did := "did:plc:counter" + // Insert some connection notes to count. + for _, target := range []string{"did:plc:a", "did:plc:b", "did:plc:c"} { + _, _ = db.ExecContext(ctx, ` + INSERT INTO connection_notes (viewer_did, target_did, notes, follow_up) + VALUES (?, ?, 'test', 0) + `, did, target) + } + + count := CountConnectionsForDID(ctx, db, did) + if count != 3 { + t.Errorf("CountConnectionsForDID = %d; want 3", count) + } +} + +// --- Connection milestone thresholds --- + +func TestConnectionMilestones_Defined(t *testing.T) { + // Verify the milestone thresholds are what we expect. + expected := []int{10, 50, 100, 200, 300, 400, 500} + if len(connectionMilestones) != len(expected) { + t.Fatalf("connectionMilestones has %d entries; want %d", len(connectionMilestones), len(expected)) + } + for i, m := range connectionMilestones { + if m != expected[i] { + t.Errorf("connectionMilestones[%d] = %d; want %d", i, m, expected[i]) + } + } +} + +// --- AwardType constants --- + +func TestAwardTypes(t *testing.T) { + cases := []struct { + awardType AwardType + slug string + }{ + {AwardFirstConnect, "first-connect"}, + {AwardEventAttendee, "event-attendee"}, + {AwardExtraCurious, "extra-curious"}, + {AwardBesties, "besties"}, + } + for _, tc := range cases { + if string(tc.awardType) != tc.slug { + t.Errorf("AwardType %q != slug %q", tc.awardType, tc.slug) + } + } +} + +// --- Badge design per-type storage --- + +func TestSaveAndGet_PerBadgeType(t *testing.T) { + ctx, db := newAwardTestDB(t) + + eventURI := "at://did:plc:org/quest.atmo.event/per-type" + _, _ = db.ExecContext(ctx, ` + INSERT INTO events (uri, name, start_time, end_time, location, organizer_did, cached_at) + VALUES (?, 'Per Type Event', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'Test', 'did:plc:org', CURRENT_TIMESTAMP) + `, eventURI) + + signer := testSignerForAward(t) + if signer == nil { + t.Skip("signer not available") + } + + // Save two different badge types for the same event. + d1 := Design{ + EventURI: eventURI, + BadgeType: "event-attendee", + Shape: "shield", + PrimaryColor: "#fab387", + AccentColor: "#1e1e2e", + RibbonColor: "#f38ba8", + Label: "Check-in", + } + _, err := Save(ctx, db, signer, d1) + if err != nil { + t.Fatalf("Save event-attendee: %v", err) + } + + d2 := Design{ + EventURI: eventURI, + BadgeType: "event-leaderboard-1st", + Shape: "star", + PrimaryColor: "#FFD700", + AccentColor: "#1e1e2e", + RibbonColor: "#f38ba8", + Label: "1st", + } + _, err = Save(ctx, db, signer, d2) + if err != nil { + t.Fatalf("Save event-leaderboard-1st: %v", err) + } + + // Retrieve each independently. + got1, err := Get(ctx, db, signer, eventURI, "event-attendee") + if err != nil { + t.Fatalf("Get event-attendee: %v", err) + } + if got1.Label != "Check-in" { + t.Errorf("event-attendee Label = %q; want 'Check-in'", got1.Label) + } + if got1.BadgeType != "event-attendee" { + t.Errorf("BadgeType = %q; want 'event-attendee'", got1.BadgeType) + } + + got2, err := Get(ctx, db, signer, eventURI, "event-leaderboard-1st") + if err != nil { + t.Fatalf("Get event-leaderboard-1st: %v", err) + } + if got2.Label != "1st" { + t.Errorf("event-leaderboard-1st Label = %q; want '1st'", got2.Label) + } + + // Getting a non-existent type returns ErrNotFound. + _, err = Get(ctx, db, signer, eventURI, "event-leaderboard-3rd") + if err != ErrNotFound { + t.Errorf("Get nonexistent type: err = %v; want ErrNotFound", err) + } +} + +func TestSave_Upsert_PerType(t *testing.T) { + ctx, db := newAwardTestDB(t) + + eventURI := "at://did:plc:org/quest.atmo.event/upsert" + _, _ = db.ExecContext(ctx, ` + INSERT INTO events (uri, name, start_time, end_time, location, organizer_did, cached_at) + VALUES (?, 'Upsert Event', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'Test', 'did:plc:org', CURRENT_TIMESTAMP) + `, eventURI) + + signer := testSignerForAward(t) + if signer == nil { + t.Skip("signer not available") + } + + d := Design{ + EventURI: eventURI, + BadgeType: "event-attendee", + Shape: "shield", + PrimaryColor: "#fab387", + AccentColor: "#1e1e2e", + RibbonColor: "#f38ba8", + Label: "v1", + } + _, _ = Save(ctx, db, signer, d) + + // Update label. + d.Label = "v2" + _, err := Save(ctx, db, signer, d) + if err != nil { + t.Fatalf("Save upsert: %v", err) + } + + got, _ := Get(ctx, db, signer, eventURI, "event-attendee") + if got.Label != "v2" { + t.Errorf("Label after upsert = %q; want 'v2'", got.Label) + } +} + +// testSignerForAward creates or loads a test signer. +func testSignerForAward(t *testing.T) *admincrypto.Signer { + t.Helper() + s, err := admincrypto.Load(true) + if err != nil { + return nil + } + return s +} diff --git a/internal/badge/badge.go b/internal/badge/badge.go index 36eef9a..4727dfb 100644 --- a/internal/badge/badge.go +++ b/internal/badge/badge.go @@ -104,6 +104,9 @@ func Save(ctx context.Context, db *sql.DB, signer *admincrypto.Signer, d Design) if err := validate(d); err != nil { return Design{}, err } + if d.BadgeType == "" { + d.BadgeType = "event-attendee" + } body, err := canonicalBytes(d) if err != nil { diff --git a/internal/badge/badge_test.go b/internal/badge/badge_test.go index 877fcdd..7f74346 100644 --- a/internal/badge/badge_test.go +++ b/internal/badge/badge_test.go @@ -79,7 +79,7 @@ func TestSave_AndGet(t *testing.T) { t.Errorf("SigningKeyID = %q; want 'v1'", saved.SigningKeyID) } - got, err := Get(ctx, db, signer, testEventURI) + got, err := Get(ctx, db, signer, testEventURI, "event-attendee") if err != nil { t.Fatalf("Get: %v", err) } @@ -109,7 +109,7 @@ func TestSave_Upsert(t *testing.T) { t.Fatalf("Save update: %v", err) } - got, _ := Get(ctx, db, signer, testEventURI) + got, _ := Get(ctx, db, signer, testEventURI, "event-attendee") if got.Label != "Updated Label" { t.Errorf("Label after upsert = %q; want 'Updated Label'", got.Label) } @@ -131,7 +131,7 @@ func TestSave_NilSigner(t *testing.T) { func TestGet_NotFound(t *testing.T) { ctx, db := newBadgeTestDB(t) signer := testSigner(t) - _, err := Get(ctx, db, signer, "at://nonexistent") + _, err := Get(ctx, db, signer, "at://nonexistent", "event-attendee") if !errors.Is(err, ErrNotFound) { t.Errorf("Get missing = %v; want ErrNotFound", err) } @@ -152,7 +152,7 @@ func TestGet_DetectsTampering(t *testing.T) { t.Fatalf("tamper: %v", err) } - _, err = Get(ctx, db, signer, testEventURI) + _, err = Get(ctx, db, signer, testEventURI, "event-attendee") if !errors.Is(err, ErrInvalidSignature) { t.Errorf("Get tampered = %v; want ErrInvalidSignature", err) } @@ -171,7 +171,7 @@ func TestGet_NilSignerSkipsVerification(t *testing.T) { `, testEventURI) // With nil signer, verification is skipped — tampered data is returned. - got, err := Get(ctx, db, nil, testEventURI) + got, err := Get(ctx, db, nil, testEventURI, "event-attendee") if err != nil { t.Fatalf("Get nil signer: %v", err) } diff --git a/internal/badge/definitions_test.go b/internal/badge/definitions_test.go index 6205077..c860464 100644 --- a/internal/badge/definitions_test.go +++ b/internal/badge/definitions_test.go @@ -32,9 +32,9 @@ func TestListDefinitions_SeededDefaults(t *testing.T) { if err != nil { t.Fatalf("ListDefinitions: %v", err) } - // Migration 007 seeds 6 badge definitions. - if len(defs) < 6 { - t.Errorf("ListDefinitions returned %d; want at least 6 seeded defaults", len(defs)) + // Migration 007 seeds 6, migration 010 adds 10, migration 012 adds 1 = 17 total. + if len(defs) < 17 { + t.Errorf("ListDefinitions returned %d; want at least 17 seeded defaults", len(defs)) } // Check known slugs exist. slugs := make(map[string]bool, len(defs)) diff --git a/internal/db/migrations/013_event_summary_cache.sql b/internal/db/migrations/013_event_summary_cache.sql new file mode 100644 index 0000000..399c9d2 --- /dev/null +++ b/internal/db/migrations/013_event_summary_cache.sql @@ -0,0 +1,19 @@ +-- 013_event_summary_cache.sql +-- Adds cached event summary statistics, computed at finalization time. +-- These power the post-event summary view without expensive re-computation. + +CREATE TABLE IF NOT EXISTS event_summary ( + event_uri TEXT PRIMARY KEY, + -- Basic stats (everyone sees) + total_connections INTEGER NOT NULL DEFAULT 0, + total_checkins INTEGER NOT NULL DEFAULT 0, + new_signups INTEGER NOT NULL DEFAULT 0, -- users whose first login was during this event + -- Hourly connection data (JSON array of {hour: "2026-06-01T10:00", count: N}) + connections_by_hour TEXT NOT NULL DEFAULT '[]', + -- Interest distribution (JSON array of {interest: "rust", count: N}) + interest_distribution TEXT NOT NULL DEFAULT '[]', + -- Peak hour label + peak_hour TEXT NOT NULL DEFAULT '', + computed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (event_uri) REFERENCES events (uri) ON DELETE CASCADE +); diff --git a/internal/db/migrations/014_device_tokens.sql b/internal/db/migrations/014_device_tokens.sql new file mode 100644 index 0000000..b7e525f --- /dev/null +++ b/internal/db/migrations/014_device_tokens.sql @@ -0,0 +1,9 @@ +-- Push notification device tokens for the mobile app. +CREATE TABLE IF NOT EXISTS device_tokens ( + did TEXT NOT NULL, + push_token TEXT NOT NULL, + platform TEXT NOT NULL CHECK (platform IN ('ios', 'android')), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (did, push_token) +); diff --git a/internal/event/put.go b/internal/event/put.go index 637db87..295561b 100644 --- a/internal/event/put.go +++ b/internal/event/put.go @@ -308,6 +308,40 @@ func Delete(ctx context.Context, sess *oauth.ClientSession, db *sql.DB, uri stri } // rkeyFromURI extracts the record key (last path segment) from an at:// URI. +// UpdateInput is the payload for editing an existing event's local cache. +type UpdateInput struct { + Name string + StartTime time.Time + EndTime time.Time + Location string + ExpectedAttendees int +} + +// UpdateLocal updates an event's local cache row. Does not touch the PDS +// record (ATProto records are immutable; a future version could delete + recreate). +func UpdateLocal(ctx context.Context, db *sql.DB, uri string, in UpdateInput) error { + if uri == "" { + return errors.New("event: empty URI") + } + if in.Name == "" { + return errors.New("event: empty name") + } + if in.EndTime.Before(in.StartTime) { + return errors.New("event: end_time before start_time") + } + _, err := db.ExecContext(ctx, ` + UPDATE events SET + name = ?, + start_time = ?, + end_time = ?, + location = ?, + expected_attendees = ?, + cached_at = CURRENT_TIMESTAMP + WHERE uri = ? + `, in.Name, in.StartTime.UTC(), in.EndTime.UTC(), in.Location, in.ExpectedAttendees, uri) + return err +} + func rkeyFromURI(uri string) string { parts := strings.Split(uri, "/") if len(parts) == 0 { diff --git a/internal/event/summary.go b/internal/event/summary.go new file mode 100644 index 0000000..9571028 --- /dev/null +++ b/internal/event/summary.go @@ -0,0 +1,181 @@ +package event + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "time" + + "github.com/bluesky-social/indigo/atproto/syntax" +) + +// Summary holds the cached event summary statistics. +type Summary struct { + EventURI string + TotalConnections int + TotalCheckins int + NewSignups int + ConnectionsByHour []HourlyCount + InterestDistribution []InterestCount + PeakHour string + ComputedAt time.Time +} + +// HourlyCount is a connection count for a specific hour. +type HourlyCount struct { + Hour string `json:"hour"` + Count int `json:"count"` +} + +// InterestCount is an interest and how many attendees share it. +type InterestCount struct { + Interest string `json:"interest"` + Count int `json:"count"` +} + +// GetSummary retrieves cached summary stats for an event. +// Returns nil if no summary has been computed yet. +func GetSummary(ctx context.Context, db *sql.DB, eventURI string) (*Summary, error) { + var s Summary + var connJSON, interestJSON string + err := db.QueryRowContext(ctx, ` + SELECT event_uri, total_connections, total_checkins, new_signups, + connections_by_hour, interest_distribution, peak_hour, computed_at + FROM event_summary WHERE event_uri = ? + `, eventURI).Scan( + &s.EventURI, &s.TotalConnections, &s.TotalCheckins, &s.NewSignups, + &connJSON, &interestJSON, &s.PeakHour, &s.ComputedAt, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + _ = json.Unmarshal([]byte(connJSON), &s.ConnectionsByHour) + _ = json.Unmarshal([]byte(interestJSON), &s.InterestDistribution) + return &s, nil +} + +// ComputeAndCacheSummary calculates event stats and caches them. +// Should be called during event finalization. +func ComputeAndCacheSummary(ctx context.Context, db *sql.DB, eventURI string, attendeeDIDs []string, ev Record, pdsLookup func(syntax.DID) string, profileFetcher func(ctx context.Context, pds string, did syntax.DID) []string) error { + // Total checkins (unique attendees). + totalCheckins := len(attendeeDIDs) + + // Count total connections at this event. + totalConnections := 0 + + // We need to scan all attendees' connections for this event. + // This is expensive but only done once at finalization. + for _, didStr := range attendeeDIDs { + did, err := syntax.ParseDID(didStr) + if err != nil { + continue + } + pds := pdsLookup(did) + + // Use the connection list helper (imported at call site). + // We'll count via a DB query instead for efficiency. + var count int + _ = db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM connection_notes WHERE viewer_did = ? + `, didStr).Scan(&count) + _ = count // we'll use PDS data below + _ = pds + } + + // Count connections by looking at connection_notes for the event timeframe. + // Actually, let's count from event_stats which already tracks this. + var stats Stats + _ = db.QueryRowContext(ctx, ` + SELECT COALESCE(unique_connectors, 0), COALESCE(total_checkins, 0) + FROM event_stats WHERE event_uri = ? + `, eventURI).Scan(&stats.UniqueConnectors, &stats.TotalCheckins) + totalConnections = stats.UniqueConnectors + + // Count new signups: users whose created_at falls within the event window. + newSignups := 0 + _ = db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM users + WHERE did IN (SELECT did FROM checkins WHERE event_uri = ?) + AND created_at BETWEEN ? AND ? + `, eventURI, ev.StartTime.UTC(), ev.EndTime.UTC()).Scan(&newSignups) + + // Compute connections by hour from checkin timestamps. + rows, err := db.QueryContext(ctx, ` + SELECT strftime('%Y-%m-%dT%H:00', checked_in_at) as hour, COUNT(*) as cnt + FROM checkins + WHERE event_uri = ? + GROUP BY hour + ORDER BY hour + `, eventURI) + var byHour []HourlyCount + peakHour := "" + peakCount := 0 + if err == nil { + defer rows.Close() + for rows.Next() { + var h HourlyCount + if rows.Scan(&h.Hour, &h.Count) == nil { + byHour = append(byHour, h) + if h.Count > peakCount { + peakCount = h.Count + peakHour = h.Hour + } + } + } + } + + // Compute interest distribution from attendee profiles. + interestCounts := make(map[string]int) + if profileFetcher != nil { + for _, didStr := range attendeeDIDs { + did, err := syntax.ParseDID(didStr) + if err != nil { + continue + } + pds := pdsLookup(did) + interests := profileFetcher(ctx, pds, did) + for _, interest := range interests { + interestCounts[interest]++ + } + } + } + var interestDist []InterestCount + for interest, count := range interestCounts { + interestDist = append(interestDist, InterestCount{Interest: interest, Count: count}) + } + // Sort by count descending. + for i := 0; i < len(interestDist); i++ { + for j := i + 1; j < len(interestDist); j++ { + if interestDist[j].Count > interestDist[i].Count { + interestDist[i], interestDist[j] = interestDist[j], interestDist[i] + } + } + } + + // Cache results. + byHourJSON, _ := json.Marshal(byHour) + interestJSON, _ := json.Marshal(interestDist) + + _, err = db.ExecContext(ctx, ` + INSERT INTO event_summary (event_uri, total_connections, total_checkins, new_signups, + connections_by_hour, interest_distribution, peak_hour, computed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(event_uri) DO UPDATE SET + total_connections = excluded.total_connections, + total_checkins = excluded.total_checkins, + new_signups = excluded.new_signups, + connections_by_hour = excluded.connections_by_hour, + interest_distribution = excluded.interest_distribution, + peak_hour = excluded.peak_hour, + computed_at = CURRENT_TIMESTAMP + `, eventURI, totalConnections, totalCheckins, newSignups, + string(byHourJSON), string(interestJSON), peakHour) + if err != nil { + return fmt.Errorf("cache event summary: %w", err) + } + return nil +} diff --git a/internal/push/push.go b/internal/push/push.go new file mode 100644 index 0000000..716321a --- /dev/null +++ b/internal/push/push.go @@ -0,0 +1,133 @@ +// Package push sends push notifications to mobile devices via the Expo Push +// Notification service (https://docs.expo.dev/push-notifications/sending-notifications/). +// +// The Expo Push API is free and handles both iOS (APNs) and Android (FCM) +// delivery transparently using Expo push tokens (format: ExponentPushToken[...]). +package push + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "time" +) + +const expoPushURL = "https://exp.host/--/api/v2/push/send" + +// Notification describes a push notification to send. +type Notification struct { + Title string `json:"title"` + Body string `json:"body"` + // Data is arbitrary JSON data the mobile app receives when tapping the + // notification. Typically includes a route to navigate to. + Data map[string]string `json:"data,omitempty"` +} + +// expoMessage is the wire format for the Expo Push API. +type expoMessage struct { + To string `json:"to"` + Title string `json:"title"` + Body string `json:"body"` + Sound string `json:"sound,omitempty"` + Data map[string]string `json:"data,omitempty"` +} + +// Sender sends push notifications via the Expo Push API. +type Sender struct { + DB *sql.DB + client *http.Client +} + +// NewSender creates a push notification sender. +func NewSender(db *sql.DB) *Sender { + return &Sender{ + DB: db, + client: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +// SendToUser sends a notification to all registered devices for a given DID. +func (s *Sender) SendToUser(ctx context.Context, did string, notif Notification) error { + tokens, err := s.getTokensForUser(ctx, did) + if err != nil { + return fmt.Errorf("push: get tokens: %w", err) + } + if len(tokens) == 0 { + return nil // User has no registered devices — not an error. + } + + messages := make([]expoMessage, 0, len(tokens)) + for _, token := range tokens { + messages = append(messages, expoMessage{ + To: token, + Title: notif.Title, + Body: notif.Body, + Sound: "default", + Data: notif.Data, + }) + } + + return s.send(ctx, messages) +} + +// SendToUsers sends the same notification to multiple users. +func (s *Sender) SendToUsers(ctx context.Context, dids []string, notif Notification) { + for _, did := range dids { + if err := s.SendToUser(ctx, did, notif); err != nil { + slog.Warn("push: send to user", "did", did, "err", err) + } + } +} + +// getTokensForUser returns all push tokens registered for a DID. +func (s *Sender) getTokensForUser(ctx context.Context, did string) ([]string, error) { + rows, err := s.DB.QueryContext(ctx, ` + SELECT push_token FROM device_tokens WHERE did = ? + `, did) + if err != nil { + return nil, err + } + defer rows.Close() + + var tokens []string + for rows.Next() { + var t string + if rows.Scan(&t) == nil { + tokens = append(tokens, t) + } + } + return tokens, rows.Err() +} + +// send posts messages to the Expo Push API. +func (s *Sender) send(ctx context.Context, messages []expoMessage) error { + body, err := json.Marshal(messages) + if err != nil { + return fmt.Errorf("push: marshal: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", expoPushURL, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("push: new request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := s.client.Do(req) + if err != nil { + return fmt.Errorf("push: send: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return fmt.Errorf("push: expo returned %d", resp.StatusCode) + } + + return nil +} diff --git a/router/router.go b/router/router.go index 37978f8..96c7447 100644 --- a/router/router.go +++ b/router/router.go @@ -19,6 +19,7 @@ import ( "atmoquest/config" "atmoquest/features/about" "atmoquest/features/admin" + "atmoquest/features/api" "atmoquest/features/auth" "atmoquest/features/connect" "atmoquest/features/connections" @@ -29,7 +30,9 @@ import ( "atmoquest/features/profile" "atmoquest/features/settings" "atmoquest/internal/admincrypto" + "atmoquest/internal/apitoken" "atmoquest/internal/connection" + "atmoquest/internal/push" "atmoquest/internal/session" "atmoquest/web/resources" ) @@ -78,6 +81,11 @@ func SetupRoutes( demo.SetupRoutes(router) index.SetupRoutes(router, conn, authH) + // JSON API for mobile app. + tokens := apitoken.New(config.Global.SessionSecret) + pusher := push.NewSender(conn) + api.SetupRoutes(router, conn, authH, connQueue, tokens, pusher) + router.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) diff --git a/web/resources/static/css/terminal.css b/web/resources/static/css/terminal.css index 71423a5..e08a84b 100644 --- a/web/resources/static/css/terminal.css +++ b/web/resources/static/css/terminal.css @@ -1305,6 +1305,14 @@ footer a:hover { color: var(--lavender); } .admin-table .nowrap { white-space: nowrap; } .admin-table .handle { color: var(--green); } .admin-table .muted { color: var(--overlay2); } +.admin-event-actions { + display: flex; + flex-direction: column; + gap: 4px; + align-items: stretch; + min-width: 80px; +} +.admin-event-actions .btn { text-align: center; } .admin-inline-form { display: inline-flex; gap: 0; margin: 0; padding: 0; } .role-pill { @@ -1968,6 +1976,88 @@ footer a:hover { color: var(--lavender); } color: var(--subtext0); } +/* ─── Event Summary ─────────────────────────────────────── */ +.event-summary-section { margin-top: 16px; } +.event-summary-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); + gap: 10px; + margin: 10px 0; +} +.summary-stat-card { + background: var(--surface0); + border: 1px solid var(--surface1); + border-radius: 8px; + padding: 14px 12px; + text-align: center; +} +.summary-stat-num { + font-size: 1.5rem; + font-weight: 700; + color: var(--peach); + line-height: 1; +} +.summary-stat-label { + font-size: 0.7rem; + color: var(--subtext0); + margin-top: 4px; +} +.followup-list { + display: flex; + flex-direction: column; + gap: 6px; + margin: 8px 0; +} +.followup-item { + display: flex; + align-items: baseline; + gap: 8px; + padding: 8px 10px; + background: var(--surface0); + border: 1px solid var(--surface1); + border-radius: 6px; + text-decoration: none; + color: inherit; + font-size: 0.8rem; +} +.followup-item:hover { border-color: var(--peach); } +.followup-notes { font-size: 0.7rem; } +.bar-chart { + display: flex; + flex-direction: column; + gap: 4px; + margin: 8px 0; +} +.bar-chart-row { + display: flex; + align-items: center; + gap: 8px; +} +.bar-chart-label { + font-size: 0.65rem; + color: var(--subtext0); + min-width: 120px; + max-width: 120px; + text-align: right; + flex-shrink: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.bar-chart-bar { + background: var(--peach); + border-radius: 3px; + padding: 2px 6px; + min-width: 20px; + transition: width 0.3s; +} +.bar-chart-bar.accent { background: var(--lavender); } +.bar-chart-val { + font-size: 0.6rem; + color: var(--crust); + font-weight: 600; +} + /* Event progress bar */ .event-progress { margin: 10px 0 4px; @@ -2892,10 +2982,11 @@ footer a:hover { color: var(--lavender); } overflow: hidden; text-overflow: ellipsis; } -.conn-handle { - font-size: 10px; - color: var(--muted); - margin-bottom: 4px; +.conn-handle-inline { + font-weight: 400; + color: var(--subtext0); + font-size: 11px; + margin-left: 6px; } .conn-meta { font-size: 9px;