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