morz-infoboard/server/backend/internal/store/store.go
Jesko Anschütz 803f355220 Baue Ebene 2: PostgreSQL-Backend, Medien-Upload und Playlist-UI
- DB-Package mit pgxpool, Migrations-Runner und eingebetteten SQL-Dateien
- Schema: tenants, screens, media_assets, playlists, playlist_items
- Store-Layer: alle Repositories (TenantStore, ScreenStore, MediaStore, PlaylistStore)
- JSON-API: Screens, Medien, Playlist-CRUD, Player-Sync-Endpunkt
- Admin-UI (/admin): Screens anlegen, löschen, zur Playlist navigieren
- Playlist-UI (/manage/{slug}): Drag&Drop-Sortierung, Item-Bearbeitung,
  Medienbibliothek, Datei-Upload (Bild/Video/PDF) und Web-URL
- Router auf RouterDeps umgestellt; manage-Routen nur wenn Stores vorhanden
- parseOptionalTime akzeptiert nun RFC3339 und datetime-local HTML-Format

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 22:53:00 +01:00

426 lines
13 KiB
Go

// Package store contains all database repositories.
package store
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// ------------------------------------------------------------------
// Domain types
// ------------------------------------------------------------------
type Tenant struct {
ID string
Slug string
Name string
CreatedAt time.Time
}
type Screen struct {
ID string
TenantID string
Slug string
Name string
Orientation string
CreatedAt time.Time
}
type MediaAsset struct {
ID string
TenantID string
Title string
Type string // image | video | pdf | web
StoragePath string
OriginalURL string
MimeType string
SizeBytes int64
Enabled bool
CreatedAt time.Time
}
type Playlist struct {
ID string
TenantID string
ScreenID string
Name string
IsActive bool
DefaultDurationSeconds int
CreatedAt time.Time
UpdatedAt time.Time
}
type PlaylistItem struct {
ID string
PlaylistID string
MediaAssetID string // may be empty for web items without asset
OrderIndex int
Type string // image | video | pdf | web
Src string
Title string
DurationSeconds int
ValidFrom *time.Time
ValidUntil *time.Time
Enabled bool
CreatedAt time.Time
}
// ------------------------------------------------------------------
// Stores
// ------------------------------------------------------------------
type TenantStore struct{ pool *pgxpool.Pool }
type ScreenStore struct{ pool *pgxpool.Pool }
type MediaStore struct{ pool *pgxpool.Pool }
type PlaylistStore struct{ pool *pgxpool.Pool }
func NewTenantStore(pool *pgxpool.Pool) *TenantStore { return &TenantStore{pool} }
func NewScreenStore(pool *pgxpool.Pool) *ScreenStore { return &ScreenStore{pool} }
func NewMediaStore(pool *pgxpool.Pool) *MediaStore { return &MediaStore{pool} }
func NewPlaylistStore(pool *pgxpool.Pool) *PlaylistStore { return &PlaylistStore{pool} }
// ------------------------------------------------------------------
// TenantStore
// ------------------------------------------------------------------
func (s *TenantStore) Get(ctx context.Context, slug string) (*Tenant, error) {
row := s.pool.QueryRow(ctx,
`select id, slug, name, created_at from tenants where slug=$1`, slug)
return scanTenant(row)
}
func (s *TenantStore) List(ctx context.Context) ([]*Tenant, error) {
rows, err := s.pool.Query(ctx, `select id, slug, name, created_at from tenants order by name`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Tenant
for rows.Next() {
t, err := scanTenant(rows)
if err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
func scanTenant(row interface {
Scan(dest ...any) error
}) (*Tenant, error) {
var t Tenant
err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.CreatedAt)
if err != nil {
return nil, fmt.Errorf("scan tenant: %w", err)
}
return &t, nil
}
// ------------------------------------------------------------------
// ScreenStore
// ------------------------------------------------------------------
func (s *ScreenStore) List(ctx context.Context, tenantID string) ([]*Screen, error) {
rows, err := s.pool.Query(ctx,
`select id, tenant_id, slug, name, orientation, created_at
from screens where tenant_id=$1 order by name`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Screen
for rows.Next() {
sc, err := scanScreen(rows)
if err != nil {
return nil, err
}
out = append(out, sc)
}
return out, rows.Err()
}
func (s *ScreenStore) ListAll(ctx context.Context) ([]*Screen, error) {
rows, err := s.pool.Query(ctx,
`select id, tenant_id, slug, name, orientation, created_at from screens order by name`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Screen
for rows.Next() {
sc, err := scanScreen(rows)
if err != nil {
return nil, err
}
out = append(out, sc)
}
return out, rows.Err()
}
func (s *ScreenStore) GetBySlug(ctx context.Context, slug string) (*Screen, error) {
row := s.pool.QueryRow(ctx,
`select id, tenant_id, slug, name, orientation, created_at from screens where slug=$1`, slug)
return scanScreen(row)
}
func (s *ScreenStore) Create(ctx context.Context, tenantID, slug, name, orientation string) (*Screen, error) {
row := s.pool.QueryRow(ctx,
`insert into screens(tenant_id, slug, name, orientation)
values($1,$2,$3,$4)
returning id, tenant_id, slug, name, orientation, created_at`,
tenantID, slug, name, orientation)
return scanScreen(row)
}
func (s *ScreenStore) Update(ctx context.Context, id, name, orientation string) (*Screen, error) {
row := s.pool.QueryRow(ctx,
`update screens set name=$2, orientation=$3 where id=$1
returning id, tenant_id, slug, name, orientation, created_at`,
id, name, orientation)
return scanScreen(row)
}
func (s *ScreenStore) Delete(ctx context.Context, id string) error {
_, err := s.pool.Exec(ctx, `delete from screens where id=$1`, id)
return err
}
func scanScreen(row interface {
Scan(dest ...any) error
}) (*Screen, error) {
var sc Screen
err := row.Scan(&sc.ID, &sc.TenantID, &sc.Slug, &sc.Name, &sc.Orientation, &sc.CreatedAt)
if err != nil {
return nil, fmt.Errorf("scan screen: %w", err)
}
return &sc, nil
}
// ------------------------------------------------------------------
// MediaStore
// ------------------------------------------------------------------
func (s *MediaStore) List(ctx context.Context, tenantID string) ([]*MediaAsset, error) {
rows, err := s.pool.Query(ctx,
`select id, tenant_id, title, type, coalesce(storage_path,''),
coalesce(original_url,''), coalesce(mime_type,''), coalesce(size_bytes,0),
enabled, created_at
from media_assets where tenant_id=$1 order by created_at desc`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*MediaAsset
for rows.Next() {
m, err := scanMedia(rows)
if err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
func (s *MediaStore) Get(ctx context.Context, id string) (*MediaAsset, error) {
row := s.pool.QueryRow(ctx,
`select id, tenant_id, title, type, coalesce(storage_path,''),
coalesce(original_url,''), coalesce(mime_type,''), coalesce(size_bytes,0),
enabled, created_at
from media_assets where id=$1`, id)
return scanMedia(row)
}
func (s *MediaStore) Create(ctx context.Context, tenantID, title, assetType, storagePath, originalURL, mimeType string, sizeBytes int64) (*MediaAsset, error) {
row := s.pool.QueryRow(ctx,
`insert into media_assets(tenant_id, title, type, storage_path, original_url, mime_type, size_bytes)
values($1,$2,$3,nullif($4,''),nullif($5,''),nullif($6,''),nullif($7,0))
returning id, tenant_id, title, type, coalesce(storage_path,''),
coalesce(original_url,''), coalesce(mime_type,''), coalesce(size_bytes,0),
enabled, created_at`,
tenantID, title, assetType, storagePath, originalURL, mimeType, sizeBytes)
return scanMedia(row)
}
func (s *MediaStore) Delete(ctx context.Context, id string) error {
_, err := s.pool.Exec(ctx, `delete from media_assets where id=$1`, id)
return err
}
func scanMedia(row interface {
Scan(dest ...any) error
}) (*MediaAsset, error) {
var m MediaAsset
err := row.Scan(&m.ID, &m.TenantID, &m.Title, &m.Type,
&m.StoragePath, &m.OriginalURL, &m.MimeType, &m.SizeBytes,
&m.Enabled, &m.CreatedAt)
if err != nil {
return nil, fmt.Errorf("scan media: %w", err)
}
return &m, nil
}
// ------------------------------------------------------------------
// PlaylistStore
// ------------------------------------------------------------------
func (s *PlaylistStore) GetOrCreateForScreen(ctx context.Context, tenantID, screenID, screenName string) (*Playlist, error) {
row := s.pool.QueryRow(ctx,
`insert into playlists(tenant_id, screen_id, name)
values($1,$2,$3)
on conflict(screen_id) do update set updated_at=now()
returning id, tenant_id, screen_id, name, is_active, default_duration_seconds, created_at, updated_at`,
tenantID, screenID, screenName+" Playlist")
return scanPlaylist(row)
}
func (s *PlaylistStore) Get(ctx context.Context, id string) (*Playlist, error) {
row := s.pool.QueryRow(ctx,
`select id, tenant_id, screen_id, name, is_active, default_duration_seconds, created_at, updated_at
from playlists where id=$1`, id)
return scanPlaylist(row)
}
func (s *PlaylistStore) GetByScreen(ctx context.Context, screenID string) (*Playlist, error) {
row := s.pool.QueryRow(ctx,
`select id, tenant_id, screen_id, name, is_active, default_duration_seconds, created_at, updated_at
from playlists where screen_id=$1`, screenID)
return scanPlaylist(row)
}
func (s *PlaylistStore) UpdateDefaultDuration(ctx context.Context, id string, seconds int) error {
_, err := s.pool.Exec(ctx,
`update playlists set default_duration_seconds=$2, updated_at=now() where id=$1`, id, seconds)
return err
}
func scanPlaylist(row interface {
Scan(dest ...any) error
}) (*Playlist, error) {
var p Playlist
err := row.Scan(&p.ID, &p.TenantID, &p.ScreenID, &p.Name,
&p.IsActive, &p.DefaultDurationSeconds, &p.CreatedAt, &p.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("scan playlist: %w", err)
}
return &p, nil
}
// ------------------------------------------------------------------
// PlaylistItemStore (part of PlaylistStore for simplicity)
// ------------------------------------------------------------------
func (s *PlaylistStore) ListItems(ctx context.Context, playlistID string) ([]*PlaylistItem, error) {
rows, err := s.pool.Query(ctx,
`select id, playlist_id, coalesce(media_asset_id,''), order_index, type, src,
coalesce(title,''), duration_seconds, valid_from, valid_until, enabled, created_at
from playlist_items where playlist_id=$1 order by order_index`, playlistID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*PlaylistItem
for rows.Next() {
item, err := scanPlaylistItem(rows)
if err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
func (s *PlaylistStore) ListActiveItems(ctx context.Context, playlistID string) ([]*PlaylistItem, error) {
rows, err := s.pool.Query(ctx,
`select id, playlist_id, coalesce(media_asset_id,''), order_index, type, src,
coalesce(title,''), duration_seconds, valid_from, valid_until, enabled, created_at
from playlist_items
where playlist_id=$1
and enabled=true
and (valid_from is null or valid_from <= now())
and (valid_until is null or valid_until > now())
order by order_index`, playlistID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*PlaylistItem
for rows.Next() {
item, err := scanPlaylistItem(rows)
if err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
func (s *PlaylistStore) AddItem(ctx context.Context, playlistID, mediaAssetID, itemType, src, title string, durationSeconds int, validFrom, validUntil *time.Time) (*PlaylistItem, error) {
// Place at end of list.
var maxIdx int
s.pool.QueryRow(ctx,
`select coalesce(max(order_index)+1, 0) from playlist_items where playlist_id=$1`, playlistID,
).Scan(&maxIdx) //nolint:errcheck
var mediaID *string
if mediaAssetID != "" {
mediaID = &mediaAssetID
}
row := s.pool.QueryRow(ctx,
`insert into playlist_items(playlist_id, media_asset_id, order_index, type, src, title, duration_seconds, valid_from, valid_until)
values($1,$2,$3,$4,$5,$6,$7,$8,$9)
returning id, playlist_id, coalesce(media_asset_id,''), order_index, type, src,
coalesce(title,''), duration_seconds, valid_from, valid_until, enabled, created_at`,
playlistID, mediaID, maxIdx, itemType, src, title, durationSeconds, validFrom, validUntil)
return scanPlaylistItem(row)
}
func (s *PlaylistStore) UpdateItem(ctx context.Context, id, title string, durationSeconds int, enabled bool, validFrom, validUntil *time.Time) error {
_, err := s.pool.Exec(ctx,
`update playlist_items
set title=$2, duration_seconds=$3, enabled=$4, valid_from=$5, valid_until=$6
where id=$1`,
id, title, durationSeconds, enabled, validFrom, validUntil)
return err
}
func (s *PlaylistStore) DeleteItem(ctx context.Context, id string) error {
_, err := s.pool.Exec(ctx, `delete from playlist_items where id=$1`, id)
return err
}
// Reorder sets order_index for each item ID in the given slice order.
func (s *PlaylistStore) Reorder(ctx context.Context, playlistID string, itemIDs []string) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx) //nolint:errcheck
for i, id := range itemIDs {
if _, err := tx.Exec(ctx,
`update playlist_items set order_index=$1 where id=$2 and playlist_id=$3`,
i, id, playlistID,
); err != nil {
return err
}
}
return tx.Commit(ctx)
}
func scanPlaylistItem(row interface {
Scan(dest ...any) error
}) (*PlaylistItem, error) {
var it PlaylistItem
err := row.Scan(&it.ID, &it.PlaylistID, &it.MediaAssetID, &it.OrderIndex,
&it.Type, &it.Src, &it.Title, &it.DurationSeconds,
&it.ValidFrom, &it.ValidUntil, &it.Enabled, &it.CreatedAt)
if err != nil {
return nil, fmt.Errorf("scan playlist_item: %w", err)
}
return &it, nil
}