diff --git a/internal/household/settings.go b/internal/household/settings.go index 1e1df89..8e6342c 100644 --- a/internal/household/settings.go +++ b/internal/household/settings.go @@ -68,6 +68,11 @@ type SettingsPatch struct { SuggestionsTime *string ChatAutosendDow *int ChatAutosendTime *string + // OnboardingCompleted, when true, records that the household finished + // onboarding: onboarding_completed_at becomes the time of the update, + // unless it is already set, when the first time is kept. Onboarding is + // never undone, so false is invalid. + OnboardingCompleted *bool } // Problem is one invalid field of a SettingsPatch, named by its JSON key. @@ -208,6 +213,9 @@ func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updat setString(&p.SuggestionsTime, patch.SuggestionsTime) setDow(&p.ChatAutosendDow, patch.ChatAutosendDow) setString(&p.ChatAutosendTime, patch.ChatAutosendTime) + if patch.OnboardingCompleted != nil && !p.OnboardingCompletedAt.Valid { + p.OnboardingCompletedAt = sql.NullString{String: p.UpdatedAt, Valid: true} + } row, err = q.UpsertHouseholdSettings(ctx, p) if err != nil { return fmt.Errorf("household: update settings: %w", err) @@ -291,6 +299,9 @@ func (p SettingsPatch) validate() error { fail(d.field, fmt.Sprintf("invalid day of the week %d (want 0-6, Sunday = 0)", *d.value)) } } + if p.OnboardingCompleted != nil && !*p.OnboardingCompleted { + fail("onboarding_completed", "onboarding cannot be undone (want true)") + } if len(problems) > 0 { return &ValidationError{Problems: problems} } diff --git a/internal/household/settings_test.go b/internal/household/settings_test.go index a01120c..9fca588 100644 --- a/internal/household/settings_test.go +++ b/internal/household/settings_test.go @@ -217,6 +217,39 @@ func TestUpdateAppliesPatchAndRecordsUpdatedBy(t *testing.T) { } } +func TestUpdateCompletesOnboardingOnce(t *testing.T) { + t.Parallel() + ctx := context.Background() + svc := seededService(t) + + got, err := svc.Update(ctx, SettingsPatch{OnboardingCompleted: ptr(true)}, "sam") + if err != nil { + t.Fatal(err) + } + if got.OnboardingCompletedAt == nil || *got.OnboardingCompletedAt != got.UpdatedAt { + t.Fatalf("onboarding_completed_at = %v, want the update time %s", got.OnboardingCompletedAt, got.UpdatedAt) + } + first := *got.OnboardingCompletedAt + + // Completing again keeps the first time. + got, err = svc.Update(ctx, SettingsPatch{OnboardingCompleted: ptr(true)}, "alex") + if err != nil { + t.Fatal(err) + } + if got.OnboardingCompletedAt == nil || *got.OnboardingCompletedAt != first { + t.Errorf("second completion: onboarding_completed_at = %v, want %s", got.OnboardingCompletedAt, first) + } + + // Other changes leave it alone. + got, err = svc.Update(ctx, SettingsPatch{DinnerTime: ptr("18:00")}, "sam") + if err != nil { + t.Fatal(err) + } + if got.OnboardingCompletedAt == nil || *got.OnboardingCompletedAt != first { + t.Errorf("after another change: onboarding_completed_at = %v, want %s", got.OnboardingCompletedAt, first) + } +} + func TestUpdateRequiresMember(t *testing.T) { t.Parallel() ctx := context.Background() @@ -259,6 +292,7 @@ func TestUpdateValidation(t *testing.T) { {"dow 7", SettingsPatch{PlanDraftDow: ptr(7)}, []string{"plan_draft_dow"}}, {"dow -1", SettingsPatch{SuggestionsDow: ptr(-1)}, []string{"suggestions_dow"}}, {"dow 100", SettingsPatch{ChatAutosendDow: ptr(100)}, []string{"chat_autosend_dow"}}, + {"onboarding undone", SettingsPatch{OnboardingCompleted: ptr(false)}, []string{"onboarding_completed"}}, { "every problem is reported", SettingsPatch{Timezone: ptr("Nowhere"), DinnerTime: ptr("5pm"), PlanDraftDow: ptr(9)}, diff --git a/internal/server/settings.go b/internal/server/settings.go index a5f06b7..cce8413 100644 --- a/internal/server/settings.go +++ b/internal/server/settings.go @@ -37,6 +37,9 @@ type settingsPatch struct { SuggestionsTime *string `json:"suggestions_time"` ChatAutosendDow *int `json:"chat_autosend_dow"` ChatAutosendTime *string `json:"chat_autosend_time"` + // OnboardingCompleted true sets onboarding_completed_at, which is + // itself read-only. + OnboardingCompleted *bool `json:"onboarding_completed"` } func getHouseholdSettings(logger *slog.Logger, svc *household.SettingsService) http.HandlerFunc { diff --git a/internal/server/settings_test.go b/internal/server/settings_test.go index 8491eb0..08b7736 100644 --- a/internal/server/settings_test.go +++ b/internal/server/settings_test.go @@ -115,6 +115,19 @@ func TestPatchHouseholdSettings(t *testing.T) { } } +func TestPatchHouseholdSettingsCompletesOnboarding(t *testing.T) { + t.Parallel() + + rec, body := serve(t, newTestRouter(t, "dev"), signedIn(http.MethodPatch, settingsPath, + strings.NewReader(`{"onboarding_completed":true}`))) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body) + } + if at, ok := body["onboarding_completed_at"].(string); !ok || at != body["updated_at"] { + t.Errorf("onboarding_completed_at = %#v, want the update time %#v", body["onboarding_completed_at"], body["updated_at"]) + } +} + func TestPatchHouseholdSettingsRejected(t *testing.T) { t.Parallel() @@ -136,6 +149,8 @@ func TestPatchHouseholdSettingsRejected(t *testing.T) { {"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"}, + {"read-only onboarding time", `{"onboarding_completed_at":"2026-10-06T12:00:00.000Z"}`, "invalid_request", "onboarding_completed_at"}, + {"onboarding undone", `{"onboarding_completed":false}`, "invalid_settings", "onboarding_completed"}, {"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"},