package savedsearch
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"go.bigb.es/auxilia/culpa"
"go.bigb.es/auxilia/scribe"
"sourcecraft.dev/bigbes/lethe/internal/pkg/apierror"
"sourcecraft.dev/bigbes/lethe/internal/pkg/httputil"
"sourcecraft.dev/bigbes/lethe/internal/server/auth"
)
// Handler is the steward-managed HTTP boundary for the saved-searches CRUD API.
// Repo is the injected SQL steward; the Handler holds no other state.
type Handler struct {
Repo *Repository `inject:""`
}
// Init satisfies the steward Initer contract. The Handler is stateless beyond
// its injected dependencies.
func (h *Handler) Init(_ context.Context) error { return nil }
// Mount registers the four CRUD routes under r. Server.Init mounts this inside
// the /api/v1 group, so the effective paths are:
//
// GET /api/v1/saved-searches
// POST /api/v1/saved-searches
// PUT /api/v1/saved-searches/{name}
// DELETE /api/v1/saved-searches/{name}
func (h *Handler) Mount(r chi.Router) {
r.Get("/saved-searches", h.List)
r.Post("/saved-searches", h.Create)
r.Put("/saved-searches/{name}", h.Update)
r.Delete("/saved-searches/{name}", h.Delete)
}
// ownerOf derives the owner from the authenticated identity on the context.
// The ?owner= query parameter is intentionally ignored (IV2).
func (h *Handler) ownerOf(r *http.Request) string {
return auth.MustIdentity(r.Context()).User
}
// validateName enforces the name constraints (IV7, UK1):
// - non-empty
// - at most 64 characters
// - no "/" character
func validateName(name string) error {
if name == "" {
return culpa.WithCode(
culpa.WithPublic(culpa.New("name is empty"), "name must not be empty"),
"VALIDATION",
)
}
if len(name) > 64 {
return culpa.WithCode(
culpa.WithPublic(culpa.New("name too long"), "name must be 64 characters or fewer"),
"VALIDATION",
)
}
if strings.Contains(name, "/") {
return culpa.WithCode(
culpa.WithPublic(culpa.New("name contains slash"), "name must not contain '/'"),
"VALIDATION",
)
}
return nil
}
// validateQuery enforces the query constraints: non-empty.
func validateQuery(query string) error {
if query == "" {
return culpa.WithCode(
culpa.WithPublic(culpa.New("query is empty"), "query must not be empty"),
"VALIDATION",
)
}
return nil
}
// listResponse is the JSON body returned by List.
type listResponse struct {
SavedSearches []SavedSearch `json:"saved_searches"`
}
// List handles GET /saved-searches. The owner is derived from the auth context;
// ?owner= is silently ignored (IV2). Writes { saved_searches: [...] }.
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
owner := h.ownerOf(r)
rows, err := h.Repo.List(r.Context(), owner)
if err != nil {
apierror.Render(w, r, err)
return
}
if writeErr := httputil.WriteJSON(w, http.StatusOK, listResponse{SavedSearches: rows}); writeErr != nil {
slog.Default().ErrorContext(r.Context(), "write saved-searches list response", scribe.Err(writeErr))
}
}
// createRequest is the decoded JSON body for POST /saved-searches.
type createRequest struct {
Name string `json:"name"`
Query string `json:"query"`
}
// Create handles POST /saved-searches. Decodes { name, query }, validates both
// fields, inserts the row with now as both timestamps, and returns 201 Created
// with the new row body.
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
// Defensive: reject any explicit ?owner= param to prevent confusion (IV2).
if r.URL.Query().Get("owner") != "" {
apierror.Render(w, r, culpa.WithCode(
culpa.WithPublic(culpa.New("?owner= not accepted on write paths"), "?owner= is not accepted on saved-search write paths"),
"INVALID",
))
return
}
var req createRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
apierror.Render(w, r, culpa.WithCode(
culpa.WithPublic(culpa.Wrap(err, "decode body"), "invalid request body"),
"INVALID",
))
return
}
if err := validateName(req.Name); err != nil {
apierror.Render(w, r, err)
return
}
if err := validateQuery(req.Query); err != nil {
apierror.Render(w, r, err)
return
}
now := time.Now().Unix()
s := SavedSearch{
Owner: h.ownerOf(r),
Name: req.Name,
Query: req.Query,
CreatedAt: now,
UpdatedAt: now,
}
if err := h.Repo.Create(r.Context(), s); err != nil {
apierror.Render(w, r, err)
return
}
if writeErr := httputil.WriteJSON(w, http.StatusCreated, s); writeErr != nil {
slog.Default().ErrorContext(r.Context(), "write saved-search create response", scribe.Err(writeErr))
}
}
// updateRequest is the decoded JSON body for PUT /saved-searches/{name}.
// Both fields are optional; at least one must be non-nil.
type updateRequest struct {
Name *string `json:"name"`
Query *string `json:"query"`
}
// Update handles PUT /saved-searches/{name}. Decodes { name?, query? }, requires
// at least one field, validates non-nil fields, and returns 200 with the updated row.
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
// Defensive: reject any explicit ?owner= param (IV2).
if r.URL.Query().Get("owner") != "" {
apierror.Render(w, r, culpa.WithCode(
culpa.WithPublic(culpa.New("?owner= not accepted on write paths"), "?owner= is not accepted on saved-search write paths"),
"INVALID",
))
return
}
urlName := chi.URLParam(r, "name")
var req updateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
apierror.Render(w, r, culpa.WithCode(
culpa.WithPublic(culpa.Wrap(err, "decode body"), "invalid request body"),
"INVALID",
))
return
}
if req.Name == nil && req.Query == nil {
apierror.Render(w, r, culpa.WithCode(
culpa.WithPublic(culpa.New("no fields to update"), "at least one of name or query must be provided"),
"VALIDATION",
))
return
}
if req.Name != nil {
if err := validateName(*req.Name); err != nil {
apierror.Render(w, r, err)
return
}
}
if req.Query != nil {
if err := validateQuery(*req.Query); err != nil {
apierror.Render(w, r, err)
return
}
}
owner := h.ownerOf(r)
now := time.Now().Unix()
updated, err := h.Repo.Update(r.Context(), owner, urlName, req.Name, req.Query, now)
if err != nil {
apierror.Render(w, r, err)
return
}
if writeErr := httputil.WriteJSON(w, http.StatusOK, updated); writeErr != nil {
slog.Default().ErrorContext(r.Context(), "write saved-search update response", scribe.Err(writeErr))
}
}
// Delete handles DELETE /saved-searches/{name}. Returns 204 No Content on
// success, 404 when the row does not exist.
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
// Defensive: reject any explicit ?owner= param to prevent confusion (IV2).
if r.URL.Query().Get("owner") != "" {
apierror.Render(w, r, culpa.WithCode(
culpa.WithPublic(culpa.New("?owner= not accepted on write paths"), "?owner= is not accepted on saved-search write paths"),
"INVALID",
))
return
}
owner := h.ownerOf(r)
name := chi.URLParam(r, "name")
if err := h.Repo.Delete(r.Context(), owner, name); err != nil {
apierror.Render(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}