From 831666012a8e15a75ede4ad0eec213ab7259b2ff Mon Sep 17 00:00:00 2001 From: vegard Date: Wed, 18 Mar 2026 03:21:18 +0000 Subject: [PATCH] =?UTF-8?q?Fullf=C3=B8rer=20oppgave=2015.1:=20Systemvarsle?= =?UTF-8?q?r=20via=20STDB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementerer systemvarsler (system_announcement-noder) som vises som banner/toast for alle aktive klienter i sanntid via SpacetimeDB. Backend (maskinrommet): - POST /intentions/create_announcement: oppretter varslingsnode med validering av announcement_type (info/warning/critical), datoer (scheduled_at, expires_at) og blocks_new_sessions. Visibility settes automatisk til 'open' for broadcast til alle klienter. - POST /intentions/expire_announcement: fjerner varslingsnode med tilgangskontroll (kun eier kan slette). Frontend: - SystemAnnouncements.svelte: filtrerer nodeStore for aktive system_announcement-noder, skjuler utløpte (expires_at), viser nedtelling for planlagte hendelser (scheduled_at). Fargekoding: rød (critical), gul (warning), blå (info). Critical kan ikke dismisses. Oppdaterer nedtelling hvert sekund. - Komponent lagt til i +layout.svelte for global synlighet. - API-funksjoner (createAnnouncement, expireAnnouncement) i api.ts. Ref: docs/concepts/adminpanelet.md § "Systemvarsler og vedlikeholdsmodus" Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/lib/api.ts | 41 ++++ .../lib/components/SystemAnnouncements.svelte | 172 ++++++++++++++++ frontend/src/routes/+layout.svelte | 2 + maskinrommet/src/intentions.rs | 191 ++++++++++++++++++ maskinrommet/src/main.rs | 3 + tasks.md | 3 +- 6 files changed, 410 insertions(+), 2 deletions(-) create mode 100644 frontend/src/lib/components/SystemAnnouncements.svelte diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 1a23373..9a585c8 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -612,6 +612,47 @@ export function deleteNode( return post(accessToken, '/intentions/delete_node', { node_id: nodeId }); } +// ============================================================================= +// Systemvarsler (oppgave 15.1) +// ============================================================================= + +export interface CreateAnnouncementRequest { + title: string; + content: string; + announcement_type: 'info' | 'warning' | 'critical'; + scheduled_at?: string; + expires_at?: string; + blocks_new_sessions?: boolean; +} + +export interface CreateAnnouncementResponse { + node_id: string; +} + +/** Opprett et systemvarsel (vises for alle klienter umiddelbart via STDB). */ +export function createAnnouncement( + accessToken: string, + data: CreateAnnouncementRequest +): Promise { + return post(accessToken, '/intentions/create_announcement', data); +} + +export interface ExpireAnnouncementRequest { + node_id: string; +} + +export interface ExpireAnnouncementResponse { + expired: boolean; +} + +/** Fjern/utløp et systemvarsel. */ +export function expireAnnouncement( + accessToken: string, + data: ExpireAnnouncementRequest +): Promise { + return post(accessToken, '/intentions/expire_announcement', data); +} + /** Anvend brukerens per-segment-valg etter re-transkripsjon. */ export function resolveRetranscription( accessToken: string, diff --git a/frontend/src/lib/components/SystemAnnouncements.svelte b/frontend/src/lib/components/SystemAnnouncements.svelte new file mode 100644 index 0000000..4289341 --- /dev/null +++ b/frontend/src/lib/components/SystemAnnouncements.svelte @@ -0,0 +1,172 @@ + + +{#if announcements.length > 0} +
+ {#each announcements as { node, meta } (node.id)} + {#if !dismissed.has(node.id)} + {@const isCritical = meta.announcement_type === 'critical'} + {@const isWarning = meta.announcement_type === 'warning'} + + {/if} + {/each} +
+{/if} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index c13a297..694b6ae 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -3,6 +3,7 @@ import { page } from '$app/stores'; import { stdb } from '$lib/spacetime'; import { browser } from '$app/environment'; + import SystemAnnouncements from '$lib/components/SystemAnnouncements.svelte'; let { children } = $props(); @@ -17,4 +18,5 @@ }); + {@render children()} diff --git a/maskinrommet/src/intentions.rs b/maskinrommet/src/intentions.rs index 09a8f22..32abbe3 100644 --- a/maskinrommet/src/intentions.rs +++ b/maskinrommet/src/intentions.rs @@ -3716,6 +3716,197 @@ pub async fn audio_info( Ok(Json(info)) } +// ============================================================================= +// Systemvarsler (oppgave 15.1) +// ============================================================================= + +/// Gyldige varslingstyper for system_announcement-noder. +const VALID_ANNOUNCEMENT_TYPES: &[&str] = &["info", "warning", "critical"]; + +#[derive(Deserialize)] +pub struct CreateAnnouncementRequest { + /// Tittel på varselet. + pub title: String, + /// Innhold/meldingstekst. + pub content: String, + /// Type varsel: info, warning, critical. + pub announcement_type: String, + /// Tidspunkt varselet gjelder (f.eks. vedlikeholdstidspunkt). Valgfritt. + pub scheduled_at: Option, + /// Når varselet automatisk utløper (ISO 8601). Valgfritt. + pub expires_at: Option, + /// Om nye sesjoner skal blokkeres (vedlikeholdsmodus). Default: false. + pub blocks_new_sessions: Option, +} + +#[derive(Serialize)] +pub struct CreateAnnouncementResponse { + pub node_id: Uuid, +} + +/// POST /intentions/create_announcement +/// +/// Oppretter en systemvarslingsnode med `visibility: open` slik at alle +/// aktive klienter ser varselet via STDB umiddelbart. +/// +/// Kun autentiserte brukere kan opprette varsler (MVP — full admin-sjekk +/// legges til når admin-rollesystemet er på plass). +/// +/// Ref: docs/concepts/adminpanelet.md § "Systemvarsler og vedlikeholdsmodus" +pub async fn create_announcement( + State(state): State, + user: AuthUser, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + // -- Valider announcement_type -- + if !VALID_ANNOUNCEMENT_TYPES.contains(&req.announcement_type.as_str()) { + return Err(bad_request(&format!( + "Ugyldig announcement_type: '{}'. Gyldige verdier: {:?}", + req.announcement_type, VALID_ANNOUNCEMENT_TYPES + ))); + } + + if req.title.trim().is_empty() { + return Err(bad_request("title kan ikke være tom")); + } + + // -- Valider datoer hvis satt -- + if let Some(ref s) = req.scheduled_at { + chrono::DateTime::parse_from_rfc3339(s) + .map_err(|_| bad_request("scheduled_at må være gyldig ISO 8601 (RFC 3339)"))?; + } + if let Some(ref s) = req.expires_at { + chrono::DateTime::parse_from_rfc3339(s) + .map_err(|_| bad_request("expires_at må være gyldig ISO 8601 (RFC 3339)"))?; + } + + // -- Bygg metadata -- + let metadata = serde_json::json!({ + "announcement_type": req.announcement_type, + "scheduled_at": req.scheduled_at, + "expires_at": req.expires_at, + "blocks_new_sessions": req.blocks_new_sessions.unwrap_or(false), + }); + + let node_id = Uuid::now_v7(); + let node_id_str = node_id.to_string(); + let created_by_str = user.node_id.to_string(); + let metadata_str = metadata.to_string(); + + // -- Skriv til SpacetimeDB (instant broadcast til alle klienter) -- + state + .stdb + .create_node( + &node_id_str, + "system_announcement", + &req.title, + &req.content, + "open", + &metadata_str, + &created_by_str, + ) + .await + .map_err(|e| stdb_error("create_node (announcement)", e))?; + + tracing::info!( + node_id = %node_id, + announcement_type = %req.announcement_type, + created_by = %user.node_id, + "Systemvarsel opprettet i STDB" + ); + + // -- Persister til PostgreSQL asynkront -- + spawn_pg_insert_node( + state.db.clone(), + node_id, + "system_announcement".to_string(), + req.title, + req.content, + "open".to_string(), + metadata, + user.node_id, + ); + + Ok(Json(CreateAnnouncementResponse { node_id })) +} + +#[derive(Deserialize)] +pub struct ExpireAnnouncementRequest { + /// ID-en til varslingsnoden som skal utløpe/fjernes. + pub node_id: Uuid, +} + +#[derive(Serialize)] +pub struct ExpireAnnouncementResponse { + pub expired: bool, +} + +/// POST /intentions/expire_announcement +/// +/// Fjerner (sletter) en systemvarslingsnode. Sletter fra STDB først +/// (umiddelbar fjerning fra alle klienter), deretter fra PG. +/// +/// Kun eier (created_by) eller admin kan fjerne varsler. +pub async fn expire_announcement( + State(state): State, + user: AuthUser, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + // -- Sjekk at noden eksisterer og er en system_announcement -- + let node = sqlx::query_as::<_, NodeRow>( + "SELECT node_kind, title, content, visibility, metadata FROM nodes WHERE id = $1", + ) + .bind(req.node_id) + .fetch_optional(&state.db) + .await + .map_err(|e| { + tracing::error!("PG-feil ved oppslag: {e}"); + internal_error("Databasefeil") + })? + .ok_or_else(|| bad_request("Noden finnes ikke"))?; + + if node.node_kind != "system_announcement" { + return Err(bad_request("Noden er ikke en systemvarslingsnode")); + } + + // -- Tilgangskontroll -- + let can_modify = user_can_modify_node(&state.db, user.node_id, req.node_id) + .await + .map_err(|e| { + tracing::error!("PG-feil ved tilgangssjekk: {e}"); + internal_error("Databasefeil ved tilgangssjekk") + })?; + + if !can_modify { + return Err(forbidden("Kun eier kan fjerne systemvarsler")); + } + + // -- Slett fra STDB (umiddelbar fjerning fra alle klienter) -- + let node_id_str = req.node_id.to_string(); + state + .stdb + .delete_node(&node_id_str) + .await + .map_err(|e| stdb_error("delete_node (announcement)", e))?; + + // -- Slett fra PG -- + let db = state.db.clone(); + let nid = req.node_id; + tokio::spawn(async move { + if let Err(e) = sqlx::query("DELETE FROM nodes WHERE id = $1") + .bind(nid) + .execute(&db) + .await + { + tracing::error!(node_id = %nid, error = %e, "Kunne ikke slette varsel fra PG"); + } else { + tracing::info!(node_id = %nid, "Systemvarsel slettet fra PG"); + } + }); + + Ok(Json(ExpireAnnouncementResponse { expired: true })) +} + // ============================================================================= // Tester // ============================================================================= diff --git a/maskinrommet/src/main.rs b/maskinrommet/src/main.rs index 47c5fac..995f646 100644 --- a/maskinrommet/src/main.rs +++ b/maskinrommet/src/main.rs @@ -187,6 +187,9 @@ async fn main() { .route("/intentions/audio_analyze", post(intentions::audio_analyze)) .route("/intentions/audio_process", post(intentions::audio_process)) .route("/intentions/ab_override", post(publishing::ab_override)) + // Systemvarsler (oppgave 15.1) + .route("/intentions/create_announcement", post(intentions::create_announcement)) + .route("/intentions/expire_announcement", post(intentions::expire_announcement)) .route("/query/audio_info", get(intentions::audio_info)) .route("/pub/{slug}/feed.xml", get(rss::generate_feed)) .route("/pub/{slug}", get(publishing::serve_index)) diff --git a/tasks.md b/tasks.md index c1e58b1..bdcea44 100644 --- a/tasks.md +++ b/tasks.md @@ -163,8 +163,7 @@ Uavhengige faser kan fortsatt plukkes. ## Fase 15: Adminpanel -- [~] 15.1 Systemvarsler: varslingsnode (`node_kind='system_announcement'`) med type (info/warning/critical), nedtelling og utløp. Frontend viser banner/toast for alle aktive klienter via STDB. Ref: `docs/concepts/adminpanelet.md`. - > Påbegynt: 2026-03-18T03:15 +- [x] 15.1 Systemvarsler: varslingsnode (`node_kind='system_announcement'`) med type (info/warning/critical), nedtelling og utløp. Frontend viser banner/toast for alle aktive klienter via STDB. Ref: `docs/concepts/adminpanelet.md`. - [ ] 15.2 Graceful shutdown: admin setter vedlikeholdstidspunkt → nedtelling i frontend → nye LiveKit-rom blokkeres → jobbkø stopper → vent på aktive jobber → restart. Vis aktive sesjoner før bekreftelse. - [ ] 15.3 Jobbkø-oversikt: admin-UI for aktive, ventende og feilede jobber. Filtrer på type/samling/status. Manuell retry og avbryt. - [ ] 15.4 AI Gateway-konfigurasjon: admin-UI for modelloversikt, API-nøkler (kryptert), ruting-regler per jobbtype, fallback-kjeder, forbruksoversikt per samling. Ref: `docs/infra/ai_gateway.md`.