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>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user