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 } }