diff --git a/server/backend/internal/httpapi/manage/override.go b/server/backend/internal/httpapi/manage/override.go new file mode 100644 index 0000000..466e673 --- /dev/null +++ b/server/backend/internal/httpapi/manage/override.go @@ -0,0 +1,134 @@ +// 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. +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") + } +}