// 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::webhook_templates; 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, målsamling og evt. template. #[derive(sqlx::FromRow)] struct WebhookLookup { webhook_id: Uuid, collection_id: Uuid, template_id: Option, } /// 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, w.metadata->>'template_id' AS template_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!({})); // -- Ekstraher title/content via template eller generisk -- let (title, content, extra_meta) = if let Some(ref tmpl_id) = lookup.template_id { if let Some(result) = webhook_templates::apply_template(tmpl_id, &payload) { (result.title, result.content, Some(result.extra_metadata)) } else { tracing::warn!(template_id = %tmpl_id, "Ukjent webhook-template, bruker generisk"); extract_generic(&payload) } } else { extract_generic(&payload) }; // -- Opprett content-node -- let node_id = Uuid::now_v7(); let mut metadata = serde_json::json!({ "source": "webhook", "webhook_id": lookup.webhook_id.to_string(), "payload": payload, }); // Merg inn ekstra metadata fra template if let Some(extra) = extra_meta { if let Some(obj) = extra.as_object() { for (k, v) in obj { metadata[k.clone()] = v.clone(); } } } 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 })) } /// Generisk ekstraksjon: prøv title og content/body-felt. fn extract_generic(payload: &serde_json::Value) -> (String, String, Option) { let title = payload .get("title") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); let content = payload .get("content") .or_else(|| payload.get("body")) .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); (title, content, None) }