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:
@@ -0,0 +1,332 @@
|
||||
package household
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// Seed values for a new household (CTXD CONTEXT.MD). Days count from
|
||||
// Sunday = 0, as time.Weekday does.
|
||||
const (
|
||||
SeedDinnerTime = "17:30"
|
||||
SeedReminderTime = "16:30"
|
||||
SeedReviewTime = "09:00" // rotation check
|
||||
SeedPlanDraftDow = 5 // Friday
|
||||
SeedPlanDraftTime = "09:00"
|
||||
SeedSuggestionsDow = 0 // Sunday
|
||||
SeedSuggestionsTime = "08:00"
|
||||
SeedChatAutosendDow = 6 // Saturday
|
||||
SeedChatAutosendTime = "08:00"
|
||||
)
|
||||
|
||||
// ErrNoDefaultZone is returned by SeedSettings when the household settings
|
||||
// do not exist yet and no default zone was configured to create them with.
|
||||
var ErrNoDefaultZone = errors.New("household settings do not exist yet and no default time zone is configured")
|
||||
|
||||
// Settings are the shared household settings. Times are household-local
|
||||
// 24-hour HH:MM in Timezone; days of the week count from Sunday = 0.
|
||||
type Settings 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"`
|
||||
OnboardingCompletedAt *string `json:"onboarding_completed_at"`
|
||||
UpdatesSeenAt *string `json:"updates_seen_at"`
|
||||
UpdatedBy *string `json:"updated_by"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// SettingsPatch changes the household settings. Nil fields are left as they
|
||||
// are.
|
||||
type SettingsPatch struct {
|
||||
Timezone *string
|
||||
DinnerTime *string
|
||||
ReminderTime *string
|
||||
ReviewTime *string
|
||||
PlanDraftDow *int
|
||||
PlanDraftTime *string
|
||||
SuggestionsDow *int
|
||||
SuggestionsTime *string
|
||||
ChatAutosendDow *int
|
||||
ChatAutosendTime *string
|
||||
}
|
||||
|
||||
// Problem is one invalid field of a SettingsPatch, named by its JSON key.
|
||||
type Problem struct {
|
||||
Field string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// ValidationError lists every invalid field of a SettingsPatch.
|
||||
type ValidationError struct {
|
||||
Problems []Problem
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string {
|
||||
parts := make([]string, len(e.Problems))
|
||||
for i, p := range e.Problems {
|
||||
parts[i] = p.Field + ": " + p.Reason
|
||||
}
|
||||
return "invalid household settings: " + strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// SeedSettings creates the household settings row with zone defaultTZ and the
|
||||
// seed times and days if it does not exist. An existing row is never changed,
|
||||
// and defaultTZ is then ignored. With no row, an empty defaultTZ is
|
||||
// ErrNoDefaultZone and an unloadable one is an error.
|
||||
func SeedSettings(ctx context.Context, st *store.Store, defaultTZ string) error {
|
||||
return st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
_, err := q.GetHouseholdSettings(ctx)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("household: read settings: %w", err)
|
||||
}
|
||||
if defaultTZ == "" {
|
||||
return ErrNoDefaultZone
|
||||
}
|
||||
if _, err := LoadZone(defaultTZ); err != nil {
|
||||
return fmt.Errorf("household: seed settings: %w", err)
|
||||
}
|
||||
now := store.Now().String()
|
||||
_, err = q.UpsertHouseholdSettings(ctx, db.UpsertHouseholdSettingsParams{
|
||||
Timezone: defaultTZ,
|
||||
DinnerTime: SeedDinnerTime,
|
||||
ReminderTime: SeedReminderTime,
|
||||
ReviewTime: SeedReviewTime,
|
||||
PlanDraftDow: SeedPlanDraftDow,
|
||||
PlanDraftTime: SeedPlanDraftTime,
|
||||
SuggestionsDow: SeedSuggestionsDow,
|
||||
SuggestionsTime: SeedSuggestionsTime,
|
||||
ChatAutosendDow: SeedChatAutosendDow,
|
||||
ChatAutosendTime: SeedChatAutosendTime,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("household: seed settings: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// SettingsService reads and changes the household settings, and tells
|
||||
// in-process subscribers (the scheduler) about every change.
|
||||
type SettingsService struct {
|
||||
st *store.Store
|
||||
|
||||
// updateMu serialises Update with its publish, so subscribers see
|
||||
// changes in commit order.
|
||||
updateMu sync.Mutex
|
||||
|
||||
subsMu sync.Mutex
|
||||
subs map[int]func(Settings)
|
||||
nextID int
|
||||
}
|
||||
|
||||
// NewSettingsService returns a service over st. Call SeedSettings first.
|
||||
func NewSettingsService(st *store.Store) *SettingsService {
|
||||
return &SettingsService{st: st, subs: make(map[int]func(Settings))}
|
||||
}
|
||||
|
||||
// Get returns the household settings.
|
||||
func (s *SettingsService) Get(ctx context.Context) (Settings, error) {
|
||||
row, err := db.New(s.st.Reader).GetHouseholdSettings(ctx)
|
||||
if err != nil {
|
||||
return Settings{}, fmt.Errorf("household: read settings: %w", err)
|
||||
}
|
||||
return fromRow(row), nil
|
||||
}
|
||||
|
||||
// Update validates patch, applies it, records updatedBy ("" when no member
|
||||
// is known) and publishes the new settings to every subscriber. An invalid
|
||||
// patch is a *ValidationError and changes nothing.
|
||||
func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updatedBy string) (Settings, error) {
|
||||
if err := patch.validate(); err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
|
||||
s.updateMu.Lock()
|
||||
defer s.updateMu.Unlock()
|
||||
|
||||
var row db.HouseholdSetting
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
cur, err := q.GetHouseholdSettings(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("household: read settings: %w", err)
|
||||
}
|
||||
p := db.UpsertHouseholdSettingsParams{
|
||||
Timezone: cur.Timezone,
|
||||
DinnerTime: cur.DinnerTime,
|
||||
ReminderTime: cur.ReminderTime,
|
||||
ReviewTime: cur.ReviewTime,
|
||||
PlanDraftDow: cur.PlanDraftDow,
|
||||
PlanDraftTime: cur.PlanDraftTime,
|
||||
SuggestionsDow: cur.SuggestionsDow,
|
||||
SuggestionsTime: cur.SuggestionsTime,
|
||||
ChatAutosendDow: cur.ChatAutosendDow,
|
||||
ChatAutosendTime: cur.ChatAutosendTime,
|
||||
OnboardingCompletedAt: cur.OnboardingCompletedAt,
|
||||
UpdatesSeenAt: cur.UpdatesSeenAt,
|
||||
UpdatedBy: sql.NullString{String: updatedBy, Valid: updatedBy != ""},
|
||||
CreatedAt: cur.CreatedAt,
|
||||
UpdatedAt: store.Now().String(),
|
||||
}
|
||||
setString(&p.Timezone, patch.Timezone)
|
||||
setString(&p.DinnerTime, patch.DinnerTime)
|
||||
setString(&p.ReminderTime, patch.ReminderTime)
|
||||
setString(&p.ReviewTime, patch.ReviewTime)
|
||||
setDow(&p.PlanDraftDow, patch.PlanDraftDow)
|
||||
setString(&p.PlanDraftTime, patch.PlanDraftTime)
|
||||
setDow(&p.SuggestionsDow, patch.SuggestionsDow)
|
||||
setString(&p.SuggestionsTime, patch.SuggestionsTime)
|
||||
setDow(&p.ChatAutosendDow, patch.ChatAutosendDow)
|
||||
setString(&p.ChatAutosendTime, patch.ChatAutosendTime)
|
||||
row, err = q.UpsertHouseholdSettings(ctx, p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("household: update settings: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
|
||||
settings := fromRow(row)
|
||||
s.publish(settings)
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// Subscribe registers fn to receive the new settings after every successful
|
||||
// Update. fn runs synchronously on the updating goroutine, so it must return
|
||||
// promptly and must not call Update. The returned function unsubscribes fn.
|
||||
func (s *SettingsService) Subscribe(fn func(Settings)) (unsubscribe func()) {
|
||||
s.subsMu.Lock()
|
||||
defer s.subsMu.Unlock()
|
||||
id := s.nextID
|
||||
s.nextID++
|
||||
s.subs[id] = fn
|
||||
return func() {
|
||||
s.subsMu.Lock()
|
||||
defer s.subsMu.Unlock()
|
||||
delete(s.subs, id)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SettingsService) publish(settings Settings) {
|
||||
s.subsMu.Lock()
|
||||
fns := make([]func(Settings), 0, len(s.subs))
|
||||
for _, fn := range s.subs {
|
||||
fns = append(fns, fn)
|
||||
}
|
||||
s.subsMu.Unlock()
|
||||
for _, fn := range fns {
|
||||
fn(settings)
|
||||
}
|
||||
}
|
||||
|
||||
func (p SettingsPatch) validate() error {
|
||||
var problems []Problem
|
||||
fail := func(field, reason string) {
|
||||
problems = append(problems, Problem{Field: field, Reason: reason})
|
||||
}
|
||||
if p == (SettingsPatch{}) {
|
||||
fail("settings", "no settings to change")
|
||||
}
|
||||
if p.Timezone != nil {
|
||||
if _, err := LoadZone(*p.Timezone); err != nil {
|
||||
fail("timezone", err.Error())
|
||||
}
|
||||
}
|
||||
for _, t := range []struct {
|
||||
field string
|
||||
value *string
|
||||
}{
|
||||
{"dinner_time", p.DinnerTime},
|
||||
{"reminder_time", p.ReminderTime},
|
||||
{"review_time", p.ReviewTime},
|
||||
{"plan_draft_time", p.PlanDraftTime},
|
||||
{"suggestions_time", p.SuggestionsTime},
|
||||
{"chat_autosend_time", p.ChatAutosendTime},
|
||||
} {
|
||||
if t.value != nil && !clockTime.MatchString(*t.value) {
|
||||
fail(t.field, fmt.Sprintf("invalid time %q (want 24-hour HH:MM, 00:00-23:59)", *t.value))
|
||||
}
|
||||
}
|
||||
for _, d := range []struct {
|
||||
field string
|
||||
value *int
|
||||
}{
|
||||
{"plan_draft_dow", p.PlanDraftDow},
|
||||
{"suggestions_dow", p.SuggestionsDow},
|
||||
{"chat_autosend_dow", p.ChatAutosendDow},
|
||||
} {
|
||||
if d.value != nil && (*d.value < 0 || *d.value > 6) {
|
||||
fail(d.field, fmt.Sprintf("invalid day of the week %d (want 0-6, Sunday = 0)", *d.value))
|
||||
}
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return &ValidationError{Problems: problems}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// clockTime matches a zero-padded 24-hour HH:MM time.
|
||||
var clockTime = regexp.MustCompile(`^([01][0-9]|2[0-3]):[0-5][0-9]$`)
|
||||
|
||||
func setString(dst *string, v *string) {
|
||||
if v != nil {
|
||||
*dst = *v
|
||||
}
|
||||
}
|
||||
|
||||
func setDow(dst *int64, v *int) {
|
||||
if v != nil {
|
||||
*dst = int64(*v)
|
||||
}
|
||||
}
|
||||
|
||||
func fromRow(r db.HouseholdSetting) Settings {
|
||||
return Settings{
|
||||
Timezone: r.Timezone,
|
||||
DinnerTime: r.DinnerTime,
|
||||
ReminderTime: r.ReminderTime,
|
||||
ReviewTime: r.ReviewTime,
|
||||
PlanDraftDow: int(r.PlanDraftDow),
|
||||
PlanDraftTime: r.PlanDraftTime,
|
||||
SuggestionsDow: int(r.SuggestionsDow),
|
||||
SuggestionsTime: r.SuggestionsTime,
|
||||
ChatAutosendDow: int(r.ChatAutosendDow),
|
||||
ChatAutosendTime: r.ChatAutosendTime,
|
||||
OnboardingCompletedAt: nullable(r.OnboardingCompletedAt),
|
||||
UpdatesSeenAt: nullable(r.UpdatesSeenAt),
|
||||
UpdatedBy: nullable(r.UpdatedBy),
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func nullable(s sql.NullString) *string {
|
||||
if !s.Valid {
|
||||
return nil
|
||||
}
|
||||
return &s.String
|
||||
}
|
||||
Reference in New Issue
Block a user