Files
MealQuest/internal/server/server.go
T
dev_agentandClaude Opus 5.5 cdbec3f3a2
CI / ci (push) Successful in 8m28s
CI / ci (pull_request) Successful in 8m20s
Add GET /api/v1/home for the Home dashboard
home.Service aggregates the Home screen from the services that own each
value, at "now" in the household's zone: the date label, tonight's dinner
from the current week's plan (null with no plan), the pending Side quest
count from the deck, the Supply run status of the upcoming week or else
the current one, and recentUpdates.unread.

unread is whether a notification was stored in notification_outbox after
household_settings.updates_seen_at (or at all while it is null), read
through the new notify.StoredSince over the HasNotificationSince query.
Nothing writes updates_seen_at yet; the updates task adds that.

Tests under America/Chicago show tonight's dinner flips at local
midnight, not UTC midnight, and cover no plan, no suggestions, the
Supply run choice and unread.

Maestro task 9ccfc3a6-cc7b-493a-aa4b-2b455d26f4df.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:19:33 -04:00

252 lines
12 KiB
Go

// Package server builds the MealQuest HTTP router.
package server
import (
"encoding/json"
"errors"
"io/fs"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/forwarded"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/push"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest"
)
// reservedPrefixes are owned by the API router and are never answered with the
// SPA's index.html.
var reservedPrefixes = []string{"/api", "/auth", "/media", "/health"}
// RouteMounter registers its own routes on the router.
type RouteMounter interface {
Mount(r chi.Router)
}
// NewRouter returns the HTTP handler for the service. Every request first
// has its client address and scheme resolved, honouring forwarded headers
// only from a peer in trusted (see resolveOrigin). version is reported by
// GET /health; auth mounts the /auth routes; sessions guards /api/v1 and
// /media; settings serves /api/v1/settings/household and
// /api/v1/settings/notifications; search serves
// /api/v1/settings/recipe-search and its /test; ai serves
// /api/v1/settings/ai, its /api-key, /verify, /models and /reset-prompt;
// chatSvc serves GET and PUT /api/v1/settings/chat-webhook and its /test;
// dinnerSvc serves GET and POST /api/v1/dinners, GET, PATCH and DELETE
// /api/v1/dinners/{id}, POST /api/v1/dinners/{id}/favorite, GET
// /api/v1/dinners/{id}/recipe-candidates and POST /api/v1/dinners/{id}/ground;
// suggestSvc serves POST /api/v1/suggestions/batches, GET
// /api/v1/suggestions/deck and POST /api/v1/suggestions/{id}/accept and
// /decline; planSvc serves GET /api/v1/plans/{weekStart}, POST
// /api/v1/plans/{weekStart}/draft, PUT
// /api/v1/plans/{weekStart}/days/{date}, GET /api/v1/rotation-reviews and
// POST /api/v1/dinners/{id}/rotation-review, and POST
// /api/v1/plans/{weekStart}/push-calendar with shopSvc, which makes the
// pushed week's Supply run; shopSvc serves GET /api/v1/shopping/archive,
// GET /api/v1/shopping/{weekStart},
// POST /api/v1/shopping/{weekStart}/generate and PATCH
// /api/v1/shopping/items/{id}; googleSvc serves GET
// /api/v1/integrations/google and its /connect, /callback and /calendars,
// PUT /api/v1/integrations/google/calendar and DELETE
// /api/v1/integrations/google; pushSvc serves GET
// /api/v1/push/vapid-public-key, POST and DELETE
// /api/v1/push/subscriptions and POST
// /api/v1/settings/notifications/test-push; ideasSvc serves POST
// /api/v1/ideas/from-ingredients and POST /api/v1/ideas/quick, and with
// dinnerSvc POST /api/v1/ideas/{jobId}/items/{i}/quest-log and, with
// planSvc too, /cook-tonight; homeSvc serves GET /api/v1/home; runner serves
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves
// GET /media/photos/{id}-{size}.jpg; assets is the web build
// served for every other GET. /health, /auth and the web build are public.
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, chatSvc *chat.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, shopSvc *shopping.Service, googleSvc *gcal.Service, pushSvc *push.Service, ideasSvc *ideas.Service, homeSvc *home.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
if settings == nil {
return nil, errors.New("server: nil household settings service")
}
if search == nil {
return nil, errors.New("server: nil recipe search settings service")
}
if ai == nil {
return nil, errors.New("server: nil AI settings service")
}
if chatSvc == nil {
return nil, errors.New("server: nil Google Chat service")
}
if dinnerSvc == nil {
return nil, errors.New("server: nil dinners service")
}
if suggestSvc == nil {
return nil, errors.New("server: nil suggestions service")
}
if planSvc == nil {
return nil, errors.New("server: nil plan service")
}
if shopSvc == nil {
return nil, errors.New("server: nil shopping service")
}
if googleSvc == nil {
return nil, errors.New("server: nil Google Calendar service")
}
if pushSvc == nil {
return nil, errors.New("server: nil push service")
}
if ideasSvc == nil {
return nil, errors.New("server: nil ideas service")
}
if homeSvc == nil {
return nil, errors.New("server: nil home service")
}
if runner == nil {
return nil, errors.New("server: nil job runner")
}
if photos == nil {
return nil, errors.New("server: nil photo files")
}
notFound := apiErrorHandler(logger, http.StatusNotFound, "not_found", "not found")
methodNotAllowed := apiErrorHandler(logger, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
web, err := frontend.NewHandler(assets, logger, notFound, reservedPrefixes...)
if err != nil {
return nil, err
}
r := chi.NewRouter()
r.Use(resolveOrigin(logger, trusted))
r.MethodNotAllowed(methodNotAllowed)
r.NotFound(notFound)
r.Get("/health", health(logger, version))
auth.Mount(r)
// The session middleware wraps the whole /api/v1 and /media subrouters
// rather than being added with Use: chi skips a subrouter's middleware
// when it has no routes, which would leave it unguarded.
signedIn := requireSession(logger, sessions)
csrf := requireCSRF(logger)
apiV1, media := chi.NewRouter(), chi.NewRouter()
for _, sub := range []*chi.Mux{apiV1, media} {
sub.MethodNotAllowed(methodNotAllowed)
sub.NotFound(notFound)
}
apiV1.Get("/me", me(logger))
apiV1.Get("/home", getHome(logger, homeSvc))
apiV1.Get("/settings/household", getHouseholdSettings(logger, settings))
apiV1.Patch("/settings/household", patchHouseholdSettings(logger, settings))
apiV1.Get("/settings/notifications", getNotificationSettings(logger, settings))
apiV1.Put("/settings/notifications", putNotificationSettings(logger, settings))
apiV1.Post("/settings/notifications/test-push", postTestPush(logger, pushSvc))
apiV1.Get("/settings/recipe-search", getRecipeSearchSettings(logger, search))
apiV1.Patch("/settings/recipe-search", patchRecipeSearchSettings(logger, search))
apiV1.Post("/settings/recipe-search/test", postRecipeSearchTest(logger, search))
apiV1.Get("/settings/ai", getAISettings(logger, ai))
apiV1.Patch("/settings/ai", patchAISettings(logger, ai))
apiV1.Put("/settings/ai/api-key", putAIKey(logger, ai))
apiV1.Post("/settings/ai/verify", postAIVerify(logger, ai))
apiV1.Get("/settings/ai/models", getAIModels(logger, ai))
apiV1.Post("/settings/ai/reset-prompt", postAIResetPrompt(logger, ai))
apiV1.Get("/settings/chat-webhook", getChatWebhook(logger, chatSvc))
apiV1.Put("/settings/chat-webhook", putChatWebhook(logger, chatSvc))
apiV1.Post("/settings/chat-webhook/test", postChatWebhookTest(logger, chatSvc))
apiV1.Get("/dinners", getDinners(logger, dinnerSvc))
apiV1.Post("/dinners", postDinner(logger, dinnerSvc))
apiV1.Get("/dinners/{id}", getDinner(logger, dinnerSvc))
apiV1.Patch("/dinners/{id}", patchDinner(logger, dinnerSvc))
apiV1.Delete("/dinners/{id}", deleteDinner(logger, dinnerSvc))
apiV1.Post("/dinners/{id}/favorite", postDinnerFavorite(logger, dinnerSvc))
apiV1.Get("/dinners/{id}/recipe-candidates", getDinnerCandidates(logger, dinnerSvc))
apiV1.Post("/dinners/{id}/ground", postDinnerGround(logger, dinnerSvc))
apiV1.Post("/suggestions/batches", postSuggestionBatch(logger, suggestSvc))
apiV1.Get("/suggestions/deck", getSuggestionDeck(logger, suggestSvc))
apiV1.Post("/suggestions/{id}/accept", postSuggestionDecision(logger, suggestSvc, true))
apiV1.Post("/suggestions/{id}/decline", postSuggestionDecision(logger, suggestSvc, false))
apiV1.Get("/plans/{weekStart}", getPlan(logger, planSvc))
apiV1.Post("/plans/{weekStart}/draft", postPlanDraft(logger, planSvc))
apiV1.Put("/plans/{weekStart}/days/{date}", putPlanDay(logger, planSvc))
apiV1.Post("/plans/{weekStart}/push-calendar", postPlanPush(logger, planSvc, shopSvc))
apiV1.Get("/shopping/archive", getSupplyArchive(logger, shopSvc))
apiV1.Get("/shopping/{weekStart}", getSupplyRun(logger, shopSvc))
apiV1.Post("/shopping/{weekStart}/generate", postSupplyRunGenerate(logger, shopSvc))
apiV1.Post("/shopping/{weekStart}/send-chat", postSupplyRunSendChat(logger, shopSvc))
apiV1.Patch("/shopping/items/{id}", patchShoppingItem(logger, shopSvc))
apiV1.Get("/rotation-reviews", getRotationReviews(logger, planSvc))
apiV1.Post("/dinners/{id}/rotation-review", postRotationReview(logger, planSvc))
// connect and callback are top-level browser navigations, so they are
// GETs: the Lax session cookie rides both and CSRF applies to neither.
// The callback's state is bound to the session that started connect.
apiV1.Get("/integrations/google/connect", getGoogleConnect(logger, googleSvc))
apiV1.Get("/integrations/google/callback", getGoogleCallback(logger, googleSvc))
apiV1.Get("/integrations/google", getGoogle(logger, googleSvc))
apiV1.Delete("/integrations/google", deleteGoogle(logger, googleSvc))
apiV1.Get("/integrations/google/calendars", getGoogleCalendars(logger, googleSvc))
apiV1.Put("/integrations/google/calendar", putGoogleCalendar(logger, googleSvc))
apiV1.Get("/push/vapid-public-key", getVAPIDPublicKey(logger, pushSvc))
apiV1.Post("/push/subscriptions", postPushSubscription(logger, pushSvc))
apiV1.Delete("/push/subscriptions", deletePushSubscription(logger, pushSvc))
apiV1.Post("/ideas/from-ingredients", postIdeasFromIngredients(logger, ideasSvc))
apiV1.Post("/ideas/quick", postIdeasQuick(logger, ideasSvc))
apiV1.Post("/ideas/{jobId}/items/{i}/quest-log", postIdeaQuestLog(logger, ideasSvc, dinnerSvc))
apiV1.Post("/ideas/{jobId}/items/{i}/cook-tonight", postIdeaCookTonight(logger, ideasSvc, dinnerSvc, planSvc))
apiV1.Post("/jobs", postJob(logger, runner))
apiV1.Get("/jobs/{id}", getJob(logger, runner))
media.Get("/photos/{name}", getPhoto(logger, photos))
r.Route("/api", func(r chi.Router) {
r.MethodNotAllowed(methodNotAllowed)
r.NotFound(notFound)
r.Mount("/v1", signedIn(csrf(apiV1)))
})
r.Mount("/media", signedIn(media))
// Catch-all for the PWA. Unknown /auth paths reach the frontend handler,
// which passes them to notFound rather than the SPA fallback.
r.Method(http.MethodGet, "/*", web)
r.Method(http.MethodHead, "/*", web)
return r, nil
}
type errorBody struct {
Error apiError `json:"error"`
}
type apiError struct {
Code string `json:"code"`
Message string `json:"message"`
Retryable bool `json:"retryable"`
}
// apiErrorHandler responds with status and a fixed, non-retryable API error.
func apiErrorHandler(logger *slog.Logger, status int, code, message string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
writeError(w, r, logger, status, code, message)
}
}
type healthResponse struct {
Status string `json:"status"`
Version string `json:"version"`
}
func health(logger *slog.Logger, version string) http.HandlerFunc {
body, err := json.Marshal(healthResponse{Status: "ok", Version: version})
if err != nil {
panic(err) // two string fields cannot fail to marshal
}
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if _, err := w.Write(body); err != nil {
logger.WarnContext(r.Context(), "write health response", "error", err)
}
}
}