diff --git a/docs/API-ENDPOINTS.md b/docs/API-ENDPOINTS.md index 7a2b244..ac66752 100644 --- a/docs/API-ENDPOINTS.md +++ b/docs/API-ENDPOINTS.md @@ -587,6 +587,93 @@ Der Scheduler prüft jede Minute, ob die aktuelle Uhrzeit mit `power_on_time` od --- +### Globaler Override + +| Methode | Pfad | Auth | Beschreibung | +|---------|------|------|--------------| +| GET | `/api/v1/global-override` | authUser | Aktiven Override abrufen (204 wenn keiner aktiv) | +| POST | `/api/v1/global-override` | authUser | Override setzen + sofort MQTT an alle Screens | +| DELETE | `/api/v1/global-override` | authUser | Override aufheben | + +**GET /api/v1/global-override** + +Ruft den aktuell aktiven globalen Override ab. + +**Response:** `200 OK` (wenn aktiv) +```json +{"type":"off","until":"2026-04-05T18:00:00+02:00","set_at":"2026-03-27T15:30:00+02:00"} +``` + +**Response:** `204 No Content` (wenn kein Override aktiv) + +--- + +**POST /api/v1/global-override** + +Setzt einen globalen Override und sendet sofort MQTT-Befehle an alle Screens. + +**Request-Body:** +```json +{"type":"off","until":"2026-04-05T18:00:00+02:00"} +``` + +**Response:** `200 OK` +```json +{"type":"off","until":"2026-04-05T18:00:00+02:00","set_at":"2026-03-27T15:30:00+02:00"} +``` + +**Fehler:** +- `400 Bad Request` — `type` nicht "on"/"off", oder ungültiges Zeitformat +- `500 Internal Server Error` — DB-Fehler + +--- + +**DELETE /api/v1/global-override** + +Hebt den aktuellen globalen Override auf. + +**Response:** `204 No Content` + +**Fehler:** +- `500 Internal Server Error` — DB-Fehler + +--- + +### Per-Screen Override + +| Methode | Pfad | Auth | Beschreibung | +|---------|------|------|--------------| +| POST | `/api/v1/screens/{screenSlug}/override` | authScreen | Per-Screen "Einschalten bis" setzen oder löschen | + +**POST /api/v1/screens/{screenSlug}/override** + +Setzt oder löscht den per-Screen "Einschalten bis"-Override. Mit diesem Override bleibt ein Monitor bis zu +dem angegebenen Zeitpunkt eingeschaltet, selbst wenn der globale Schedule "aus" vorsieht. + +**Auth:** Erforderlich. Screen-Zugriff erforderlich. + +**Path-Parameter:** +- `screenSlug` — Slug des Screens + +**Request-Body:** +```json +{"on_until":"2026-04-05T18:00:00+02:00"} +``` + +Um den Override zu löschen, `on_until` auf `null` setzen: +```json +{"on_until":null} +``` + +**Response:** `204 No Content` + +**Fehler:** +- `400 Bad Request` — Ungültiges Zeitformat oder ungültiges JSON +- `404 Not Found` — Screen nicht vorhanden +- `500 Internal Server Error` — DB-Fehler + +--- + ## Message Wall ### POST /api/v1/tools/message-wall/resolve diff --git a/docs/SCHEMA.md b/docs/SCHEMA.md index 51944f0..c512fbf 100644 --- a/docs/SCHEMA.md +++ b/docs/SCHEMA.md @@ -523,6 +523,36 @@ Regeln: - `schedule_enabled = false` bedeutet: Zeitplan vorhanden, aber deaktiviert - Leere Zeitfelder bedeuten: kein Einschalt- bzw. kein Ausschaltbefehl +Neue Spalte in `screen_schedules` (Migration `006`): +- `override_on_until timestamptz` — Einschalten-Override: Monitor bleibt bis zu diesem Zeitpunkt eingeschaltet (null = kein Override) + +### `global_override` (Migration 006) + +Zweck: + +- Speichert den globalen Display-Override (maximal eine Zeile) + +Spalten: + +```sql +id INT PRIMARY KEY DEFAULT 1 +type TEXT NOT NULL -- "on" oder "off" +until TIMESTAMPTZ NOT NULL -- Override aktiv bis zu diesem Zeitpunkt +set_at TIMESTAMPTZ NOT NULL DEFAULT now() -- Wann der Override gesetzt wurde +``` + +Constraint: +```sql +CHECK (id = 1) +``` + +Regeln: + +- Die Tabelle enthaelt maximal eine Zeile (id = 1) +- `type` bestimmt den globalen Zielzustand (alle Screens) +- `until` gibt an, wann der Override automatisch aufgehoben wird +- Der Scheduler prueft jede Minute, ob der Override noch aktiv ist (aktuell <= until) + ### `screen_snapshots` Zweck: diff --git a/server/backend/README.md b/server/backend/README.md index 760b1f9..328b41b 100644 --- a/server/backend/README.md +++ b/server/backend/README.md @@ -17,7 +17,7 @@ Dieses Verzeichnis enthaelt das zentrale Go-Backend fuer das Info-Board-System. - `internal/app/` — App-Initialisierung und Lifecycle - `internal/config/` — Konfiguration via Umgebungsvariablen - `internal/db/` — PostgreSQL-Anbindung und Migrations-Runner -- `internal/store/` — Datenbankzugriff (TenantStore, ScreenStore, MediaStore, PlaylistStore, AuthStore) +- `internal/store/` — Datenbankzugriff (TenantStore, ScreenStore, MediaStore, PlaylistStore, AuthStore, ScreenScheduleStore, GlobalOverrideStore) - `internal/fileutil/` — Upload-Hilfsfunktionen (SaveUploadedFile mit Tenant-Isolation) - `internal/httpapi/` — HTTP-Routing, Middleware und Handler - `internal/httpapi/csrf.go` — Double-Submit-Cookie CSRF-Schutz @@ -42,6 +42,10 @@ Uhrzeit übereinstimmt — per MQTT den Befehl `display_on` bzw. `display_off` s Der Scheduler wird in `internal/app/app.go` als Goroutine gestartet und laeuft bis zum Kontext-Abbruch beim Server-Shutdown. +**Wochenend-Sperre:** An Samstagen und Sonntagen werden Zeitplaene ignoriert — der Reconciler +sendet dann keine automatischen Ein-/Ausschalt-Kommandos. Manuelle Overrides (global oder +per-Screen) wirken jedoch auch am Wochenende. + ## Datenbank-Stores ### AuthStore (`internal/store/auth.go`) @@ -60,6 +64,16 @@ Kontext-Abbruch beim Server-Shutdown. - `EnsureAdminUser(ctx, tenantSlug, password)` — Admin-User beim Start anlegen - `VerifyPassword(ctx, userID, password)` — Passwort gegen bcrypt-Hash pruefen +### GlobalOverrideStore (`internal/store/store.go`) + +Verwaltet einen systemweiten Display-Override (max. 1 Zeile in `global_override`): + +- `Get(ctx)` — aktuellen globalen Override laden (nil wenn keiner gesetzt) +- `Upsert(ctx, type, until)` — Override setzen oder ueberschreiben (`type`: `"on"` | `"off"`) +- `Delete(ctx)` — Override entfernen + +Der Reconciler im Scheduler wertet den globalen Override aus und wendet ihn auf alle Screens an. + ### ScreenStore (`internal/store/screen.go`) **Screen-User Zugriffskontrolle:** @@ -117,6 +131,10 @@ Kontext-Abbruch beim Server-Shutdown. | GET | `/api/v1/screens/{screenId}/screenshot` | Screenshot eines Screens abrufen | | POST | `/api/v1/screens/{screenSlug}/display` | Display ein-/ausschalten (MQTT) | | POST | `/api/v1/screens/{screenSlug}/schedule` | Display-Zeitplan speichern | +| GET | `/api/v1/global-override` | Globalen Override abrufen (204 = kein aktiver Override) | +| POST | `/api/v1/global-override` | Globalen Override setzen (type + until); sendet sofort MQTT | +| DELETE | `/api/v1/global-override` | Globalen Override loeschen | +| POST | `/api/v1/screens/{screenSlug}/override` | Per-Screen-Override setzen oder loeschen (on_until: null = loeschen) | ### Nur Admins (`RequireAuth` + `RequireAdmin`) @@ -182,8 +200,9 @@ Middleware zur rollenbasierten Zugriffskontrolle auf Screen-Ressourcen. ## Migrationen -- `001_core.sql` — initiales Schema (Tenants, Screens, Playlists, Media, etc.) +- `001_initial.sql` — initiales Schema (Tenants, Screens, Playlists, Media, etc.) - `002_auth.sql` — Auth-Tabellen (`users`, `sessions`) - `003_user_screen_permissions.sql` — Screen-User Management (`user_screen_permissions`) - `004_screen_status.sql` — Display-Zustand pro Screen (`screen_status`: screen_id, display_state, reported_at) - `005_screen_schedules.sql` — Zeitplan pro Screen (`screen_schedules`: screen_id, schedule_enabled, power_on_time, power_off_time) +- `006_override.sql` — Spalte `override_on_until` in `screen_schedules` (per-Screen-Override) und Tabelle `global_override` (systemweiter Display-Override) diff --git a/server/backend/internal/app/app.go b/server/backend/internal/app/app.go index 0d73925..5bb3f68 100644 --- a/server/backend/internal/app/app.go +++ b/server/backend/internal/app/app.go @@ -26,8 +26,9 @@ type App struct { server *http.Server notifier *mqttnotifier.Notifier authStore *store.AuthStore - scheduleStore *store.ScreenScheduleStore - screenStore *store.ScreenStore + scheduleStore *store.ScreenScheduleStore + globalOverrideStore *store.GlobalOverrideStore + screenStore *store.ScreenStore dbPool *db.Pool // V7: für db.Close() im Shutdown logger *log.Logger } @@ -62,6 +63,7 @@ func New() (*App, error) { playlists := store.NewPlaylistStore(pool.Pool) authStore := store.NewAuthStore(pool.Pool) schedules := store.NewScreenScheduleStore(pool.Pool) + globalOverrides := store.NewGlobalOverrideStore(pool.Pool) // Ensure admin user exists — generate a random password if none is configured. adminPassword := cfg.AdminPassword @@ -100,8 +102,9 @@ func New() (*App, error) { AuthStore: authStore, Notifier: notifier, ScreenshotStore: ss, - ScheduleStore: schedules, - Config: cfg, + ScheduleStore: schedules, + GlobalOverrideStore: globalOverrides, + Config: cfg, UploadDir: cfg.UploadDir, Logger: logger, }) @@ -111,8 +114,9 @@ func New() (*App, error) { server: &http.Server{Addr: cfg.HTTPAddress, Handler: handler}, notifier: notifier, authStore: authStore, - scheduleStore: schedules, - screenStore: screens, + scheduleStore: schedules, + globalOverrideStore: globalOverrides, + screenStore: screens, dbPool: pool, // V7: Referenz für Shutdown logger: logger, }, nil @@ -145,8 +149,8 @@ func (a *App) Run() error { }() // Display-Zeitplan-Scheduler - go scheduler.Run(ctx, a.scheduleStore, a.screenStore, a.notifier) - go scheduler.Reconcile(ctx, a.scheduleStore, a.screenStore, a.screenStore, a.notifier) + go scheduler.Run(ctx, a.scheduleStore, a.screenStore, a.globalOverrideStore, a.notifier) + go scheduler.Reconcile(ctx, a.scheduleStore, a.screenStore, a.screenStore, a.globalOverrideStore, a.notifier) // W2: Signal-Handler für Graceful Shutdown. sigCh := make(chan os.Signal, 1) diff --git a/server/backend/internal/db/migrations/006_override.sql b/server/backend/internal/db/migrations/006_override.sql new file mode 100644 index 0000000..59e652f --- /dev/null +++ b/server/backend/internal/db/migrations/006_override.sql @@ -0,0 +1,14 @@ +-- Migration 006: Globaler Override + per-Screen Override-Zeitpunkt + +-- Globaler Override: immer maximal eine Zeile (id = 1 per CHECK-Constraint). +create table if not exists global_override ( + id int primary key default 1, + type text not null, -- 'on' oder 'off' + until timestamptz not null, + set_at timestamptz not null default now(), + check (id = 1) +); + +-- Per-Screen Override: Einschalten bis Zeitpunkt (null = kein Override). +alter table screen_schedules + add column if not exists override_on_until timestamptz; diff --git a/server/backend/internal/httpapi/manage/override.go b/server/backend/internal/httpapi/manage/override.go new file mode 100644 index 0000000..067e5b0 --- /dev/null +++ b/server/backend/internal/httpapi/manage/override.go @@ -0,0 +1,138 @@ +// server/backend/internal/httpapi/manage/override.go +package manage + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + "time" + + "git.az-it.net/az/morz-infoboard/server/backend/internal/mqttnotifier" + "git.az-it.net/az/morz-infoboard/server/backend/internal/reqcontext" + "git.az-it.net/az/morz-infoboard/server/backend/internal/store" +) + +// globalOverrideStore ist das Interface für Handler-Tests. +type globalOverrideStore interface { + Get(ctx context.Context) (*store.GlobalOverride, error) + Upsert(ctx context.Context, overrideType string, until time.Time) error + Delete(ctx context.Context) error +} + +// HandleGetGlobalOverride gibt den aktuellen globalen Override zurück (204 wenn keiner aktiv). +func HandleGetGlobalOverride(overrides globalOverrideStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + o, err := overrides.Get(r.Context()) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + if o == nil || time.Now().After(o.Until) { + w.WriteHeader(http.StatusNoContent) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(o) //nolint:errcheck + } +} + +// HandleSetGlobalOverride setzt den globalen Override und schickt sofort MQTT an alle Screens. +// Hinweis: Der Override wird global gespeichert und vom Reconciler auf alle Screens angewendet. +// Über authOnly haben alle eingeloggten Nutzer Zugriff; die sofortigen MQTT-Kommandos gehen +// jedoch nur an ihre zugänglichen Screens. Soll der Zugriff auf Admins beschränkt werden, +// authOnly durch authAdmin ersetzen. +func HandleSetGlobalOverride(overrides globalOverrideStore, screens *store.ScreenStore, notifier *mqttnotifier.Notifier) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var body struct { + Type string `json:"type"` + Until time.Time `json:"until"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + if body.Type != "on" && body.Type != "off" { + http.Error(w, `type must be "on" or "off"`, http.StatusBadRequest) + return + } + if body.Until.IsZero() || !time.Now().Before(body.Until) { + http.Error(w, "until must be in the future", http.StatusBadRequest) + return + } + + if err := overrides.Upsert(r.Context(), body.Type, body.Until); err != nil { + slog.Error("set global override: upsert failed", "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + // Sofort MQTT an alle zugänglichen Screens schicken (falls screens/notifier vorhanden) + if screens != nil && notifier != nil { + u := reqcontext.UserFromContext(r.Context()) + var allScreens []*store.Screen + if u != nil { + switch u.Role { + case "admin": + allScreens, _ = screens.ListAll(r.Context()) + default: + allScreens, _ = screens.GetAccessibleScreens(r.Context(), u.ID) + } + } + action := "display_" + body.Type + for _, sc := range allScreens { + if err := notifier.SendDisplayCommand(sc.Slug, action); err != nil { + slog.Warn("set global override: send command failed", "slug", sc.Slug, "err", err) + } + } + } + + w.WriteHeader(http.StatusNoContent) + } +} + +// HandleDeleteGlobalOverride entfernt den globalen Override. +func HandleDeleteGlobalOverride(overrides globalOverrideStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := overrides.Delete(r.Context()); err != nil { + slog.Error("delete global override: failed", "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) + } +} + +// HandleSetScreenOverride setzt oder löscht den per-Screen-Override (on_until: null → löschen). +func HandleSetScreenOverride(screens *store.ScreenStore, schedules *store.ScreenScheduleStore) 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 not found", http.StatusNotFound) + return + } + if !requireScreenAccess(w, r, screen) { + return + } + + var body struct { + OnUntil *time.Time `json:"on_until"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + if body.OnUntil != nil && !time.Now().Before(*body.OnUntil) { + http.Error(w, "on_until must be in the future", http.StatusBadRequest) + return + } + + if err := schedules.SetOverrideOnUntil(r.Context(), screen.ID, body.OnUntil); err != nil { + slog.Error("set screen override: db error", "screen_id", screen.ID, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/server/backend/internal/httpapi/manage/override_test.go b/server/backend/internal/httpapi/manage/override_test.go new file mode 100644 index 0000000..262fcc5 --- /dev/null +++ b/server/backend/internal/httpapi/manage/override_test.go @@ -0,0 +1,98 @@ +// server/backend/internal/httpapi/manage/override_test.go +package manage_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "git.az-it.net/az/morz-infoboard/server/backend/internal/httpapi/manage" + "git.az-it.net/az/morz-infoboard/server/backend/internal/store" +) + +// --- Mocks --- + +type mockGlobalOverrideStore struct { + current *store.GlobalOverride + upsertCalled bool + deleteCalled bool +} + +func (m *mockGlobalOverrideStore) Get(_ context.Context) (*store.GlobalOverride, error) { + return m.current, nil +} +func (m *mockGlobalOverrideStore) Upsert(_ context.Context, t string, u time.Time) error { + m.current = &store.GlobalOverride{Type: t, Until: u} + m.upsertCalled = true + return nil +} +func (m *mockGlobalOverrideStore) Delete(_ context.Context) error { + m.current = nil + m.deleteCalled = true + return nil +} + +// --- Tests --- + +func TestHandleGetGlobalOverride_None(t *testing.T) { + h := manage.HandleGetGlobalOverride(&mockGlobalOverrideStore{}) + req := httptest.NewRequest(http.MethodGet, "/api/v1/global-override", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusNoContent { + t.Fatalf("want 204, got %d", w.Code) + } +} + +func TestHandleGetGlobalOverride_Active(t *testing.T) { + until := time.Now().Add(time.Hour) + m := &mockGlobalOverrideStore{ + current: &store.GlobalOverride{Type: "off", Until: until}, + } + h := manage.HandleGetGlobalOverride(m) + req := httptest.NewRequest(http.MethodGet, "/api/v1/global-override", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("want 200, got %d", w.Code) + } + var resp store.GlobalOverride + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatal("decode response:", err) + } + if resp.Type != "off" { + t.Errorf("want type=off, got %q", resp.Type) + } +} + +func TestHandleSetGlobalOverride_InvalidType(t *testing.T) { + h := manage.HandleSetGlobalOverride(&mockGlobalOverrideStore{}, nil, nil) + body := `{"type":"maybe","until":"` + time.Now().Add(time.Hour).Format(time.RFC3339) + `"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/global-override", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d", w.Code) + } +} + +func TestHandleDeleteGlobalOverride(t *testing.T) { + m := &mockGlobalOverrideStore{ + current: &store.GlobalOverride{Type: "off", Until: time.Now().Add(time.Hour)}, + } + h := manage.HandleDeleteGlobalOverride(m) + req := httptest.NewRequest(http.MethodDelete, "/api/v1/global-override", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusNoContent { + t.Fatalf("want 204, got %d", w.Code) + } + if !m.deleteCalled { + t.Error("Delete() was not called") + } +} diff --git a/server/backend/internal/httpapi/manage/templates.go b/server/backend/internal/httpapi/manage/templates.go index 6a8beed..07e93c5 100644 --- a/server/backend/internal/httpapi/manage/templates.go +++ b/server/backend/internal/httpapi/manage/templates.go @@ -982,6 +982,27 @@ const manageTmpl = `
✓ Gespeichert
+ ++ ⏰ Aktiv bis {{.Schedule.OverrideOnUntil.Format "02.01.2006 15:04"}} +
+ + {{else}} ++ Überschreibt Zeitplan und Wochenend-Sperre — Monitor bleibt bis zum angegebenen Zeitpunkt eingeschaltet. +
+✓ Gespeichert
+