diff --git a/server/backend/go.mod b/server/backend/go.mod index 014633d..6da0bfa 100644 --- a/server/backend/go.mod +++ b/server/backend/go.mod @@ -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 +) diff --git a/server/backend/go.sum b/server/backend/go.sum new file mode 100644 index 0000000..ca531aa --- /dev/null +++ b/server/backend/go.sum @@ -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= diff --git a/server/backend/internal/app/app.go b/server/backend/internal/app/app.go index efc45ce..82c25b7 100644 --- a/server/backend/internal/app/app.go +++ b/server/backend/internal/app/app.go @@ -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 } diff --git a/server/backend/internal/config/config.go b/server/backend/internal/config/config.go index 943d41f..e838778 100644 --- a/server/backend/internal/config/config.go +++ b/server/backend/internal/config/config.go @@ -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"), } } diff --git a/server/backend/internal/db/db.go b/server/backend/internal/db/db.go new file mode 100644 index 0000000..7e68707 --- /dev/null +++ b/server/backend/internal/db/db.go @@ -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 +} diff --git a/server/backend/internal/db/migrations/001_initial.sql b/server/backend/internal/db/migrations/001_initial.sql new file mode 100644 index 0000000..b53e703 --- /dev/null +++ b/server/backend/internal/db/migrations/001_initial.sql @@ -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; diff --git a/server/backend/internal/httpapi/integration_test.go b/server/backend/internal/httpapi/integration_test.go index 265c77c..23737b2 100644 --- a/server/backend/internal/httpapi/integration_test.go +++ b/server/backend/internal/httpapi/integration_test.go @@ -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 := `{ diff --git a/server/backend/internal/httpapi/manage/media.go b/server/backend/internal/httpapi/manage/media.go new file mode 100644 index 0000000..92de86b --- /dev/null +++ b/server/backend/internal/httpapi/manage/media.go @@ -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 +} diff --git a/server/backend/internal/httpapi/manage/playlist.go b/server/backend/internal/httpapi/manage/playlist.go new file mode 100644 index 0000000..3cd6db0 --- /dev/null +++ b/server/backend/internal/httpapi/manage/playlist.go @@ -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) +} diff --git a/server/backend/internal/httpapi/manage/templates.go b/server/backend/internal/httpapi/manage/templates.go new file mode 100644 index 0000000..24566c5 --- /dev/null +++ b/server/backend/internal/httpapi/manage/templates.go @@ -0,0 +1,422 @@ +package manage + +const adminTmpl = ` + + + + + MORZ Infoboard – Admin + + + + + +
+
+ +
+

Bildschirme

+ {{if .Screens}} + + + + + + + + + + + {{range .Screens}} + + + + + + + {{end}} + +
NameSlugFormatAktionen
{{.Name}}{{.Slug}}{{orientationLabel .Orientation}} + Playlist verwalten +   +
+ +
+
+ {{else}} +

Noch keine Bildschirme angelegt.

+ {{end}} +
+ +
+

Neuer Bildschirm

+
+
+
+
+ +
+ +
+

URL-sichere Kennung (eindeutig)

+
+
+
+
+ +
+ +
+
+
+
+
+ +
+
+ +
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+ +
+
+ +` + +const manageTmpl = ` + + + + + Playlist – {{.Screen.Name}} + + + + + + + + +
+
+ + +
+

Aktuelle Playlist

+ {{if .Items}} + + + + + + + + + + + + + {{range .Items}} + + + + + + + + + + + + {{end}} + +
TypTitel / QuelleDauerStatusAktionen
β Ώ + {{typeIcon .Type}} {{.Type}} + +
{{if .Title}}{{.Title}}{{else}}{{shortSrc .Src}}{{end}}
+ {{if .Title}}{{shortSrc .Src}}{{end}} +
{{.DurationSeconds}} s + {{if .Enabled}} + Aktiv + {{else}} + Deaktiviert + {{end}} + + +
+ +
+
+

EintrΓ€ge per Drag & Drop in der Reihenfolge verschieben.

+ {{else}} +
+ Die Playlist ist noch leer. FΓΌge unten Medien aus der Bibliothek hinzu oder lade neue Dateien hoch. +
+ {{end}} +
+ + +
+

Medienbibliothek

+ {{if .Assets}} + + + + + + + + + + + {{range .Assets}} + + + + + + + {{end}} + +
TypTitelQuelleAktionen
{{typeIcon .Type}} {{.Type}}{{.Title}} + + {{if .StoragePath}}{{shortSrc .StoragePath}}{{else}}{{shortSrc .OriginalURL}}{{end}} + + + {{if index $.AddedAssets .ID}} + βœ“ In Playlist + {{else}} +
+ + +
+   + {{end}} +
+ +
+
+ {{else}} +

Noch keine Medien hochgeladen. Lade unten eine Datei hoch oder fΓΌge eine Webseite hinzu.

+ {{end}} +
+ + +
+

Neues Medium hinzufΓΌgen

+ + + +
+
+
+
+
+ +
+ +
+
+
+
+
+ + +
+
+
+
+ +
+ +
+
+
+
+
+ + +
+
+
+
+
+ +
+
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+ +
+
+
+ + + + +` diff --git a/server/backend/internal/httpapi/manage/ui.go b/server/backend/internal/httpapi/manage/ui.go new file mode 100644 index 0000000..6c52afe --- /dev/null +++ b/server/backend/internal/httpapi/manage/ui.go @@ -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) + } +} diff --git a/server/backend/internal/httpapi/router.go b/server/backend/internal/httpapi/router.go index df9597b..7564f0f 100644 --- a/server/backend/internal/httpapi/router.go +++ b/server/backend/internal/httpapi/router.go @@ -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)) +} diff --git a/server/backend/internal/httpapi/router_test.go b/server/backend/internal/httpapi/router_test.go index e9012e3..1761789 100644 --- a/server/backend/internal/httpapi/router_test.go +++ b/server/backend/internal/httpapi/router_test.go @@ -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) diff --git a/server/backend/internal/store/store.go b/server/backend/internal/store/store.go new file mode 100644 index 0000000..aafba7a --- /dev/null +++ b/server/backend/internal/store/store.go @@ -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 +}