Files
silo-server/internal/sections/repo.go
T
Silo Server Migration dce8978cdf Track template poster origin in collection sync
- 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
2026-05-23 14:19:04 -04:00

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