// 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::webhook_templates; 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 template_id: Option, 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, w.metadata->>'template_id' AS template_id, 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, pub template_id: Option, } #[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")); } // Valider template_id hvis oppgitt if let Some(ref tmpl_id) = req.template_id { if webhook_templates::get_template(tmpl_id).is_none() { return Err(bad_request(&format!("Ukjent template: {tmpl_id}"))); } } let webhook_id = Uuid::now_v7(); let token = Uuid::now_v7(); let title = req.title.unwrap_or_else(|| "Webhook".to_string()); let mut metadata = serde_json::json!({ "token": token.to_string(), }); if let Some(ref tmpl_id) = req.template_id { metadata["template_id"] = serde_json::Value::String(tmpl_id.clone()); } // 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, })) } // ============================================================================= // GET /admin/webhooks/templates — liste tilgjengelige templates // ============================================================================= pub async fn list_templates( _admin: AdminUser, ) -> Json> { Json(webhook_templates::list_templates()) } // ============================================================================= // POST /admin/webhooks/set_template — sett eller fjern template på webhook // ============================================================================= #[derive(Deserialize)] pub struct SetTemplateRequest { pub webhook_id: Uuid, /// None eller tom streng fjerner template. pub template_id: Option, } #[derive(Serialize)] pub struct SetTemplateResponse { pub template_id: Option, } pub async fn set_template( State(state): State, _admin: AdminUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { let tmpl_id = req.template_id.filter(|s| !s.is_empty()); // Valider template_id if let Some(ref id) = tmpl_id { if webhook_templates::get_template(id).is_none() { return Err(bad_request(&format!("Ukjent template: {id}"))); } } // Oppdater metadata: sett eller fjern template_id let (query, effective_id) = if let Some(ref id) = tmpl_id { ( r#"UPDATE nodes SET metadata = jsonb_set(metadata, '{template_id}', to_jsonb($1::text)), updated_at = now() WHERE id = $2 AND node_kind = 'webhook'"#, id.clone(), ) } else { ( r#"UPDATE nodes SET metadata = metadata - 'template_id', updated_at = now() WHERE id = $2 AND node_kind = 'webhook'"#, String::new(), ) }; let result = sqlx::query(query) .bind(&effective_id) .bind(req.webhook_id) .execute(&state.db) .await .map_err(|e| internal_error(&format!("Feil ved oppdatering av template: {e}")))?; if result.rows_affected() == 0 { return Err(bad_request("Webhook finnes ikke")); } tracing::info!( webhook_id = %req.webhook_id, template_id = ?tmpl_id, "Webhook-template oppdatert" ); Ok(Json(SetTemplateResponse { template_id: tmpl_id })) }