feat(manage): Handler für globalen + per-Screen-Override
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0ca63a5367
commit
42458e68ff
2 changed files with 232 additions and 0 deletions
134
server/backend/internal/httpapi/manage/override.go
Normal file
134
server/backend/internal/httpapi/manage/override.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
98
server/backend/internal/httpapi/manage/override_test.go
Normal file
98
server/backend/internal/httpapi/manage/override_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue