- ScreenScheduleStore.Upsert: override_on_until aus INSERT und ON CONFLICT entfernt — verhindert stillen Datenverlust beim Speichern eines Zeitplans. SetOverrideOnUntil bleibt alleinig zuständig für diese Spalte. - README.md: GlobalOverrideStore, vier neue API-Routen, Wochenend-Sperre und Migration 006_override.sql dokumentiert. - override.go: Auth-Scope-Kommentar über HandleSetGlobalOverride ergänzt. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
767 lines
26 KiB
Go
767 lines
26 KiB
Go
// Package store contains all database repositories.
|
||
package store
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"time"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
"github.com/jackc/pgx/v5/pgxpool"
|
||
)
|
||
|
||
// ErrReorderMismatch wird von Reorder zurückgegeben, wenn die übergebene
|
||
// ID-Liste nicht mit den tatsächlichen Items der Playlist übereinstimmt.
|
||
var ErrReorderMismatch = errors.New("reorder: item list does not match playlist")
|
||
|
||
// ------------------------------------------------------------------
|
||
// Domain types
|
||
// ------------------------------------------------------------------
|
||
|
||
type Tenant struct {
|
||
ID string `json:"id"`
|
||
Slug string `json:"slug"`
|
||
Name string `json:"name"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
}
|
||
|
||
type Screen struct {
|
||
ID string `json:"id"`
|
||
TenantID string `json:"tenant_id"`
|
||
Slug string `json:"slug"`
|
||
Name string `json:"name"`
|
||
Orientation string `json:"orientation"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
}
|
||
|
||
type ScreenStatus struct {
|
||
ScreenID string `json:"screen_id"`
|
||
DisplayState string `json:"display_state"`
|
||
ReportedAt time.Time `json:"reported_at"`
|
||
}
|
||
|
||
type ScreenSchedule struct {
|
||
ScreenID string `json:"screen_id"`
|
||
ScheduleEnabled bool `json:"schedule_enabled"`
|
||
PowerOnTime string `json:"power_on_time"`
|
||
PowerOffTime string `json:"power_off_time"`
|
||
OverrideOnUntil *time.Time `json:"override_on_until,omitempty"`
|
||
}
|
||
|
||
type MediaAsset struct {
|
||
ID string `json:"id"`
|
||
TenantID string `json:"tenant_id"`
|
||
Title string `json:"title"`
|
||
Type string `json:"type"` // image | video | pdf | web
|
||
StoragePath string `json:"storage_path,omitempty"`
|
||
OriginalURL string `json:"original_url,omitempty"`
|
||
MimeType string `json:"mime_type,omitempty"`
|
||
SizeBytes int64 `json:"size_bytes,omitempty"`
|
||
Enabled bool `json:"enabled"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
}
|
||
|
||
type Playlist struct {
|
||
ID string `json:"id"`
|
||
TenantID string `json:"tenant_id"`
|
||
ScreenID string `json:"screen_id"`
|
||
Name string `json:"name"`
|
||
IsActive bool `json:"is_active"`
|
||
DefaultDurationSeconds int `json:"default_duration_seconds"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
UpdatedAt time.Time `json:"updated_at"`
|
||
}
|
||
|
||
// ScreenUserEntry is a lightweight view used when listing users assigned to a screen.
|
||
type ScreenUserEntry struct {
|
||
ID string `json:"id"`
|
||
Username string `json:"username"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
}
|
||
|
||
type PlaylistItem struct {
|
||
ID string `json:"id"`
|
||
PlaylistID string `json:"playlist_id"`
|
||
MediaAssetID string `json:"media_asset_id,omitempty"`
|
||
OrderIndex int `json:"order_index"`
|
||
Type string `json:"type"` // image | video | pdf | web
|
||
Src string `json:"src"`
|
||
Title string `json:"title,omitempty"`
|
||
DurationSeconds int `json:"duration_seconds"`
|
||
ValidFrom *time.Time `json:"valid_from,omitempty"`
|
||
ValidUntil *time.Time `json:"valid_until,omitempty"`
|
||
Enabled bool `json:"enabled"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// 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 }
|
||
type ScreenScheduleStore 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} }
|
||
func NewScreenScheduleStore(pool *pgxpool.Pool) *ScreenScheduleStore {
|
||
return &ScreenScheduleStore{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) GetByID(ctx context.Context, id string) (*Screen, error) {
|
||
row := s.pool.QueryRow(ctx,
|
||
`select id, tenant_id, slug, name, orientation, created_at from screens where id=$1`, id)
|
||
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)
|
||
}
|
||
|
||
// Upsert creates or updates a screen by slug (idempotent).
|
||
// Used by agents on startup to self-register.
|
||
func (s *ScreenStore) Upsert(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)
|
||
on conflict (slug) do update
|
||
set name = excluded.name,
|
||
orientation = excluded.orientation
|
||
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
|
||
}
|
||
|
||
// GetAccessibleScreens returns all screens that userID has explicit access to
|
||
// via user_screen_permissions.
|
||
func (s *ScreenStore) GetAccessibleScreens(ctx context.Context, userID string) ([]*Screen, error) {
|
||
rows, err := s.pool.Query(ctx,
|
||
`select sc.id, sc.tenant_id, sc.slug, sc.name, sc.orientation, sc.created_at
|
||
from screens sc
|
||
join user_screen_permissions usp on usp.screen_id = sc.id
|
||
where usp.user_id = $1
|
||
order by sc.name`, userID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("screens: get accessible: %w", 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()
|
||
}
|
||
|
||
// HasUserScreenAccess returns true when userID has an explicit permission entry
|
||
// for screenID in user_screen_permissions.
|
||
func (s *ScreenStore) HasUserScreenAccess(ctx context.Context, userID, screenID string) (bool, error) {
|
||
var ok bool
|
||
err := s.pool.QueryRow(ctx,
|
||
`select exists(
|
||
select 1 from user_screen_permissions
|
||
where user_id = $1 and screen_id = $2
|
||
)`, userID, screenID).Scan(&ok)
|
||
return ok, err
|
||
}
|
||
|
||
// AddUserToScreen creates a permission entry granting userID access to screenID.
|
||
// Silently succeeds if the entry already exists (ON CONFLICT DO NOTHING).
|
||
func (s *ScreenStore) AddUserToScreen(ctx context.Context, userID, screenID string) error {
|
||
_, err := s.pool.Exec(ctx,
|
||
`insert into user_screen_permissions(user_id, screen_id)
|
||
values($1, $2)
|
||
on conflict (user_id, screen_id) do nothing`,
|
||
userID, screenID)
|
||
if err != nil {
|
||
return fmt.Errorf("screens: add user to screen: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// RemoveUserFromScreen deletes the permission entry for userID / screenID.
|
||
func (s *ScreenStore) RemoveUserFromScreen(ctx context.Context, userID, screenID string) error {
|
||
_, err := s.pool.Exec(ctx,
|
||
`delete from user_screen_permissions where user_id = $1 and screen_id = $2`,
|
||
userID, screenID)
|
||
if err != nil {
|
||
return fmt.Errorf("screens: remove user from screen: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// GetScreenUsers returns all users that have explicit access to screenID.
|
||
func (s *ScreenStore) GetScreenUsers(ctx context.Context, screenID string) ([]*ScreenUserEntry, error) {
|
||
rows, err := s.pool.Query(ctx,
|
||
`select u.id, u.username, u.created_at
|
||
from users u
|
||
join user_screen_permissions usp on usp.user_id = u.id
|
||
where usp.screen_id = $1
|
||
order by u.username`, screenID)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("screens: get screen users: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
var out []*ScreenUserEntry
|
||
for rows.Next() {
|
||
var e ScreenUserEntry
|
||
if err := rows.Scan(&e.ID, &e.Username, &e.CreatedAt); err != nil {
|
||
return nil, fmt.Errorf("scan screen user entry: %w", err)
|
||
}
|
||
out = append(out, &e)
|
||
}
|
||
return out, rows.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
|
||
}
|
||
|
||
// UpsertDisplayState speichert den zuletzt gemeldeten Display-Zustand eines Screens.
|
||
func (s *ScreenStore) UpsertDisplayState(ctx context.Context, screenID, displayState string) error {
|
||
_, err := s.pool.Exec(ctx,
|
||
`insert into screen_status (screen_id, display_state, reported_at)
|
||
values ($1, $2, now())
|
||
on conflict (screen_id) do update
|
||
set display_state = excluded.display_state,
|
||
reported_at = excluded.reported_at`,
|
||
screenID, displayState)
|
||
return err
|
||
}
|
||
|
||
// GetDisplayState gibt den zuletzt gemeldeten Display-Zustand zurück.
|
||
// Gibt "unknown" zurück wenn kein Eintrag vorhanden ist.
|
||
func (s *ScreenStore) GetDisplayState(ctx context.Context, screenID string) (string, error) {
|
||
var state string
|
||
err := s.pool.QueryRow(ctx,
|
||
`select coalesce(display_state,'unknown')
|
||
from screen_status where screen_id = $1`, screenID).Scan(&state)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return "unknown", nil
|
||
}
|
||
if err != nil {
|
||
return "unknown", err
|
||
}
|
||
return state, 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)
|
||
}
|
||
|
||
// GetByItemID returns the playlist that contains the given playlist item.
|
||
// Used for tenant-isolation checks (K4).
|
||
func (s *PlaylistStore) GetByItemID(ctx context.Context, itemID string) (*Playlist, error) {
|
||
row := s.pool.QueryRow(ctx,
|
||
`select pl.id, pl.tenant_id, pl.screen_id, pl.name, pl.is_active,
|
||
pl.default_duration_seconds, pl.created_at, pl.updated_at
|
||
from playlists pl
|
||
join playlist_items pi on pi.playlist_id = pl.id
|
||
where pi.id = $1`, itemID)
|
||
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) {
|
||
var mediaID *string
|
||
if mediaAssetID != "" {
|
||
mediaID = &mediaAssetID
|
||
}
|
||
|
||
// W1: Atomare Subquery statt 2 separater Queries — verhindert Race Condition bei order_index.
|
||
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,
|
||
(select coalesce(max(order_index)+1, 0) from playlist_items where playlist_id=$1),
|
||
$3,$4,$5,$6,$7,$8)
|
||
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, 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
|
||
}
|
||
|
||
// ScreenSlugByPlaylistID returns the slug of the screen that owns playlistID.
|
||
func (s *PlaylistStore) ScreenSlugByPlaylistID(ctx context.Context, playlistID string) (string, error) {
|
||
var slug string
|
||
err := s.pool.QueryRow(ctx,
|
||
`select sc.slug
|
||
from playlists pl
|
||
join screens sc on sc.id = pl.screen_id
|
||
where pl.id = $1`, playlistID).Scan(&slug)
|
||
return slug, err
|
||
}
|
||
|
||
// ScreenSlugByItemID returns the slug of the screen that owns itemID.
|
||
func (s *PlaylistStore) ScreenSlugByItemID(ctx context.Context, itemID string) (string, error) {
|
||
var slug string
|
||
err := s.pool.QueryRow(ctx,
|
||
`select sc.slug
|
||
from playlist_items pi
|
||
join playlists pl on pl.id = pi.playlist_id
|
||
join screens sc on sc.id = pl.screen_id
|
||
where pi.id = $1`, itemID).Scan(&slug)
|
||
return slug, err
|
||
}
|
||
|
||
// Reorder sets order_index for each item ID in the given slice order.
|
||
// Returns ErrReorderMismatch if the number of provided IDs does not match
|
||
// the number of items in the playlist, or if any ID does not belong to it.
|
||
func (s *PlaylistStore) Reorder(ctx context.Context, playlistID string, itemIDs []string) error {
|
||
seen := make(map[string]struct{}, len(itemIDs))
|
||
for _, id := range itemIDs {
|
||
if _, dup := seen[id]; dup {
|
||
return fmt.Errorf("%w: duplicate id %s", ErrReorderMismatch, id)
|
||
}
|
||
seen[id] = struct{}{}
|
||
}
|
||
|
||
tx, err := s.pool.Begin(ctx)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer tx.Rollback(ctx) //nolint:errcheck
|
||
|
||
var count int
|
||
if err := tx.QueryRow(ctx,
|
||
`select count(*) from playlist_items where playlist_id=$1`, playlistID,
|
||
).Scan(&count); err != nil {
|
||
return err
|
||
}
|
||
if count != len(itemIDs) {
|
||
return fmt.Errorf("%w: got %d ids, playlist has %d items",
|
||
ErrReorderMismatch, len(itemIDs), count)
|
||
}
|
||
|
||
for i, id := range itemIDs {
|
||
tag, err := tx.Exec(ctx,
|
||
`update playlist_items set order_index=$1 where id=$2 and playlist_id=$3`,
|
||
i, id, playlistID,
|
||
)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if tag.RowsAffected() != 1 {
|
||
return fmt.Errorf("%w: id %s not found in playlist %s",
|
||
ErrReorderMismatch, id, playlistID)
|
||
}
|
||
}
|
||
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
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// ScreenScheduleStore
|
||
// ------------------------------------------------------------------
|
||
|
||
// Get lädt den Zeitplan eines Screens. Gibt einen leeren ScreenSchedule zurück wenn keiner vorhanden.
|
||
func (s *ScreenScheduleStore) Get(ctx context.Context, screenID string) (*ScreenSchedule, error) {
|
||
var sc ScreenSchedule
|
||
err := s.pool.QueryRow(ctx,
|
||
`select screen_id, schedule_enabled, power_on_time, power_off_time, override_on_until
|
||
from screen_schedules where screen_id = $1`, screenID).
|
||
Scan(&sc.ScreenID, &sc.ScheduleEnabled, &sc.PowerOnTime, &sc.PowerOffTime, &sc.OverrideOnUntil)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return &ScreenSchedule{ScreenID: screenID}, nil
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &sc, nil
|
||
}
|
||
|
||
// Upsert speichert oder aktualisiert den Zeitplan eines Screens.
|
||
// Hinweis: override_on_until wird hier bewusst nicht angefasst – das ist
|
||
// ausschließlich Aufgabe von SetOverrideOnUntil (saubere Trennung, kein Datenverlust).
|
||
func (s *ScreenScheduleStore) Upsert(ctx context.Context, sc *ScreenSchedule) error {
|
||
_, err := s.pool.Exec(ctx,
|
||
`insert into screen_schedules (screen_id, schedule_enabled, power_on_time, power_off_time)
|
||
values ($1, $2, $3, $4)
|
||
on conflict (screen_id) do update
|
||
set schedule_enabled = excluded.schedule_enabled,
|
||
power_on_time = excluded.power_on_time,
|
||
power_off_time = excluded.power_off_time`,
|
||
sc.ScreenID, sc.ScheduleEnabled, sc.PowerOnTime, sc.PowerOffTime)
|
||
return err
|
||
}
|
||
|
||
// ListEnabled gibt alle Screens mit aktivem Zeitplan zurück.
|
||
func (s *ScreenScheduleStore) ListEnabled(ctx context.Context) ([]*ScreenSchedule, error) {
|
||
rows, err := s.pool.Query(ctx,
|
||
`select screen_id, schedule_enabled, power_on_time, power_off_time, override_on_until
|
||
from screen_schedules
|
||
where schedule_enabled = true
|
||
and (power_on_time != '' or power_off_time != '')`)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
var out []*ScreenSchedule
|
||
for rows.Next() {
|
||
var sc ScreenSchedule
|
||
if err := rows.Scan(&sc.ScreenID, &sc.ScheduleEnabled, &sc.PowerOnTime, &sc.PowerOffTime, &sc.OverrideOnUntil); err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, &sc)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// SetOverrideOnUntil setzt oder löscht den per-Screen-Override (null = löschen).
|
||
func (s *ScreenScheduleStore) SetOverrideOnUntil(ctx context.Context, screenID string, until *time.Time) error {
|
||
_, err := s.pool.Exec(ctx,
|
||
`insert into screen_schedules (screen_id, override_on_until)
|
||
values ($1, $2)
|
||
on conflict (screen_id) do update
|
||
set override_on_until = excluded.override_on_until`,
|
||
screenID, until)
|
||
return err
|
||
}
|
||
|
||
// ------------------------------------------------------------------
|
||
// GlobalOverrideStore
|
||
// ------------------------------------------------------------------
|
||
|
||
// GlobalOverride beschreibt einen aktiven globalen Display-Override.
|
||
type GlobalOverride struct {
|
||
Type string `json:"type"` // "on" oder "off"
|
||
Until time.Time `json:"until"`
|
||
SetAt time.Time `json:"set_at"`
|
||
}
|
||
|
||
// GlobalOverrideStore verwaltet den globalen Display-Override (max. 1 Zeile).
|
||
type GlobalOverrideStore struct{ pool *pgxpool.Pool }
|
||
|
||
func NewGlobalOverrideStore(pool *pgxpool.Pool) *GlobalOverrideStore {
|
||
return &GlobalOverrideStore{pool: pool}
|
||
}
|
||
|
||
// Get lädt den aktuellen globalen Override. Gibt nil zurück wenn keiner gesetzt ist.
|
||
func (s *GlobalOverrideStore) Get(ctx context.Context) (*GlobalOverride, error) {
|
||
var o GlobalOverride
|
||
err := s.pool.QueryRow(ctx,
|
||
`select type, until, set_at from global_override where id = 1`).
|
||
Scan(&o.Type, &o.Until, &o.SetAt)
|
||
if errors.Is(err, pgx.ErrNoRows) {
|
||
return nil, nil
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &o, nil
|
||
}
|
||
|
||
// Upsert setzt oder überschreibt den globalen Override.
|
||
func (s *GlobalOverrideStore) Upsert(ctx context.Context, overrideType string, until time.Time) error {
|
||
_, err := s.pool.Exec(ctx,
|
||
`insert into global_override (id, type, until, set_at)
|
||
values (1, $1, $2, now())
|
||
on conflict (id) do update
|
||
set type = excluded.type,
|
||
until = excluded.until,
|
||
set_at = excluded.set_at`,
|
||
overrideType, until)
|
||
return err
|
||
}
|
||
|
||
// Delete entfernt den globalen Override.
|
||
func (s *GlobalOverrideStore) Delete(ctx context.Context) error {
|
||
_, err := s.pool.Exec(ctx, `delete from global_override where id = 1`)
|
||
return err
|
||
}
|