go-bananas/internal/store/user_preferences.go

153 lines
5.4 KiB
Go
Raw Normal View History

2026-10-07 15:11:35 +02:00
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/urpi/go-bananas/internal/domain"
)
// Preferences returns what the subject has chosen for their own interface.
//
// A subject with no row, which is everybody until they save a preference once,
// reads as empty rather than as an error: the zero value is the documented "no
// choice made" state and every caller renders a default for it.
//
// A stored theme that has since been deleted or archived is reported as no
// choice, for the same reason. Resolving the name against the themes table on
// read keeps a stale preference from pinning the UI to something that no longer
// exists, and it means an unpublish is reflected without a migration.
func (s *Store) Preferences(ctx context.Context, subject string) (domain.UserPreferences, error) {
if subject == "" {
return domain.UserPreferences{}, fmt.Errorf("%w: subject must not be empty", domain.ErrInvalid)
}
var stored domain.UserPreferences
err := s.db.QueryRowContext(ctx, `
SELECT selected_theme, selected_appearance
FROM user_preferences
WHERE subject = ?`, subject).
Scan(&stored.SelectedTheme, &stored.SelectedAppearance)
if errors.Is(err, sql.ErrNoRows) {
return domain.UserPreferences{}, nil
}
if err != nil {
return domain.UserPreferences{}, translateError(err)
}
if stored.SelectedTheme != "" {
usable, err := s.themeUsable(ctx, stored.SelectedTheme)
if err != nil {
return domain.UserPreferences{}, err
}
if !usable {
return domain.UserPreferences{}, nil
}
}
if stored.SelectedAppearance != "" && !stored.SelectedAppearance.Valid() {
// A build that no longer knows an appearance must not hand it to a
// client, which would set an attribute nothing styles.
return domain.UserPreferences{}, nil
}
return stored, nil
}
// SetPreferences records the subject's choice and returns what was stored.
//
// The theme must already exist and be published or archived. Drafts are rejected
// because they are invisible to runtime consumers, so a preference pointing at one
// would leave the UI rendering a theme it cannot load.
func (s *Store) SetPreferences(ctx context.Context, subject string, prefs domain.UserPreferences) (domain.UserPreferences, error) {
if subject == "" {
return domain.UserPreferences{}, fmt.Errorf("%w: subject must not be empty", domain.ErrInvalid)
}
if err := prefs.Validate(); err != nil {
return domain.UserPreferences{}, err
}
if prefs.SelectedTheme != "" {
usable, err := s.themeUsable(ctx, prefs.SelectedTheme)
if err != nil {
return domain.UserPreferences{}, err
}
if !usable {
return domain.UserPreferences{}, fmt.Errorf(
"%w: selectedTheme %q is not a published theme", domain.ErrInvalid, prefs.SelectedTheme)
}
} else {
// An appearance without a theme has nothing to select a scheme from, so it
// is dropped rather than stored. Clearing a choice by sending an empty
// theme therefore works even if the client leaves the appearance set.
prefs.SelectedAppearance = ""
}
_, err := s.db.ExecContext(ctx, `
INSERT INTO user_preferences (subject, selected_theme, selected_appearance, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(subject) DO UPDATE SET
selected_theme = excluded.selected_theme,
selected_appearance = excluded.selected_appearance,
updated_at = excluded.updated_at`,
subject, prefs.SelectedTheme, string(prefs.SelectedAppearance), s.timestamp())
if err != nil {
return domain.UserPreferences{}, translateError(err)
}
return prefs, nil
}
// ClearPreferences forgets the subject's choice, which is how the UI returns to
// its own default. It reports whether a row was removed.
func (s *Store) ClearPreferences(ctx context.Context, subject string) (bool, error) {
if subject == "" {
return false, fmt.Errorf("%w: subject must not be empty", domain.ErrInvalid)
}
result, err := s.db.ExecContext(ctx, `DELETE FROM user_preferences WHERE subject = ?`, subject)
if err != nil {
return false, translateError(err)
}
affected, err := result.RowsAffected()
if err != nil {
// Drivers that cannot report this are not a reason to fail the request:
// the row is gone either way, which is all the caller asked for.
return true, nil //nolint:nilerr // see comment above
}
return affected > 0, nil
}
// ThemePreferenceCount reports how many subjects have chosen a theme, which the
// admin UI shows so an editor can see who a change would affect before making it.
func (s *Store) ThemePreferenceCount(ctx context.Context, slug string) (int, error) {
if err := domain.ValidateSlug(slug); err != nil {
return 0, fmt.Errorf("%w: %s", domain.ErrInvalid, err.Error())
}
var count int
err := s.db.QueryRowContext(ctx, `
SELECT count(*) FROM user_preferences WHERE selected_theme = ?`, slug).Scan(&count)
if err != nil {
return 0, translateError(err)
}
return count, nil
}
// themeUsable reports whether a slug names a theme a runtime consumer may load.
// Archived themes qualify because the last published revision of an archived theme
// stays resolvable for consumers pinned to it.
func (s *Store) themeUsable(ctx context.Context, slug string) (bool, error) {
var status string
err := s.db.QueryRowContext(ctx, `SELECT status FROM themes WHERE slug = ?`, slug).Scan(&status)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, translateError(err)
}
switch domain.ThemeStatus(status) {
case domain.StatusPublished, domain.StatusArchived:
return true, nil
default:
return false, nil
}
}