Seed household settings at boot and serve the settings API
Independent review Codex gpt-6-sol high: CHANGES_REQUIRED (1 high, 1 medium)
CI / ci (push) Successful in 1m26s
CI / ci (pull_request) Successful in 1m26s

On first boot create the single household_settings row with the zone from
MEALQUEST_DEFAULT_TZ and the seed times and days (dinner 17:30, reminder
16:30, rotation check 09:00, plan draft Fri 09:00, suggestions Sun 08:00,
chat autosend Sat 08:00). Boot fails if the row is missing and the zone is
unset; an existing row is never overwritten. Config validates the zone
whenever it is set.

Add household.SettingsService with Get, a validating Update (IANA zone,
zero-padded 24-hour HH:MM, day of week 0-6) that records updated_by, and
in-process Subscribe for the scheduler. Serve it as GET and PATCH
/api/v1/settings/household; invalid input is a 400 in the standard error
body. Migration 0002 adds household_settings.updated_by, and
internal/actor carries the member name in the request context.

Link time/tzdata from internal/household so zones load in the minimal
image, which has no system zone database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 10:04:05 -04:00
co-authored by Claude Opus 5.5
parent 5e78465d34
commit 7629cab8f4
19 changed files with 1262 additions and 28 deletions
+13 -14
View File
@@ -3,6 +3,7 @@ package server
import (
"encoding/json"
"errors"
"io/fs"
"log/slog"
"net/http"
@@ -10,6 +11,7 @@ import (
"github.com/go-chi/chi/v5"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
)
// reservedPrefixes are owned by the API router and are never answered with the
@@ -17,8 +19,12 @@ import (
var reservedPrefixes = []string{"/api", "/auth", "/media", "/health"}
// NewRouter returns the HTTP handler for the service. version is reported by
// GET /health; assets is the web build served for every other GET.
func NewRouter(logger *slog.Logger, version string, assets fs.FS) (http.Handler, error) {
// GET /health; assets is the web build served for every other GET; settings
// serves /api/v1/settings/household.
func NewRouter(logger *slog.Logger, version string, assets fs.FS, settings *household.SettingsService) (http.Handler, error) {
if settings == nil {
return nil, errors.New("server: nil household settings service")
}
notFound := apiErrorHandler(logger, http.StatusNotFound, "not_found", "not found")
methodNotAllowed := apiErrorHandler(logger, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
@@ -35,6 +41,10 @@ func NewRouter(logger *slog.Logger, version string, assets fs.FS) (http.Handler,
r.Route("/api", func(r chi.Router) {
r.MethodNotAllowed(methodNotAllowed)
r.NotFound(notFound)
r.Route("/v1", func(r chi.Router) {
r.Get("/settings/household", getHouseholdSettings(logger, settings))
r.Patch("/settings/household", patchHouseholdSettings(logger, settings))
})
})
// Catch-all for the PWA. /auth and /media have no routes yet; the frontend
// handler passes them to notFound rather than the SPA fallback.
@@ -55,19 +65,8 @@ type apiError struct {
// apiErrorHandler responds with status and a fixed, non-retryable API error.
func apiErrorHandler(logger *slog.Logger, status int, code, message string) http.HandlerFunc {
body, err := json.Marshal(errorBody{Error: apiError{
Code: code,
Message: message,
}})
if err != nil {
panic(err) // fixed string and bool fields cannot fail to marshal
}
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if _, err := w.Write(body); err != nil {
logger.WarnContext(r.Context(), "write error response", "code", code, "error", err)
}
writeError(w, r, logger, status, code, message)
}
}
+43 -1
View File
@@ -1,6 +1,7 @@
package server
import (
"context"
"encoding/json"
"io"
"log/slog"
@@ -10,6 +11,10 @@ import (
"strings"
"testing"
"testing/fstest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
const (
@@ -19,19 +24,56 @@ const (
)
func newTestRouter(t *testing.T, version string) http.Handler {
t.Helper()
return newTestRouterWith(t, version, newTestSettings(t))
}
func newTestRouterWith(t *testing.T, version string, settings *household.SettingsService) http.Handler {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
assets := fstest.MapFS{
"index.html": {Data: []byte(fixtureIndex)},
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
}
r, err := NewRouter(logger, version, assets)
r, err := NewRouter(logger, version, assets, settings)
if err != nil {
t.Fatal(err)
}
return r
}
// newTestSettings returns a settings service over a migrated temp database
// seeded with zone America/New_York.
func newTestSettings(t *testing.T) *household.SettingsService {
t.Helper()
ctx := context.Background()
st, err := store.Open(ctx, t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := st.Close(); err != nil {
t.Error(err)
}
})
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
if err := household.SeedSettings(ctx, st, "America/New_York"); err != nil {
t.Fatal(err)
}
return household.NewSettingsService(st)
}
func TestNewRouterRequiresSettings(t *testing.T) {
t.Parallel()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := NewRouter(logger, "dev", fstest.MapFS{"index.html": {}}, nil); err == nil {
t.Fatal("NewRouter accepted a nil settings service")
}
}
func TestHealth(t *testing.T) {
t.Parallel()
+128
View File
@@ -0,0 +1,128 @@
package server
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/actor"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
)
// maxSettingsBody bounds a PATCH /settings/household request body.
const maxSettingsBody = 16 << 10
// settingsPatch is the PATCH /settings/household body. Every field is
// optional; unknown fields and null values are refused.
type settingsPatch struct {
Timezone *string `json:"timezone"`
DinnerTime *string `json:"dinner_time"`
ReminderTime *string `json:"reminder_time"`
ReviewTime *string `json:"review_time"`
PlanDraftDow *int `json:"plan_draft_dow"`
PlanDraftTime *string `json:"plan_draft_time"`
SuggestionsDow *int `json:"suggestions_dow"`
SuggestionsTime *string `json:"suggestions_time"`
ChatAutosendDow *int `json:"chat_autosend_dow"`
ChatAutosendTime *string `json:"chat_autosend_time"`
}
func getHouseholdSettings(logger *slog.Logger, svc *household.SettingsService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
settings, err := svc.Get(r.Context())
if err != nil {
logger.ErrorContext(r.Context(), "get household settings", "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
return
}
writeJSON(w, r, logger, http.StatusOK, settings)
}
}
func patchHouseholdSettings(logger *slog.Logger, svc *household.SettingsService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
patch, err := decodeSettingsPatch(http.MaxBytesReader(w, r.Body, maxSettingsBody))
if err != nil {
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
return
}
settings, err := svc.Update(r.Context(), household.SettingsPatch(patch), actor.From(r.Context()))
var invalid *household.ValidationError
if errors.As(err, &invalid) {
writeError(w, r, logger, http.StatusBadRequest, "invalid_settings", invalid.Error())
return
}
if err != nil {
logger.ErrorContext(r.Context(), "update household settings", "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
return
}
writeJSON(w, r, logger, http.StatusOK, settings)
}
}
// decodeSettingsPatch reads a single JSON object, refusing unknown fields,
// null values and trailing data.
func decodeSettingsPatch(body io.Reader) (settingsPatch, error) {
data, err := io.ReadAll(body)
if err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
return settingsPatch{}, fmt.Errorf("request body exceeds %d bytes", tooLarge.Limit)
}
return settingsPatch{}, errors.New("cannot read request body")
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(data, &fields); err != nil || fields == nil {
return settingsPatch{}, errors.New("request body must be a JSON object")
}
for name, raw := range fields {
if bytes.Equal(raw, []byte("null")) {
return settingsPatch{}, fmt.Errorf("%s: must not be null", name)
}
}
var patch settingsPatch
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
if err := dec.Decode(&patch); err != nil {
var typeErr *json.UnmarshalTypeError
if errors.As(err, &typeErr) {
return settingsPatch{}, fmt.Errorf("%s: want a JSON %s", typeErr.Field, typeErr.Type)
}
return settingsPatch{}, fmt.Errorf("invalid settings: %v", err)
}
return patch, nil
}
func writeJSON(w http.ResponseWriter, r *http.Request, logger *slog.Logger, status int, v any) {
body, err := json.Marshal(v)
if err != nil {
logger.ErrorContext(r.Context(), "encode response", "error", err)
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if _, err := w.Write(body); err != nil {
logger.WarnContext(r.Context(), "write response", "error", err)
}
}
// writeError responds with status and a non-retryable API error.
func writeError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, status int, code, message string) {
body, err := json.Marshal(errorBody{Error: apiError{Code: code, Message: message}})
if err != nil {
panic(err) // string and bool fields cannot fail to marshal
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if _, err := w.Write(body); err != nil {
logger.WarnContext(r.Context(), "write error response", "code", code, "error", err)
}
}
+174
View File
@@ -0,0 +1,174 @@
package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/actor"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
)
const settingsPath = "/api/v1/settings/household"
func serve(t *testing.T, h http.Handler, req *http.Request) (*httptest.ResponseRecorder, map[string]any) {
t.Helper()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type = %q, want application/json", ct)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode body %q: %v", rec.Body.String(), err)
}
return rec, body
}
func TestGetHouseholdSettings(t *testing.T) {
t.Parallel()
rec, body := serve(t, newTestRouter(t, "dev"), httptest.NewRequest(http.MethodGet, settingsPath, nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body)
}
want := map[string]any{
"timezone": "America/New_York",
"dinner_time": "17:30",
"reminder_time": "16:30",
"review_time": "09:00",
"plan_draft_dow": float64(5),
"plan_draft_time": "09:00",
"suggestions_dow": float64(0),
"suggestions_time": "08:00",
"chat_autosend_dow": float64(6),
"chat_autosend_time": "08:00",
"onboarding_completed_at": nil,
"updates_seen_at": nil,
"updated_by": nil,
}
for k, v := range want {
got, ok := body[k]
if !ok || got != v {
t.Errorf("%s = %#v (present %v), want %#v", k, got, ok, v)
}
}
for _, k := range []string{"created_at", "updated_at"} {
if s, ok := body[k].(string); !ok || s == "" {
t.Errorf("%s = %#v, want a timestamp", k, body[k])
}
}
if len(body) != len(want)+2 {
t.Errorf("body has %d fields, want %d: %v", len(body), len(want)+2, body)
}
}
func TestPatchHouseholdSettings(t *testing.T) {
t.Parallel()
settings := newTestSettings(t)
router := newTestRouterWith(t, "dev", settings)
var notified []household.Settings
settings.Subscribe(func(s household.Settings) { notified = append(notified, s) })
req := httptest.NewRequest(http.MethodPatch, settingsPath,
strings.NewReader(`{"timezone":"Europe/Berlin","dinner_time":"18:00","plan_draft_dow":4}`))
req = req.WithContext(actor.With(req.Context(), "sam"))
rec, body := serve(t, router, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body)
}
for k, v := range map[string]any{
"timezone": "Europe/Berlin",
"dinner_time": "18:00",
"plan_draft_dow": float64(4),
"reminder_time": "16:30", // unchanged
"updated_by": "sam",
} {
if body[k] != v {
t.Errorf("%s = %#v, want %#v", k, body[k], v)
}
}
if len(notified) != 1 || notified[0].Timezone != "Europe/Berlin" {
t.Errorf("subscriber notified with %+v, want one change to Europe/Berlin", notified)
}
_, body = serve(t, router, httptest.NewRequest(http.MethodGet, settingsPath, nil))
if body["timezone"] != "Europe/Berlin" || body["updated_by"] != "sam" {
t.Errorf("GET after PATCH = %v", body)
}
}
func TestPatchHouseholdSettingsRejected(t *testing.T) {
t.Parallel()
tests := []struct {
name string
body string
code string
in string // the error message must contain this
}{
{"bad timezone", `{"timezone":"Mars/Olympus_Mons"}`, "invalid_settings", "timezone"},
{"bad time", `{"dinner_time":"25:00"}`, "invalid_settings", "dinner_time"},
{"unpadded time", `{"reminder_time":"9:30"}`, "invalid_settings", "reminder_time"},
{"bad dow", `{"suggestions_dow":7}`, "invalid_settings", "suggestions_dow"},
{"negative dow", `{"chat_autosend_dow":-1}`, "invalid_settings", "chat_autosend_dow"},
{"empty object", `{}`, "invalid_settings", "no settings to change"},
{"fractional dow", `{"plan_draft_dow":5.5}`, "invalid_request", "plan_draft_dow"},
{"string dow", `{"plan_draft_dow":"5"}`, "invalid_request", "plan_draft_dow"},
{"numeric time", `{"dinner_time":1730}`, "invalid_request", "dinner_time"},
{"null value", `{"timezone":null}`, "invalid_request", "timezone"},
{"unknown field", `{"dinner":"17:30"}`, "invalid_request", "dinner"},
{"read-only field", `{"updated_by":"someone"}`, "invalid_request", "updated_by"},
{"not an object", `["timezone"]`, "invalid_request", "JSON object"},
{"null body", `null`, "invalid_request", "JSON object"},
{"trailing data", `{"dinner_time":"18:00"} {}`, "invalid_request", "JSON object"},
{"malformed", `{"dinner_time":`, "invalid_request", "JSON object"},
{"too large", `{"timezone":"` + strings.Repeat("a", maxSettingsBody) + `"}`, "invalid_request", "exceeds"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
settings := newTestSettings(t)
router := newTestRouterWith(t, "dev", settings)
notified := 0
settings.Subscribe(func(household.Settings) { notified++ })
rec, body := serve(t, router, httptest.NewRequest(http.MethodPatch, settingsPath, strings.NewReader(tt.body)))
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400: %s", rec.Code, rec.Body)
}
apiErr, ok := body["error"].(map[string]any)
if !ok || len(body) != 1 || len(apiErr) != 3 {
t.Fatalf("body = %s, want {\"error\":{code,message,retryable}}", rec.Body)
}
if apiErr["code"] != tt.code || apiErr["retryable"] != false {
t.Errorf("error = %v, want code %q, retryable false", apiErr, tt.code)
}
if msg, _ := apiErr["message"].(string); !strings.Contains(msg, tt.in) {
t.Errorf("message %q does not contain %q", msg, tt.in)
}
if notified != 0 {
t.Errorf("rejected PATCH notified subscribers %d times", notified)
}
_, after := serve(t, router, httptest.NewRequest(http.MethodGet, settingsPath, nil))
if after["timezone"] != "America/New_York" || after["dinner_time"] != "17:30" || after["updated_by"] != nil {
t.Errorf("rejected PATCH changed settings: %v", after)
}
})
}
}
func TestHouseholdSettingsMethodNotAllowed(t *testing.T) {
t.Parallel()
rec, body := serve(t, newTestRouter(t, "dev"), httptest.NewRequest(http.MethodPut, settingsPath, strings.NewReader(`{}`)))
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("status = %d, want 405", rec.Code)
}
if apiErr, _ := body["error"].(map[string]any); apiErr["code"] != "method_not_allowed" {
t.Errorf("body = %v", body)
}
}