package web import ( "context" "errors" "log/slog" "net/http" "github.com/a-h/templ" "gitea.d-ma.be/mathias/tapir/internal/adapters/store" ) // Store is the read/write surface the web handlers depend on — a narrow port over // the Postgres store (Clean Architecture: handlers depend on this interface, not // the concrete *store.Store). *store.Store satisfies it; tests can substitute a // fake without a database. type Store interface { ListSummaries(ctx context.Context, userID string, limit int) ([]store.SummaryRow, error) GetSummaryByVideo(ctx context.Context, userID, videoID string) (*store.SummaryRow, error) ActionsFor(ctx context.Context, userID string, videoIDs []string) (map[string][]string, error) SetAction(ctx context.Context, userID, videoID, action string) error ClearAction(ctx context.Context, userID, videoID, action string) error } // App is the Stage-0 web surface: handlers over the store, gated by an Auth // implementation. UserID is the single configured tapir user every store // operation runs as (ADR-011 — Auth only gates access; it does not select the // store identity). type App struct { Store Store Auth Auth UserID string Log *slog.Logger } func (a *App) logger() *slog.Logger { if a.Log != nil { return a.Log } return slog.Default() } // Router wires the routes: /healthz (no auth) and /auth/* (the Auth impl's own // endpoints) sit outside the guard; everything else is wrapped by // Auth.Middleware. Go 1.22+ method+path patterns route directly. func (a *App) Router() http.Handler { root := http.NewServeMux() root.HandleFunc("GET /healthz", a.handleHealthz) root.Handle("GET /static/", staticHandler()) root.Handle("/auth/", a.Auth.Routes()) app := http.NewServeMux() app.HandleFunc("GET /{$}", a.handleList) app.HandleFunc("GET /v/{videoId}", a.handleDetail) app.HandleFunc("POST /v/{videoId}/action", a.handleAction) root.Handle("/", a.Auth.Middleware(app)) return root } // handleHealthz is the unauthenticated liveness/readiness probe. func (a *App) handleHealthz(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") _, _ = w.Write([]byte("ok")) } // handleList renders the summary list, applying the channel/date filters from the // query string. An HTMX request gets only the table fragment so the filter form // can swap #summary-list in place; a plain request gets the full page. func (a *App) handleList(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() f := Filter{ Channel: q.Get("channel"), From: q.Get("from"), To: q.Get("to"), } rows, err := a.Store.ListSummaries(r.Context(), a.UserID, 0) if err != nil { a.serverError(w, r, "list summaries", err) return } rows = f.apply(rows) if isHTMX(r) { a.render(w, r, summaryList(rows)) return } a.render(w, r, ListPage(rows, f)) } // handleDetail renders one summary in full (highlights, takeaways, action group). func (a *App) handleDetail(w http.ResponseWriter, r *http.Request) { videoID := r.PathValue("videoId") row, err := a.Store.GetSummaryByVideo(r.Context(), a.UserID, videoID) if errors.Is(err, store.ErrNotFound) { http.NotFound(w, r) return } if err != nil { a.serverError(w, r, "get summary", err) return } a.render(w, r, DetailPage(*row)) } // handleAction toggles one action: re-clicking an active verb clears it, else it // is set (the store enforces watched↔skipped exclusion atomically). It returns // the refreshed button-group fragment for HTMX; without JS it redirects back to // the detail page (POST→redirect→GET). func (a *App) handleAction(w http.ResponseWriter, r *http.Request) { videoID := r.PathValue("videoId") action := r.FormValue("action") if !isActionVerb(action) { http.Error(w, "unknown action", http.StatusBadRequest) return } current, err := a.Store.ActionsFor(r.Context(), a.UserID, []string{videoID}) if err != nil { a.serverError(w, r, "read actions", err) return } if actionSet(current[videoID])[action] { err = a.Store.ClearAction(r.Context(), a.UserID, videoID, action) } else { err = a.Store.SetAction(r.Context(), a.UserID, videoID, action) } if err != nil { a.serverError(w, r, "toggle action", err) return } updated, err := a.Store.ActionsFor(r.Context(), a.UserID, []string{videoID}) if err != nil { a.serverError(w, r, "read actions", err) return } if isHTMX(r) { a.render(w, r, ActionButtons(videoID, actionSet(updated[videoID]))) return } http.Redirect(w, r, "/v/"+videoID, http.StatusSeeOther) } // render writes a templ component as HTML. A render error is logged, not retried: // headers may already be flushed, so there is nothing useful to send the client. func (a *App) render(w http.ResponseWriter, r *http.Request, c templ.Component) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := c.Render(r.Context(), w); err != nil { a.logger().Error("render", "path", r.URL.Path, "err", err) } } func (a *App) serverError(w http.ResponseWriter, r *http.Request, op string, err error) { a.logger().Error("handler error", "op", op, "path", r.URL.Path, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) } // isHTMX reports whether the request came from HTMX, which sets HX-Request: true. func isHTMX(r *http.Request) bool { return r.Header.Get("HX-Request") == "true" }