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:
parent
96746fa2f0
commit
097ef02aea
6 changed files with 787 additions and 2 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
391
frontend/src/routes/admin/webhooks/+page.svelte
Normal file
391
frontend/src/routes/admin/webhooks/+page.svelte
Normal 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 'nå';
|
||||
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>
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
296
maskinrommet/src/webhook_admin.rs
Normal file
296
maskinrommet/src/webhook_admin.rs
Normal 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,
|
||||
}))
|
||||
}
|
||||
3
tasks.md
3
tasks.md
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue