// Package store contains all database repositories. package store import ( "context" "errors" "fmt" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) // ErrReorderMismatch wird von Reorder zurückgegeben, wenn die übergebene // ID-Liste nicht mit den tatsächlichen Items der Playlist übereinstimmt. var ErrReorderMismatch = errors.New("reorder: item list does not match playlist") // ------------------------------------------------------------------ // Domain types // ------------------------------------------------------------------ type Tenant struct { ID string `json:"id"` Slug string `json:"slug"` Name string `json:"name"` CreatedAt time.Time `json:"created_at"` } type Screen struct { ID string `json:"id"` TenantID string `json:"tenant_id"` Slug string `json:"slug"` Name string `json:"name"` Orientation string `json:"orientation"` CreatedAt time.Time `json:"created_at"` } type ScreenStatus struct { ScreenID string `json:"screen_id"` DisplayState string `json:"display_state"` ReportedAt time.Time `json:"reported_at"` } 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"` OverrideOnUntil *time.Time `json:"override_on_until,omitempty"` } type MediaAsset struct { ID string `json:"id"` TenantID string `json:"tenant_id"` Title string `json:"title"` Type string `json:"type"` // image | video | pdf | web StoragePath string `json:"storage_path,omitempty"` OriginalURL string `json:"original_url,omitempty"` MimeType string `json:"mime_type,omitempty"` SizeBytes int64 `json:"size_bytes,omitempty"` Enabled bool `json:"enabled"` CreatedAt time.Time `json:"created_at"` } type Playlist struct { ID string `json:"id"` TenantID string `json:"tenant_id"` ScreenID string `json:"screen_id"` Name string `json:"name"` IsActive bool `json:"is_active"` DefaultDurationSeconds int `json:"default_duration_seconds"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // ScreenUserEntry is a lightweight view used when listing users assigned to a screen. type ScreenUserEntry struct { ID string `json:"id"` Username string `json:"username"` CreatedAt time.Time `json:"created_at"` } type PlaylistItem struct { ID string `json:"id"` PlaylistID string `json:"playlist_id"` MediaAssetID string `json:"media_asset_id,omitempty"` OrderIndex int `json:"order_index"` Type string `json:"type"` // image | video | pdf | web Src string `json:"src"` Title string `json:"title,omitempty"` DurationSeconds int `json:"duration_seconds"` ValidFrom *time.Time `json:"valid_from,omitempty"` ValidUntil *time.Time `json:"valid_until,omitempty"` Enabled bool `json:"enabled"` CreatedAt time.Time `json:"created_at"` } // ------------------------------------------------------------------ // Stores // ------------------------------------------------------------------ type TenantStore struct{ pool *pgxpool.Pool } type ScreenStore struct{ pool *pgxpool.Pool } type MediaStore struct{ pool *pgxpool.Pool } type PlaylistStore struct{ pool *pgxpool.Pool } type ScreenScheduleStore struct{ pool *pgxpool.Pool } func NewTenantStore(pool *pgxpool.Pool) *TenantStore { return &TenantStore{pool} } func NewScreenStore(pool *pgxpool.Pool) *ScreenStore { return &ScreenStore{pool} } func NewMediaStore(pool *pgxpool.Pool) *MediaStore { return &MediaStore{pool} } func NewPlaylistStore(pool *pgxpool.Pool) *PlaylistStore { return &PlaylistStore{pool} } func NewScreenScheduleStore(pool *pgxpool.Pool) *ScreenScheduleStore { return &ScreenScheduleStore{pool} } // ------------------------------------------------------------------ // TenantStore // ------------------------------------------------------------------ func (s *TenantStore) Get(ctx context.Context, slug string) (*Tenant, error) { row := s.pool.QueryRow(ctx, `select id, slug, name, created_at from tenants where slug=$1`, slug) return scanTenant(row) } func (s *TenantStore) List(ctx context.Context) ([]*Tenant, error) { rows, err := s.pool.Query(ctx, `select id, slug, name, created_at from tenants order by name`) if err != nil { return nil, err } defer rows.Close() var out []*Tenant for rows.Next() { t, err := scanTenant(rows) if err != nil { return nil, err } out = append(out, t) } return out, rows.Err() } func scanTenant(row interface { Scan(dest ...any) error }) (*Tenant, error) { var t Tenant err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.CreatedAt) if err != nil { return nil, fmt.Errorf("scan tenant: %w", err) } return &t, nil } // ------------------------------------------------------------------ // ScreenStore // ------------------------------------------------------------------ func (s *ScreenStore) List(ctx context.Context, tenantID string) ([]*Screen, error) { rows, err := s.pool.Query(ctx, `select id, tenant_id, slug, name, orientation, created_at from screens where tenant_id=$1 order by name`, tenantID) if err != nil { return nil, err } defer rows.Close() var out []*Screen for rows.Next() { sc, err := scanScreen(rows) if err != nil { return nil, err } out = append(out, sc) } return out, rows.Err() } func (s *ScreenStore) ListAll(ctx context.Context) ([]*Screen, error) { rows, err := s.pool.Query(ctx, `select id, tenant_id, slug, name, orientation, created_at from screens order by name`) if err != nil { return nil, err } defer rows.Close() var out []*Screen for rows.Next() { sc, err := scanScreen(rows) if err != nil { return nil, err } out = append(out, sc) } return out, rows.Err() } func (s *ScreenStore) GetBySlug(ctx context.Context, slug string) (*Screen, error) { row := s.pool.QueryRow(ctx, `select id, tenant_id, slug, name, orientation, created_at from screens where slug=$1`, slug) return scanScreen(row) } func (s *ScreenStore) GetByID(ctx context.Context, id string) (*Screen, error) { row := s.pool.QueryRow(ctx, `select id, tenant_id, slug, name, orientation, created_at from screens where id=$1`, id) return scanScreen(row) } func (s *ScreenStore) Create(ctx context.Context, tenantID, slug, name, orientation string) (*Screen, error) { row := s.pool.QueryRow(ctx, `insert into screens(tenant_id, slug, name, orientation) values($1,$2,$3,$4) returning id, tenant_id, slug, name, orientation, created_at`, tenantID, slug, name, orientation) return scanScreen(row) } // Upsert creates or updates a screen by slug (idempotent). // Used by agents on startup to self-register. func (s *ScreenStore) Upsert(ctx context.Context, tenantID, slug, name, orientation string) (*Screen, error) { row := s.pool.QueryRow(ctx, `insert into screens(tenant_id, slug, name, orientation) values($1,$2,$3,$4) on conflict (slug) do update set name = excluded.name, orientation = excluded.orientation returning id, tenant_id, slug, name, orientation, created_at`, tenantID, slug, name, orientation) return scanScreen(row) } func (s *ScreenStore) Update(ctx context.Context, id, name, orientation string) (*Screen, error) { row := s.pool.QueryRow(ctx, `update screens set name=$2, orientation=$3 where id=$1 returning id, tenant_id, slug, name, orientation, created_at`, id, name, orientation) return scanScreen(row) } func (s *ScreenStore) Delete(ctx context.Context, id string) error { _, err := s.pool.Exec(ctx, `delete from screens where id=$1`, id) return err } // GetAccessibleScreens returns all screens that userID has explicit access to // via user_screen_permissions. func (s *ScreenStore) GetAccessibleScreens(ctx context.Context, userID string) ([]*Screen, error) { rows, err := s.pool.Query(ctx, `select sc.id, sc.tenant_id, sc.slug, sc.name, sc.orientation, sc.created_at from screens sc join user_screen_permissions usp on usp.screen_id = sc.id where usp.user_id = $1 order by sc.name`, userID) if err != nil { return nil, fmt.Errorf("screens: get accessible: %w", err) } defer rows.Close() var out []*Screen for rows.Next() { sc, err := scanScreen(rows) if err != nil { return nil, err } out = append(out, sc) } return out, rows.Err() } // HasUserScreenAccess returns true when userID has an explicit permission entry // for screenID in user_screen_permissions. func (s *ScreenStore) HasUserScreenAccess(ctx context.Context, userID, screenID string) (bool, error) { var ok bool err := s.pool.QueryRow(ctx, `select exists( select 1 from user_screen_permissions where user_id = $1 and screen_id = $2 )`, userID, screenID).Scan(&ok) return ok, err } // AddUserToScreen creates a permission entry granting userID access to screenID. // Silently succeeds if the entry already exists (ON CONFLICT DO NOTHING). func (s *ScreenStore) AddUserToScreen(ctx context.Context, userID, screenID string) error { _, err := s.pool.Exec(ctx, `insert into user_screen_permissions(user_id, screen_id) values($1, $2) on conflict (user_id, screen_id) do nothing`, userID, screenID) if err != nil { return fmt.Errorf("screens: add user to screen: %w", err) } return nil } // RemoveUserFromScreen deletes the permission entry for userID / screenID. func (s *ScreenStore) RemoveUserFromScreen(ctx context.Context, userID, screenID string) error { _, err := s.pool.Exec(ctx, `delete from user_screen_permissions where user_id = $1 and screen_id = $2`, userID, screenID) if err != nil { return fmt.Errorf("screens: remove user from screen: %w", err) } return nil } // GetScreenUsers returns all users that have explicit access to screenID. func (s *ScreenStore) GetScreenUsers(ctx context.Context, screenID string) ([]*ScreenUserEntry, error) { rows, err := s.pool.Query(ctx, `select u.id, u.username, u.created_at from users u join user_screen_permissions usp on usp.user_id = u.id where usp.screen_id = $1 order by u.username`, screenID) if err != nil { return nil, fmt.Errorf("screens: get screen users: %w", err) } defer rows.Close() var out []*ScreenUserEntry for rows.Next() { var e ScreenUserEntry if err := rows.Scan(&e.ID, &e.Username, &e.CreatedAt); err != nil { return nil, fmt.Errorf("scan screen user entry: %w", err) } out = append(out, &e) } return out, rows.Err() } func scanScreen(row interface { Scan(dest ...any) error }) (*Screen, error) { var sc Screen err := row.Scan(&sc.ID, &sc.TenantID, &sc.Slug, &sc.Name, &sc.Orientation, &sc.CreatedAt) if err != nil { return nil, fmt.Errorf("scan screen: %w", err) } return &sc, nil } // UpsertDisplayState speichert den zuletzt gemeldeten Display-Zustand eines Screens. func (s *ScreenStore) UpsertDisplayState(ctx context.Context, screenID, displayState string) error { _, err := s.pool.Exec(ctx, `insert into screen_status (screen_id, display_state, reported_at) values ($1, $2, now()) on conflict (screen_id) do update set display_state = excluded.display_state, reported_at = excluded.reported_at`, screenID, displayState) return err } // GetDisplayState gibt den zuletzt gemeldeten Display-Zustand zurück. // Gibt "unknown" zurück wenn kein Eintrag vorhanden ist. func (s *ScreenStore) GetDisplayState(ctx context.Context, screenID string) (string, error) { var state string err := s.pool.QueryRow(ctx, `select coalesce(display_state,'unknown') from screen_status where screen_id = $1`, screenID).Scan(&state) if errors.Is(err, pgx.ErrNoRows) { return "unknown", nil } if err != nil { return "unknown", err } return state, nil } // ------------------------------------------------------------------ // MediaStore // ------------------------------------------------------------------ func (s *MediaStore) List(ctx context.Context, tenantID string) ([]*MediaAsset, error) { rows, err := s.pool.Query(ctx, `select id, tenant_id, title, type, coalesce(storage_path,''), coalesce(original_url,''), coalesce(mime_type,''), coalesce(size_bytes,0), enabled, created_at from media_assets where tenant_id=$1 order by created_at desc`, tenantID) if err != nil { return nil, err } defer rows.Close() var out []*MediaAsset for rows.Next() { m, err := scanMedia(rows) if err != nil { return nil, err } out = append(out, m) } return out, rows.Err() } func (s *MediaStore) Get(ctx context.Context, id string) (*MediaAsset, error) { row := s.pool.QueryRow(ctx, `select id, tenant_id, title, type, coalesce(storage_path,''), coalesce(original_url,''), coalesce(mime_type,''), coalesce(size_bytes,0), enabled, created_at from media_assets where id=$1`, id) return scanMedia(row) } func (s *MediaStore) Create(ctx context.Context, tenantID, title, assetType, storagePath, originalURL, mimeType string, sizeBytes int64) (*MediaAsset, error) { row := s.pool.QueryRow(ctx, `insert into media_assets(tenant_id, title, type, storage_path, original_url, mime_type, size_bytes) values($1,$2,$3,nullif($4,''),nullif($5,''),nullif($6,''),nullif($7,0)) returning id, tenant_id, title, type, coalesce(storage_path,''), coalesce(original_url,''), coalesce(mime_type,''), coalesce(size_bytes,0), enabled, created_at`, tenantID, title, assetType, storagePath, originalURL, mimeType, sizeBytes) return scanMedia(row) } func (s *MediaStore) Delete(ctx context.Context, id string) error { _, err := s.pool.Exec(ctx, `delete from media_assets where id=$1`, id) return err } func scanMedia(row interface { Scan(dest ...any) error }) (*MediaAsset, error) { var m MediaAsset err := row.Scan(&m.ID, &m.TenantID, &m.Title, &m.Type, &m.StoragePath, &m.OriginalURL, &m.MimeType, &m.SizeBytes, &m.Enabled, &m.CreatedAt) if err != nil { return nil, fmt.Errorf("scan media: %w", err) } return &m, nil } // ------------------------------------------------------------------ // PlaylistStore // ------------------------------------------------------------------ func (s *PlaylistStore) GetOrCreateForScreen(ctx context.Context, tenantID, screenID, screenName string) (*Playlist, error) { row := s.pool.QueryRow(ctx, `insert into playlists(tenant_id, screen_id, name) values($1,$2,$3) on conflict(screen_id) do update set updated_at=now() returning id, tenant_id, screen_id, name, is_active, default_duration_seconds, created_at, updated_at`, tenantID, screenID, screenName+" Playlist") return scanPlaylist(row) } func (s *PlaylistStore) Get(ctx context.Context, id string) (*Playlist, error) { row := s.pool.QueryRow(ctx, `select id, tenant_id, screen_id, name, is_active, default_duration_seconds, created_at, updated_at from playlists where id=$1`, id) return scanPlaylist(row) } func (s *PlaylistStore) GetByScreen(ctx context.Context, screenID string) (*Playlist, error) { row := s.pool.QueryRow(ctx, `select id, tenant_id, screen_id, name, is_active, default_duration_seconds, created_at, updated_at from playlists where screen_id=$1`, screenID) return scanPlaylist(row) } // GetByItemID returns the playlist that contains the given playlist item. // Used for tenant-isolation checks (K4). func (s *PlaylistStore) GetByItemID(ctx context.Context, itemID string) (*Playlist, error) { row := s.pool.QueryRow(ctx, `select pl.id, pl.tenant_id, pl.screen_id, pl.name, pl.is_active, pl.default_duration_seconds, pl.created_at, pl.updated_at from playlists pl join playlist_items pi on pi.playlist_id = pl.id where pi.id = $1`, itemID) return scanPlaylist(row) } func (s *PlaylistStore) UpdateDefaultDuration(ctx context.Context, id string, seconds int) error { _, err := s.pool.Exec(ctx, `update playlists set default_duration_seconds=$2, updated_at=now() where id=$1`, id, seconds) return err } func scanPlaylist(row interface { Scan(dest ...any) error }) (*Playlist, error) { var p Playlist err := row.Scan(&p.ID, &p.TenantID, &p.ScreenID, &p.Name, &p.IsActive, &p.DefaultDurationSeconds, &p.CreatedAt, &p.UpdatedAt) if err != nil { return nil, fmt.Errorf("scan playlist: %w", err) } return &p, nil } // ------------------------------------------------------------------ // PlaylistItemStore (part of PlaylistStore for simplicity) // ------------------------------------------------------------------ func (s *PlaylistStore) ListItems(ctx context.Context, playlistID string) ([]*PlaylistItem, error) { rows, err := s.pool.Query(ctx, `select id, playlist_id, coalesce(media_asset_id,''), order_index, type, src, coalesce(title,''), duration_seconds, valid_from, valid_until, enabled, created_at from playlist_items where playlist_id=$1 order by order_index`, playlistID) if err != nil { return nil, err } defer rows.Close() var out []*PlaylistItem for rows.Next() { item, err := scanPlaylistItem(rows) if err != nil { return nil, err } out = append(out, item) } return out, rows.Err() } func (s *PlaylistStore) ListActiveItems(ctx context.Context, playlistID string) ([]*PlaylistItem, error) { rows, err := s.pool.Query(ctx, `select id, playlist_id, coalesce(media_asset_id,''), order_index, type, src, coalesce(title,''), duration_seconds, valid_from, valid_until, enabled, created_at from playlist_items where playlist_id=$1 and enabled=true and (valid_from is null or valid_from <= now()) and (valid_until is null or valid_until > now()) order by order_index`, playlistID) if err != nil { return nil, err } defer rows.Close() var out []*PlaylistItem for rows.Next() { item, err := scanPlaylistItem(rows) if err != nil { return nil, err } out = append(out, item) } return out, rows.Err() } func (s *PlaylistStore) AddItem(ctx context.Context, playlistID, mediaAssetID, itemType, src, title string, durationSeconds int, validFrom, validUntil *time.Time) (*PlaylistItem, error) { var mediaID *string if mediaAssetID != "" { mediaID = &mediaAssetID } // W1: Atomare Subquery statt 2 separater Queries — verhindert Race Condition bei order_index. row := s.pool.QueryRow(ctx, `insert into playlist_items(playlist_id, media_asset_id, order_index, type, src, title, duration_seconds, valid_from, valid_until) values($1,$2, (select coalesce(max(order_index)+1, 0) from playlist_items where playlist_id=$1), $3,$4,$5,$6,$7,$8) returning id, playlist_id, coalesce(media_asset_id,''), order_index, type, src, coalesce(title,''), duration_seconds, valid_from, valid_until, enabled, created_at`, playlistID, mediaID, itemType, src, title, durationSeconds, validFrom, validUntil) return scanPlaylistItem(row) } func (s *PlaylistStore) UpdateItem(ctx context.Context, id, title string, durationSeconds int, enabled bool, validFrom, validUntil *time.Time) error { _, err := s.pool.Exec(ctx, `update playlist_items set title=$2, duration_seconds=$3, enabled=$4, valid_from=$5, valid_until=$6 where id=$1`, id, title, durationSeconds, enabled, validFrom, validUntil) return err } func (s *PlaylistStore) DeleteItem(ctx context.Context, id string) error { _, err := s.pool.Exec(ctx, `delete from playlist_items where id=$1`, id) return err } // ScreenSlugByPlaylistID returns the slug of the screen that owns playlistID. func (s *PlaylistStore) ScreenSlugByPlaylistID(ctx context.Context, playlistID string) (string, error) { var slug string err := s.pool.QueryRow(ctx, `select sc.slug from playlists pl join screens sc on sc.id = pl.screen_id where pl.id = $1`, playlistID).Scan(&slug) return slug, err } // ScreenSlugByItemID returns the slug of the screen that owns itemID. func (s *PlaylistStore) ScreenSlugByItemID(ctx context.Context, itemID string) (string, error) { var slug string err := s.pool.QueryRow(ctx, `select sc.slug from playlist_items pi join playlists pl on pl.id = pi.playlist_id join screens sc on sc.id = pl.screen_id where pi.id = $1`, itemID).Scan(&slug) return slug, err } // Reorder sets order_index for each item ID in the given slice order. // Returns ErrReorderMismatch if the number of provided IDs does not match // the number of items in the playlist, or if any ID does not belong to it. func (s *PlaylistStore) Reorder(ctx context.Context, playlistID string, itemIDs []string) error { seen := make(map[string]struct{}, len(itemIDs)) for _, id := range itemIDs { if _, dup := seen[id]; dup { return fmt.Errorf("%w: duplicate id %s", ErrReorderMismatch, id) } seen[id] = struct{}{} } tx, err := s.pool.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) //nolint:errcheck var count int if err := tx.QueryRow(ctx, `select count(*) from playlist_items where playlist_id=$1`, playlistID, ).Scan(&count); err != nil { return err } if count != len(itemIDs) { return fmt.Errorf("%w: got %d ids, playlist has %d items", ErrReorderMismatch, len(itemIDs), count) } for i, id := range itemIDs { tag, err := tx.Exec(ctx, `update playlist_items set order_index=$1 where id=$2 and playlist_id=$3`, i, id, playlistID, ) if err != nil { return err } if tag.RowsAffected() != 1 { return fmt.Errorf("%w: id %s not found in playlist %s", ErrReorderMismatch, id, playlistID) } } return tx.Commit(ctx) } func scanPlaylistItem(row interface { Scan(dest ...any) error }) (*PlaylistItem, error) { var it PlaylistItem err := row.Scan(&it.ID, &it.PlaylistID, &it.MediaAssetID, &it.OrderIndex, &it.Type, &it.Src, &it.Title, &it.DurationSeconds, &it.ValidFrom, &it.ValidUntil, &it.Enabled, &it.CreatedAt) if err != nil { return nil, fmt.Errorf("scan playlist_item: %w", err) } return &it, nil } // ------------------------------------------------------------------ // ScreenScheduleStore // ------------------------------------------------------------------ // Get lädt den Zeitplan eines Screens. Gibt einen leeren ScreenSchedule zurück wenn keiner vorhanden. 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, override_on_until from screen_schedules where screen_id = $1`, screenID). Scan(&sc.ScreenID, &sc.ScheduleEnabled, &sc.PowerOnTime, &sc.PowerOffTime, &sc.OverrideOnUntil) if errors.Is(err, pgx.ErrNoRows) { return &ScreenSchedule{ScreenID: screenID}, nil } if err != nil { return nil, err } return &sc, nil } // 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, 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, 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, override_on_until from screen_schedules where schedule_enabled = true and (power_on_time != '' or power_off_time != '')`) if err != nil { return nil, err } defer rows.Close() var out []*ScreenSchedule for rows.Next() { var sc ScreenSchedule if err := rows.Scan(&sc.ScreenID, &sc.ScheduleEnabled, &sc.PowerOnTime, &sc.PowerOffTime, &sc.OverrideOnUntil); err != nil { return nil, err } out = append(out, &sc) } 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 }