- add poster_from_template to collection records and update flows - preserve template-applied posters while keeping admin uploads sticky - improve template bundle sync/status reporting and MDBList limit handling
496 lines
16 KiB
Go
496 lines
16 KiB
Go
package sections
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/idgen"
|
|
)
|
|
|
|
// ErrSectionNotFound is returned when a section cannot be found.
|
|
var ErrSectionNotFound = errors.New("section not found")
|
|
|
|
// ReorderEntry represents a section ID and its new position.
|
|
type ReorderEntry struct {
|
|
ID string `json:"id"`
|
|
Position int `json:"position"`
|
|
}
|
|
|
|
// Repository provides CRUD operations on admin-defined page_sections.
|
|
type Repository struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
// NewRepository creates a new section Repository.
|
|
func NewRepository(pool *pgxpool.Pool) *Repository {
|
|
return &Repository{pool: pool}
|
|
}
|
|
|
|
const sectionColumns = `id, scope, library_id, position, section_type, title, featured,
|
|
item_limit, config, enabled, created_at, updated_at`
|
|
|
|
func scanSection(row pgx.Row) (*PageSection, error) {
|
|
var s PageSection
|
|
err := row.Scan(
|
|
&s.ID, &s.Scope, &s.LibraryID, &s.Position, &s.SectionType, &s.Title,
|
|
&s.Featured, &s.ItemLimit, &s.Config, &s.Enabled, &s.CreatedAt, &s.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrSectionNotFound
|
|
}
|
|
return nil, fmt.Errorf("scanning section: %w", err)
|
|
}
|
|
return &s, nil
|
|
}
|
|
|
|
func scanSections(rows pgx.Rows) ([]*PageSection, error) {
|
|
var result []*PageSection
|
|
for rows.Next() {
|
|
var s PageSection
|
|
err := rows.Scan(
|
|
&s.ID, &s.Scope, &s.LibraryID, &s.Position, &s.SectionType, &s.Title,
|
|
&s.Featured, &s.ItemLimit, &s.Config, &s.Enabled, &s.CreatedAt, &s.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scanning section row: %w", err)
|
|
}
|
|
result = append(result, &s)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
// Create inserts a new section and returns it.
|
|
func (r *Repository) Create(ctx context.Context, s *PageSection) (*PageSection, error) {
|
|
id, err := idgen.NextID()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("generate section id: %w", err)
|
|
}
|
|
s.ID = id
|
|
if s.Config == nil {
|
|
s.Config = json.RawMessage(`{}`)
|
|
}
|
|
|
|
query := fmt.Sprintf(`INSERT INTO page_sections (%s) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,NOW(),NOW())
|
|
RETURNING %s`, sectionColumns, sectionColumns)
|
|
|
|
return scanSection(r.pool.QueryRow(ctx, query,
|
|
s.ID, s.Scope, s.LibraryID, s.Position, s.SectionType, s.Title,
|
|
s.Featured, s.ItemLimit, s.Config, s.Enabled,
|
|
))
|
|
}
|
|
|
|
// GetByID returns a single section by ID.
|
|
func (r *Repository) GetByID(ctx context.Context, id string) (*PageSection, error) {
|
|
query := fmt.Sprintf("SELECT %s FROM page_sections WHERE id = $1", sectionColumns)
|
|
return scanSection(r.pool.QueryRow(ctx, query, id))
|
|
}
|
|
|
|
// ListByScope returns all enabled sections for a scope, ordered by position.
|
|
func (r *Repository) ListByScope(ctx context.Context, scope string, libraryID *int) ([]*PageSection, error) {
|
|
var query string
|
|
var args []any
|
|
|
|
if scope == "library" && libraryID != nil {
|
|
query = fmt.Sprintf("SELECT %s FROM page_sections WHERE scope = $1 AND library_id = $2 AND enabled = true ORDER BY position ASC", sectionColumns)
|
|
args = []any{scope, *libraryID}
|
|
} else {
|
|
query = fmt.Sprintf("SELECT %s FROM page_sections WHERE scope = $1 AND library_id IS NULL AND enabled = true ORDER BY position ASC", sectionColumns)
|
|
args = []any{scope}
|
|
}
|
|
|
|
rows, err := r.pool.Query(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("listing sections: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
return scanSections(rows)
|
|
}
|
|
|
|
// ListByScopeAll returns all sections (including disabled) for admin views.
|
|
func (r *Repository) ListByScopeAll(ctx context.Context, scope string, libraryID *int) ([]*PageSection, error) {
|
|
var query string
|
|
var args []any
|
|
|
|
if scope == "library" && libraryID != nil {
|
|
query = fmt.Sprintf("SELECT %s FROM page_sections WHERE scope = $1 AND library_id = $2 ORDER BY position ASC", sectionColumns)
|
|
args = []any{scope, *libraryID}
|
|
} else {
|
|
query = fmt.Sprintf("SELECT %s FROM page_sections WHERE scope = $1 AND library_id IS NULL ORDER BY position ASC", sectionColumns)
|
|
args = []any{scope}
|
|
}
|
|
|
|
rows, err := r.pool.Query(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("listing all sections: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
return scanSections(rows)
|
|
}
|
|
|
|
// Update modifies an existing section.
|
|
func (r *Repository) Update(ctx context.Context, s *PageSection) error {
|
|
query := `UPDATE page_sections SET
|
|
position = $2, section_type = $3, title = $4, featured = $5,
|
|
item_limit = $6, config = $7, enabled = $8, updated_at = NOW()
|
|
WHERE id = $1`
|
|
|
|
tag, err := r.pool.Exec(ctx, query,
|
|
s.ID, s.Position, s.SectionType, s.Title, s.Featured,
|
|
s.ItemLimit, s.Config, s.Enabled,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("updating section: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrSectionNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ClearFeaturedForSurface unsets featured on every other section in the same
|
|
// home or library surface. This keeps the hero invariant owned by the backend.
|
|
func (r *Repository) ClearFeaturedForSurface(ctx context.Context, scope string, libraryID *int, exceptID string) error {
|
|
var libraryArg any
|
|
if libraryID != nil {
|
|
libraryArg = *libraryID
|
|
}
|
|
_, err := r.pool.Exec(ctx, `
|
|
UPDATE page_sections
|
|
SET featured = false, updated_at = NOW()
|
|
WHERE scope = $1
|
|
AND library_id IS NOT DISTINCT FROM $2
|
|
AND featured = true
|
|
AND ($3 = '' OR id <> $3)
|
|
`, scope, libraryArg, exceptID)
|
|
if err != nil {
|
|
return fmt.Errorf("clearing featured sections: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetGeneratedTemplateBundleFeaturedSection returns the generated featured
|
|
// collection section for a template bundle surface, if one already exists.
|
|
func (r *Repository) GetGeneratedTemplateBundleFeaturedSection(ctx context.Context, bundleID, scope string, libraryID *int) (*PageSection, error) {
|
|
var libraryArg any
|
|
if libraryID != nil {
|
|
libraryArg = *libraryID
|
|
}
|
|
query := fmt.Sprintf(`
|
|
SELECT %s
|
|
FROM page_sections
|
|
WHERE scope = $1
|
|
AND library_id IS NOT DISTINCT FROM $2
|
|
AND section_type = $3
|
|
AND config->>'generated_source' = 'template_bundle_featured'
|
|
AND config->>'template_bundle' = $4
|
|
ORDER BY created_at ASC
|
|
LIMIT 1
|
|
`, sectionColumns)
|
|
return scanSection(r.pool.QueryRow(ctx, query, scope, libraryArg, SectionCollection, bundleID))
|
|
}
|
|
|
|
// DeleteGeneratedTemplateBundleFeaturedSections removes generated featured
|
|
// sections tied to selected libraries so bundle collection replacement is not
|
|
// blocked by stale generated section references. Scoped to a specific
|
|
// bundleID — featured sections produced by sibling bundles in the same
|
|
// libraries are left intact so multi-bundle setups coexist cleanly.
|
|
func (r *Repository) DeleteGeneratedTemplateBundleFeaturedSections(ctx context.Context, bundleID string, libraryIDs []int) error {
|
|
if len(libraryIDs) == 0 {
|
|
return nil
|
|
}
|
|
_, err := r.pool.Exec(ctx, `
|
|
DELETE FROM page_sections
|
|
WHERE section_type = $1
|
|
AND config->>'generated_source' = 'template_bundle_featured'
|
|
AND config->>'template_bundle' = $2
|
|
AND (
|
|
(scope = 'library' AND library_id = ANY($3::int[]))
|
|
OR (
|
|
scope = 'home'
|
|
AND config->>'library_id' ~ '^[0-9]+$'
|
|
AND (config->>'library_id')::int = ANY($3::int[])
|
|
)
|
|
)
|
|
`, SectionCollection, bundleID, libraryIDs)
|
|
if err != nil {
|
|
return fmt.Errorf("deleting generated template bundle featured sections: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Delete removes a section by ID.
|
|
func (r *Repository) Delete(ctx context.Context, id string) error {
|
|
tag, err := r.pool.Exec(ctx, "DELETE FROM page_sections WHERE id = $1", id)
|
|
if err != nil {
|
|
return fmt.Errorf("deleting section: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrSectionNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CountLibraryCollectionReferences counts sections whose config points at the
|
|
// given library collection. excludeSectionID is optional and is ignored when
|
|
// empty.
|
|
func (r *Repository) CountLibraryCollectionReferences(ctx context.Context, collectionID, excludeSectionID string) (int, error) {
|
|
if collectionID == "" {
|
|
return 0, nil
|
|
}
|
|
query := "SELECT COUNT(*) FROM page_sections WHERE config->>'library_collection_id' = $1"
|
|
args := []any{collectionID}
|
|
if excludeSectionID != "" {
|
|
query += " AND id <> $2"
|
|
args = append(args, excludeSectionID)
|
|
}
|
|
var count int
|
|
if err := r.pool.QueryRow(ctx, query, args...).Scan(&count); err != nil {
|
|
return 0, fmt.Errorf("counting library collection section references: %w", err)
|
|
}
|
|
return count, nil
|
|
}
|
|
|
|
// Reorder updates positions for multiple sections in a transaction.
|
|
func (r *Repository) Reorder(ctx context.Context, entries []ReorderEntry) error {
|
|
tx, err := r.pool.Begin(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("beginning reorder transaction: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
for _, e := range entries {
|
|
if _, err := tx.Exec(ctx, "UPDATE page_sections SET position = $1, updated_at = NOW() WHERE id = $2", e.Position, e.ID); err != nil {
|
|
return fmt.Errorf("reordering section %s: %w", e.ID, err)
|
|
}
|
|
}
|
|
|
|
return tx.Commit(ctx)
|
|
}
|
|
|
|
// SeedDefaults inserts default sections for a scope if none exist yet.
|
|
// It is idempotent — if any sections already exist for the scope+library, it is a no-op.
|
|
func (r *Repository) SeedDefaults(ctx context.Context, scope string, libraryID *int, defaults []*PageSection) error {
|
|
existing, err := r.ListByScopeAll(ctx, scope, libraryID)
|
|
if err != nil {
|
|
return fmt.Errorf("checking existing sections: %w", err)
|
|
}
|
|
if len(existing) > 0 {
|
|
return nil
|
|
}
|
|
for _, d := range defaults {
|
|
s := *d // copy so Create can mutate ID
|
|
if _, err := r.Create(ctx, &s); err != nil {
|
|
return fmt.Errorf("seeding default section %q: %w", d.Title, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RestoreDefaults replaces all sections for a scope+library with the given defaults.
|
|
// It deletes existing sections and inserts the defaults in a single transaction.
|
|
func (r *Repository) RestoreDefaults(ctx context.Context, scope string, libraryID *int, defaults []*PageSection) ([]*PageSection, error) {
|
|
tx, err := r.pool.Begin(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("beginning restore transaction: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
// Delete existing sections for this scope+library.
|
|
if libraryID != nil {
|
|
_, err = tx.Exec(ctx, "DELETE FROM page_sections WHERE scope = $1 AND library_id = $2", scope, *libraryID)
|
|
} else {
|
|
_, err = tx.Exec(ctx, "DELETE FROM page_sections WHERE scope = $1 AND library_id IS NULL", scope)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("deleting existing sections: %w", err)
|
|
}
|
|
|
|
// Insert defaults.
|
|
var created []*PageSection
|
|
for _, d := range defaults {
|
|
id, err := idgen.NextID()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("generating section id: %w", err)
|
|
}
|
|
cfg := d.Config
|
|
if cfg == nil {
|
|
cfg = json.RawMessage(`{}`)
|
|
}
|
|
query := fmt.Sprintf(`INSERT INTO page_sections (%s) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,NOW(),NOW())
|
|
RETURNING %s`, sectionColumns, sectionColumns)
|
|
s, err := scanSection(tx.QueryRow(ctx, query,
|
|
id, d.Scope, d.LibraryID, d.Position, d.SectionType, d.Title,
|
|
d.Featured, d.ItemLimit, cfg, d.Enabled,
|
|
))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("inserting default section %q: %w", d.Title, err)
|
|
}
|
|
created = append(created, s)
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, fmt.Errorf("committing restore transaction: %w", err)
|
|
}
|
|
return created, nil
|
|
}
|
|
|
|
// ClearAllProfileOverrides removes section overrides for ALL users for a given scope+library.
|
|
// This is an admin-level operation used when restoring default sections.
|
|
func (r *Repository) ClearAllProfileOverrides(ctx context.Context, scope, libraryID string) error {
|
|
key := fmt.Sprintf("section_overrides:%s:%s", scope, libraryID)
|
|
_, err := r.pool.Exec(ctx, "DELETE FROM user_settings WHERE key = $1", key)
|
|
if err != nil {
|
|
return fmt.Errorf("clearing profile overrides: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Repository) listGeneratedHomeLibraryRecentSections(ctx context.Context, libraryID int) ([]*PageSection, error) {
|
|
sections, err := r.ListByScopeAll(ctx, "home", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := make([]*PageSection, 0, 2)
|
|
for _, section := range sections {
|
|
if IsGeneratedHomeLibraryRecentSection(section, libraryID) {
|
|
result = append(result, section)
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (r *Repository) nextHomePosition(ctx context.Context) (int, error) {
|
|
sections, err := r.ListByScopeAll(ctx, "home", nil)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
maxPosition := -1
|
|
for _, section := range sections {
|
|
if section.Position > maxPosition {
|
|
maxPosition = section.Position
|
|
}
|
|
}
|
|
return maxPosition + 1, nil
|
|
}
|
|
|
|
func (r *Repository) CreateGeneratedHomeLibraryRecentSections(ctx context.Context, libraryID int, libraryName string) ([]*PageSection, error) {
|
|
existing, err := r.listGeneratedHomeLibraryRecentSections(ctx, libraryID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("listing generated home sections: %w", err)
|
|
}
|
|
|
|
existingByType := make(map[SectionType]*PageSection, len(existing))
|
|
for _, section := range existing {
|
|
existingByType[section.SectionType] = section
|
|
}
|
|
|
|
position, err := r.nextHomePosition(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("loading next home section position: %w", err)
|
|
}
|
|
|
|
created := make([]*PageSection, 0, 2)
|
|
for _, sectionType := range []SectionType{SectionRecentlyAdded, SectionRecentlyReleased} {
|
|
if _, ok := existingByType[sectionType]; ok {
|
|
continue
|
|
}
|
|
section := &PageSection{
|
|
Scope: "home",
|
|
Position: position,
|
|
SectionType: sectionType,
|
|
Title: GeneratedHomeLibraryRecentTitle(sectionType, libraryName),
|
|
ItemLimit: 20,
|
|
Config: GeneratedHomeLibraryRecentConfig(libraryID),
|
|
Enabled: true,
|
|
}
|
|
createdSection, createErr := r.Create(ctx, section)
|
|
if createErr != nil {
|
|
return nil, fmt.Errorf("creating generated home section %q: %w", section.Title, createErr)
|
|
}
|
|
created = append(created, createdSection)
|
|
position++
|
|
}
|
|
|
|
return created, nil
|
|
}
|
|
|
|
func (r *Repository) SyncGeneratedHomeLibraryRecentTitles(ctx context.Context, libraryID int, oldLibraryName, newLibraryName string) error {
|
|
sections, err := r.listGeneratedHomeLibraryRecentSections(ctx, libraryID)
|
|
if err != nil {
|
|
return fmt.Errorf("listing generated home sections: %w", err)
|
|
}
|
|
|
|
for _, section := range sections {
|
|
if !ShouldSyncGeneratedHomeLibraryRecentTitle(section, oldLibraryName) {
|
|
continue
|
|
}
|
|
section.Title = GeneratedHomeLibraryRecentTitle(section.SectionType, newLibraryName)
|
|
if err := r.Update(ctx, section); err != nil {
|
|
return fmt.Errorf("updating generated home section %s: %w", section.ID, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (r *Repository) DeleteGeneratedHomeLibraryRecentSections(ctx context.Context, libraryID int) error {
|
|
sections, err := r.listGeneratedHomeLibraryRecentSections(ctx, libraryID)
|
|
if err != nil {
|
|
return fmt.Errorf("listing generated home sections: %w", err)
|
|
}
|
|
|
|
for _, section := range sections {
|
|
if err := r.Delete(ctx, section.ID); err != nil && !errors.Is(err, ErrSectionNotFound) {
|
|
return fmt.Errorf("deleting generated home section %s: %w", section.ID, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// CreateMany inserts multiple sections in a single transaction. If any insert
|
|
// fails the entire batch is rolled back. Each row gets a fresh ID, and
|
|
// position is computed as MAX(position)+1 within the same scope/library_id.
|
|
func (r *Repository) CreateMany(ctx context.Context, rows []*PageSection) error {
|
|
if len(rows) == 0 {
|
|
return nil
|
|
}
|
|
tx, err := r.pool.Begin(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("begin tx: %w", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
for _, row := range rows {
|
|
id, err := idgen.NextID()
|
|
if err != nil {
|
|
return fmt.Errorf("generate section id: %w", err)
|
|
}
|
|
row.ID = id
|
|
if row.Config == nil {
|
|
row.Config = json.RawMessage(`{}`)
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO page_sections (id, scope, library_id, position, section_type, title, featured, item_limit, config, enabled, created_at, updated_at)
|
|
VALUES ($1, $2, $3,
|
|
COALESCE((SELECT MAX(position)+1 FROM page_sections WHERE scope = $2 AND library_id IS NOT DISTINCT FROM $3), 0),
|
|
$4, $5, $6, $7, $8, $9, NOW(), NOW())`,
|
|
row.ID, row.Scope, row.LibraryID,
|
|
row.SectionType, row.Title, row.Featured, row.ItemLimit, row.Config, row.Enabled,
|
|
); err != nil {
|
|
return fmt.Errorf("inserting section: %w", err)
|
|
}
|
|
}
|
|
return tx.Commit(ctx)
|
|
}
|