From 097ef02aeaacba77a34841c1f4115255c5e6cc83 Mon Sep 17 00:00:00 2001 From: vegard Date: Wed, 18 Mar 2026 21:55:24 +0000 Subject: [PATCH] =?UTF-8?q?Webhook-admin:=20UI=20for=20=C3=A5=20opprette/a?= =?UTF-8?q?dministrere=20webhooks=20(oppgave=2029.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (maskinrommet): - GET /admin/webhooks — liste alle webhooks med aktivitetsinfo - GET /admin/webhooks/events?webhook_id=... — siste hendelser - POST /admin/webhooks/create — opprett webhook for samling - POST /admin/webhooks/regenerate_token — nytt token - POST /admin/webhooks/delete — slett webhook Frontend: - /admin/webhooks side med full CRUD - Vis token, mål-samling, hendelsesteller, siste aktivitet - Kopier token/URL til utklippstavle - Utfellbar hendelseslogg per webhook med payload-visning - Regenerer token med bekreftelse - Slett med bekreftelse - Nav-lenke fra admin-hub --- frontend/src/lib/api.ts | 89 ++++ frontend/src/routes/admin/+page.svelte | 3 + .../src/routes/admin/webhooks/+page.svelte | 391 ++++++++++++++++++ maskinrommet/src/main.rs | 7 + maskinrommet/src/webhook_admin.rs | 296 +++++++++++++ tasks.md | 3 +- 6 files changed, 787 insertions(+), 2 deletions(-) create mode 100644 frontend/src/routes/admin/webhooks/+page.svelte create mode 100644 maskinrommet/src/webhook_admin.rs diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 8f1e9e0..7ff3615 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1424,6 +1424,95 @@ export async function fetchOrchestrationLog( return res.json(); } +// ============================================================================= +// Webhook-admin (oppgave 29.5) +// ============================================================================= + +export interface WebhookInfo { + id: string; + title: string | null; + token: string; + collection_id: string; + collection_title: string | null; + created_at: string; + event_count: number; + last_event_at: string | null; +} + +export interface WebhookEvent { + node_id: string; + title: string | null; + created_at: string; + payload: Record | null; +} + +export interface WebhooksOverviewResponse { + webhooks: WebhookInfo[]; +} + +export interface CreateWebhookResponse { + webhook_id: string; + token: string; +} + +export interface RegenerateTokenResponse { + new_token: string; +} + +/** Hent alle webhooks med aktivitetsinfo. */ +export async function fetchWebhooks(accessToken: string): Promise { + const res = await fetch(`${BASE_URL}/admin/webhooks`, { + headers: { Authorization: `Bearer ${accessToken}` } + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`webhooks failed (${res.status}): ${body}`); + } + return res.json(); +} + +/** Hent siste hendelser for en webhook. */ +export async function fetchWebhookEvents( + accessToken: string, + webhookId: string, + limit?: number +): Promise { + const sp = new URLSearchParams({ webhook_id: webhookId }); + if (limit) sp.set('limit', String(limit)); + const res = await fetch(`${BASE_URL}/admin/webhooks/events?${sp}`, { + headers: { Authorization: `Bearer ${accessToken}` } + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`webhook events failed (${res.status}): ${body}`); + } + return res.json(); +} + +/** Opprett ny webhook for en samling. */ +export function createWebhook( + accessToken: string, + data: { title?: string; collection_id: string } +): Promise { + return post(accessToken, '/admin/webhooks/create', data); +} + +/** Regenerer webhook-token. */ +export function regenerateWebhookToken( + accessToken: string, + webhookId: string +): Promise { + return post(accessToken, '/admin/webhooks/regenerate_token', { webhook_id: webhookId }); +} + +/** Slett en webhook. */ +export function deleteWebhook( + accessToken: string, + webhookId: string +): Promise<{ deleted: boolean }> { + return post(accessToken, '/admin/webhooks/delete', { webhook_id: webhookId }); +} + export async function setMixerRole( accessToken: string, roomId: string, diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index bd30540..7d0bcd4 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -106,6 +106,9 @@ Forbruk + + Webhooks + diff --git a/frontend/src/routes/admin/webhooks/+page.svelte b/frontend/src/routes/admin/webhooks/+page.svelte new file mode 100644 index 0000000..7fbe4f7 --- /dev/null +++ b/frontend/src/routes/admin/webhooks/+page.svelte @@ -0,0 +1,391 @@ + + +
+
+ +
+ +
+ {#if !accessToken} +

Logg inn for tilgang.

+ {:else} + + {#if error} +
+ {error} +
+ {/if} + + +
+

+ Webhooks + ({webhooks.length}) +

+ +
+ + + {#if showCreateForm} +
+

Opprett webhook

+
+
+ + +
+
+ + +
+ +
+
+ {/if} + + + {#if webhooks.length === 0 && !loading} +
+ Ingen webhooks opprettet ennå. +
+ {:else} +
+ {#each webhooks as wh (wh.id)} +
+ +
+
+
+

+ {wh.title || 'Webhook'} +

+ + {wh.collection_title || wh.collection_id.slice(0, 8)} + +
+ + +
+ + {wh.token} + + +
+ + +
+ URL: + + {webhookUrl(wh.token)} + + +
+ + +
+ {wh.event_count} hendelser + {#if wh.last_event_at} + Siste: {timeAgo(wh.last_event_at)} + {:else} + Ingen hendelser ennå + {/if} + Opprettet {new Date(wh.created_at).toLocaleDateString('nb-NO')} +
+
+ + +
+ + + +
+
+ + + {#if expandedWebhook === wh.id} +
+

Siste hendelser

+ {#if eventsLoading} +

Laster...

+ {:else if events.length === 0} +

Ingen hendelser registrert.

+ {:else} +
+ {#each events as ev (ev.node_id)} +
+
+ + {ev.title || 'Uten tittel'} + + + {timeAgo(ev.created_at)} + +
+ {#if ev.payload} +
{JSON.stringify(ev.payload, null, 2)}
+ {/if} +
+ {/each} +
+ {/if} +
+ {/if} +
+ {/each} +
+ {/if} + {/if} +
+
diff --git a/maskinrommet/src/main.rs b/maskinrommet/src/main.rs index 572b505..d739bda 100644 --- a/maskinrommet/src/main.rs +++ b/maskinrommet/src/main.rs @@ -31,6 +31,7 @@ pub mod mixer; pub mod feed_poller; pub mod orchestration_trigger; mod webhook; +mod webhook_admin; pub mod script_compiler; pub mod script_executor; pub mod tiptap; @@ -292,6 +293,12 @@ async fn main() { .route("/intentions/set_mute", post(mixer::set_mute)) .route("/intentions/toggle_effect", post(mixer::toggle_effect)) .route("/intentions/set_mixer_role", post(mixer::set_mixer_role)) + // Webhook-admin (oppgave 29.5) + .route("/admin/webhooks", get(webhook_admin::list_webhooks)) + .route("/admin/webhooks/events", get(webhook_admin::webhook_events)) + .route("/admin/webhooks/create", post(webhook_admin::create_webhook)) + .route("/admin/webhooks/regenerate_token", post(webhook_admin::regenerate_token)) + .route("/admin/webhooks/delete", post(webhook_admin::delete_webhook)) // Webhook universell input (oppgave 29.4) .route("/api/webhook/{token}", post(webhook::handle_webhook)) // Observerbarhet (oppgave 12.1) diff --git a/maskinrommet/src/webhook_admin.rs b/maskinrommet/src/webhook_admin.rs new file mode 100644 index 0000000..fb88dfd --- /dev/null +++ b/maskinrommet/src/webhook_admin.rs @@ -0,0 +1,296 @@ +// Webhook-administrasjon (oppgave 29.5) +// +// Admin-API for å opprette, liste, regenerere token og slette webhooks. +// Webhooks er noder med node_kind='webhook', koblet til samlinger via +// belongs_to-edge. Token lagres i metadata.token. +// +// Ref: docs/features/universell_input.md (Webhook-seksjon) + +use axum::{extract::State, http::StatusCode, Json}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::auth::AdminUser; +use crate::AppState; + +// ============================================================================= +// Datatyper +// ============================================================================= + +#[derive(Serialize)] +pub struct ErrorResponse { + pub error: String, +} + +fn bad_request(msg: &str) -> (StatusCode, Json) { + (StatusCode::BAD_REQUEST, Json(ErrorResponse { error: msg.to_string() })) +} + +fn internal_error(msg: &str) -> (StatusCode, Json) { + (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: msg.to_string() })) +} + +/// En webhook med info om målsamling og aktivitet. +#[derive(Serialize, sqlx::FromRow)] +pub struct WebhookInfo { + pub id: Uuid, + pub title: Option, + pub token: String, + pub collection_id: Uuid, + pub collection_title: Option, + pub created_at: DateTime, + pub event_count: i64, + pub last_event_at: Option>, +} + +/// Siste hendelser mottatt via en webhook. +#[derive(Serialize, sqlx::FromRow)] +pub struct WebhookEvent { + pub node_id: Uuid, + pub title: Option, + pub created_at: DateTime, + pub payload: Option, +} + +#[derive(Serialize)] +pub struct WebhooksOverviewResponse { + pub webhooks: Vec, +} + +// ============================================================================= +// GET /admin/webhooks — liste alle webhooks +// ============================================================================= + +pub async fn list_webhooks( + State(state): State, + _admin: AdminUser, +) -> Result, (StatusCode, Json)> { + let webhooks = sqlx::query_as::<_, WebhookInfo>( + r#"SELECT + w.id, + w.title, + w.metadata->>'token' AS token, + e.target_id AS collection_id, + c.title AS collection_title, + w.created_at, + COALESCE(ev.event_count, 0) AS event_count, + ev.last_event_at + FROM nodes w + JOIN edges e ON e.source_id = w.id AND e.edge_type = 'belongs_to' + JOIN nodes c ON c.id = e.target_id + LEFT JOIN LATERAL ( + SELECT COUNT(*) AS event_count, MAX(n.created_at) AS last_event_at + FROM nodes n + WHERE n.node_kind = 'content' + AND n.metadata->>'source' = 'webhook' + AND n.metadata->>'webhook_id' = w.id::text + ) ev ON true + WHERE w.node_kind = 'webhook' + ORDER BY w.created_at DESC"#, + ) + .fetch_all(&state.db) + .await + .map_err(|e| internal_error(&format!("Feil ved henting av webhooks: {e}")))?; + + Ok(Json(WebhooksOverviewResponse { webhooks })) +} + +// ============================================================================= +// GET /admin/webhooks/events?webhook_id=... — siste hendelser for en webhook +// ============================================================================= + +#[derive(Deserialize)] +pub struct EventsQuery { + pub webhook_id: Uuid, + pub limit: Option, +} + +pub async fn webhook_events( + State(state): State, + _admin: AdminUser, + axum::extract::Query(params): axum::extract::Query, +) -> Result>, (StatusCode, Json)> { + let limit = params.limit.unwrap_or(20).min(100); + + let events = sqlx::query_as::<_, WebhookEvent>( + r#"SELECT + n.id AS node_id, + n.title, + n.created_at, + n.metadata->'payload' AS payload + FROM nodes n + WHERE n.node_kind = 'content' + AND n.metadata->>'source' = 'webhook' + AND n.metadata->>'webhook_id' = $1::text + ORDER BY n.created_at DESC + LIMIT $2"#, + ) + .bind(params.webhook_id.to_string()) + .bind(limit) + .fetch_all(&state.db) + .await + .map_err(|e| internal_error(&format!("Feil ved henting av webhook-hendelser: {e}")))?; + + Ok(Json(events)) +} + +// ============================================================================= +// POST /admin/webhooks/create — opprett ny webhook +// ============================================================================= + +#[derive(Deserialize)] +pub struct CreateWebhookRequest { + pub title: Option, + pub collection_id: Uuid, +} + +#[derive(Serialize)] +pub struct CreateWebhookResponse { + pub webhook_id: Uuid, + pub token: Uuid, +} + +pub async fn create_webhook( + State(state): State, + admin: AdminUser, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + // Verifiser at samlingen eksisterer + let collection_exists: Option<(Uuid,)> = sqlx::query_as( + "SELECT id FROM nodes WHERE id = $1 AND node_kind = 'collection'", + ) + .bind(req.collection_id) + .fetch_optional(&state.db) + .await + .map_err(|e| internal_error(&format!("DB-feil: {e}")))?; + + if collection_exists.is_none() { + return Err(bad_request("Samlingen finnes ikke")); + } + + let webhook_id = Uuid::now_v7(); + let token = Uuid::now_v7(); + let title = req.title.unwrap_or_else(|| "Webhook".to_string()); + + let metadata = serde_json::json!({ + "token": token.to_string(), + }); + + // Opprett webhook-node + sqlx::query( + r#"INSERT INTO nodes (id, node_kind, title, visibility, metadata, created_by) + VALUES ($1, 'webhook', $2, 'hidden'::visibility, $3, $4)"#, + ) + .bind(webhook_id) + .bind(&title) + .bind(&metadata) + .bind(admin.node_id) + .execute(&state.db) + .await + .map_err(|e| internal_error(&format!("Feil ved opprettelse av webhook: {e}")))?; + + // belongs_to-edge til samlingen + let edge_id = Uuid::now_v7(); + sqlx::query( + r#"INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by) + VALUES ($1, $2, $3, 'belongs_to', '{}'::jsonb, true, $4)"#, + ) + .bind(edge_id) + .bind(webhook_id) + .bind(req.collection_id) + .bind(admin.node_id) + .execute(&state.db) + .await + .map_err(|e| internal_error(&format!("Feil ved opprettelse av belongs_to-edge: {e}")))?; + + tracing::info!( + webhook_id = %webhook_id, + collection_id = %req.collection_id, + "Webhook opprettet" + ); + + Ok(Json(CreateWebhookResponse { webhook_id, token })) +} + +// ============================================================================= +// POST /admin/webhooks/regenerate_token — generer nytt token +// ============================================================================= + +#[derive(Deserialize)] +pub struct RegenerateTokenRequest { + pub webhook_id: Uuid, +} + +#[derive(Serialize)] +pub struct RegenerateTokenResponse { + pub new_token: Uuid, +} + +pub async fn regenerate_token( + State(state): State, + _admin: AdminUser, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let new_token = Uuid::now_v7(); + + let result = sqlx::query( + r#"UPDATE nodes + SET metadata = jsonb_set(metadata, '{token}', to_jsonb($1::text)), + updated_at = now() + WHERE id = $2 AND node_kind = 'webhook'"#, + ) + .bind(new_token.to_string()) + .bind(req.webhook_id) + .execute(&state.db) + .await + .map_err(|e| internal_error(&format!("Feil ved regenerering av token: {e}")))?; + + if result.rows_affected() == 0 { + return Err(bad_request("Webhook finnes ikke")); + } + + tracing::info!(webhook_id = %req.webhook_id, "Webhook-token regenerert"); + + Ok(Json(RegenerateTokenResponse { new_token })) +} + +// ============================================================================= +// POST /admin/webhooks/delete — slett webhook +// ============================================================================= + +#[derive(Deserialize)] +pub struct DeleteWebhookRequest { + pub webhook_id: Uuid, +} + +#[derive(Serialize)] +pub struct DeleteWebhookResponse { + pub deleted: bool, +} + +pub async fn delete_webhook( + State(state): State, + _admin: AdminUser, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + // Slett edges først (belongs_to fra webhook til samling) + sqlx::query("DELETE FROM edges WHERE source_id = $1 OR target_id = $1") + .bind(req.webhook_id) + .execute(&state.db) + .await + .map_err(|e| internal_error(&format!("Feil ved sletting av edges: {e}")))?; + + // Slett selve webhook-noden + let result = sqlx::query("DELETE FROM nodes WHERE id = $1 AND node_kind = 'webhook'") + .bind(req.webhook_id) + .execute(&state.db) + .await + .map_err(|e| internal_error(&format!("Feil ved sletting av webhook: {e}")))?; + + tracing::info!(webhook_id = %req.webhook_id, "Webhook slettet"); + + Ok(Json(DeleteWebhookResponse { + deleted: result.rows_affected() > 0, + })) +} diff --git a/tasks.md b/tasks.md index 751bbd2..d3ed6ba 100644 --- a/tasks.md +++ b/tasks.md @@ -398,8 +398,7 @@ noden er det som lever videre. ### Webhook (universell ekstern input) - [x] 29.4 Webhook-endepunkt i vaktmesteren: `POST /api/webhook/` → opprett node fra JSON-body. Hvert webhook har et unikt token (UUID) knyttet til en `webhook`-node med `belongs_to`-edge til målsamling. Validerer token, oppretter `content`-node med payload i metadata. -- [~] 29.5 Webhook-admin: UI for å opprette/administrere webhooks. Vis token, mål-samling, siste hendelser, aktivitet-logg. Regenerer token. Deaktiver/slett. - > Påbegynt: 2026-03-18T21:45 +- [x] 29.5 Webhook-admin: UI for å opprette/administrere webhooks. Vis token, mål-samling, siste hendelser, aktivitet-logg. Regenerer token. Deaktiver/slett. - [ ] 29.6 Webhook-templates: forhåndsdefinerte mappinger for kjente tjenester (GitHub → commits/issues, Slack → meldinger, CI/CD → build-status). Template mapper JSON-felt til node title/content/metadata. ### Video