Legger til et template-system for webhooks som vet hvordan kjente tjenester strukturerer sine JSON-payloads, og mapper dem til meningsfulle node title/content/metadata. Templates: - github-push: Commits med repo, branch, pusher, formaterte meldinger - github-issues: Issue-hendelser med nummer, labels, state - github-pull-request: PR-hendelser med branch-info, merge-status - slack-message: Slack Event API-meldinger med kanal og bruker - ci-build: Generisk CI/CD (GitHub Actions, GitLab CI, Jenkins) Backend: - webhook_templates.rs: Template-definisjoner og apply-logikk - webhook.rs: Bruker template fra webhook-nodens metadata.template_id - webhook_admin.rs: GET /admin/webhooks/templates, POST set_template, template_id i create og list Frontend: - Template-velger i opprett-skjema og på hver webhook-kort - Kan bytte template på eksisterende webhooks 6 unit-tester for alle templates. Verifisert med curl mot live endpoint.
389 lines
12 KiB
Rust
389 lines
12 KiB
Rust
// 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<ErrorResponse>) {
|
|
(StatusCode::BAD_REQUEST, Json(ErrorResponse { error: msg.to_string() }))
|
|
}
|
|
|
|
fn internal_error(msg: &str) -> (StatusCode, Json<ErrorResponse>) {
|
|
(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<String>,
|
|
pub token: String,
|
|
pub template_id: Option<String>,
|
|
pub collection_id: Uuid,
|
|
pub collection_title: Option<String>,
|
|
pub created_at: DateTime<Utc>,
|
|
pub event_count: i64,
|
|
pub last_event_at: Option<DateTime<Utc>>,
|
|
}
|
|
|
|
/// Siste hendelser mottatt via en webhook.
|
|
#[derive(Serialize, sqlx::FromRow)]
|
|
pub struct WebhookEvent {
|
|
pub node_id: Uuid,
|
|
pub title: Option<String>,
|
|
pub created_at: DateTime<Utc>,
|
|
pub payload: Option<serde_json::Value>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct WebhooksOverviewResponse {
|
|
pub webhooks: Vec<WebhookInfo>,
|
|
}
|
|
|
|
// =============================================================================
|
|
// GET /admin/webhooks — liste alle webhooks
|
|
// =============================================================================
|
|
|
|
pub async fn list_webhooks(
|
|
State(state): State<AppState>,
|
|
_admin: AdminUser,
|
|
) -> Result<Json<WebhooksOverviewResponse>, (StatusCode, Json<ErrorResponse>)> {
|
|
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<i64>,
|
|
}
|
|
|
|
pub async fn webhook_events(
|
|
State(state): State<AppState>,
|
|
_admin: AdminUser,
|
|
axum::extract::Query(params): axum::extract::Query<EventsQuery>,
|
|
) -> Result<Json<Vec<WebhookEvent>>, (StatusCode, Json<ErrorResponse>)> {
|
|
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<String>,
|
|
pub collection_id: Uuid,
|
|
pub template_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct CreateWebhookResponse {
|
|
pub webhook_id: Uuid,
|
|
pub token: Uuid,
|
|
}
|
|
|
|
pub async fn create_webhook(
|
|
State(state): State<AppState>,
|
|
admin: AdminUser,
|
|
Json(req): Json<CreateWebhookRequest>,
|
|
) -> Result<Json<CreateWebhookResponse>, (StatusCode, Json<ErrorResponse>)> {
|
|
// 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<AppState>,
|
|
_admin: AdminUser,
|
|
Json(req): Json<RegenerateTokenRequest>,
|
|
) -> Result<Json<RegenerateTokenResponse>, (StatusCode, Json<ErrorResponse>)> {
|
|
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<AppState>,
|
|
_admin: AdminUser,
|
|
Json(req): Json<DeleteWebhookRequest>,
|
|
) -> Result<Json<DeleteWebhookResponse>, (StatusCode, Json<ErrorResponse>)> {
|
|
// 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<Vec<webhook_templates::TemplateInfo>> {
|
|
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<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct SetTemplateResponse {
|
|
pub template_id: Option<String>,
|
|
}
|
|
|
|
pub async fn set_template(
|
|
State(state): State<AppState>,
|
|
_admin: AdminUser,
|
|
Json(req): Json<SetTemplateRequest>,
|
|
) -> Result<Json<SetTemplateResponse>, (StatusCode, Json<ErrorResponse>)> {
|
|
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 }))
|
|
}
|