morz-infoboard/server/backend/internal/store/store.go

798 lines
27 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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"`
CreatedByUserID string `json:"created_by_user_id,omitempty"`
OwnerIsRestricted bool `json:"owner_is_restricted,omitempty"`
OwnerUsername string `json:"owner_username,omitempty"`
}
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, ownerUserID string) ([]*MediaAsset, error) {
const base = `
SELECT m.id, m.tenant_id, m.title, m.type,
coalesce(m.storage_path,''), coalesce(m.original_url,''),
coalesce(m.mime_type,''), coalesce(m.size_bytes,0),
m.enabled, m.created_at, coalesce(m.created_by_user_id,''),
coalesce(u.role,''), coalesce(u.username,'')
FROM media_assets m
LEFT JOIN users u ON m.created_by_user_id = u.id
WHERE m.tenant_id=$1`
var rows pgx.Rows
var err error
if ownerUserID != "" {
rows, err = s.pool.Query(ctx, base+` AND m.created_by_user_id=$2 ORDER BY m.created_at DESC`, tenantID, ownerUserID)
} else {
rows, err = s.pool.Query(ctx, base+` ORDER BY u.username NULLS LAST, m.created_at DESC`, tenantID)
}
if err != nil {
return nil, err
}
defer rows.Close()
var out []*MediaAsset
for rows.Next() {
m, err := scanMediaFull(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, coalesce(created_by_user_id,'')
FROM media_assets WHERE id=$1`, id)
return scanMedia(row)
}
func (s *MediaStore) Create(ctx context.Context, tenantID, title, assetType, storagePath, originalURL, mimeType, createdByUserID 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, created_by_user_id)
VALUES($1,$2,$3,nullif($4,''),nullif($5,''),nullif($6,''),nullif($7,0),nullif($8,''))
RETURNING id, tenant_id, title, type, coalesce(storage_path,''),
coalesce(original_url,''), coalesce(mime_type,''), coalesce(size_bytes,0),
enabled, created_at, coalesce(created_by_user_id,'')`,
tenantID, title, assetType, storagePath, originalURL, mimeType, sizeBytes, createdByUserID)
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, &m.CreatedByUserID)
if err != nil {
return nil, fmt.Errorf("scan media: %w", err)
}
return &m, nil
}
func scanMediaFull(row interface {
Scan(dest ...any) error
}) (*MediaAsset, error) {
var m MediaAsset
var ownerRole string
err := row.Scan(&m.ID, &m.TenantID, &m.Title, &m.Type,
&m.StoragePath, &m.OriginalURL, &m.MimeType, &m.SizeBytes,
&m.Enabled, &m.CreatedAt, &m.CreatedByUserID,
&ownerRole, &m.OwnerUsername)
if err != nil {
return nil, fmt.Errorf("scan media full: %w", err)
}
m.OwnerIsRestricted = ownerRole == "restricted"
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
}