initial commit
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
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
This commit is contained in:
commit
951dd8d26b
175 changed files with 50041 additions and 0 deletions
152
internal/store/user_preferences.go
Normal file
152
internal/store/user_preferences.go
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue