diff --git a/maskinrommet/src/main.rs b/maskinrommet/src/main.rs index 93a0ff3..572b505 100644 --- a/maskinrommet/src/main.rs +++ b/maskinrommet/src/main.rs @@ -30,6 +30,7 @@ pub mod ws; pub mod mixer; pub mod feed_poller; pub mod orchestration_trigger; +mod webhook; pub mod script_compiler; pub mod script_executor; pub mod tiptap; @@ -291,6 +292,8 @@ 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 universell input (oppgave 29.4) + .route("/api/webhook/{token}", post(webhook::handle_webhook)) // Observerbarhet (oppgave 12.1) .route("/metrics", get(metrics::metrics_endpoint)) .layer(middleware::from_fn_with_state(state.clone(), metrics::latency_middleware)) diff --git a/maskinrommet/src/webhook.rs b/maskinrommet/src/webhook.rs new file mode 100644 index 0000000..80a91cc --- /dev/null +++ b/maskinrommet/src/webhook.rs @@ -0,0 +1,163 @@ +// Webhook-endepunkt: POST /api/webhook/ +// +// Offentlig endepunkt (ingen JWT). Validerer token mot en webhook-node +// i databasen, oppretter content-node med payload i metadata, og kobler +// den til målsamlingen via belongs_to-edge. +// +// Ref: docs/features/universell_input.md (Webhook-seksjon) + +use axum::{ + extract::{Path, State}, + http::StatusCode, + Json, +}; +use serde::Serialize; +use uuid::Uuid; + +use crate::AppState; + +#[derive(Serialize)] +pub struct ErrorResponse { + pub error: String, +} + +#[derive(Serialize)] +pub struct WebhookResponse { + pub node_id: Uuid, + pub edge_id: Uuid, +} + +fn not_found(msg: &str) -> (StatusCode, Json) { + ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { error: msg.to_string() }), + ) +} + +fn internal_error(msg: &str) -> (StatusCode, Json) { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { error: msg.to_string() }), + ) +} + +/// Rad fra token-oppslag: webhook-nodens id og dens målsamling. +#[derive(sqlx::FromRow)] +struct WebhookLookup { + webhook_id: Uuid, + collection_id: Uuid, +} + +/// POST /api/webhook/ +/// +/// Mottar vilkårlig JSON-body og oppretter en content-node med payload +/// i metadata. Noden knyttes til webhook-nodens målsamling via belongs_to-edge. +pub async fn handle_webhook( + State(state): State, + Path(token): Path, + body: Option>, +) -> Result, (StatusCode, Json)> { + // -- Slå opp webhook-node via token -- + // Token lagres i metadata.token på en webhook-node. + // Målsamlingen finnes via belongs_to-edge fra webhook-noden. + let lookup = sqlx::query_as::<_, WebhookLookup>( + r#"SELECT w.id AS webhook_id, e.target_id AS collection_id + FROM nodes w + JOIN edges e ON e.source_id = w.id AND e.edge_type = 'belongs_to' + WHERE w.node_kind = 'webhook' + AND w.metadata->>'token' = $1::text"#, + ) + .bind(token.to_string()) + .fetch_optional(&state.db) + .await + .map_err(|e| { + tracing::error!("PG-feil ved webhook-token-oppslag: {e}"); + internal_error("Databasefeil") + })?; + + let lookup = match lookup { + Some(l) => l, + None => return Err(not_found("Ugyldig webhook-token")), + }; + + // -- Payload -- + let payload = body + .map(|Json(v)| v) + .unwrap_or(serde_json::json!({})); + + // Trekk ut tittel fra payload hvis den har et "title"-felt + let title = payload + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + // Trekk ut innhold fra payload hvis den har et "content" eller "body"-felt + let content = payload + .get("content") + .or_else(|| payload.get("body")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + // -- Opprett content-node -- + let node_id = Uuid::now_v7(); + let metadata = serde_json::json!({ + "source": "webhook", + "webhook_id": lookup.webhook_id.to_string(), + "payload": payload, + }); + + sqlx::query( + r#"INSERT INTO nodes (id, node_kind, title, content, visibility, metadata, created_by) + VALUES ($1, 'content', NULLIF($2, ''), NULLIF($3, ''), 'hidden'::visibility, $4, $5)"#, + ) + .bind(node_id) + .bind(&title) + .bind(&content) + .bind(&metadata) + .bind(lookup.webhook_id) + .execute(&state.db) + .await + .map_err(|e| { + tracing::error!("PG-feil ved opprettelse av webhook-node: {e}"); + internal_error("Databasefeil ved opprettelse av node") + })?; + + // -- belongs_to-edge til målsamling -- + 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(node_id) + .bind(lookup.collection_id) + .bind(lookup.webhook_id) + .execute(&state.db) + .await + .map_err(|e| { + tracing::error!("PG-feil ved opprettelse av belongs_to-edge: {e}"); + internal_error("Databasefeil ved opprettelse av edge") + })?; + + // -- Propager tilgang fra samlingen til den nye noden -- + if let Err(e) = sqlx::query("SELECT propagate_belongs_to_access($1, $2)") + .bind(node_id) + .bind(lookup.collection_id) + .execute(&state.db) + .await + { + tracing::error!("propagate_belongs_to_access feilet for webhook-node: {e}"); + } + + tracing::info!( + node_id = %node_id, + edge_id = %edge_id, + webhook_id = %lookup.webhook_id, + collection_id = %lookup.collection_id, + "Webhook mottatt — content-node opprettet" + ); + + Ok(Json(WebhookResponse { node_id, edge_id })) +} diff --git a/tasks.md b/tasks.md index 508078c..0e73c7e 100644 --- a/tasks.md +++ b/tasks.md @@ -397,8 +397,7 @@ noden er det som lever videre. - [x] 29.3 Feed-orkestrering: standard-orkestrering "Overvåk RSS-feed" som bruker synops-feed. Konfigurerbar per samling. Nye artikler havner i innboks eller direkte i en kanal. ### Webhook (universell ekstern input) -- [~] 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. - > Påbegynt: 2026-03-18T21:35 +- [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. - [ ] 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.