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>
This commit is contained in:
Jesko Anschütz 2026-03-22 22:53:00 +01:00
parent bbcf0a1228
commit 803f355220
14 changed files with 2027 additions and 23 deletions

View file

@ -1,3 +1,12 @@
module git.az-it.net/az/morz-infoboard/server/backend
go 1.24.0
go 1.25.0
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.9.1 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.29.0 // indirect
)

19
server/backend/go.sum Normal file
View file

@ -0,0 +1,19 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -1,11 +1,16 @@
package app
import (
"context"
"errors"
"log"
"net/http"
"os"
"git.az-it.net/az/morz-infoboard/server/backend/internal/config"
"git.az-it.net/az/morz-infoboard/server/backend/internal/db"
"git.az-it.net/az/morz-infoboard/server/backend/internal/httpapi"
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
)
type App struct {
@ -15,17 +20,47 @@ type App struct {
func New() (*App, error) {
cfg := config.Load()
logger := log.New(os.Stdout, "backend ", log.LstdFlags|log.LUTC)
store, err := httpapi.NewStoreFromConfig(cfg.StatusStorePath)
// Ensure upload directory exists.
if err := os.MkdirAll(cfg.UploadDir, 0755); err != nil {
return nil, err
}
// Connect to database and run migrations.
pool, err := db.Connect(context.Background(), cfg.DatabaseURL, logger)
if err != nil {
return nil, err
}
// Status store (existing in-memory/file store).
statusStore, err := httpapi.NewStoreFromConfig(cfg.StatusStorePath)
if err != nil {
pool.Close()
return nil, err
}
// Domain stores.
tenants := store.NewTenantStore(pool.Pool)
screens := store.NewScreenStore(pool.Pool)
media := store.NewMediaStore(pool.Pool)
playlists := store.NewPlaylistStore(pool.Pool)
handler := httpapi.NewRouter(httpapi.RouterDeps{
StatusStore: statusStore,
TenantStore: tenants,
ScreenStore: screens,
MediaStore: media,
PlaylistStore: playlists,
UploadDir: cfg.UploadDir,
Logger: logger,
})
return &App{
Config: cfg,
server: &http.Server{
Addr: cfg.HTTPAddress,
Handler: httpapi.NewRouter(store),
Handler: handler,
},
}, nil
}
@ -35,6 +70,5 @@ func (a *App) Run() error {
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
}

View file

@ -5,12 +5,16 @@ import "os"
type Config struct {
HTTPAddress string
StatusStorePath string
DatabaseURL string
UploadDir string
}
func Load() Config {
return Config{
HTTPAddress: getenv("MORZ_INFOBOARD_HTTP_ADDR", ":8080"),
StatusStorePath: os.Getenv("MORZ_INFOBOARD_STATUS_STORE_PATH"),
DatabaseURL: getenv("MORZ_INFOBOARD_DATABASE_URL", "postgres://morz_infoboard:morz_infoboard@localhost:5432/morz_infoboard?sslmode=disable"),
UploadDir: getenv("MORZ_INFOBOARD_UPLOAD_DIR", "/tmp/morz-uploads"),
}
}

View file

@ -0,0 +1,90 @@
package db
import (
"context"
"embed"
"fmt"
"log"
"github.com/jackc/pgx/v5/pgxpool"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
// Pool wraps a pgxpool.Pool with migration support.
type Pool struct {
*pgxpool.Pool
}
// Connect opens a connection pool and runs pending migrations.
func Connect(ctx context.Context, databaseURL string, logger *log.Logger) (*Pool, error) {
pool, err := pgxpool.New(ctx, databaseURL)
if err != nil {
return nil, fmt.Errorf("db: open pool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("db: ping: %w", err)
}
p := &Pool{pool}
if err := p.migrate(ctx, logger); err != nil {
pool.Close()
return nil, fmt.Errorf("db: migrate: %w", err)
}
return p, nil
}
// migrate runs all embedded SQL migration files in order (idempotent).
func (p *Pool) migrate(ctx context.Context, logger *log.Logger) error {
// Ensure schema_migrations table exists first.
_, err := p.Exec(ctx, `
create table if not exists schema_migrations (
version integer primary key,
applied_at timestamptz not null default now()
)`)
if err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
entries, err := migrationsFS.ReadDir("migrations")
if err != nil {
return err
}
for i, e := range entries {
version := i + 1
var applied bool
err := p.QueryRow(ctx,
"select exists(select 1 from schema_migrations where version=$1)", version,
).Scan(&applied)
if err != nil {
return fmt.Errorf("check migration %d: %w", version, err)
}
if applied {
continue
}
sql, err := migrationsFS.ReadFile("migrations/" + e.Name())
if err != nil {
return err
}
if _, err := p.Exec(ctx, string(sql)); err != nil {
return fmt.Errorf("run migration %s: %w", e.Name(), err)
}
if _, err := p.Exec(ctx,
"insert into schema_migrations(version) values($1)", version,
); err != nil {
return fmt.Errorf("record migration %d: %w", version, err)
}
logger.Printf("event=migration_applied version=%d file=%s", version, e.Name())
}
return nil
}

View file

@ -0,0 +1,76 @@
-- 001_initial.sql
-- Basis-Schema: Tenants, Screens, Medien, Playlists
create extension if not exists pgcrypto;
create table if not exists tenants (
id text primary key default gen_random_uuid()::text,
slug text not null unique,
name text not null,
created_at timestamptz not null default now()
);
create table if not exists screens (
id text primary key default gen_random_uuid()::text,
tenant_id text not null references tenants(id) on delete cascade,
slug text not null unique,
name text not null,
orientation text not null default 'landscape',
created_at timestamptz not null default now()
);
create table if not exists media_assets (
id text primary key default gen_random_uuid()::text,
tenant_id text not null references tenants(id) on delete cascade,
title text not null,
type text not null, -- image | video | pdf | web
storage_path text null, -- set for uploads
original_url text null, -- set for web/remote
mime_type text null,
size_bytes bigint null,
enabled boolean not null default true,
created_at timestamptz not null default now()
);
create table if not exists playlists (
id text primary key default gen_random_uuid()::text,
tenant_id text not null references tenants(id) on delete cascade,
screen_id text not null references screens(id) on delete cascade,
name text not null,
is_active boolean not null default true,
default_duration_seconds integer not null default 20,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (screen_id) -- one active playlist per screen (simplified)
);
create table if not exists playlist_items (
id text primary key default gen_random_uuid()::text,
playlist_id text not null references playlists(id) on delete cascade,
media_asset_id text null references media_assets(id) on delete set null,
order_index integer not null default 0,
type text not null, -- image | video | pdf | web
src text not null, -- URL or served path
title text null,
duration_seconds integer not null default 20,
valid_from timestamptz null,
valid_until timestamptz null,
enabled boolean not null default true,
created_at timestamptz not null default now()
);
create index if not exists idx_screens_tenant_id on screens(tenant_id);
create index if not exists idx_media_assets_tenant_id on media_assets(tenant_id);
create index if not exists idx_playlists_screen_id on playlists(screen_id);
create index if not exists idx_playlist_items_order on playlist_items(playlist_id, order_index);
-- Schema-Versions-Tabelle
create table if not exists schema_migrations (
version integer primary key,
applied_at timestamptz not null default now()
);
-- Seed: Standard-Tenant und erster Screen (idempotent)
insert into tenants (id, slug, name)
values ('tenant-morz', 'morz', 'MORZ Schule')
on conflict (slug) do nothing;

View file

@ -17,7 +17,7 @@ func TestPlayerStatusLifecycle(t *testing.T) {
store.now = func() time.Time {
return time.Date(2026, 3, 22, 16, 10, 0, 0, time.UTC)
}
router := NewRouter(store)
router := NewRouter(RouterDeps{StatusStore: store})
// 1. POST /api/v1/player/status ingest a status report
body := `{

View file

@ -0,0 +1,154 @@
package manage
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
)
const maxUploadSize = 512 << 20 // 512 MB
// HandleListMedia returns all media assets for a tenant as JSON.
func HandleListMedia(media *store.MediaStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tenantID := r.PathValue("tenantId")
assets, err := media.List(r.Context(), tenantID)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(assets) //nolint:errcheck
}
}
// HandleUploadMedia handles multipart file upload and web-URL registration.
func HandleUploadMedia(media *store.MediaStore, uploadDir string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tenantID := r.PathValue("tenantId")
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
http.Error(w, "request too large or not multipart", http.StatusBadRequest)
return
}
assetType := strings.TrimSpace(r.FormValue("type"))
title := strings.TrimSpace(r.FormValue("title"))
switch assetType {
case "web":
url := strings.TrimSpace(r.FormValue("url"))
if url == "" {
http.Error(w, "url required for type=web", http.StatusBadRequest)
return
}
if title == "" {
title = url
}
asset, err := media.Create(r.Context(), tenantID, title, "web", "", url, "", 0)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(asset) //nolint:errcheck
return
case "image", "video", "pdf":
file, header, err := r.FormFile("file")
if err != nil {
http.Error(w, "file required for type="+assetType, http.StatusBadRequest)
return
}
defer file.Close()
mimeType := header.Header.Get("Content-Type")
if title == "" {
title = strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename))
}
// Generate unique storage path.
ext := filepath.Ext(header.Filename)
filename := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), sanitize(title), ext)
destPath := filepath.Join(uploadDir, filename)
dest, err := os.Create(destPath)
if err != nil {
http.Error(w, "storage error", http.StatusInternalServerError)
return
}
defer dest.Close()
size, err := io.Copy(dest, file)
if err != nil {
os.Remove(destPath) //nolint:errcheck
http.Error(w, "write error", http.StatusInternalServerError)
return
}
// Storage path relative (served via /uploads/).
storagePath := "/uploads/" + filename
asset, err := media.Create(r.Context(), tenantID, title, assetType, storagePath, "", mimeType, size)
if err != nil {
os.Remove(destPath) //nolint:errcheck
http.Error(w, "db error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(asset) //nolint:errcheck
default:
http.Error(w, "type must be one of: image, video, pdf, web", http.StatusBadRequest)
}
}
}
// HandleDeleteMedia deletes a media asset from the database (and file if local).
func HandleDeleteMedia(media *store.MediaStore, uploadDir string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
asset, err := media.Get(r.Context(), id)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
// Delete physical file if it's a local upload.
if asset.StoragePath != "" {
filename := filepath.Base(asset.StoragePath)
os.Remove(filepath.Join(uploadDir, filename)) //nolint:errcheck
}
if err := media.Delete(r.Context(), id); err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func sanitize(s string) string {
var b strings.Builder
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
b.WriteRune(r)
} else {
b.WriteRune('_')
}
}
out := b.String()
if len(out) > 40 {
out = out[:40]
}
return out
}

View file

@ -0,0 +1,295 @@
package manage
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
)
// HandleGetPlaylist returns the playlist and its items for a screen.
func HandleGetPlaylist(screens *store.ScreenStore, playlists *store.PlaylistStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
screenID := r.PathValue("screenId")
screen, err := screens.GetBySlug(r.Context(), screenID)
if err != nil {
// Try by id if slug not found.
screen = &store.Screen{ID: screenID}
}
playlist, err := playlists.GetOrCreateForScreen(r.Context(), screen.TenantID, screen.ID, screen.Name)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
items, err := playlists.ListItems(r.Context(), playlist.ID)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{ //nolint:errcheck
"playlist": playlist,
"items": items,
})
}
}
// HandleAddItem adds a playlist item (from existing media asset or direct URL).
func HandleAddItem(playlists *store.PlaylistStore, media *store.MediaStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
playlistID := r.PathValue("playlistId")
var body struct {
MediaAssetID string `json:"media_asset_id"`
Type string `json:"type"`
Src string `json:"src"`
Title string `json:"title"`
DurationSeconds int `json:"duration_seconds"`
ValidFrom string `json:"valid_from"`
ValidUntil string `json:"valid_until"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
// If adding from media library, fill in src and type from asset.
if body.MediaAssetID != "" {
asset, err := media.Get(r.Context(), body.MediaAssetID)
if err != nil {
http.Error(w, "media asset not found", http.StatusBadRequest)
return
}
body.Type = asset.Type
if asset.StoragePath != "" {
body.Src = asset.StoragePath
} else {
body.Src = asset.OriginalURL
}
if body.Title == "" {
body.Title = asset.Title
}
}
if body.Type == "" || body.Src == "" {
http.Error(w, "type and src required", http.StatusBadRequest)
return
}
if body.DurationSeconds <= 0 {
body.DurationSeconds = 20
}
validFrom, _ := parseOptionalTime(body.ValidFrom)
validUntil, _ := parseOptionalTime(body.ValidUntil)
item, err := playlists.AddItem(r.Context(), playlistID, body.MediaAssetID,
body.Type, body.Src, body.Title, body.DurationSeconds, validFrom, validUntil)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(item) //nolint:errcheck
}
}
// HandleUpdateItem updates duration, title, enabled, valid_from, valid_until.
func HandleUpdateItem(playlists *store.PlaylistStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("itemId")
var body struct {
Title string `json:"title"`
DurationSeconds int `json:"duration_seconds"`
Enabled *bool `json:"enabled"`
ValidFrom string `json:"valid_from"`
ValidUntil string `json:"valid_until"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
enabled := true
if body.Enabled != nil {
enabled = *body.Enabled
}
if body.DurationSeconds <= 0 {
body.DurationSeconds = 20
}
validFrom, _ := parseOptionalTime(body.ValidFrom)
validUntil, _ := parseOptionalTime(body.ValidUntil)
if err := playlists.UpdateItem(r.Context(), id, body.Title, body.DurationSeconds, enabled, validFrom, validUntil); err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// HandleDeleteItem removes a playlist item.
func HandleDeleteItem(playlists *store.PlaylistStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("itemId")
if err := playlists.DeleteItem(r.Context(), id); err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// HandleReorder accepts an ordered list of item IDs and updates order_index.
func HandleReorder(playlists *store.PlaylistStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
playlistID := r.PathValue("playlistId")
var ids []string
if err := json.NewDecoder(r.Body).Decode(&ids); err != nil {
http.Error(w, "body must be JSON array of item IDs", http.StatusBadRequest)
return
}
if err := playlists.Reorder(r.Context(), playlistID, ids); err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// HandleUpdatePlaylistDuration sets the default duration for a playlist.
func HandleUpdatePlaylistDuration(playlists *store.PlaylistStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("playlistId")
secs, err := strconv.Atoi(strings.TrimSpace(r.FormValue("default_duration_seconds")))
if err != nil || secs <= 0 {
http.Error(w, "invalid duration", http.StatusBadRequest)
return
}
if err := playlists.UpdateDefaultDuration(r.Context(), id, secs); err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// HandlePlayerPlaylist returns the active playlist for a screen (player sync).
func HandlePlayerPlaylist(screens *store.ScreenStore, playlists *store.PlaylistStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
screenSlug := r.PathValue("screenId")
screen, err := screens.GetBySlug(r.Context(), screenSlug)
if err != nil {
http.Error(w, "screen not found", http.StatusNotFound)
return
}
playlist, err := playlists.GetByScreen(r.Context(), screen.ID)
if err != nil {
// No playlist yet — return empty.
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"items": []any{}}) //nolint:errcheck
return
}
items, err := playlists.ListActiveItems(r.Context(), playlist.ID)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{ //nolint:errcheck
"playlist_id": playlist.ID,
"default_duration_seconds": playlist.DefaultDurationSeconds,
"items": items,
})
}
}
// HandleListScreens returns all screens for a tenant.
func HandleListScreens(tenants *store.TenantStore, screens *store.ScreenStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tenantSlug := r.PathValue("tenantSlug")
tenant, err := tenants.Get(r.Context(), tenantSlug)
if err != nil {
http.Error(w, "tenant not found", http.StatusNotFound)
return
}
list, err := screens.List(r.Context(), tenant.ID)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(list) //nolint:errcheck
}
}
// HandleCreateScreen creates a new screen for a tenant.
func HandleCreateScreen(tenants *store.TenantStore, screens *store.ScreenStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tenantSlug := r.PathValue("tenantSlug")
tenant, err := tenants.Get(r.Context(), tenantSlug)
if err != nil {
http.Error(w, "tenant not found", http.StatusNotFound)
return
}
var body struct {
Slug string `json:"slug"`
Name string `json:"name"`
Orientation string `json:"orientation"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
if body.Slug == "" || body.Name == "" {
http.Error(w, "slug and name required", http.StatusBadRequest)
return
}
if body.Orientation == "" {
body.Orientation = "landscape"
}
screen, err := screens.Create(r.Context(), tenant.ID, body.Slug, body.Name, body.Orientation)
if err != nil {
http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(screen) //nolint:errcheck
}
}
func parseOptionalTime(s string) (*time.Time, error) {
s = strings.TrimSpace(s)
if s == "" {
return nil, nil
}
// Accept RFC3339 (API) and datetime-local HTML input format.
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04", "2006-01-02T15:04:05"} {
if t, err := time.Parse(layout, s); err == nil {
return &t, nil
}
}
return nil, fmt.Errorf("cannot parse time: %q", s)
}

View file

@ -0,0 +1,422 @@
package manage
const adminTmpl = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MORZ Infoboard Admin</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css">
<style>
body { background: #f5f5f5; }
.navbar { margin-bottom: 1.5rem; }
</style>
</head>
<body>
<nav class="navbar is-dark" role="navigation">
<div class="navbar-brand">
<span class="navbar-item"><strong>📺 MORZ Infoboard</strong></span>
</div>
</nav>
<section class="section pt-0">
<div class="container">
<div class="box">
<h2 class="title is-5">Bildschirme</h2>
{{if .Screens}}
<table class="table is-fullwidth is-hoverable is-striped">
<thead>
<tr>
<th>Name</th>
<th>Slug</th>
<th>Format</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{{range .Screens}}
<tr>
<td><strong>{{.Name}}</strong></td>
<td><code>{{.Slug}}</code></td>
<td>{{orientationLabel .Orientation}}</td>
<td>
<a class="button is-small is-link" href="/manage/{{.Slug}}">Playlist verwalten</a>
&nbsp;
<form method="POST" action="/admin/screens/{{.ID}}/delete" style="display:inline"
onsubmit="return confirm('Bildschirm löschen?\n\nAlle Playlist-Einträge werden ebenfalls gelöscht.')">
<button class="button is-small is-danger is-outlined" type="submit">Löschen</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="has-text-grey">Noch keine Bildschirme angelegt.</p>
{{end}}
</div>
<div class="box">
<h2 class="title is-5">Neuer Bildschirm</h2>
<form method="POST" action="/admin/screens">
<div class="columns is-vcentered">
<div class="column is-3">
<div class="field">
<label class="label">Slug</label>
<div class="control">
<input class="input" type="text" name="slug" placeholder="z.B. flur-eg" required
pattern="[a-z0-9-]+" title="Nur Kleinbuchstaben, Zahlen und Bindestriche">
</div>
<p class="help">URL-sichere Kennung (eindeutig)</p>
</div>
</div>
<div class="column is-4">
<div class="field">
<label class="label">Name</label>
<div class="control">
<input class="input" type="text" name="name" placeholder="z.B. Flur Erdgeschoss" required>
</div>
</div>
</div>
<div class="column is-2">
<div class="field">
<label class="label">Format</label>
<div class="control">
<div class="select is-fullwidth">
<select name="orientation">
<option value="landscape">Querformat</option>
<option value="portrait">Hochformat</option>
</select>
</div>
</div>
</div>
</div>
<div class="column is-3">
<div class="field">
<label class="label">&nbsp;</label>
<div class="control">
<button class="button is-primary is-fullwidth" type="submit">Erstellen</button>
</div>
</div>
</div>
</div>
</form>
</div>
</div>
</section>
</body>
</html>`
const manageTmpl = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Playlist {{.Screen.Name}}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css">
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.3/Sortable.min.js"></script>
<style>
body { background: #f5f5f5; }
.drag-handle { cursor: grab; color: #aaa; font-size: 1.2em; user-select: none; }
.drag-handle:hover { color: #333; }
.item-disabled td { opacity: 0.5; }
.edit-row td { background: #fffbf0; padding: 0.75rem 1rem; }
.tag-type { font-size: 0.7em; text-transform: uppercase; letter-spacing: 0.05em; }
.sortable-ghost { background: #e8f4fd !important; }
.tab-panel { display: none; }
.tab-panel.is-active { display: block; }
</style>
</head>
<body>
<nav class="navbar is-dark" role="navigation">
<div class="navbar-brand">
<a class="navbar-item" href="/admin"> Admin</a>
<span class="navbar-item">
<strong>{{.Screen.Name}}</strong>
&nbsp;
<span class="tag is-info is-light">{{orientationLabel .Screen.Orientation}}</span>
</span>
</div>
</nav>
<section class="section pt-4">
<div class="container">
<!-- Playlist -->
<div class="box">
<h2 class="title is-5 mb-3">Aktuelle Playlist</h2>
{{if .Items}}
<table class="table is-fullwidth" id="playlist-table">
<thead>
<tr>
<th style="width:2rem"></th>
<th style="width:5rem">Typ</th>
<th>Titel / Quelle</th>
<th style="width:6rem">Dauer</th>
<th style="width:7rem">Status</th>
<th style="width:12rem">Aktionen</th>
</tr>
</thead>
<tbody id="sortable-items">
{{range .Items}}
<tr id="item-{{.ID}}" class="{{if not .Enabled}}item-disabled{{end}}">
<td class="drag-handle" title="Ziehen zum Sortieren"></td>
<td>
<span class="tag is-light tag-type">{{typeIcon .Type}}&nbsp;{{.Type}}</span>
</td>
<td>
<div>{{if .Title}}<strong>{{.Title}}</strong>{{else}}<em class="has-text-grey">{{shortSrc .Src}}</em>{{end}}</div>
{{if .Title}}<small class="has-text-grey">{{shortSrc .Src}}</small>{{end}}
</td>
<td>{{.DurationSeconds}}&thinsp;s</td>
<td>
{{if .Enabled}}
<span class="tag is-success is-light">Aktiv</span>
{{else}}
<span class="tag is-warning is-light">Deaktiviert</span>
{{end}}
</td>
<td>
<button class="button is-small is-info is-outlined" onclick="toggleEdit('{{.ID}}')">Bearbeiten</button>
<form method="POST" action="/manage/{{$.Screen.Slug}}/items/{{.ID}}/delete"
style="display:inline"
onsubmit="return confirm('Eintrag wirklich aus der Playlist entfernen?')">
<button class="button is-small is-danger is-outlined" type="submit" title="Entfernen"></button>
</form>
</td>
</tr>
<tr id="edit-{{.ID}}" class="edit-row" style="display:none">
<td colspan="6">
<form method="POST" action="/manage/{{$.Screen.Slug}}/items/{{.ID}}">
<div class="columns is-vcentered is-multiline">
<div class="column is-4">
<label class="label is-small">Titel</label>
<input class="input is-small" type="text" name="title" value="{{.Title}}"
placeholder="Anzeigename (optional)">
</div>
<div class="column is-narrow">
<label class="label is-small">Dauer (Sek.)</label>
<input class="input is-small" type="number" name="duration_seconds"
value="{{.DurationSeconds}}" min="1" max="3600" style="width:6rem">
</div>
<div class="column is-narrow">
<label class="label is-small">Gültig ab</label>
<input class="input is-small" type="datetime-local" name="valid_from"
value="{{formatDT .ValidFrom}}">
</div>
<div class="column is-narrow">
<label class="label is-small">Gültig bis</label>
<input class="input is-small" type="datetime-local" name="valid_until"
value="{{formatDT .ValidUntil}}">
</div>
<div class="column is-narrow">
<label class="label is-small">Aktiv</label>
<div class="select is-small">
<select name="enabled">
<option value="true"{{if .Enabled}} selected{{end}}>Ja</option>
<option value="false"{{if not .Enabled}} selected{{end}}>Nein</option>
</select>
</div>
</div>
<div class="column is-narrow">
<label class="label is-small">&nbsp;</label>
<div class="buttons">
<button class="button is-small is-success" type="submit">Speichern</button>
<button class="button is-small" type="button" onclick="toggleEdit('{{.ID}}')">Abbrechen</button>
</div>
</div>
</div>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
<p class="help has-text-grey mt-2">Einträge per Drag &amp; Drop in der Reihenfolge verschieben.</p>
{{else}}
<div class="notification is-light">
Die Playlist ist noch leer. Füge unten Medien aus der Bibliothek hinzu oder lade neue Dateien hoch.
</div>
{{end}}
</div>
<!-- Medienbibliothek -->
<div class="box">
<h2 class="title is-5 mb-3">Medienbibliothek</h2>
{{if .Assets}}
<table class="table is-fullwidth is-hoverable is-striped">
<thead>
<tr>
<th style="width:5rem">Typ</th>
<th>Titel</th>
<th>Quelle</th>
<th style="width:14rem">Aktionen</th>
</tr>
</thead>
<tbody>
{{range .Assets}}
<tr>
<td><span class="tag is-light tag-type">{{typeIcon .Type}}&nbsp;{{.Type}}</span></td>
<td>{{.Title}}</td>
<td>
<small class="has-text-grey">
{{if .StoragePath}}{{shortSrc .StoragePath}}{{else}}{{shortSrc .OriginalURL}}{{end}}
</small>
</td>
<td>
{{if index $.AddedAssets .ID}}
<span class="tag is-success is-light mr-2"> In Playlist</span>
{{else}}
<form method="POST" action="/manage/{{$.Screen.Slug}}/items" style="display:inline">
<input type="hidden" name="media_asset_id" value="{{.ID}}">
<button class="button is-small is-primary" type="submit">+ Hinzufügen</button>
</form>
&nbsp;
{{end}}
<form method="POST" action="/manage/{{$.Screen.Slug}}/media/{{.ID}}/delete"
style="display:inline"
onsubmit="return confirm('Medium wirklich aus der Bibliothek löschen?\n(Playlist-Einträge bleiben bestehen, zeigen dann aber nichts an.)')">
<button class="button is-small is-danger is-outlined" type="submit" title="Aus Bibliothek löschen">🗑</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="has-text-grey">Noch keine Medien hochgeladen. Lade unten eine Datei hoch oder füge eine Webseite hinzu.</p>
{{end}}
</div>
<!-- Neues Medium hinzufügen -->
<div class="box">
<h2 class="title is-5 mb-3">Neues Medium hinzufügen</h2>
<div class="tabs" id="upload-tabs">
<ul>
<li id="tab-file" class="is-active"><a onclick="switchTab('file')">📁 Datei hochladen</a></li>
<li id="tab-web"><a onclick="switchTab('web')">🌐 Webseite / URL</a></li>
</ul>
</div>
<div id="panel-file" class="tab-panel is-active">
<form method="POST" action="/manage/{{.Screen.Slug}}/upload" enctype="multipart/form-data">
<div class="columns is-vcentered">
<div class="column is-2">
<div class="field">
<label class="label">Typ</label>
<div class="select is-fullwidth">
<select name="type">
<option value="image">🖼 Bild</option>
<option value="video">🎬 Video</option>
<option value="pdf">📄 PDF</option>
</select>
</div>
</div>
</div>
<div class="column">
<div class="field">
<label class="label">Titel <span class="has-text-grey is-size-7">(optional)</span></label>
<input class="input" type="text" name="title"
placeholder="Wird aus Dateinamen abgeleitet, wenn leer">
</div>
</div>
<div class="column">
<div class="field">
<label class="label">Datei</label>
<div class="control">
<input class="input" type="file" name="file" required
accept="image/*,video/*,application/pdf">
</div>
</div>
</div>
<div class="column is-narrow">
<div class="field">
<label class="label">&nbsp;</label>
<button class="button is-primary" type="submit">Hochladen</button>
</div>
</div>
</div>
</form>
</div>
<div id="panel-web" class="tab-panel">
<form method="POST" action="/manage/{{.Screen.Slug}}/upload" enctype="multipart/form-data">
<input type="hidden" name="type" value="web">
<div class="columns is-vcentered">
<div class="column">
<div class="field">
<label class="label">URL</label>
<input class="input" type="url" name="url"
placeholder="https://example.com" required>
</div>
</div>
<div class="column">
<div class="field">
<label class="label">Titel <span class="has-text-grey is-size-7">(optional)</span></label>
<input class="input" type="text" name="title" placeholder="Anzeigename">
</div>
</div>
<div class="column is-narrow">
<div class="field">
<label class="label">&nbsp;</label>
<button class="button is-primary" type="submit">Hinzufügen</button>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</section>
<script>
function toggleEdit(id) {
var row = document.getElementById('edit-' + id);
if (row) {
row.style.display = (row.style.display === 'none' || row.style.display === '') ? 'table-row' : 'none';
}
}
function switchTab(tab) {
var panels = ['file', 'web'];
panels.forEach(function(p) {
var panel = document.getElementById('panel-' + p);
var tabEl = document.getElementById('tab-' + p);
if (p === tab) {
panel.classList.add('is-active');
tabEl.classList.add('is-active');
} else {
panel.classList.remove('is-active');
tabEl.classList.remove('is-active');
}
});
}
// Drag-and-drop reordering
var sortableEl = document.getElementById('sortable-items');
if (sortableEl) {
Sortable.create(sortableEl, {
handle: '.drag-handle',
animation: 150,
ghostClass: 'sortable-ghost',
onEnd: function() {
var ids = [];
sortableEl.querySelectorAll('tr[id^="item-"]').forEach(function(tr) {
ids.push(tr.id.replace('item-', ''));
});
fetch('/manage/{{.Screen.Slug}}/reorder', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(ids)
});
}
});
}
</script>
</body>
</html>`

View file

@ -0,0 +1,390 @@
package manage
import (
"encoding/json"
"fmt"
"html/template"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
)
var tmplFuncs = template.FuncMap{
"typeIcon": func(t string) string {
switch t {
case "image":
return "🖼"
case "video":
return "🎬"
case "pdf":
return "📄"
case "web":
return "🌐"
default:
return "📁"
}
},
"orientationLabel": func(o string) string {
if o == "portrait" {
return "Hochformat"
}
return "Querformat"
},
"shortSrc": func(s string) string {
if len(s) > 60 {
return s[:57] + "..."
}
return s
},
"formatDT": func(t *time.Time) string {
if t == nil {
return ""
}
return t.Format("2006-01-02T15:04")
},
}
// HandleAdminUI renders the admin overview page.
func HandleAdminUI(tenants *store.TenantStore, screens *store.ScreenStore) http.HandlerFunc {
t := template.Must(template.New("admin").Funcs(tmplFuncs).Parse(adminTmpl))
return func(w http.ResponseWriter, r *http.Request) {
allScreens, err := screens.ListAll(r.Context())
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
allTenants, err := tenants.List(r.Context())
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
t.Execute(w, map[string]any{ //nolint:errcheck
"Screens": allScreens,
"Tenants": allTenants,
})
}
}
// HandleManageUI renders the playlist management UI for a specific screen.
func HandleManageUI(
tenants *store.TenantStore,
screens *store.ScreenStore,
media *store.MediaStore,
playlists *store.PlaylistStore,
) http.HandlerFunc {
t := template.Must(template.New("manage").Funcs(tmplFuncs).Parse(manageTmpl))
return func(w http.ResponseWriter, r *http.Request) {
screenSlug := r.PathValue("screenSlug")
screen, err := screens.GetBySlug(r.Context(), screenSlug)
if err != nil {
http.Error(w, "Screen nicht gefunden: "+screenSlug, http.StatusNotFound)
return
}
tenant, _ := tenants.Get(r.Context(), "morz") // v1: single tenant
if tenant == nil {
tenant = &store.Tenant{ID: screen.TenantID, Name: "MORZ"}
}
playlist, err := playlists.GetOrCreateForScreen(r.Context(), screen.TenantID, screen.ID, screen.Name)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
items, err := playlists.ListItems(r.Context(), playlist.ID)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
assets, err := media.List(r.Context(), screen.TenantID)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
// Build set of already-added asset IDs to mark them in library.
addedAssets := map[string]bool{}
for _, it := range items {
if it.MediaAssetID != "" {
addedAssets[it.MediaAssetID] = true
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
t.Execute(w, map[string]any{ //nolint:errcheck
"Screen": screen,
"Tenant": tenant,
"Playlist": playlist,
"Items": items,
"Assets": assets,
"AddedAssets": addedAssets,
})
}
}
// HandleCreateScreenUI handles form POST to create a screen, then redirects.
func HandleCreateScreenUI(tenants *store.TenantStore, screens *store.ScreenStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
slug := strings.TrimSpace(r.FormValue("slug"))
name := strings.TrimSpace(r.FormValue("name"))
orientation := r.FormValue("orientation")
if slug == "" || name == "" {
http.Error(w, "slug und name erforderlich", http.StatusBadRequest)
return
}
if orientation == "" {
orientation = "landscape"
}
tenant, err := tenants.Get(r.Context(), "morz")
if err != nil {
http.Error(w, "standard-tenant nicht gefunden", http.StatusInternalServerError)
return
}
_, err = screens.Create(r.Context(), tenant.ID, slug, name, orientation)
if err != nil {
http.Error(w, "Fehler: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin", http.StatusSeeOther)
}
}
// HandleDeleteScreenUI handles DELETE for a screen, then redirects.
func HandleDeleteScreenUI(screens *store.ScreenStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("screenId")
if err := screens.Delete(r.Context(), id); err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/admin", http.StatusSeeOther)
}
}
// HandleUploadMediaUI handles form upload from the manage UI and redirects back.
func HandleUploadMediaUI(media *store.MediaStore, screens *store.ScreenStore, uploadDir string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
screenSlug := r.PathValue("screenSlug")
screen, err := screens.GetBySlug(r.Context(), screenSlug)
if err != nil {
http.Error(w, "screen nicht gefunden", http.StatusNotFound)
return
}
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
http.Error(w, "Upload zu groß oder ungültig", http.StatusBadRequest)
return
}
assetType := strings.TrimSpace(r.FormValue("type"))
title := strings.TrimSpace(r.FormValue("title"))
switch assetType {
case "web":
url := strings.TrimSpace(r.FormValue("url"))
if url == "" {
http.Error(w, "URL erforderlich", http.StatusBadRequest)
return
}
if title == "" {
title = url
}
_, err = media.Create(r.Context(), screen.TenantID, title, "web", "", url, "", 0)
case "image", "video", "pdf":
file, header, ferr := r.FormFile("file")
if ferr != nil {
http.Error(w, "Datei erforderlich", http.StatusBadRequest)
return
}
defer file.Close()
if title == "" {
title = strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename))
}
mimeType := header.Header.Get("Content-Type")
ext := filepath.Ext(header.Filename)
filename := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), sanitize(title), ext)
destPath := filepath.Join(uploadDir, filename)
dest, ferr := os.Create(destPath)
if ferr != nil {
http.Error(w, "Speicherfehler", http.StatusInternalServerError)
return
}
defer dest.Close()
size, _ := io.Copy(dest, file)
storagePath := "/uploads/" + filename
_, err = media.Create(r.Context(), screen.TenantID, title, assetType, storagePath, "", mimeType, size)
default:
http.Error(w, "Unbekannter Typ", http.StatusBadRequest)
return
}
if err != nil {
http.Error(w, "DB-Fehler: "+err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/manage/"+screenSlug, http.StatusSeeOther)
}
}
// HandleAddItemUI handles form POST to add a playlist item, then redirects.
func HandleAddItemUI(playlists *store.PlaylistStore, media *store.MediaStore, screens *store.ScreenStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
screenSlug := r.PathValue("screenSlug")
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
screen, err := screens.GetBySlug(r.Context(), screenSlug)
if err != nil {
http.Error(w, "screen nicht gefunden", http.StatusNotFound)
return
}
playlist, err := playlists.GetOrCreateForScreen(r.Context(), screen.TenantID, screen.ID, screen.Name)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
mediaAssetID := r.FormValue("media_asset_id")
itemType := r.FormValue("type")
src := r.FormValue("src")
title := r.FormValue("title")
durationSeconds := 20
if d, err := strconv.Atoi(strings.TrimSpace(r.FormValue("duration_seconds"))); err == nil && d > 0 {
durationSeconds = d
}
validFrom, _ := parseOptionalTime(r.FormValue("valid_from"))
validUntil, _ := parseOptionalTime(r.FormValue("valid_until"))
if mediaAssetID != "" {
asset, err := media.Get(r.Context(), mediaAssetID)
if err != nil {
http.Error(w, "Medium nicht gefunden", http.StatusBadRequest)
return
}
itemType = asset.Type
if asset.StoragePath != "" {
src = asset.StoragePath
} else {
src = asset.OriginalURL
}
if title == "" {
title = asset.Title
}
}
if itemType == "" || src == "" {
http.Error(w, "type und src erforderlich", http.StatusBadRequest)
return
}
_, err = playlists.AddItem(r.Context(), playlist.ID, mediaAssetID,
itemType, src, title, durationSeconds, validFrom, validUntil)
if err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/manage/"+screenSlug, http.StatusSeeOther)
}
}
// HandleDeleteItemUI removes a playlist item and redirects back.
func HandleDeleteItemUI(playlists *store.PlaylistStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
screenSlug := r.PathValue("screenSlug")
itemID := r.PathValue("itemId")
if err := playlists.DeleteItem(r.Context(), itemID); err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/manage/"+screenSlug, http.StatusSeeOther)
}
}
// HandleReorderUI accepts JSON body with ordered IDs (HTMX/fetch).
func HandleReorderUI(playlists *store.PlaylistStore, screens *store.ScreenStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
screenSlug := r.PathValue("screenSlug")
screen, err := screens.GetBySlug(r.Context(), screenSlug)
if err != nil {
http.Error(w, "screen nicht gefunden", http.StatusNotFound)
return
}
playlist, err := playlists.GetByScreen(r.Context(), screen.ID)
if err != nil {
http.Error(w, "playlist nicht gefunden", http.StatusNotFound)
return
}
var ids []string
if err := json.NewDecoder(r.Body).Decode(&ids); err != nil {
http.Error(w, "JSON erwartet: array von item-IDs", http.StatusBadRequest)
return
}
if err := playlists.Reorder(r.Context(), playlist.ID, ids); err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// HandleUpdateItemUI handles form PATCH/POST to update a single item.
func HandleUpdateItemUI(playlists *store.PlaylistStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
screenSlug := r.PathValue("screenSlug")
itemID := r.PathValue("itemId")
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
title := r.FormValue("title")
durationSeconds := 20
if d, err := strconv.Atoi(strings.TrimSpace(r.FormValue("duration_seconds"))); err == nil && d > 0 {
durationSeconds = d
}
enabled := r.FormValue("enabled") != "false"
validFrom, _ := parseOptionalTime(r.FormValue("valid_from"))
validUntil, _ := parseOptionalTime(r.FormValue("valid_until"))
if err := playlists.UpdateItem(r.Context(), itemID, title, durationSeconds, enabled, validFrom, validUntil); err != nil {
http.Error(w, "db error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/manage/"+screenSlug, http.StatusSeeOther)
}
}
// HandleDeleteMediaUI deletes media and redirects back.
func HandleDeleteMediaUI(media *store.MediaStore, screens *store.ScreenStore, uploadDir string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
screenSlug := r.PathValue("screenSlug")
mediaID := r.PathValue("mediaId")
asset, err := media.Get(r.Context(), mediaID)
if err == nil && asset.StoragePath != "" {
os.Remove(filepath.Join(uploadDir, filepath.Base(asset.StoragePath))) //nolint:errcheck
}
media.Delete(r.Context(), mediaID) //nolint:errcheck
http.Redirect(w, r, "/manage/"+screenSlug, http.StatusSeeOther)
}
}

View file

@ -1,12 +1,28 @@
package httpapi
import (
"log"
"net/http"
"git.az-it.net/az/morz-infoboard/server/backend/internal/httpapi/manage"
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
)
func NewRouter(store playerStatusStore) http.Handler {
// RouterDeps holds all dependencies needed to build the HTTP router.
type RouterDeps struct {
StatusStore playerStatusStore
TenantStore *store.TenantStore
ScreenStore *store.ScreenStore
MediaStore *store.MediaStore
PlaylistStore *store.PlaylistStore
UploadDir string
Logger *log.Logger
}
func NewRouter(deps RouterDeps) http.Handler {
mux := http.NewServeMux()
// ── Health ───────────────────────────────────────────────────────────
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{
"status": "ok",
@ -14,9 +30,11 @@ func NewRouter(store playerStatusStore) http.Handler {
})
})
mux.HandleFunc("GET /status", handleStatusPage(store))
mux.HandleFunc("GET /status/{screenId}", handleScreenDetailPage(store))
// ── Status / diagnostic UI ───────────────────────────────────────────
mux.HandleFunc("GET /status", handleStatusPage(deps.StatusStore))
mux.HandleFunc("GET /status/{screenId}", handleScreenDetailPage(deps.StatusStore))
// ── API meta ─────────────────────────────────────────────────────────
mux.HandleFunc("GET /api/v1", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"name": "morz-infoboard-backend",
@ -30,15 +48,82 @@ func NewRouter(store playerStatusStore) http.Handler {
},
})
})
mux.HandleFunc("GET /api/v1/meta", handleMeta)
mux.HandleFunc("POST /api/v1/player/status", handlePlayerStatus(store))
mux.HandleFunc("GET /api/v1/screens/status", handleListLatestPlayerStatuses(store))
mux.HandleFunc("GET /api/v1/screens/{screenId}/status", handleGetLatestPlayerStatus(store))
mux.HandleFunc("DELETE /api/v1/screens/{screenId}/status", handleDeletePlayerStatus(store))
// ── Player status (existing) ──────────────────────────────────────────
mux.HandleFunc("POST /api/v1/player/status", handlePlayerStatus(deps.StatusStore))
mux.HandleFunc("GET /api/v1/screens/status", handleListLatestPlayerStatuses(deps.StatusStore))
mux.HandleFunc("GET /api/v1/screens/{screenId}/status", handleGetLatestPlayerStatus(deps.StatusStore))
mux.HandleFunc("DELETE /api/v1/screens/{screenId}/status", handleDeletePlayerStatus(deps.StatusStore))
// ── Message wall ──────────────────────────────────────────────────────
mux.HandleFunc("POST /api/v1/tools/message-wall/resolve", handleResolveMessageWall)
// ── Playlist management — only register if stores are wired up ────────
if deps.TenantStore != nil {
registerManageRoutes(mux, deps)
}
return mux
}
func registerManageRoutes(mux *http.ServeMux, d RouterDeps) {
uploadDir := d.UploadDir
if uploadDir == "" {
uploadDir = "/tmp/morz-uploads"
}
// Serve uploaded files.
mux.Handle("GET /uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir(uploadDir))))
// ── Admin UI ──────────────────────────────────────────────────────────
mux.HandleFunc("GET /admin", manage.HandleAdminUI(d.TenantStore, d.ScreenStore))
mux.HandleFunc("POST /admin/screens", manage.HandleCreateScreenUI(d.TenantStore, d.ScreenStore))
mux.HandleFunc("POST /admin/screens/{screenId}/delete", manage.HandleDeleteScreenUI(d.ScreenStore))
// ── Playlist management UI ────────────────────────────────────────────
mux.HandleFunc("GET /manage/{screenSlug}",
manage.HandleManageUI(d.TenantStore, d.ScreenStore, d.MediaStore, d.PlaylistStore))
mux.HandleFunc("POST /manage/{screenSlug}/upload",
manage.HandleUploadMediaUI(d.MediaStore, d.ScreenStore, uploadDir))
mux.HandleFunc("POST /manage/{screenSlug}/items",
manage.HandleAddItemUI(d.PlaylistStore, d.MediaStore, d.ScreenStore))
mux.HandleFunc("POST /manage/{screenSlug}/items/{itemId}",
manage.HandleUpdateItemUI(d.PlaylistStore))
mux.HandleFunc("POST /manage/{screenSlug}/items/{itemId}/delete",
manage.HandleDeleteItemUI(d.PlaylistStore))
mux.HandleFunc("POST /manage/{screenSlug}/reorder",
manage.HandleReorderUI(d.PlaylistStore, d.ScreenStore))
mux.HandleFunc("POST /manage/{screenSlug}/media/{mediaId}/delete",
manage.HandleDeleteMediaUI(d.MediaStore, d.ScreenStore, uploadDir))
// ── JSON API — screens ────────────────────────────────────────────────
mux.HandleFunc("GET /api/v1/tenants/{tenantSlug}/screens",
manage.HandleListScreens(d.TenantStore, d.ScreenStore))
mux.HandleFunc("POST /api/v1/tenants/{tenantSlug}/screens",
manage.HandleCreateScreen(d.TenantStore, d.ScreenStore))
// ── JSON API — media ──────────────────────────────────────────────────
mux.HandleFunc("GET /api/v1/tenants/{tenantSlug}/media",
manage.HandleListMedia(d.MediaStore))
mux.HandleFunc("POST /api/v1/tenants/{tenantSlug}/media",
manage.HandleUploadMedia(d.MediaStore, uploadDir))
mux.HandleFunc("DELETE /api/v1/media/{id}",
manage.HandleDeleteMedia(d.MediaStore, uploadDir))
// ── JSON API — playlists ──────────────────────────────────────────────
mux.HandleFunc("GET /api/v1/screens/{screenId}/playlist",
manage.HandlePlayerPlaylist(d.ScreenStore, d.PlaylistStore))
mux.HandleFunc("GET /api/v1/playlists/{screenId}",
manage.HandleGetPlaylist(d.ScreenStore, d.PlaylistStore))
mux.HandleFunc("POST /api/v1/playlists/{playlistId}/items",
manage.HandleAddItem(d.PlaylistStore, d.MediaStore))
mux.HandleFunc("PATCH /api/v1/items/{itemId}",
manage.HandleUpdateItem(d.PlaylistStore))
mux.HandleFunc("DELETE /api/v1/items/{itemId}",
manage.HandleDeleteItem(d.PlaylistStore))
mux.HandleFunc("PUT /api/v1/playlists/{playlistId}/order",
manage.HandleReorder(d.PlaylistStore))
mux.HandleFunc("PATCH /api/v1/playlists/{playlistId}/duration",
manage.HandleUpdatePlaylistDuration(d.PlaylistStore))
}

View file

@ -14,7 +14,7 @@ func TestRouterHealthz(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
w := httptest.NewRecorder()
NewRouter(newInMemoryPlayerStatusStore()).ServeHTTP(w, req)
NewRouter(RouterDeps{StatusStore: newInMemoryPlayerStatusStore()}).ServeHTTP(w, req)
if got, want := w.Code, http.StatusOK; got != want {
t.Fatalf("status = %d, want %d", got, want)
@ -42,7 +42,7 @@ func TestRouterBaseAPI(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1", nil)
w := httptest.NewRecorder()
NewRouter(newInMemoryPlayerStatusStore()).ServeHTTP(w, req)
NewRouter(RouterDeps{StatusStore: newInMemoryPlayerStatusStore()}).ServeHTTP(w, req)
if got, want := w.Code, http.StatusOK; got != want {
t.Fatalf("status = %d, want %d", got, want)
@ -95,7 +95,7 @@ func TestRouterMeta(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/meta", nil)
w := httptest.NewRecorder()
NewRouter(newInMemoryPlayerStatusStore()).ServeHTTP(w, req)
NewRouter(RouterDeps{StatusStore: newInMemoryPlayerStatusStore()}).ServeHTTP(w, req)
if got, want := w.Code, http.StatusOK; got != want {
t.Fatalf("status = %d, want %d", got, want)
@ -168,7 +168,7 @@ func TestRouterPlayerStatusRoute(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewBufferString(`{"screen_id":"demo","ts":"2026-03-22T16:00:00Z","status":"running","heartbeat_every_seconds":30}`))
w := httptest.NewRecorder()
NewRouter(newInMemoryPlayerStatusStore()).ServeHTTP(w, req)
NewRouter(RouterDeps{StatusStore: newInMemoryPlayerStatusStore()}).ServeHTTP(w, req)
if got, want := w.Code, http.StatusOK; got != want {
t.Fatalf("status = %d, want %d", got, want)
@ -181,7 +181,7 @@ func TestRouterScreenStatusRoute(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/screens/demo/status", nil)
w := httptest.NewRecorder()
NewRouter(store).ServeHTTP(w, req)
NewRouter(RouterDeps{StatusStore: store}).ServeHTTP(w, req)
if got, want := w.Code, http.StatusOK; got != want {
t.Fatalf("status = %d, want %d", got, want)
@ -193,7 +193,7 @@ func TestRouterScreenStatusListRoute(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/screens/status", nil)
w := httptest.NewRecorder()
NewRouter(store).ServeHTTP(w, req)
NewRouter(RouterDeps{StatusStore: store}).ServeHTTP(w, req)
if got, want := w.Code, http.StatusOK; got != want {
t.Fatalf("status = %d, want %d", got, want)
@ -219,7 +219,7 @@ func TestRouterScreenDetailPageRoute(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/status/info01-dev", nil)
w := httptest.NewRecorder()
NewRouter(store).ServeHTTP(w, req)
NewRouter(RouterDeps{StatusStore: store}).ServeHTTP(w, req)
if got, want := w.Code, http.StatusOK; got != want {
t.Fatalf("status = %d, want %d", got, want)
@ -254,7 +254,7 @@ func TestRouterScreenDetailPageNotFound(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/status/missing-screen", nil)
w := httptest.NewRecorder()
NewRouter(newInMemoryPlayerStatusStore()).ServeHTTP(w, req)
NewRouter(RouterDeps{StatusStore: newInMemoryPlayerStatusStore()}).ServeHTTP(w, req)
if got, want := w.Code, http.StatusNotFound; got != want {
t.Fatalf("status = %d, want %d", got, want)
@ -286,7 +286,7 @@ func TestRouterStatusPageRejectsInvalidQueryParams(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/status"+tc.query, nil)
w := httptest.NewRecorder()
NewRouter(newInMemoryPlayerStatusStore()).ServeHTTP(w, req)
NewRouter(RouterDeps{StatusStore: newInMemoryPlayerStatusStore()}).ServeHTTP(w, req)
if got, want := w.Code, http.StatusBadRequest; got != want {
t.Fatalf("status = %d, want %d", got, want)
@ -309,7 +309,7 @@ func TestRouterStatusPageRoute(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/status?server_connectivity=offline&stale=true&updated_since=2026-03-22T15:55:00Z&limit=10", nil)
w := httptest.NewRecorder()
NewRouter(store).ServeHTTP(w, req)
NewRouter(RouterDeps{StatusStore: store}).ServeHTTP(w, req)
if got, want := w.Code, http.StatusOK; got != want {
t.Fatalf("status = %d, want %d", got, want)

View file

@ -0,0 +1,426 @@
// 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
}