From 1b7c48f27f9c77d1ebefeb97565d45f69e674f01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesko=20Ansch=C3=BCtz?= Date: Fri, 27 Mar 2026 20:09:23 +0100 Subject: [PATCH 01/12] =?UTF-8?q?feat(db):=20Migration=20006=20=E2=80=93?= =?UTF-8?q?=20global=5Foverride-Tabelle=20+=20override=5Fon=5Funtil?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/db/migrations/006_override.sql | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 server/backend/internal/db/migrations/006_override.sql 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; From 8f1abd977bebbd0f0a07e2fb18eb8e0145b04400 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesko=20Ansch=C3=BCtz?= Date: Fri, 27 Mar 2026 20:10:36 +0100 Subject: [PATCH 02/12] feat(store): GlobalOverrideStore + SetOverrideOnUntil Co-Authored-By: Claude Sonnet 4.6 --- server/backend/internal/store/store.go | 63 ++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/server/backend/internal/store/store.go b/server/backend/internal/store/store.go index bc7e556..986deb8 100644 --- a/server/backend/internal/store/store.go +++ b/server/backend/internal/store/store.go @@ -699,3 +699,66 @@ func (s *ScreenScheduleStore) ListEnabled(ctx context.Context) ([]*ScreenSchedul } return out, rows.Err() } + +// SetOverrideOnUntil setzt oder löscht den per-Screen-Override (null = löschen). +func (s *ScreenScheduleStore) SetOverrideOnUntil(ctx context.Context, screenID string, until *time.Time) error { + _, err := s.pool.Exec(ctx, + `insert into screen_schedules (screen_id, override_on_until) + values ($1, $2) + on conflict (screen_id) do update + set override_on_until = excluded.override_on_until`, + screenID, until) + return err +} + +// ------------------------------------------------------------------ +// GlobalOverrideStore +// ------------------------------------------------------------------ + +// GlobalOverride beschreibt einen aktiven globalen Display-Override. +type GlobalOverride struct { + Type string `json:"type"` // "on" oder "off" + Until time.Time `json:"until"` + SetAt time.Time `json:"set_at"` +} + +// GlobalOverrideStore verwaltet den globalen Display-Override (max. 1 Zeile). +type GlobalOverrideStore struct{ pool *pgxpool.Pool } + +func NewGlobalOverrideStore(pool *pgxpool.Pool) *GlobalOverrideStore { + return &GlobalOverrideStore{pool: pool} +} + +// Get lädt den aktuellen globalen Override. Gibt nil zurück wenn keiner gesetzt ist. +func (s *GlobalOverrideStore) Get(ctx context.Context) (*GlobalOverride, error) { + var o GlobalOverride + err := s.pool.QueryRow(ctx, + `select type, until, set_at from global_override where id = 1`). + Scan(&o.Type, &o.Until, &o.SetAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &o, nil +} + +// Upsert setzt oder überschreibt den globalen Override. +func (s *GlobalOverrideStore) Upsert(ctx context.Context, overrideType string, until time.Time) error { + _, err := s.pool.Exec(ctx, + `insert into global_override (id, type, until, set_at) + values (1, $1, $2, now()) + on conflict (id) do update + set type = excluded.type, + until = excluded.until, + set_at = excluded.set_at`, + overrideType, until) + return err +} + +// Delete entfernt den globalen Override. +func (s *GlobalOverrideStore) Delete(ctx context.Context) error { + _, err := s.pool.Exec(ctx, `delete from global_override where id = 1`) + return err +} From be3a5f5aac4a3ec38204c91a8161b82481db8e9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesko=20Ansch=C3=BCtz?= Date: Fri, 27 Mar 2026 20:11:41 +0100 Subject: [PATCH 03/12] =?UTF-8?q?feat(store):=20ScreenSchedule.OverrideOnU?= =?UTF-8?q?ntil=20=E2=80=93=20Struct,=20Get,=20Upsert,=20ListEnabled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- server/backend/internal/store/store.go | 30 ++++++++++++++------------ 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/server/backend/internal/store/store.go b/server/backend/internal/store/store.go index 986deb8..9f98f30 100644 --- a/server/backend/internal/store/store.go +++ b/server/backend/internal/store/store.go @@ -42,10 +42,11 @@ type ScreenStatus struct { } type ScreenSchedule struct { - ScreenID string `json:"screen_id"` - ScheduleEnabled bool `json:"schedule_enabled"` - PowerOnTime string `json:"power_on_time"` - PowerOffTime string `json:"power_off_time"` + ScreenID string `json:"screen_id"` + ScheduleEnabled bool `json:"schedule_enabled"` + PowerOnTime string `json:"power_on_time"` + PowerOffTime string `json:"power_off_time"` + OverrideOnUntil *time.Time `json:"override_on_until,omitempty"` } type MediaAsset struct { @@ -653,9 +654,9 @@ func scanPlaylistItem(row interface { func (s *ScreenScheduleStore) Get(ctx context.Context, screenID string) (*ScreenSchedule, error) { var sc ScreenSchedule err := s.pool.QueryRow(ctx, - `select screen_id, schedule_enabled, power_on_time, power_off_time + `select screen_id, schedule_enabled, power_on_time, power_off_time, override_on_until from screen_schedules where screen_id = $1`, screenID). - Scan(&sc.ScreenID, &sc.ScheduleEnabled, &sc.PowerOnTime, &sc.PowerOffTime) + Scan(&sc.ScreenID, &sc.ScheduleEnabled, &sc.PowerOnTime, &sc.PowerOffTime, &sc.OverrideOnUntil) if errors.Is(err, pgx.ErrNoRows) { return &ScreenSchedule{ScreenID: screenID}, nil } @@ -668,20 +669,21 @@ func (s *ScreenScheduleStore) Get(ctx context.Context, screenID string) (*Screen // Upsert speichert oder aktualisiert den Zeitplan eines Screens. func (s *ScreenScheduleStore) Upsert(ctx context.Context, sc *ScreenSchedule) error { _, err := s.pool.Exec(ctx, - `insert into screen_schedules (screen_id, schedule_enabled, power_on_time, power_off_time) - values ($1, $2, $3, $4) + `insert into screen_schedules (screen_id, schedule_enabled, power_on_time, power_off_time, override_on_until) + values ($1, $2, $3, $4, $5) on conflict (screen_id) do update - set schedule_enabled = excluded.schedule_enabled, - power_on_time = excluded.power_on_time, - power_off_time = excluded.power_off_time`, - sc.ScreenID, sc.ScheduleEnabled, sc.PowerOnTime, sc.PowerOffTime) + set schedule_enabled = excluded.schedule_enabled, + power_on_time = excluded.power_on_time, + power_off_time = excluded.power_off_time, + override_on_until = excluded.override_on_until`, + sc.ScreenID, sc.ScheduleEnabled, sc.PowerOnTime, sc.PowerOffTime, sc.OverrideOnUntil) return err } // ListEnabled gibt alle Screens mit aktivem Zeitplan zurück. func (s *ScreenScheduleStore) ListEnabled(ctx context.Context) ([]*ScreenSchedule, error) { rows, err := s.pool.Query(ctx, - `select screen_id, schedule_enabled, power_on_time, power_off_time + `select screen_id, schedule_enabled, power_on_time, power_off_time, override_on_until from screen_schedules where schedule_enabled = true and (power_on_time != '' or power_off_time != '')`) @@ -692,7 +694,7 @@ func (s *ScreenScheduleStore) ListEnabled(ctx context.Context) ([]*ScreenSchedul var out []*ScreenSchedule for rows.Next() { var sc ScreenSchedule - if err := rows.Scan(&sc.ScreenID, &sc.ScheduleEnabled, &sc.PowerOnTime, &sc.PowerOffTime); err != nil { + if err := rows.Scan(&sc.ScreenID, &sc.ScheduleEnabled, &sc.PowerOnTime, &sc.PowerOffTime, &sc.OverrideOnUntil); err != nil { return nil, err } out = append(out, &sc) From e76f89798f044872d74bbfe626a6fd03b8f34e3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesko=20Ansch=C3=BCtz?= Date: Fri, 27 Mar 2026 20:13:23 +0100 Subject: [PATCH 04/12] =?UTF-8?q?feat(scheduler):=20resolveDesiredState=20?= =?UTF-8?q?=E2=80=93=20per-Screen,=20global,=20Wochenende,=20Zeitplan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../backend/internal/scheduler/scheduler.go | 29 ++++ .../internal/scheduler/scheduler_test.go | 129 ++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 server/backend/internal/scheduler/scheduler_test.go diff --git a/server/backend/internal/scheduler/scheduler.go b/server/backend/internal/scheduler/scheduler.go index ab052f3..dcf6efe 100644 --- a/server/backend/internal/scheduler/scheduler.go +++ b/server/backend/internal/scheduler/scheduler.go @@ -113,6 +113,35 @@ func desiredState(onTime, offTime, now string) string { return "off" } +// resolveDesiredState ermittelt den Soll-Zustand eines Screens unter Berücksichtigung +// aller Prioritätsstufen: +// 1. per-Screen override_on_until (höchste Priorität) +// 2. globaler Override +// 3. Wochenende (Sa/So) +// 4. normaler Zeitplan +// +// Gibt ("", false) zurück wenn keine Automatisierung aktiv ist. +func resolveDesiredState(sc store.ScreenSchedule, globalOverride *store.GlobalOverride, now time.Time) (desired string, shouldControl bool) { + // 1. Per-Screen-Override: überschreibt alles + if sc.OverrideOnUntil != nil && now.Before(*sc.OverrideOnUntil) { + return "on", true + } + // 2. Globaler Override + if globalOverride != nil && now.Before(globalOverride.Until) { + return globalOverride.Type, true + } + // 3. Wochenende: Zeitpläne werden ignoriert, Monitore bleiben aus + wd := now.Weekday() + if wd == time.Saturday || wd == time.Sunday { + return "off", true + } + // 4. Normaler Zeitplan + if !sc.ScheduleEnabled || (sc.PowerOnTime == "" && sc.PowerOffTime == "") { + return "", false + } + return desiredState(sc.PowerOnTime, sc.PowerOffTime, now.Format("15:04")), true +} + // check prüft alle aktiven Zeitpläne und sendet ggf. Befehle. func check(ctx context.Context, schedules *store.ScreenScheduleStore, screens ScreenSlugGetter, notifier DisplayCommander) { // Uses process-local timezone — ensure TZ env var is set in the container (e.g. Europe/Berlin). diff --git a/server/backend/internal/scheduler/scheduler_test.go b/server/backend/internal/scheduler/scheduler_test.go new file mode 100644 index 0000000..ffb6066 --- /dev/null +++ b/server/backend/internal/scheduler/scheduler_test.go @@ -0,0 +1,129 @@ +package scheduler + +import ( + "testing" + "time" + + "git.az-it.net/az/morz-infoboard/server/backend/internal/store" +) + +func ptrTime(t time.Time) *time.Time { return &t } + +func TestResolveDesiredState(t *testing.T) { + // Donnerstag 10:00 UTC + thu := time.Date(2026, 3, 26, 10, 0, 0, 0, time.UTC) + // Samstag 10:00 UTC + sat := time.Date(2026, 3, 28, 10, 0, 0, 0, time.UTC) + + tests := []struct { + name string + sc store.ScreenSchedule + override *store.GlobalOverride + now time.Time + wantDesired string + wantControl bool + }{ + { + name: "per-screen override aktiv → on", + sc: store.ScreenSchedule{OverrideOnUntil: ptrTime(thu.Add(time.Hour))}, + now: thu, + wantDesired: "on", wantControl: true, + }, + { + name: "per-screen override abgelaufen → fällt durch zu Zeitplan", + sc: store.ScreenSchedule{ + ScheduleEnabled: true, + PowerOnTime: "09:00", PowerOffTime: "17:00", + OverrideOnUntil: ptrTime(thu.Add(-time.Hour)), // abgelaufen + }, + now: thu, + wantDesired: "on", wantControl: true, // Zeitplan: 09:00–17:00, jetzt 10:00 + }, + { + name: "per-screen override schlägt globalen Override off", + sc: store.ScreenSchedule{OverrideOnUntil: ptrTime(thu.Add(time.Hour))}, + override: &store.GlobalOverride{ + Type: "off", + Until: thu.Add(time.Hour), + }, + now: thu, + wantDesired: "on", wantControl: true, + }, + { + name: "globaler Override off aktiv → off", + sc: store.ScreenSchedule{ScheduleEnabled: true, PowerOnTime: "09:00", PowerOffTime: "17:00"}, + override: &store.GlobalOverride{ + Type: "off", + Until: thu.Add(time.Hour), + }, + now: thu, + wantDesired: "off", wantControl: true, + }, + { + name: "globaler Override on aktiv → on", + sc: store.ScreenSchedule{}, + override: &store.GlobalOverride{ + Type: "on", + Until: thu.Add(time.Hour), + }, + now: thu, + wantDesired: "on", wantControl: true, + }, + { + name: "globaler Override abgelaufen → fällt durch", + sc: store.ScreenSchedule{ScheduleEnabled: true, PowerOnTime: "09:00", PowerOffTime: "17:00"}, + override: &store.GlobalOverride{ + Type: "off", + Until: thu.Add(-time.Hour), // abgelaufen + }, + now: thu, + wantDesired: "on", wantControl: true, + }, + { + name: "Wochenende → off", + sc: store.ScreenSchedule{ScheduleEnabled: true, PowerOnTime: "09:00", PowerOffTime: "17:00"}, + now: sat, + wantDesired: "off", wantControl: true, + }, + { + name: "Wochenende + per-screen override → on", + sc: store.ScreenSchedule{OverrideOnUntil: ptrTime(sat.Add(time.Hour))}, + now: sat, + wantDesired: "on", wantControl: true, + }, + { + name: "normaler Zeitplan: innerhalb → on", + sc: store.ScreenSchedule{ScheduleEnabled: true, PowerOnTime: "09:00", PowerOffTime: "17:00"}, + now: thu, // 10:00 + wantDesired: "on", wantControl: true, + }, + { + name: "normaler Zeitplan: außerhalb → off", + sc: store.ScreenSchedule{ScheduleEnabled: true, PowerOnTime: "09:00", PowerOffTime: "17:00"}, + now: time.Date(2026, 3, 26, 8, 0, 0, 0, time.UTC), // 08:00 + wantDesired: "off", wantControl: true, + }, + { + name: "kein Zeitplan, kein Override → keine Steuerung", + sc: store.ScreenSchedule{}, + now: thu, + wantDesired: "", wantControl: false, + }, + { + name: "Zeitplan deaktiviert → keine Steuerung", + sc: store.ScreenSchedule{ScheduleEnabled: false, PowerOnTime: "09:00", PowerOffTime: "17:00"}, + now: thu, + wantDesired: "", wantControl: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotDesired, gotControl := resolveDesiredState(tt.sc, tt.override, tt.now) + if gotDesired != tt.wantDesired || gotControl != tt.wantControl { + t.Errorf("resolveDesiredState() = (%q, %v), want (%q, %v)", + gotDesired, gotControl, tt.wantDesired, tt.wantControl) + } + }) + } +} From 81711f2f3dc25f1196737e720004f1dda801fd48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesko=20Ansch=C3=BCtz?= Date: Fri, 27 Mar 2026 20:14:32 +0100 Subject: [PATCH 05/12] feat(scheduler): Reconciler iteriert alle Screens + resolveDesiredState Co-Authored-By: Claude Sonnet 4.6 --- .../backend/internal/scheduler/scheduler.go | 48 +++++++++++-------- 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/server/backend/internal/scheduler/scheduler.go b/server/backend/internal/scheduler/scheduler.go index dcf6efe..0de331f 100644 --- a/server/backend/internal/scheduler/scheduler.go +++ b/server/backend/internal/scheduler/scheduler.go @@ -25,6 +25,11 @@ type DisplayStateGetter interface { GetDisplayState(ctx context.Context, screenID string) (string, error) } +// AllScreensLister lädt alle bekannten Screens. +type AllScreensLister interface { + ListAll(ctx context.Context) ([]*store.Screen, error) +} + // Run startet den Scheduler-Loop. Blockiert bis ctx abgebrochen wird. func Run(ctx context.Context, schedules *store.ScreenScheduleStore, screens ScreenSlugGetter, notifier DisplayCommander) { ticker := time.NewTicker(1 * time.Minute) @@ -41,39 +46,50 @@ func Run(ctx context.Context, schedules *store.ScreenScheduleStore, screens Scre } // Reconcile läuft alle 5 Minuten und gleicht Ist- und Soll-Zustand ab. -func Reconcile(ctx context.Context, schedules *store.ScreenScheduleStore, screens ScreenSlugGetter, states DisplayStateGetter, notifier DisplayCommander) { +func Reconcile(ctx context.Context, schedules *store.ScreenScheduleStore, screens AllScreensLister, states DisplayStateGetter, globalOverrides *store.GlobalOverrideStore, notifier DisplayCommander) { ticker := time.NewTicker(5 * time.Minute) defer ticker.Stop() for { select { case <-ticker.C: - reconcile(ctx, schedules, screens, states, notifier) + reconcile(ctx, schedules, screens, states, globalOverrides, notifier) case <-ctx.Done(): return } } } -func reconcile(ctx context.Context, schedules *store.ScreenScheduleStore, screens ScreenSlugGetter, states DisplayStateGetter, notifier DisplayCommander) { - now := time.Now().Format("15:04") +func reconcile(ctx context.Context, schedules *store.ScreenScheduleStore, allScreens AllScreensLister, states DisplayStateGetter, globalOverrides *store.GlobalOverrideStore, notifier DisplayCommander) { + now := time.Now() - enabled, err := schedules.ListEnabled(ctx) + screenList, err := allScreens.ListAll(ctx) if err != nil { - slog.Error("reconciler: list enabled schedules failed", "err", err) + slog.Error("reconciler: list all screens failed", "err", err) return } - for _, sc := range enabled { - if sc.PowerOnTime == "" || sc.PowerOffTime == "" { + globalOverride, err := globalOverrides.Get(ctx) + if err != nil { + slog.Warn("reconciler: get global override failed", "err", err) + // nicht fatal — ohne Override fortfahren + } + + for _, screen := range screenList { + sc, err := schedules.Get(ctx, screen.ID) + if err != nil { + slog.Warn("reconciler: get schedule failed", "screen_id", screen.ID, "err", err) continue } - want := desiredState(sc.PowerOnTime, sc.PowerOffTime, now) + want, shouldControl := resolveDesiredState(*sc, globalOverride, now) + if !shouldControl { + continue + } - got, err := states.GetDisplayState(ctx, sc.ScreenID) + got, err := states.GetDisplayState(ctx, screen.ID) if err != nil { - slog.Warn("reconciler: get display state failed", "screen_id", sc.ScreenID, "err", err) + slog.Warn("reconciler: get display state failed", "screen_id", screen.ID, "err", err) continue } @@ -81,17 +97,11 @@ func reconcile(ctx context.Context, schedules *store.ScreenScheduleStore, screen continue } - screen, err := screens.GetByID(ctx, sc.ScreenID) - if err != nil { - slog.Warn("reconciler: screen not found", "screen_id", sc.ScreenID, "err", err) - continue - } - action := "display_" + want if err := notifier.SendDisplayCommand(screen.Slug, action); err != nil { - slog.Error("reconciler: send command failed", "screen_id", sc.ScreenID, "action", action, "err", err) + slog.Error("reconciler: send command failed", "screen_id", screen.ID, "action", action, "err", err) } else { - slog.Info("reconciler: corrected display state", "screen_id", sc.ScreenID, "slug", screen.Slug, "was", got, "want", want) + slog.Info("reconciler: corrected display state", "screen_id", screen.ID, "slug", screen.Slug, "was", got, "want", want) } } } From 0ca63a5367124cd99aa33af4a16e2c43c3a90bb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesko=20Ansch=C3=BCtz?= Date: Fri, 27 Mar 2026 20:15:21 +0100 Subject: [PATCH 06/12] =?UTF-8?q?feat(scheduler):=20check()=20unterdr?= =?UTF-8?q?=C3=BCckt=20display=5Fon=20bei=20Wochenende/Override?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/internal/scheduler/scheduler.go | 35 +++++++++++++++---- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/server/backend/internal/scheduler/scheduler.go b/server/backend/internal/scheduler/scheduler.go index 0de331f..02225ce 100644 --- a/server/backend/internal/scheduler/scheduler.go +++ b/server/backend/internal/scheduler/scheduler.go @@ -31,14 +31,14 @@ type AllScreensLister interface { } // Run startet den Scheduler-Loop. Blockiert bis ctx abgebrochen wird. -func Run(ctx context.Context, schedules *store.ScreenScheduleStore, screens ScreenSlugGetter, notifier DisplayCommander) { +func Run(ctx context.Context, schedules *store.ScreenScheduleStore, screens ScreenSlugGetter, globalOverrides *store.GlobalOverrideStore, notifier DisplayCommander) { ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() for { select { case <-ticker.C: - check(ctx, schedules, screens, notifier) + check(ctx, schedules, screens, globalOverrides, notifier) case <-ctx.Done(): return } @@ -153,9 +153,19 @@ func resolveDesiredState(sc store.ScreenSchedule, globalOverride *store.GlobalOv } // check prüft alle aktiven Zeitpläne und sendet ggf. Befehle. -func check(ctx context.Context, schedules *store.ScreenScheduleStore, screens ScreenSlugGetter, notifier DisplayCommander) { +func check(ctx context.Context, schedules *store.ScreenScheduleStore, screens ScreenSlugGetter, globalOverrides *store.GlobalOverrideStore, notifier DisplayCommander) { // Uses process-local timezone — ensure TZ env var is set in the container (e.g. Europe/Berlin). - now := time.Now().Format("15:04") + now := time.Now() + nowStr := now.Format("15:04") + + // Wochenende: keine Einschalte-Kommandos senden + isWeekend := now.Weekday() == time.Saturday || now.Weekday() == time.Sunday + + // Globaler Override "off" aktiv? + globalOverrideOff := false + if o, err := globalOverrides.Get(ctx); err == nil && o != nil && now.Before(o.Until) && o.Type == "off" { + globalOverrideOff = true + } enabled, err := schedules.ListEnabled(ctx) if err != nil { @@ -171,9 +181,9 @@ func check(ctx context.Context, schedules *store.ScreenScheduleStore, screens Sc } var action string - if sc.PowerOnTime != "" && sc.PowerOnTime == now { + if sc.PowerOnTime != "" && sc.PowerOnTime == nowStr { action = "display_on" - } else if sc.PowerOffTime != "" && sc.PowerOffTime == now { + } else if sc.PowerOffTime != "" && sc.PowerOffTime == nowStr { action = "display_off" } @@ -181,6 +191,19 @@ func check(ctx context.Context, schedules *store.ScreenScheduleStore, screens Sc continue } + // display_on unterdrücken wenn per-Screen-Override, Wochenende oder globaler Override "off" + if action == "display_on" { + if sc.OverrideOnUntil != nil && now.Before(*sc.OverrideOnUntil) { + // per-Screen Override aktiv → Kommando trotzdem senden (Override = on) + } else if isWeekend { + slog.Info("scheduler: display_on unterdrückt (Wochenende)", "screen_id", sc.ScreenID) + continue + } else if globalOverrideOff { + slog.Info("scheduler: display_on unterdrückt (globaler Override off)", "screen_id", sc.ScreenID) + continue + } + } + if err := notifier.SendDisplayCommand(screen.Slug, action); err != nil { slog.Error("scheduler: send command failed", "screen_id", sc.ScreenID, "action", action, "err", err) } else { From 42458e68ffd0a17a2ad9090ca153467ec7d50c17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesko=20Ansch=C3=BCtz?= Date: Fri, 27 Mar 2026 20:17:20 +0100 Subject: [PATCH 07/12] =?UTF-8?q?feat(manage):=20Handler=20f=C3=BCr=20glob?= =?UTF-8?q?alen=20+=20per-Screen-Override?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../internal/httpapi/manage/override.go | 134 ++++++++++++++++++ .../internal/httpapi/manage/override_test.go | 98 +++++++++++++ 2 files changed, 232 insertions(+) create mode 100644 server/backend/internal/httpapi/manage/override.go create mode 100644 server/backend/internal/httpapi/manage/override_test.go 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") + } +} From 9aabf18aa22f8a130ad06072495d71ef3d343641 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesko=20Ansch=C3=BCtz?= Date: Fri, 27 Mar 2026 20:19:03 +0100 Subject: [PATCH 08/12] feat(wiring): GlobalOverrideStore in Router, App und Scheduler-Goroutinen Co-Authored-By: Claude Sonnet 4.6 --- server/backend/internal/app/app.go | 20 ++++++++++++-------- server/backend/internal/httpapi/router.go | 17 +++++++++++++++-- 2 files changed, 27 insertions(+), 10 deletions(-) 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/httpapi/router.go b/server/backend/internal/httpapi/router.go index 753571e..4dac8c3 100644 --- a/server/backend/internal/httpapi/router.go +++ b/server/backend/internal/httpapi/router.go @@ -21,8 +21,9 @@ type RouterDeps struct { AuthStore *store.AuthStore Notifier *mqttnotifier.Notifier ScreenshotStore *ScreenshotStore - ScheduleStore *store.ScreenScheduleStore - Config config.Config + ScheduleStore *store.ScreenScheduleStore + GlobalOverrideStore *store.GlobalOverrideStore + Config config.Config UploadDir string Logger *log.Logger } @@ -192,6 +193,18 @@ func registerManageRoutes(mux *http.ServeMux, d RouterDeps) { mux.Handle("POST /api/v1/screens/{screenSlug}/schedule", authScreen(http.HandlerFunc(manage.HandleUpdateSchedule(d.ScreenStore, d.ScheduleStore)))) + // ── Globaler Override ──────────────────────────────────────────────── + mux.Handle("GET /api/v1/global-override", + authOnly(http.HandlerFunc(manage.HandleGetGlobalOverride(d.GlobalOverrideStore)))) + mux.Handle("POST /api/v1/global-override", + authOnly(http.HandlerFunc(manage.HandleSetGlobalOverride(d.GlobalOverrideStore, d.ScreenStore, notifier)))) + mux.Handle("DELETE /api/v1/global-override", + authOnly(http.HandlerFunc(manage.HandleDeleteGlobalOverride(d.GlobalOverrideStore)))) + + // ── Per-Screen Override ─────────────────────────────────────────────── + mux.Handle("POST /api/v1/screens/{screenSlug}/override", + authScreen(http.HandlerFunc(manage.HandleSetScreenOverride(d.ScreenStore, d.ScheduleStore)))) + // ── JSON API — screens ──────────────────────────────────────────────── // Self-registration: no auth (player calls this on startup). mux.HandleFunc("POST /api/v1/screens/register", From c263d97cca36186e5355c0fb4625215e27c9e7f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesko=20Ansch=C3=BCtz?= Date: Fri, 27 Mar 2026 20:21:06 +0100 Subject: [PATCH 09/12] =?UTF-8?q?feat(ui):=20=C3=9Cbersichtsseite=20?= =?UTF-8?q?=E2=80=93=20globaler=20Override-Banner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../internal/httpapi/manage/templates.go | 62 +++++++++++++++++++ server/backend/internal/httpapi/manage/ui.go | 25 ++++++-- server/backend/internal/httpapi/router.go | 2 +- 3 files changed, 82 insertions(+), 7 deletions(-) diff --git a/server/backend/internal/httpapi/manage/templates.go b/server/backend/internal/httpapi/manage/templates.go index 6a8beed..1038207 100644 --- a/server/backend/internal/httpapi/manage/templates.go +++ b/server/backend/internal/httpapi/manage/templates.go @@ -1392,6 +1392,29 @@ const screenOverviewTmpl = `

Meine Bildschirme

+ +
+ {{if .GlobalOverride}} +
+ + Alle Monitore {{if eq .GlobalOverride.Type "off"}}ausgeschaltet{{else}}eingeschaltet{{end}} + bis {{.GlobalOverride.Until.Format "02.01.2006 15:04"}} + + +
+ {{else}} +
+ + + +
+ + {{end}} +
{{if gt (len .Cards) 1}}
Alle Displays: @@ -1513,6 +1536,45 @@ function bulkDisplay(state) { }).catch(function(){}); }); } + +var _globalOverrideType = ''; + +function showGlobalOverrideForm(type) { + _globalOverrideType = type; + var form = document.getElementById('global-override-form'); + if (form) form.style.display = 'flex'; +} + +function hideGlobalOverrideForm() { + var form = document.getElementById('global-override-form'); + if (form) form.style.display = 'none'; +} + +function setGlobalOverride() { + var val = document.getElementById('global-override-until').value; + if (!val) { + document.getElementById('override-result').textContent = 'Bitte Datum und Uhrzeit angeben'; + return; + } + var dt = new Date(val); + fetch('/api/v1/global-override', { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf(), 'X-Requested-With': 'fetch'}, + body: JSON.stringify({type: _globalOverrideType, until: dt.toISOString()}) + }).then(function(r) { + if (r.ok) { location.reload(); } + else { document.getElementById('override-result').textContent = 'Fehler beim Setzen'; } + }).catch(function() { document.getElementById('override-result').textContent = 'Netzwerkfehler'; }); +} + +function deleteGlobalOverride() { + fetch('/api/v1/global-override', { + method: 'DELETE', + headers: {'X-CSRF-Token': getCsrf(), 'X-Requested-With': 'fetch'} + }).then(function(r) { + if (r.ok) { location.reload(); } + }).catch(function(){}); +} ` diff --git a/server/backend/internal/httpapi/manage/ui.go b/server/backend/internal/httpapi/manage/ui.go index 5c94d40..e7dc136 100644 --- a/server/backend/internal/httpapi/manage/ui.go +++ b/server/backend/internal/httpapi/manage/ui.go @@ -281,12 +281,13 @@ func HandleRemoveUserFromScreen(screens *store.ScreenStore) http.HandlerFunc { } type screenCard struct { - Screen *store.Screen - DisplayState string + Screen *store.Screen + DisplayState string + OverrideOnUntil *time.Time } // HandleScreenOverview renders a card-based overview of all accessible screens for a screen_user. -func HandleScreenOverview(screens *store.ScreenStore, notifier *mqttnotifier.Notifier, cfg config.Config) http.HandlerFunc { +func HandleScreenOverview(screens *store.ScreenStore, schedules *store.ScreenScheduleStore, overrides *store.GlobalOverrideStore, notifier *mqttnotifier.Notifier, cfg config.Config) http.HandlerFunc { t := template.Must(template.New("screenOverview").Funcs(tmplFuncs).Parse(screenOverviewTmpl)) return func(w http.ResponseWriter, r *http.Request) { u := reqcontext.UserFromContext(r.Context()) @@ -310,11 +311,23 @@ func HandleScreenOverview(screens *store.ScreenStore, notifier *mqttnotifier.Not cards := make([]screenCard, 0, len(accessible)) for _, sc := range accessible { ds, _ := screens.GetDisplayState(r.Context(), sc.ID) - cards = append(cards, screenCard{Screen: sc, DisplayState: ds}) + sched, _ := schedules.Get(r.Context(), sc.ID) + var overrideOnUntil *time.Time + if sched != nil && sched.OverrideOnUntil != nil && time.Now().Before(*sched.OverrideOnUntil) { + overrideOnUntil = sched.OverrideOnUntil + } + cards = append(cards, screenCard{Screen: sc, DisplayState: ds, OverrideOnUntil: overrideOnUntil}) } + + var activeOverride *store.GlobalOverride + if o, err := overrides.Get(r.Context()); err == nil && o != nil && time.Now().Before(o.Until) { + activeOverride = o + } + renderTemplate(w, t, map[string]any{ - "Cards": cards, - "CSRFToken": csrfToken, + "Cards": cards, + "CSRFToken": csrfToken, + "GlobalOverride": activeOverride, }) } } diff --git a/server/backend/internal/httpapi/router.go b/server/backend/internal/httpapi/router.go index 4dac8c3..f4e1068 100644 --- a/server/backend/internal/httpapi/router.go +++ b/server/backend/internal/httpapi/router.go @@ -165,7 +165,7 @@ func registerManageRoutes(mux *http.ServeMux, d RouterDeps) { // ── Playlist management UI ──────────────────────────────────────────── // authScreen enforces that screen_user only accesses their permitted screens. mux.Handle("GET /manage", - authOnly(http.HandlerFunc(manage.HandleScreenOverview(d.ScreenStore, notifier, d.Config)))) + authOnly(http.HandlerFunc(manage.HandleScreenOverview(d.ScreenStore, d.ScheduleStore, d.GlobalOverrideStore, notifier, d.Config)))) mux.Handle("GET /manage/{screenSlug}", authScreen(http.HandlerFunc(manage.HandleManageUI(d.TenantStore, d.ScreenStore, d.ScheduleStore, d.MediaStore, d.PlaylistStore, d.Config, notifier)))) mux.Handle("POST /manage/{screenSlug}/upload", From fc94f56162faf5df15a661a23e545386a2241cbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesko=20Ansch=C3=BCtz?= Date: Fri, 27 Mar 2026 20:24:21 +0100 Subject: [PATCH 10/12] =?UTF-8?q?feat(ui):=20per-Screen-Override=20in=20?= =?UTF-8?q?=C3=9Cbersichtskarte=20und=20Detailseite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../internal/httpapi/manage/templates.go | 89 +++++++++++++++++++ server/backend/internal/httpapi/manage/ui.go | 3 + 2 files changed, 92 insertions(+) diff --git a/server/backend/internal/httpapi/manage/templates.go b/server/backend/internal/httpapi/manage/templates.go index 1038207..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

+ +
+

Einschalten bis (Override)

+ {{if not_expired .Schedule.OverrideOnUntil}} +

+ ⏰ Aktiv bis {{.Schedule.OverrideOnUntil.Format "02.01.2006 15:04"}} +

+ + {{else}} +

+ Überschreibt Zeitplan und Wochenend-Sperre — Monitor bleibt bis zum angegebenen Zeitpunkt eingeschaltet. +

+
+ + +
+ {{end}} +

✓ Gespeichert

+
@@ -1189,6 +1210,33 @@ function saveSchedule() { }).catch(function() { showToast('Netzwerkfehler', 'is-danger'); }); } +function setScreenOverridePage() { + var val = document.getElementById('screen-override-until').value; + if (!val) { showToast('Bitte Datum und Uhrzeit angeben', 'is-warning'); return; } + var dt = new Date(val); + fetch('/api/v1/screens/' + SCREEN_SLUG + '/override', { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf(), 'X-Requested-With': 'fetch'}, + body: JSON.stringify({on_until: dt.toISOString()}) + }).then(function(r) { + if (r.ok) { + var ok = document.getElementById('screen-override-ok'); + if (ok) { ok.classList.add('show'); setTimeout(function() { ok.classList.remove('show'); }, 2000); } + } else { showToast('Fehler beim Setzen des Overrides', 'is-danger'); } + }).catch(function() { showToast('Netzwerkfehler', 'is-danger'); }); +} + +function clearScreenOverridePage() { + fetch('/api/v1/screens/' + SCREEN_SLUG + '/override', { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf(), 'X-Requested-With': 'fetch'}, + body: JSON.stringify({on_until: null}) + }).then(function(r) { + if (r.ok) { location.reload(); } + else { showToast('Fehler beim Aufheben des Overrides', 'is-danger'); } + }).catch(function() { showToast('Netzwerkfehler', 'is-danger'); }); +} + // ─── ?msg= toast ───────────────────────────────────────────────── (function() { var msg = new URLSearchParams(window.location.search).get('msg'); @@ -1447,6 +1495,24 @@ const screenOverviewTmpl = `
+ +
+ {{if .OverrideOnUntil}} + ⏰ Ein bis {{.OverrideOnUntil.Format "02.01. 15:04"}} + + {{else}} +
+ Einschalten bis… +
+ + +
+
+ {{end}} +
@@ -1575,6 +1641,29 @@ function deleteGlobalOverride() { if (r.ok) { location.reload(); } }).catch(function(){}); } + +function setScreenOverride(slug) { + var val = document.getElementById('override-until-' + slug).value; + if (!val) return; + var dt = new Date(val); + fetch('/api/v1/screens/' + slug + '/override', { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf(), 'X-Requested-With': 'fetch'}, + body: JSON.stringify({on_until: dt.toISOString()}) + }).then(function(r) { + if (r.ok) { location.reload(); } + }).catch(function(){}); +} + +function clearScreenOverride(slug) { + fetch('/api/v1/screens/' + slug + '/override', { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf(), 'X-Requested-With': 'fetch'}, + body: JSON.stringify({on_until: null}) + }).then(function(r) { + if (r.ok) { location.reload(); } + }).catch(function(){}); +} ` diff --git a/server/backend/internal/httpapi/manage/ui.go b/server/backend/internal/httpapi/manage/ui.go index e7dc136..fb7a443 100644 --- a/server/backend/internal/httpapi/manage/ui.go +++ b/server/backend/internal/httpapi/manage/ui.go @@ -137,6 +137,9 @@ var tmplFuncs = template.FuncMap{ } return t.Format("02.01.2006 15:04") }, + "not_expired": func(t *time.Time) bool { + return t != nil && time.Now().Before(*t) + }, } // HandleAdminUI renders the admin overview page (screens + users tabs). From db68c84d45cb683485f3f64efcdafa91a42a4d42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesko=20Ansch=C3=BCtz?= Date: Fri, 27 Mar 2026 20:25:50 +0100 Subject: [PATCH 11/12] =?UTF-8?q?docs:=20API-ENDPOINTS=20+=20SCHEMA=20f?= =?UTF-8?q?=C3=BCr=20Override=20und=20Wochenend-Sperre?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- docs/API-ENDPOINTS.md | 87 +++++++++++++++++++++++++++++++++++++++++++ docs/SCHEMA.md | 30 +++++++++++++++ 2 files changed, 117 insertions(+) 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: From 2bf82eed5379c49a79420160002afd2c6715459e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesko=20Ansch=C3=BCtz?= Date: Fri, 27 Mar 2026 20:30:52 +0100 Subject: [PATCH 12/12] =?UTF-8?q?fix:=20Upsert=20l=C3=B6scht=20override=5F?= =?UTF-8?q?on=5Funtil=20nicht=20mehr;=20README=20+=20Auth-Kommentar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ScreenScheduleStore.Upsert: override_on_until aus INSERT und ON CONFLICT entfernt — verhindert stillen Datenverlust beim Speichern eines Zeitplans. SetOverrideOnUntil bleibt alleinig zuständig für diese Spalte. - README.md: GlobalOverrideStore, vier neue API-Routen, Wochenend-Sperre und Migration 006_override.sql dokumentiert. - override.go: Auth-Scope-Kommentar über HandleSetGlobalOverride ergänzt. Co-Authored-By: Claude Sonnet 4.6 --- server/backend/README.md | 23 +++++++++++++++++-- .../internal/httpapi/manage/override.go | 4 ++++ server/backend/internal/store/store.go | 15 ++++++------ 3 files changed, 33 insertions(+), 9 deletions(-) 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/httpapi/manage/override.go b/server/backend/internal/httpapi/manage/override.go index 466e673..067e5b0 100644 --- a/server/backend/internal/httpapi/manage/override.go +++ b/server/backend/internal/httpapi/manage/override.go @@ -38,6 +38,10 @@ func HandleGetGlobalOverride(overrides globalOverrideStore) http.HandlerFunc { } // 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 { diff --git a/server/backend/internal/store/store.go b/server/backend/internal/store/store.go index 9f98f30..2595920 100644 --- a/server/backend/internal/store/store.go +++ b/server/backend/internal/store/store.go @@ -667,16 +667,17 @@ func (s *ScreenScheduleStore) Get(ctx context.Context, screenID string) (*Screen } // Upsert speichert oder aktualisiert den Zeitplan eines Screens. +// Hinweis: override_on_until wird hier bewusst nicht angefasst – das ist +// ausschließlich Aufgabe von SetOverrideOnUntil (saubere Trennung, kein Datenverlust). func (s *ScreenScheduleStore) Upsert(ctx context.Context, sc *ScreenSchedule) error { _, err := s.pool.Exec(ctx, - `insert into screen_schedules (screen_id, schedule_enabled, power_on_time, power_off_time, override_on_until) - values ($1, $2, $3, $4, $5) + `insert into screen_schedules (screen_id, schedule_enabled, power_on_time, power_off_time) + values ($1, $2, $3, $4) on conflict (screen_id) do update - set schedule_enabled = excluded.schedule_enabled, - power_on_time = excluded.power_on_time, - power_off_time = excluded.power_off_time, - override_on_until = excluded.override_on_until`, - sc.ScreenID, sc.ScheduleEnabled, sc.PowerOnTime, sc.PowerOffTime, sc.OverrideOnUntil) + set schedule_enabled = excluded.schedule_enabled, + power_on_time = excluded.power_on_time, + power_off_time = excluded.power_off_time`, + sc.ScreenID, sc.ScheduleEnabled, sc.PowerOnTime, sc.PowerOffTime) return err }