Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled
152 lines
5.4 KiB
Go
152 lines
5.4 KiB
Go
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
|
|
}
|
|
}
|