chore: build
ci-api / test (push) Failing after 8m7s
ci-web / test (push) Failing after 5m5s

This commit is contained in:
Xor290
2026-09-20 12:18:33 +02:00
parent 8f4c7fa47a
commit 919c807004
174 changed files with 9669 additions and 1308 deletions
+228 -13
View File
@@ -3,11 +3,40 @@ package site
import (
"net/http"
"regexp"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
var slugPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
var (
colorPattern = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
validProductLayouts = map[string]bool{"grid": true, "list": true, "grid-overlay": true, "grid-minimal": true}
validProductColumns = map[int]bool{0: true, 2: true, 3: true, 4: true, 5: true}
// validProductScrolls controls whether the storefront product list
// scrolls the normal way (down the page) or sideways as a swipeable
// row -- independent from ProductLayout (the card style), so any card
// style can be browsed either way.
validProductScrolls = map[string]bool{"vertical": true, "horizontal": true}
// validHeroPages are the storefront pages the hero banner can be
// toggled on for -- kept to a fixed, known set (rather than arbitrary
// paths) so a stale/invalid value can never sneak into a page that
// doesn't know what to do with it.
validHeroPages = map[string]bool{"catalog": true, "home": true, "cart": true, "checkout": true, "account": true, "contact": true}
)
func heroPagesToString(pages []string) string {
return strings.Join(pages, ",")
}
func heroPagesFromString(s string) []string {
if s == "" {
return []string{}
}
return strings.Split(s, ",")
}
type Handler struct {
service *Service
@@ -18,13 +47,59 @@ func NewHandler(service *Service) *Handler {
}
type settingsResponse struct {
Name string `json:"name"`
Description string `json:"description"`
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description"`
OrdersEnabled bool `json:"orders_enabled"`
HeaderBgColor string `json:"header_bg_color"`
HeaderTextColor string `json:"header_text_color"`
BodyBgColor string `json:"body_bg_color"`
BodyTextColor string `json:"body_text_color"`
FooterBgColor string `json:"footer_bg_color"`
FooterTextColor string `json:"footer_text_color"`
AccentColor string `json:"accent_color"`
ProductLayout string `json:"product_layout"`
ProductColumns int `json:"product_columns"`
ProductScroll string `json:"product_scroll"`
ContactCardTransparent bool `json:"contact_card_transparent"`
ContactCardBgColor string `json:"contact_card_bg_color"`
LogoMediaID *uuid.UUID `json:"logo_media_id"`
HeroMediaID *uuid.UUID `json:"hero_media_id"`
HeroPages []string `json:"hero_pages"`
CustomerLoginEnabled bool `json:"customer_login_enabled"`
CustomerRegistrationEnabled bool `json:"customer_registration_enabled"`
CustomerVerificationRequired bool `json:"customer_verification_required"`
VerificationContactLinkID *uuid.UUID `json:"verification_contact_link_id"`
}
func toResponse(s *Settings) settingsResponse {
return settingsResponse{Name: s.Name, Description: s.Description, Slug: s.Slug}
return settingsResponse{
Name: s.Name,
Description: s.Description,
OrdersEnabled: s.OrdersEnabled,
HeaderBgColor: s.HeaderBgColor,
HeaderTextColor: s.HeaderTextColor,
BodyBgColor: s.BodyBgColor,
BodyTextColor: s.BodyTextColor,
FooterBgColor: s.FooterBgColor,
FooterTextColor: s.FooterTextColor,
AccentColor: s.AccentColor,
ProductLayout: s.ProductLayout,
ProductColumns: s.ProductColumns,
ProductScroll: s.ProductScroll,
ContactCardTransparent: s.ContactCardTransparent,
ContactCardBgColor: s.ContactCardBgColor,
LogoMediaID: s.LogoMediaID,
HeroMediaID: s.HeroMediaID,
HeroPages: heroPagesFromString(s.HeroPages),
CustomerLoginEnabled: s.CustomerLoginEnabled,
CustomerRegistrationEnabled: s.CustomerRegistrationEnabled,
CustomerVerificationRequired: s.CustomerVerificationRequired,
VerificationContactLinkID: s.VerificationContactLinkID,
}
}
func (h *Handler) Get(c *gin.Context) {
@@ -37,9 +112,9 @@ func (h *Handler) Get(c *gin.Context) {
}
type updateRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Slug string `json:"slug" binding:"required"`
Name string `json:"name" binding:"required"`
Description string `json:"description"`
OrdersEnabled bool `json:"orders_enabled"`
}
func (h *Handler) Update(c *gin.Context) {
@@ -48,15 +123,155 @@ func (h *Handler) Update(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()})
return
}
if !slugPattern.MatchString(req.Slug) {
c.JSON(http.StatusBadRequest, gin.H{"error": "slug must be lowercase letters, digits and hyphens only"})
return
}
settings, err := h.service.Update(c.Request.Context(), req.Name, req.Description, req.Slug)
settings, err := h.service.Update(c.Request.Context(), req.Name, req.Description, req.OrdersEnabled)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update site settings"})
return
}
c.JSON(http.StatusOK, toResponse(settings))
}
// updateAppearanceRequest fields are all optional pointers: the admin can
// send just the one option they changed (e.g. {"product_layout": "list"})
// and every other appearance setting keeps its current value.
type updateAppearanceRequest struct {
HeaderBgColor *string `json:"header_bg_color"`
HeaderTextColor *string `json:"header_text_color"`
BodyBgColor *string `json:"body_bg_color"`
BodyTextColor *string `json:"body_text_color"`
FooterBgColor *string `json:"footer_bg_color"`
FooterTextColor *string `json:"footer_text_color"`
AccentColor *string `json:"accent_color"`
ProductLayout *string `json:"product_layout"`
ProductColumns *int `json:"product_columns"`
ProductScroll *string `json:"product_scroll"`
ContactCardTransparent *bool `json:"contact_card_transparent"`
ContactCardBgColor *string `json:"contact_card_bg_color"`
LogoMediaID *uuid.UUID `json:"logo_media_id"`
ClearLogo bool `json:"clear_logo"`
HeroMediaID *uuid.UUID `json:"hero_media_id"`
ClearHero bool `json:"clear_hero"`
HeroPages *[]string `json:"hero_pages"`
}
func (h *Handler) UpdateAppearance(c *gin.Context) {
var req updateAppearanceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()})
return
}
for _, color := range []*string{
req.HeaderBgColor, req.HeaderTextColor,
req.BodyBgColor, req.BodyTextColor,
req.FooterBgColor, req.FooterTextColor,
req.AccentColor, req.ContactCardBgColor,
} {
if color != nil && !colorPattern.MatchString(*color) {
c.JSON(http.StatusBadRequest, gin.H{"error": "colors must be hex values like #RRGGBB"})
return
}
}
if req.ProductLayout != nil && !validProductLayouts[*req.ProductLayout] {
c.JSON(http.StatusBadRequest, gin.H{"error": "product_layout must be one of: grid, list, grid-overlay, grid-minimal"})
return
}
if req.ProductColumns != nil && !validProductColumns[*req.ProductColumns] {
c.JSON(http.StatusBadRequest, gin.H{"error": "product_columns must be one of: 0 (auto), 2, 3, 4, 5"})
return
}
if req.ProductScroll != nil && !validProductScrolls[*req.ProductScroll] {
c.JSON(http.StatusBadRequest, gin.H{"error": "product_scroll must be one of: vertical, horizontal"})
return
}
var heroPagesStr *string
if req.HeroPages != nil {
for _, page := range *req.HeroPages {
if !validHeroPages[page] {
c.JSON(http.StatusBadRequest, gin.H{"error": "hero_pages must only contain: home, cart, checkout, account, contact"})
return
}
}
joined := heroPagesToString(*req.HeroPages)
heroPagesStr = &joined
}
in := AppearanceInput{
HeaderBgColor: req.HeaderBgColor,
HeaderTextColor: req.HeaderTextColor,
BodyBgColor: req.BodyBgColor,
BodyTextColor: req.BodyTextColor,
FooterBgColor: req.FooterBgColor,
FooterTextColor: req.FooterTextColor,
AccentColor: req.AccentColor,
ProductLayout: req.ProductLayout,
ProductColumns: req.ProductColumns,
ContactCardTransparent: req.ContactCardTransparent,
ContactCardBgColor: req.ContactCardBgColor,
ProductScroll: req.ProductScroll,
HeroPages: heroPagesStr,
}
if req.ClearLogo {
in.LogoMediaIDSet = true
in.LogoMediaID = nil
} else if req.LogoMediaID != nil {
in.LogoMediaIDSet = true
in.LogoMediaID = req.LogoMediaID
}
if req.ClearHero {
in.HeroMediaIDSet = true
in.HeroMediaID = nil
} else if req.HeroMediaID != nil {
in.HeroMediaIDSet = true
in.HeroMediaID = req.HeroMediaID
}
settings, err := h.service.UpdateAppearance(c.Request.Context(), in)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update appearance settings"})
return
}
c.JSON(http.StatusOK, toResponse(settings))
}
// updateCustomerAuthRequest fields are all optional: the admin can flip
// just one toggle at a time. ClearVerificationContactLink is a separate
// explicit flag (rather than overloading a nil ID) so "field omitted" and
// "admin picked no link" are unambiguous.
type updateCustomerAuthRequest struct {
LoginEnabled *bool `json:"login_enabled"`
RegistrationEnabled *bool `json:"registration_enabled"`
VerificationRequired *bool `json:"verification_required"`
VerificationContactLinkID *uuid.UUID `json:"verification_contact_link_id"`
ClearVerificationContactLink bool `json:"clear_verification_contact_link"`
}
func (h *Handler) UpdateCustomerAuth(c *gin.Context) {
var req updateCustomerAuthRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()})
return
}
in := CustomerAuthInput{
LoginEnabled: req.LoginEnabled,
RegistrationEnabled: req.RegistrationEnabled,
VerificationRequired: req.VerificationRequired,
}
if req.ClearVerificationContactLink {
in.VerificationContactLinkSet = true
in.VerificationContactLinkID = nil
} else if req.VerificationContactLinkID != nil {
in.VerificationContactLinkSet = true
in.VerificationContactLinkID = req.VerificationContactLinkID
}
settings, err := h.service.UpdateCustomerAuth(c.Request.Context(), in)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update customer auth settings"})
return
}
c.JSON(http.StatusOK, toResponse(settings))
}
+68 -7
View File
@@ -1,19 +1,80 @@
// Package site owns the minimal, config-driven site identity (name,
// description, slug). It is the proof of the "code defines capabilities,
// admin defines content" pattern: future modules (appearance, menu, pages,
// ...) will follow this same shape (single-row or keyed settings table +
// description). It is the proof of the "code defines capabilities, admin
// defines content" pattern: future modules (appearance, menu, pages, ...)
// will follow this same shape (single-row or keyed settings table +
// admin-only read/write endpoints).
package site
import "time"
import (
"time"
"github.com/google/uuid"
)
type Settings struct {
ID int16 `gorm:"primaryKey"`
Name string
Description string
Slug string
CreatedAt time.Time
UpdatedAt time.Time
// OrdersEnabled is the "vitrine vs boutique" site-mode switch (spec
// section 6-8): off hides the cart/checkout entirely on the storefront
// (showcase-only, prices shown as indicative), on exposes the full
// cart/order flow (still subject to CustomerLoginEnabled below). On by
// default so a fresh deployment behaves like a shop out of the box.
OrdersEnabled bool
// Appearance: lets the admin restyle the storefront (header/body/footer
// colors, accent color, product listing layout) without touching code.
HeaderBgColor string
HeaderTextColor string
BodyBgColor string
BodyTextColor string
FooterBgColor string
FooterTextColor string
AccentColor string
ProductLayout string
// ProductColumns pins the storefront product grid to a fixed number of
// columns per row (2-5). 0 means "auto" -- the grid falls back to
// filling as many columns as fit the viewport (see .product-grid in
// index.css).
ProductColumns int
// ProductScroll is "vertical" (products flow down the page, the normal
// grid/list layouts) or "horizontal" (the product list becomes a
// swipeable sideways-scrolling row) -- independent from ProductLayout,
// so any card style can be browsed either way.
ProductScroll string
// Contact link cards (storefront Contact page): transparent by default
// so a hero image or the body background shows through the cards
// instead of a solid block; ContactCardBgColor is only used when the
// admin turns transparency off in favor of a solid color.
ContactCardTransparent bool
ContactCardBgColor string
// LogoMediaID replaces the text site name in the storefront header when
// set. HeroMediaID is a full-width banner image shown above the normal
// page content, only on the pages listed in HeroPages (comma-separated
// page keys, e.g. "home,contact" -- see handler.go's validHeroPages).
LogoMediaID *uuid.UUID `gorm:"type:uuid"`
HeroMediaID *uuid.UUID `gorm:"type:uuid"`
HeroPages string
// Customer accounts: the login page and the registration page are two
// independent switches the admin can flip separately (e.g. login-only
// for an invite-only shop where accounts are created from the admin
// panel, or registration-only during a pre-launch signup window).
// CustomerLoginEnabled is also what ordering checks (see
// orders.RequireAccountsEnabled): off by default, meaning nobody --
// guest or not -- can check out until the admin turns it on, optionally
// alongside an admin-approved identity verification too (e.g. for
// age/ID-restricted goods).
CustomerLoginEnabled bool
CustomerRegistrationEnabled bool
CustomerVerificationRequired bool
VerificationContactLinkID *uuid.UUID `gorm:"type:uuid"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (Settings) TableName() string { return "site_settings" }
+36 -3
View File
@@ -9,6 +9,8 @@ import (
type Repository interface {
Get(ctx context.Context) (*Settings, error)
Update(ctx context.Context, s *Settings) error
UpdateAppearance(ctx context.Context, s *Settings) error
UpdateCustomerAuth(ctx context.Context, s *Settings) error
}
type gormRepository struct {
@@ -30,8 +32,39 @@ func (r *gormRepository) Get(ctx context.Context) (*Settings, error) {
func (r *gormRepository) Update(ctx context.Context, s *Settings) error {
s.ID = 1
return r.db.WithContext(ctx).Model(&Settings{}).Where("id = 1").Updates(map[string]any{
"name": s.Name,
"description": s.Description,
"slug": s.Slug,
"name": s.Name,
"description": s.Description,
"orders_enabled": s.OrdersEnabled,
}).Error
}
func (r *gormRepository) UpdateAppearance(ctx context.Context, s *Settings) error {
s.ID = 1
return r.db.WithContext(ctx).Model(&Settings{}).Where("id = 1").Updates(map[string]any{
"header_bg_color": s.HeaderBgColor,
"header_text_color": s.HeaderTextColor,
"body_bg_color": s.BodyBgColor,
"body_text_color": s.BodyTextColor,
"footer_bg_color": s.FooterBgColor,
"footer_text_color": s.FooterTextColor,
"accent_color": s.AccentColor,
"product_layout": s.ProductLayout,
"product_columns": s.ProductColumns,
"product_scroll": s.ProductScroll,
"contact_card_transparent": s.ContactCardTransparent,
"contact_card_bg_color": s.ContactCardBgColor,
"logo_media_id": s.LogoMediaID,
"hero_media_id": s.HeroMediaID,
"hero_pages": s.HeroPages,
}).Error
}
func (r *gormRepository) UpdateCustomerAuth(ctx context.Context, s *Settings) error {
s.ID = 1
return r.db.WithContext(ctx).Model(&Settings{}).Where("id = 1").Updates(map[string]any{
"customer_login_enabled": s.CustomerLoginEnabled,
"customer_registration_enabled": s.CustomerRegistrationEnabled,
"customer_verification_required": s.CustomerVerificationRequired,
"verification_contact_link_id": s.VerificationContactLinkID,
}).Error
}
+8
View File
@@ -6,4 +6,12 @@ func RegisterRoutes(rg *gin.RouterGroup, h *Handler, requireAdmin gin.HandlerFun
group := rg.Group("/admin/site-settings")
group.GET("", h.Get)
group.PUT("", requireAdmin, h.Update)
group.PUT("/appearance", requireAdmin, h.UpdateAppearance)
group.PUT("/customer-auth", requireAdmin, h.UpdateCustomerAuth)
}
// RegisterPublicRoutes exposes the site identity (name, description)
// under a plain, non-admin-prefixed path for the storefront to read.
func RegisterPublicRoutes(rg *gin.RouterGroup, h *Handler) {
rg.GET("/site-settings", h.Get)
}
+187 -3
View File
@@ -1,6 +1,10 @@
package site
import "context"
import (
"context"
"github.com/google/uuid"
)
type Service struct {
repo Repository
@@ -14,10 +18,190 @@ func (s *Service) Get(ctx context.Context) (*Settings, error) {
return s.repo.Get(ctx)
}
func (s *Service) Update(ctx context.Context, name, description, slug string) (*Settings, error) {
settings := &Settings{Name: name, Description: description, Slug: slug}
func (s *Service) Update(ctx context.Context, name, description string, ordersEnabled bool) (*Settings, error) {
settings := &Settings{Name: name, Description: description, OrdersEnabled: ordersEnabled}
if err := s.repo.Update(ctx, settings); err != nil {
return nil, err
}
return s.repo.Get(ctx)
}
// AppearanceInput is updated independently from site identity (name/slug):
// the "Appearance" admin page only ever touches these fields, so it must
// not be blocked by identity validation (e.g. an unset slug) or clobber
// name/description/slug on save. Each field is a pointer so the admin can
// change a single option (e.g. just the product layout) without resending
// every color -- nil fields keep their current, already-saved value.
type AppearanceInput struct {
HeaderBgColor *string
HeaderTextColor *string
BodyBgColor *string
BodyTextColor *string
FooterBgColor *string
FooterTextColor *string
AccentColor *string
ProductLayout *string
ProductColumns *int
ProductScroll *string
ContactCardTransparent *bool
ContactCardBgColor *string
// LogoMediaIDSet/HeroMediaIDSet distinguish "the admin didn't touch
// this field" (false: keep the current value) from "the admin
// explicitly chose an image, possibly clearing it to nil" (true: use
// the ID as-is, nil included) -- same reasoning as
// CustomerAuthInput.VerificationContactLinkSet.
LogoMediaIDSet bool
LogoMediaID *uuid.UUID
HeroMediaIDSet bool
HeroMediaID *uuid.UUID
HeroPages *string
}
func (s *Service) UpdateAppearance(ctx context.Context, in AppearanceInput) (*Settings, error) {
current, err := s.repo.Get(ctx)
if err != nil {
return nil, err
}
settings := &Settings{
HeaderBgColor: applyIfSet(in.HeaderBgColor, current.HeaderBgColor),
HeaderTextColor: applyIfSet(in.HeaderTextColor, current.HeaderTextColor),
BodyBgColor: applyIfSet(in.BodyBgColor, current.BodyBgColor),
BodyTextColor: applyIfSet(in.BodyTextColor, current.BodyTextColor),
FooterBgColor: applyIfSet(in.FooterBgColor, current.FooterBgColor),
FooterTextColor: applyIfSet(in.FooterTextColor, current.FooterTextColor),
AccentColor: applyIfSet(in.AccentColor, current.AccentColor),
ProductLayout: applyIfSet(in.ProductLayout, current.ProductLayout),
ProductColumns: applyIntIfSet(in.ProductColumns, current.ProductColumns),
ProductScroll: applyIfSet(in.ProductScroll, current.ProductScroll),
ContactCardTransparent: applyBoolIfSet(in.ContactCardTransparent, current.ContactCardTransparent),
ContactCardBgColor: applyIfSet(in.ContactCardBgColor, current.ContactCardBgColor),
LogoMediaID: current.LogoMediaID,
HeroMediaID: current.HeroMediaID,
HeroPages: applyIfSet(in.HeroPages, current.HeroPages),
}
if in.LogoMediaIDSet {
settings.LogoMediaID = in.LogoMediaID
}
if in.HeroMediaIDSet {
settings.HeroMediaID = in.HeroMediaID
}
if err := s.repo.UpdateAppearance(ctx, settings); err != nil {
return nil, err
}
return s.repo.Get(ctx)
}
func applyIfSet(value *string, fallback string) string {
if value == nil {
return fallback
}
return *value
}
func applyIntIfSet(value *int, fallback int) int {
if value == nil {
return fallback
}
return *value
}
// CustomerAuthInput is updated independently from identity/appearance, same
// reasoning as AppearanceInput: the "Customer accounts" admin page only
// ever touches these fields. LoginEnabled and RegistrationEnabled are two
// independent switches (e.g. an invite-only shop can enable login while
// keeping registration off), so each is its own optional pointer.
type CustomerAuthInput struct {
LoginEnabled *bool
RegistrationEnabled *bool
VerificationRequired *bool
// VerificationContactLinkSet distinguishes "the admin didn't touch this
// field" (false: keep the current value) from "the admin explicitly
// chose a link, possibly clearing it to nil" (true: use
// VerificationContactLinkID as-is, nil included).
VerificationContactLinkSet bool
VerificationContactLinkID *uuid.UUID
}
func (s *Service) UpdateCustomerAuth(ctx context.Context, in CustomerAuthInput) (*Settings, error) {
current, err := s.repo.Get(ctx)
if err != nil {
return nil, err
}
settings := &Settings{
CustomerLoginEnabled: applyBoolIfSet(in.LoginEnabled, current.CustomerLoginEnabled),
CustomerRegistrationEnabled: applyBoolIfSet(in.RegistrationEnabled, current.CustomerRegistrationEnabled),
CustomerVerificationRequired: applyBoolIfSet(in.VerificationRequired, current.CustomerVerificationRequired),
VerificationContactLinkID: current.VerificationContactLinkID,
}
if in.VerificationContactLinkSet {
settings.VerificationContactLinkID = in.VerificationContactLinkID
}
if err := s.repo.UpdateCustomerAuth(ctx, settings); err != nil {
return nil, err
}
return s.repo.Get(ctx)
}
func applyBoolIfSet(value *bool, fallback bool) bool {
if value == nil {
return fallback
}
return *value
}
// CustomerLoginEnabled is consumed by auth.CustomerHandler to gate the
// customer login page/endpoint behind the admin's toggle.
func (s *Service) CustomerLoginEnabled(ctx context.Context) (bool, error) {
settings, err := s.repo.Get(ctx)
if err != nil {
return false, err
}
return settings.CustomerLoginEnabled, nil
}
// CustomerRegistrationEnabled is consumed by auth.CustomerHandler to gate
// the customer registration page/endpoint behind the admin's toggle,
// independently of CustomerLoginEnabled.
func (s *Service) CustomerRegistrationEnabled(ctx context.Context) (bool, error) {
settings, err := s.repo.Get(ctx)
if err != nil {
return false, err
}
return settings.CustomerRegistrationEnabled, nil
}
// CustomerAccountsAvailable is consumed by users.Service.Create to prevent
// creating customer-role users when neither login nor registration is
// enabled -- such an account could never be used to sign in.
func (s *Service) CustomerAccountsAvailable(ctx context.Context) (bool, error) {
settings, err := s.repo.Get(ctx)
if err != nil {
return false, err
}
return settings.CustomerLoginEnabled || settings.CustomerRegistrationEnabled, nil
}
// CustomerCheckoutSettings is consumed by orders.RequireAccountsEnabled /
// RequireApprovedVerificationIfNeeded to gate order creation.
func (s *Service) CustomerCheckoutSettings(ctx context.Context) (accountsEnabled, verificationRequired bool, err error) {
settings, err := s.repo.Get(ctx)
if err != nil {
return false, false, err
}
return settings.CustomerLoginEnabled, settings.CustomerVerificationRequired, nil
}
// OrdersEnabled is consumed by orders.RequireOrdersEnabled: the site-wide
// "vitrine vs boutique" switch, checked before any of the customer-account
// gates so a pure showcase site never has to think about accounts at all.
func (s *Service) OrdersEnabled(ctx context.Context) (bool, error) {
settings, err := s.repo.Get(ctx)
if err != nil {
return false, err
}
return settings.OrdersEnabled, nil
}