Files
MealQuest/internal/server/settings.go
T
dev_agentandClaude Opus 5.5 7629cab8f4
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
Seed household settings at boot and serve the settings API
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>
2026-10-03 10:04:05 -04:00

129 lines
4.5 KiB
Go

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)
}
}