Webhook-admin: UI for å opprette/administrere webhooks (oppgave 29.5)

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
This commit is contained in:
vegard 2026-03-18 21:55:24 +00:00
parent 96746fa2f0
commit 097ef02aea
6 changed files with 787 additions and 2 deletions

View file

@ -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<string, unknown> | 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<WebhooksOverviewResponse> {
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<WebhookEvent[]> {
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<CreateWebhookResponse> {
return post(accessToken, '/admin/webhooks/create', data);
}
/** Regenerer webhook-token. */
export function regenerateWebhookToken(
accessToken: string,
webhookId: string
): Promise<RegenerateTokenResponse> {
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,

View file

@ -106,6 +106,9 @@
<a href="/admin/usage" class="rounded-lg bg-gray-100 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-200">
Forbruk
</a>
<a href="/admin/webhooks" class="rounded-lg bg-gray-100 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-200">
Webhooks
</a>
</nav>
</div>
</header>

View file

@ -0,0 +1,391 @@
<script lang="ts">
/**
* Admin — Webhook-administrasjon (oppgave 29.5)
*
* Opprett, vis, regenerer token og slett webhooks.
* Viser aktivitetslogg med siste hendelser per webhook.
*/
import { page } from '$app/stores';
import {
fetchWebhooks,
fetchWebhookEvents,
createWebhook,
regenerateWebhookToken,
deleteWebhook,
type WebhookInfo,
type WebhookEvent
} from '$lib/api';
const session = $derived($page.data.session as Record<string, unknown> | undefined);
const accessToken = $derived(session?.accessToken as string | undefined);
let webhooks = $state<WebhookInfo[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
let actionLoading = $state<string | null>(null);
// Opprett-skjema
let showCreateForm = $state(false);
let newTitle = $state('');
let newCollectionId = $state('');
let collections = $state<{ id: string; title: string | null }[]>([]);
// Hendelser per webhook (utfelt)
let expandedWebhook = $state<string | null>(null);
let events = $state<WebhookEvent[]>([]);
let eventsLoading = $state(false);
// Kopier-feedback
let copiedToken = $state<string | null>(null);
// Poll webhooks every 10 seconds
$effect(() => {
if (!accessToken) return;
loadWebhooks();
const interval = setInterval(loadWebhooks, 10000);
return () => clearInterval(interval);
});
async function loadWebhooks() {
if (!accessToken) return;
try {
const res = await fetchWebhooks(accessToken);
webhooks = res.webhooks;
error = null;
} catch (e) {
error = String(e);
}
}
async function loadCollections() {
if (!accessToken) return;
try {
const BASE_URL = import.meta.env.VITE_API_URL ?? '/api';
const res = await fetch(
`${BASE_URL}/query/nodes?kind=collection&limit=200`,
{ headers: { Authorization: `Bearer ${accessToken}` } }
);
if (res.ok) {
const data = await res.json();
collections = data.nodes.map((n: Record<string, unknown>) => ({
id: n.id,
title: n.title
}));
}
} catch {
// Ikke kritisk — dropdown blir tom
}
}
function toggleCreateForm() {
showCreateForm = !showCreateForm;
if (showCreateForm) loadCollections();
}
async function handleCreate() {
if (!accessToken || !newCollectionId) return;
actionLoading = 'create';
error = null;
try {
await createWebhook(accessToken, {
title: newTitle || undefined,
collection_id: newCollectionId
});
newTitle = '';
newCollectionId = '';
showCreateForm = false;
await loadWebhooks();
} catch (e) {
error = String(e);
} finally {
actionLoading = null;
}
}
async function handleRegenerateToken(webhookId: string) {
if (!accessToken) return;
if (!confirm('Er du sikker? Det gamle tokenet slutter å fungere umiddelbart.')) return;
actionLoading = `regen-${webhookId}`;
error = null;
try {
await regenerateWebhookToken(accessToken, webhookId);
await loadWebhooks();
} catch (e) {
error = String(e);
} finally {
actionLoading = null;
}
}
async function handleDelete(webhookId: string) {
if (!accessToken) return;
if (!confirm('Slett webhook permanent? Eksisterende innhold beholdes.')) return;
actionLoading = `del-${webhookId}`;
error = null;
try {
await deleteWebhook(accessToken, webhookId);
if (expandedWebhook === webhookId) expandedWebhook = null;
await loadWebhooks();
} catch (e) {
error = String(e);
} finally {
actionLoading = null;
}
}
async function toggleEvents(webhookId: string) {
if (expandedWebhook === webhookId) {
expandedWebhook = null;
events = [];
return;
}
if (!accessToken) return;
expandedWebhook = webhookId;
eventsLoading = true;
try {
events = await fetchWebhookEvents(accessToken, webhookId, 20);
} catch (e) {
error = String(e);
} finally {
eventsLoading = false;
}
}
async function copyToken(token: string) {
try {
await navigator.clipboard.writeText(token);
copiedToken = token;
setTimeout(() => { copiedToken = null; }, 2000);
} catch {
// Fallback: select teksten
}
}
function webhookUrl(token: string): string {
return `${window.location.origin}/api/webhook/${token}`;
}
function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return '';
if (mins < 60) return `${mins}m siden`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}t siden`;
const days = Math.floor(hours / 24);
return `${days}d siden`;
}
</script>
<div class="min-h-screen bg-gray-50">
<header class="border-b border-gray-200 bg-white">
<div class="mx-auto flex max-w-3xl items-center justify-between px-4 py-3">
<div class="flex items-center gap-3">
<a href="/" class="text-sm text-gray-500 hover:text-gray-700">Hjem</a>
<a href="/admin" class="text-sm text-gray-500 hover:text-gray-700">Admin</a>
<h1 class="text-lg font-semibold text-gray-900">Webhooks</h1>
</div>
<nav class="flex gap-2 text-sm">
<a href="/admin" class="rounded-lg bg-gray-100 px-3 py-1.5 text-gray-700 hover:bg-gray-200">
Vedlikehold
</a>
<a href="/admin/jobs" class="rounded-lg bg-gray-100 px-3 py-1.5 text-gray-700 hover:bg-gray-200">
Jobbkø
</a>
<a href="/admin/ai" class="rounded-lg bg-gray-100 px-3 py-1.5 text-gray-700 hover:bg-gray-200">
AI
</a>
</nav>
</div>
</header>
<main class="mx-auto max-w-3xl px-4 py-6">
{#if !accessToken}
<p class="text-sm text-gray-400">Logg inn for tilgang.</p>
{:else}
<!-- Feilmelding -->
{#if error}
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700">
{error}
</div>
{/if}
<!-- Opprett-knapp -->
<div class="mb-4 flex items-center justify-between">
<h2 class="text-base font-semibold text-gray-800">
Webhooks
<span class="ml-1 text-sm font-normal text-gray-400">({webhooks.length})</span>
</h2>
<button
onclick={toggleCreateForm}
class="rounded-lg bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
{showCreateForm ? 'Avbryt' : 'Ny webhook'}
</button>
</div>
<!-- Opprett-skjema -->
{#if showCreateForm}
<section class="mb-4 rounded-lg border border-blue-200 bg-blue-50 p-4">
<h3 class="mb-3 text-sm font-semibold text-gray-800">Opprett webhook</h3>
<div class="space-y-3">
<div>
<label for="wh-title" class="mb-1 block text-xs font-medium text-gray-600">
Navn (valgfritt)
</label>
<input
id="wh-title"
type="text"
placeholder="F.eks. GitHub Push"
bind:value={newTitle}
class="w-full rounded border border-gray-300 px-2 py-1.5 text-sm"
/>
</div>
<div>
<label for="wh-collection" class="mb-1 block text-xs font-medium text-gray-600">
Målsamling
</label>
<select
id="wh-collection"
bind:value={newCollectionId}
class="w-full rounded border border-gray-300 px-2 py-1.5 text-sm"
>
<option value="">Velg samling...</option>
{#each collections as col (col.id)}
<option value={col.id}>{col.title || col.id}</option>
{/each}
</select>
</div>
<button
onclick={handleCreate}
disabled={!newCollectionId || actionLoading === 'create'}
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{actionLoading === 'create' ? 'Oppretter...' : 'Opprett'}
</button>
</div>
</section>
{/if}
<!-- Webhook-liste -->
{#if webhooks.length === 0 && !loading}
<div class="rounded-lg border border-gray-200 bg-white p-8 text-center text-sm text-gray-400">
Ingen webhooks opprettet ennå.
</div>
{:else}
<div class="space-y-3">
{#each webhooks as wh (wh.id)}
<div class="rounded-lg border border-gray-200 bg-white shadow-sm">
<!-- Webhook-header -->
<div class="flex items-start justify-between p-4">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<h3 class="text-sm font-semibold text-gray-800">
{wh.title || 'Webhook'}
</h3>
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-500">
{wh.collection_title || wh.collection_id.slice(0, 8)}
</span>
</div>
<!-- Token -->
<div class="mt-2 flex items-center gap-2">
<code class="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600">
{wh.token}
</code>
<button
onclick={() => copyToken(wh.token)}
class="text-xs text-blue-600 hover:text-blue-800"
title="Kopier token"
>
{copiedToken === wh.token ? 'Kopiert!' : 'Kopier'}
</button>
</div>
<!-- URL -->
<div class="mt-1 flex items-center gap-2">
<span class="text-xs text-gray-400">URL:</span>
<code class="truncate rounded bg-gray-50 px-1.5 py-0.5 text-xs text-gray-500">
{webhookUrl(wh.token)}
</code>
<button
onclick={() => copyToken(webhookUrl(wh.token))}
class="shrink-0 text-xs text-blue-600 hover:text-blue-800"
>
Kopier
</button>
</div>
<!-- Aktivitet -->
<div class="mt-2 flex items-center gap-4 text-xs text-gray-400">
<span>{wh.event_count} hendelser</span>
{#if wh.last_event_at}
<span>Siste: {timeAgo(wh.last_event_at)}</span>
{:else}
<span>Ingen hendelser ennå</span>
{/if}
<span>Opprettet {new Date(wh.created_at).toLocaleDateString('nb-NO')}</span>
</div>
</div>
<!-- Handlinger -->
<div class="ml-3 flex shrink-0 gap-2">
<button
onclick={() => toggleEvents(wh.id)}
class="rounded bg-gray-100 px-2 py-1 text-xs text-gray-600 hover:bg-gray-200"
>
{expandedWebhook === wh.id ? 'Skjul' : 'Logg'}
</button>
<button
onclick={() => handleRegenerateToken(wh.id)}
disabled={actionLoading === `regen-${wh.id}`}
class="rounded bg-amber-100 px-2 py-1 text-xs text-amber-700 hover:bg-amber-200 disabled:opacity-50"
>
{actionLoading === `regen-${wh.id}` ? '...' : 'Nytt token'}
</button>
<button
onclick={() => handleDelete(wh.id)}
disabled={actionLoading === `del-${wh.id}`}
class="rounded bg-red-100 px-2 py-1 text-xs text-red-700 hover:bg-red-200 disabled:opacity-50"
>
{actionLoading === `del-${wh.id}` ? '...' : 'Slett'}
</button>
</div>
</div>
<!-- Hendelseslogg (utfelt) -->
{#if expandedWebhook === wh.id}
<div class="border-t border-gray-100 bg-gray-50 px-4 py-3">
<h4 class="mb-2 text-xs font-semibold text-gray-600">Siste hendelser</h4>
{#if eventsLoading}
<p class="text-xs text-gray-400">Laster...</p>
{:else if events.length === 0}
<p class="text-xs text-gray-400">Ingen hendelser registrert.</p>
{:else}
<div class="max-h-64 space-y-2 overflow-y-auto">
{#each events as ev (ev.node_id)}
<div class="rounded border border-gray-200 bg-white px-3 py-2">
<div class="flex items-center justify-between text-xs">
<span class="font-medium text-gray-700">
{ev.title || 'Uten tittel'}
</span>
<span class="text-gray-400">
{timeAgo(ev.created_at)}
</span>
</div>
{#if ev.payload}
<pre class="mt-1 max-h-20 overflow-auto rounded bg-gray-50 p-1.5 text-xs text-gray-500">{JSON.stringify(ev.payload, null, 2)}</pre>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
{/each}
</div>
{/if}
{/if}
</main>
</div>

View file

@ -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)

View file

@ -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<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 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,
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,
}
#[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"));
}
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<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,
}))
}

View file

@ -398,8 +398,7 @@ noden er det som lever videre.
### Webhook (universell ekstern input)
- [x] 29.4 Webhook-endepunkt i vaktmesteren: `POST /api/webhook/<token>` → 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