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
+22
View File
@@ -11,6 +11,8 @@ import (
"strconv"
"strings"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
)
// Environment variable names.
@@ -20,6 +22,7 @@ const (
EnvDataDir = "MEALQUEST_DATA_DIR"
EnvShutdownTimeout = "MEALQUEST_SHUTDOWN_TIMEOUT"
EnvSecretKeyFile = "MEALQUEST_SECRET_KEY_FILE"
EnvDefaultTZ = "MEALQUEST_DEFAULT_TZ"
)
// Config is the validated service configuration.
@@ -29,6 +32,10 @@ type Config struct {
DataDir string
ShutdownTimeout time.Duration
SecretKey Secret
// DefaultTZ is the IANA zone the household settings are created with on
// first boot. It is optional here: boot requires it only while the
// settings do not exist yet. Empty when unset.
DefaultTZ string
}
// PublicOrigin returns the scheme and host of PublicURL, the only parts of it
@@ -51,6 +58,7 @@ func load(lookup func(string) (string, bool)) (Config, error) {
DataDir: r.dir(EnvDataDir),
ShutdownTimeout: r.duration(EnvShutdownTimeout),
SecretKey: r.secretFile(EnvSecretKeyFile),
DefaultTZ: r.optionalZone(EnvDefaultTZ),
}
if len(r.problems) > 0 {
return Config{}, &Error{Problems: r.problems}
@@ -207,6 +215,20 @@ func (r *reader) duration(name string) time.Duration {
return d
}
// optionalZone returns the IANA zone name in the variable, or "" when it is
// unset. A set value must be a loadable zone.
func (r *reader) optionalZone(name string) string {
v, ok := r.lookup(name)
if !ok {
return ""
}
if _, err := household.LoadZone(v); err != nil {
r.fail(name, "%v", err)
return ""
}
return v
}
// secretFile reads a secret from the file named by name, which must end in
// _FILE. Setting the secret inline (name without _FILE) is rejected.
func (r *reader) secretFile(name string) Secret {
+23
View File
@@ -55,6 +55,25 @@ func TestLoad(t *testing.T) {
name: "all required vars present",
env: valid(),
},
{
name: "default time zone set",
env: with(map[string]string{EnvDefaultTZ: "America/New_York"}),
},
{
name: "default time zone not an IANA zone",
env: with(map[string]string{EnvDefaultTZ: "Mars/Olympus_Mons"}),
badVars: []string{EnvDefaultTZ},
},
{
name: "default time zone set but empty",
env: with(map[string]string{EnvDefaultTZ: ""}),
badVars: []string{EnvDefaultTZ},
},
{
name: "default time zone Local refused",
env: with(map[string]string{EnvDefaultTZ: "Local"}),
badVars: []string{EnvDefaultTZ},
},
{
name: "one missing",
env: with(nil, EnvHTTPAddr),
@@ -232,6 +251,7 @@ func TestLoadValues(t *testing.T) {
EnvDataDir: dir,
EnvShutdownTimeout: "1m30s",
EnvSecretKeyFile: secretPath,
EnvDefaultTZ: "Europe/London",
}
cfg, err := load(func(k string) (string, bool) { v, ok := env[k]; return v, ok })
if err != nil {
@@ -253,6 +273,9 @@ func TestLoadValues(t *testing.T) {
if cfg.SecretKey.Reveal() != "s3cret-value" {
t.Errorf("SecretKey.Reveal() = %q, want trailing newline trimmed", cfg.SecretKey.Reveal())
}
if cfg.DefaultTZ != "Europe/London" {
t.Errorf("DefaultTZ = %q, want Europe/London", cfg.DefaultTZ)
}
}
func TestSecretRedacted(t *testing.T) {