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-1 web surface: handlers over the store, gated by an Auth // implementation (authentication) and a registration gate (which resolves the // authenticated subject to its tapir user_id and stashes it per request). Every // data handler scopes by that resolved id — CurrentUserID(r) — not by a single // configured user (ADR-012, multi-user with enforced isolation). type App struct { Store Store Identity Identity Auth Auth Log *slog.Logger // Connect runs the web-initiated YouTube OAuth connect flow. Optional: when // nil (e.g. dev without YouTube client credentials), the /oauth/youtube/* // routes are not mounted. Connect *ConnectHandler } 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) app.HandleFunc("GET /register", a.handleRegisterForm) app.HandleFunc("POST /register", a.handleRegister) // Web-initiated YouTube connect (ADR-006). Gated like every app route, so // CurrentUserID is set and the connection binds to the authenticated user. if a.Connect != nil { app.HandleFunc("GET /oauth/youtube/connect", a.Connect.handleConnect) app.HandleFunc("GET /oauth/youtube/callback", a.Connect.handleCallback) } // Two layers: Auth.Middleware requires a Dex session (you must be logged in); // registrationGate requires a tapir user (else → /register) and stashes the // resolved user_id. /register lives inside the auth guard but is exempt from // the registration gate (you must be able to reach it before you have a user). root.Handle("/", a.Auth.Middleware(a.registrationGate(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) { userID, ok := a.currentUserID(w, r) if !ok { return } 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(), 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, takeFlash(w, r))) } // handleDetail renders one summary in full (highlights, takeaways, action group). func (a *App) handleDetail(w http.ResponseWriter, r *http.Request) { userID, ok := a.currentUserID(w, r) if !ok { return } videoID := r.PathValue("videoId") row, err := a.Store.GetSummaryByVideo(r.Context(), 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) { userID, ok := a.currentUserID(w, r) if !ok { return } 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(), 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(), userID, videoID, action) } else { err = a.Store.SetAction(r.Context(), userID, videoID, action) } if err != nil { a.serverError(w, r, "toggle action", err) return } updated, err := a.Store.ActionsFor(r.Context(), 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) } // currentUserID returns the tapir user_id the registration gate resolved for this // request. Behind the gate it is always present; a miss means a handler was // reached without scoping (a wiring bug), so it answers 500 and reports false. func (a *App) currentUserID(w http.ResponseWriter, r *http.Request) (string, bool) { id, ok := CurrentUserID(r) if !ok { a.serverError(w, r, "current user", errNoCurrentUser) } return id, ok } // 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) { a.renderStatus(w, r, http.StatusOK, c) } // renderStatus writes a templ component as HTML with an explicit status code (the // Content-Type must be set before WriteHeader, so this is the single place that // orders them correctly). func (a *App) renderStatus(w http.ResponseWriter, r *http.Request, status int, c templ.Component) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(status) 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" }