Fullfører oppgave 15.1: Systemvarsler via STDB

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) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-18 03:21:18 +00:00
parent 1c313f3857
commit 831666012a
6 changed files with 410 additions and 2 deletions

View file

@ -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<CreateAnnouncementResponse> {
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<ExpireAnnouncementResponse> {
return post(accessToken, '/intentions/expire_announcement', data);
}
/** Anvend brukerens per-segment-valg etter re-transkripsjon. */
export function resolveRetranscription(
accessToken: string,

View file

@ -0,0 +1,172 @@
<script lang="ts">
/**
* SystemAnnouncements — viser aktive systemvarsler som banner/toast.
*
* Filtrerer nodeStore for noder med node_kind='system_announcement',
* skjuler utløpte varsler (expires_at), og viser nedtelling for
* planlagte hendelser (scheduled_at).
*
* Varslingstyper:
* - info: blå banner (generell melding)
* - warning: gul banner (planlagt vedlikehold med nedtelling)
* - critical: rød banner (umiddelbar handling kreves)
*
* Ref: docs/concepts/adminpanelet.md § "Systemvarsler og vedlikeholdsmodus"
* Oppgave 15.1
*/
import { nodeStore, connectionState } from '$lib/spacetime';
import type { Node } from '$lib/spacetime';
const connected = $derived(connectionState.current === 'connected');
// Tick every second for countdown updates
let now = $state(Date.now());
$effect(() => {
const interval = setInterval(() => {
now = Date.now();
}, 1000);
return () => clearInterval(interval);
});
interface AnnouncementMeta {
announcement_type: 'info' | 'warning' | 'critical';
scheduled_at?: string | null;
expires_at?: string | null;
blocks_new_sessions?: boolean;
}
function parseMeta(node: Node): AnnouncementMeta | null {
try {
const meta = JSON.parse(node.metadata ?? '{}');
if (!meta.announcement_type) return null;
return meta as AnnouncementMeta;
} catch {
return null;
}
}
/** Active (non-expired) system announcements, sorted by type priority. */
const announcements = $derived.by(() => {
if (!connected) return [];
const typePriority: Record<string, number> = { critical: 0, warning: 1, info: 2 };
const result: Array<{ node: Node; meta: AnnouncementMeta }> = [];
for (const node of nodeStore.byKind('system_announcement')) {
const meta = parseMeta(node);
if (!meta) continue;
// Skip expired announcements
if (meta.expires_at) {
const expiresAt = new Date(meta.expires_at).getTime();
if (expiresAt <= now) continue;
}
result.push({ node, meta });
}
// Sort: critical first, then warning, then info
result.sort((a, b) => {
const pa = typePriority[a.meta.announcement_type] ?? 9;
const pb = typePriority[b.meta.announcement_type] ?? 9;
return pa - pb;
});
return result;
});
/** Format countdown string from now to a target date. */
function countdown(targetIso: string): string {
const target = new Date(targetIso).getTime();
const diff = target - now;
if (diff <= 0) return 'nå';
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
const remainMinutes = minutes % 60;
return `${hours}t ${remainMinutes}m`;
}
if (minutes > 0) {
const remainSeconds = seconds % 60;
return `${minutes}m ${remainSeconds}s`;
}
return `${seconds}s`;
}
// Dismissed announcements (per session, not persisted)
let dismissed = $state<Set<string>>(new Set());
function dismiss(nodeId: string) {
dismissed = new Set([...dismissed, nodeId]);
}
</script>
{#if announcements.length > 0}
<div class="fixed top-0 left-0 right-0 z-50 flex flex-col gap-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'}
<div
class="flex items-center justify-between gap-3 px-4 py-2.5 text-sm shadow-sm {
isCritical
? 'bg-red-600 text-white'
: isWarning
? 'bg-amber-500 text-amber-950'
: 'bg-blue-500 text-white'
}"
role="alert"
>
<div class="mx-auto flex max-w-4xl flex-1 items-center gap-3">
<!-- Icon -->
<span class="shrink-0 text-base" aria-hidden="true">
{#if isCritical}{:else if isWarning}🔧{:else}{/if}
</span>
<!-- Message -->
<div class="flex-1">
<span class="font-semibold">{node.title}</span>
{#if node.content}
<span class="ml-1 opacity-90">{node.content}</span>
{/if}
</div>
<!-- Countdown (for scheduled events) -->
{#if meta.scheduled_at}
{@const remaining = countdown(meta.scheduled_at)}
<span class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-bold {
isCritical
? 'bg-red-800 text-red-100'
: isWarning
? 'bg-amber-700 text-amber-100'
: 'bg-blue-700 text-blue-100'
}">
om {remaining}
</span>
{/if}
<!-- Dismiss button (not for critical) -->
{#if !isCritical}
<button
onclick={() => dismiss(node.id)}
class="shrink-0 rounded p-1 opacity-70 hover:opacity-100 {
isWarning ? 'hover:bg-amber-600' : 'hover:bg-blue-600'
}"
aria-label="Lukk varsel"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/if}
</div>
</div>
{/if}
{/each}
</div>
{/if}

View file

@ -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 @@
});
</script>
<SystemAnnouncements />
{@render children()}

View file

@ -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<String>,
/// Når varselet automatisk utløper (ISO 8601). Valgfritt.
pub expires_at: Option<String>,
/// Om nye sesjoner skal blokkeres (vedlikeholdsmodus). Default: false.
pub blocks_new_sessions: Option<bool>,
}
#[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<AppState>,
user: AuthUser,
Json(req): Json<CreateAnnouncementRequest>,
) -> Result<Json<CreateAnnouncementResponse>, (StatusCode, Json<ErrorResponse>)> {
// -- 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<AppState>,
user: AuthUser,
Json(req): Json<ExpireAnnouncementRequest>,
) -> Result<Json<ExpireAnnouncementResponse>, (StatusCode, Json<ErrorResponse>)> {
// -- 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
// =============================================================================

View file

@ -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))

View file

@ -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`.