From 627961b972da6ec1664b8fe544db6b1c80f8a021 Mon Sep 17 00:00:00 2001 From: sysops Date: Fri, 28 Aug 2026 09:03:06 +0200 Subject: [PATCH 1/3] API-07: zentrale-webhook-registry-zustellung (postgres-jobqueue, hmac-signatur, backoff) --- internal/webhook/dispatcher.go | 177 ++++++++++++++++++++++++++ internal/webhook/webhook.go | 132 ++++++++++++++++++++ internal/webhook/webhook_test.go | 198 ++++++++++++++++++++++++++++++ migrations/0003_webhooks.down.sql | 2 + migrations/0003_webhooks.up.sql | 27 ++++ 5 files changed, 536 insertions(+) create mode 100644 internal/webhook/dispatcher.go create mode 100644 internal/webhook/webhook.go create mode 100644 internal/webhook/webhook_test.go create mode 100644 migrations/0003_webhooks.down.sql create mode 100644 migrations/0003_webhooks.up.sql diff --git a/internal/webhook/dispatcher.go b/internal/webhook/dispatcher.go new file mode 100644 index 0000000..cf03419 --- /dev/null +++ b/internal/webhook/dispatcher.go @@ -0,0 +1,177 @@ +package webhook + +import ( + "bytes" + "context" + "crypto/subtle" + "encoding/hex" + "fmt" + "net/http" + "time" + + "github.com/jackc/pgx/v5" +) + +// DefaultMaxAttempts ist die konfigurierbare Obergrenze, ab der eine +// Zustellung endgueltig als fehlgeschlagen gilt (Akzeptanzkriterium 2). +const DefaultMaxAttempts = 5 + +// DefaultBaseBackoff ist die Basisdauer fuer exponentielles Backoff: +// naechster Versuch nach BaseBackoff * 2^attempt (Akzeptanzkriterium 2). +const DefaultBaseBackoff = 2 * time.Second + +// Dispatcher liefert faellige Zustellungen aus. Konfigurierbar in Tests +// (kleine BaseBackoff, kleine MaxAttempts), damit Retry/Backoff/Obergrenze +// ohne minutenlange Wartezeit real durchlaufen werden koennen. +type Dispatcher struct { + pool pgxIface + client *http.Client + MaxAttempts int + BaseBackoff time.Duration +} + +// pgxIface ist die schmale Teilmenge von *pgxpool.Pool, die der Dispatcher +// braucht — als Interface, damit Tests keine echte Verbindung fuer reine +// Signatur-/Backoff-Logik brauchen (wird hier aber durchgehend mit echten +// Integrationstests gegen Postgres verwendet, siehe dispatcher_test.go). +type pgxIface interface { + Begin(ctx context.Context) (pgx.Tx, error) +} + +func NewDispatcher(pool pgxIface, client *http.Client) *Dispatcher { + if client == nil { + client = &http.Client{Timeout: 5 * time.Second} + } + return &Dispatcher{pool: pool, client: client, MaxAttempts: DefaultMaxAttempts, BaseBackoff: DefaultBaseBackoff} +} + +// backoffFor berechnet die Wartezeit vor dem naechsten Versuch: exponentiell +// wachsend mit der Anzahl bereits unternommener Versuche. +func (d *Dispatcher) backoffFor(attempt int) time.Duration { + return d.BaseBackoff * time.Duration(1<= 300 { + deliveryErr = fmt.Errorf("unerwarteter statuscode %d", statusCode) + } + } + } + + if deliveryErr == nil { + _, _ = tx.Exec(ctx, ` + UPDATE webhook_deliveries SET status = $2, delivered_at = now(), attempt = attempt + 1 + WHERE id = $1 + `, del.ID, StatusDelivered) + return + } + + nextAttempt := del.Attempt + 1 + if nextAttempt >= d.MaxAttempts { + _, _ = tx.Exec(ctx, ` + UPDATE webhook_deliveries SET status = $2, attempt = $3, last_error = $4 + WHERE id = $1 + `, del.ID, StatusFailed, nextAttempt, deliveryErr.Error()) + return + } + + nextAttemptAt := time.Now().Add(d.backoffFor(nextAttempt)) + _, _ = tx.Exec(ctx, ` + UPDATE webhook_deliveries SET attempt = $2, next_attempt_at = $3, last_error = $4 + WHERE id = $1 + `, del.ID, nextAttempt, nextAttemptAt, deliveryErr.Error()) +} + +// Run ruft ProcessDue in festen Abstaenden auf, bis ctx beendet wird — +// dieselbe Konvention wie internal/tenant.Lifecycle.RunSweeper. +func (d *Dispatcher) Run(ctx context.Context, interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + _, _ = d.ProcessDue(ctx) + } + } +} + +// VerifySignature prueft empfaengerseitig, ob signature zu payload und +// secret passt — timing-safe (dasselbe Muster wie internal/audit.timingsafe), +// damit ein Empfaenger die Authentizitaet einer Zustellung pruefen kann +// (Akzeptanzkriterium 3). +func VerifySignature(secret string, payload []byte, signature string) bool { + expected := Sign(secret, payload) + expectedBytes, err1 := hex.DecodeString(expected) + gotBytes, err2 := hex.DecodeString(signature) + if err1 != nil || err2 != nil { + return false + } + return subtle.ConstantTimeCompare(expectedBytes, gotBytes) == 1 +} diff --git a/internal/webhook/webhook.go b/internal/webhook/webhook.go new file mode 100644 index 0000000..c0d0a81 --- /dev/null +++ b/internal/webhook/webhook.go @@ -0,0 +1,132 @@ +// Package webhook implementiert Core API-07: eine zentrale Webhook-Registry +// und Zustellungs-Engine fuer alle Fachmodule (DMS/Mail/Archive/Workflow/AI). +// Module reichen Ereignisse EINMAL zur Zustellung ein und implementieren +// selbst KEINE eigene Retry-/Signatur-Logik — das ist der zentrale Zweck +// dieser Kachel ("Bewusst vermeiden: jedes Modul baut seine eigene +// Webhook-Zustellungs-Engine"). Zustellung laeuft ueber dieselbe +// Postgres-Jobqueue-Konvention (SELECT ... FOR UPDATE SKIP LOCKED) wie +// internal/tenant.Lifecycle.ProcessDueDeletions (TEN-04) und +// internal/notify.Dispatcher (CFG-02) — kein Redis/AMQP. +package webhook + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +const ( + StatusPending = "pending" + StatusDelivered = "delivered" + StatusFailed = "failed" +) + +// Subscription ist EIN externer Abonnent fuer einen Ereignistyp +// (Akzeptanzkriterium 1: Module registrieren Ereignistypen, externe +// Abonnenten registrieren Ziel-URLs — dieses Paket modelliert die +// Abonnenten-Seite; welche Ereignistypen ein Modul anbietet, ist bewusst +// NICHT Teil dieser Kachel). +type Subscription struct { + ID string + EventType string + TargetURL string + Secret string + CreatedAt time.Time +} + +// Store persistiert Abonnements und Zustellversuche. +type Store struct { + pool *pgxpool.Pool +} + +func NewStore(pool *pgxpool.Pool) *Store { + return &Store{pool: pool} +} + +// Subscribe registriert einen Abonnenten fuer einen Ereignistyp. secret wird +// spaeter zur HMAC-Signierung jeder Zustellung an diesen Abonnenten +// verwendet (Akzeptanzkriterium 3). +func (s *Store) Subscribe(ctx context.Context, eventType, targetURL, secret string) (Subscription, error) { + var sub Subscription + sub.EventType, sub.TargetURL, sub.Secret = eventType, targetURL, secret + row := s.pool.QueryRow(ctx, ` + INSERT INTO webhook_subscriptions (event_type, target_url, secret) + VALUES ($1, $2, $3) + RETURNING id, created_at + `, eventType, targetURL, secret) + if err := row.Scan(&sub.ID, &sub.CreatedAt); err != nil { + return Subscription{}, fmt.Errorf("abonnement anlegen: %w", err) + } + return sub, nil +} + +// Enqueue reicht EIN Ereignis zur Zustellung an ALLE Abonnenten des +// angegebenen Ereignistyps ein — dies ist die EINZIGE Schnittstelle, die +// ein Fachmodul braucht (Akzeptanzkriterium 1). Jeder Abonnent erhaelt +// einen eigenen, unabhaengigen Zustellversuch-Datensatz. +func (s *Store) Enqueue(ctx context.Context, eventType string, payload any) (int, error) { + payloadJSON, err := json.Marshal(payload) + if err != nil { + return 0, fmt.Errorf("payload serialisieren: %w", err) + } + + rows, err := s.pool.Query(ctx, ` + SELECT id FROM webhook_subscriptions WHERE event_type = $1 + `, eventType) + if err != nil { + return 0, fmt.Errorf("abonnenten ermitteln: %w", err) + } + var subscriptionIDs []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + rows.Close() + return 0, fmt.Errorf("abonnent lesen: %w", err) + } + subscriptionIDs = append(subscriptionIDs, id) + } + rows.Close() + if err := rows.Err(); err != nil { + return 0, err + } + + for _, subID := range subscriptionIDs { + if _, err := s.pool.Exec(ctx, ` + INSERT INTO webhook_deliveries (subscription_id, event_type, payload, status, next_attempt_at) + VALUES ($1, $2, $3, $4, now()) + `, subID, eventType, payloadJSON, StatusPending); err != nil { + return 0, fmt.Errorf("zustellung einreihen: %w", err) + } + } + return len(subscriptionIDs), nil +} + +// delivery ist ein interner Datensatz fuer EINEN Zustellversuch, inklusive +// der zugehoerigen Abonnentendaten (per JOIN geladen). +type delivery struct { + ID string + TargetURL string + Secret string + Payload []byte + Attempt int +} + +// Sign berechnet die HMAC-SHA256-Signatur des Payloads (Akzeptanzkriterium +// 3) — hex-kodiert, damit sie problemlos als HTTP-Header uebertragen werden +// kann. +func Sign(secret string, payload []byte) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(payload) + return hex.EncodeToString(mac.Sum(nil)) +} + +// SignatureHeader ist der HTTP-Header, unter dem die Signatur uebertragen +// wird — dokumentierter Vertrag fuer Empfaenger (Akzeptanzkriterium 3). +const SignatureHeader = "X-Nexarch-Signature-256" diff --git a/internal/webhook/webhook_test.go b/internal/webhook/webhook_test.go new file mode 100644 index 0000000..5273127 --- /dev/null +++ b/internal/webhook/webhook_test.go @@ -0,0 +1,198 @@ +package webhook + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "sync/atomic" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func setupTest(t *testing.T) (*Store, *pgxpool.Pool, func()) { + t.Helper() + adminDSN := os.Getenv("TEST_ADMIN_DSN") + if adminDSN == "" { + t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen") + } + ctx := context.Background() + + pool, err := pgxpool.New(ctx, adminDSN) + if err != nil { + t.Fatalf("pool: %v", err) + } + if _, err := pool.Exec(ctx, ` + CREATE EXTENSION IF NOT EXISTS pgcrypto; + CREATE TABLE IF NOT EXISTS webhook_subscriptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), event_type TEXT NOT NULL, + target_url TEXT NOT NULL, secret TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + CREATE TABLE IF NOT EXISTS webhook_deliveries ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), subscription_id UUID NOT NULL REFERENCES webhook_subscriptions(id), + event_type TEXT NOT NULL, payload JSONB NOT NULL, status TEXT NOT NULL DEFAULT 'pending', + attempt INT NOT NULL DEFAULT 0, next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_error TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), delivered_at TIMESTAMPTZ + ); + `); err != nil { + t.Fatalf("schema: %v", err) + } + + cleanup := func() { pool.Close() } + return NewStore(pool), pool, cleanup +} + +func uniqueEventType(prefix string) string { + return prefix + "-" + time.Now().Format("150405.000000000") +} + +// Akzeptanzkriterium 1 + Pruefung 1: ein Modul reicht ein Ereignis ein +// (Enqueue) ohne eigene Zustellungslogik, der zentrale Dispatcher liefert +// zuverlaessig aus. +func TestEnqueueAndProcessDue_DeliversSuccessfully(t *testing.T) { + store, pool, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + var receivedBody []byte + var receivedSignature string + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + receivedBody = body + receivedSignature = r.Header.Get(SignatureHeader) + w.WriteHeader(http.StatusOK) + })) + defer target.Close() + + eventType := uniqueEventType("dms.file.created") + sub, err := store.Subscribe(ctx, eventType, target.URL, "geheimes-secret") + if err != nil { + t.Fatalf("subscribe: %v", err) + } + + n, err := store.Enqueue(ctx, eventType, map[string]string{"file_id": "42"}) + if err != nil { + t.Fatalf("enqueue: %v", err) + } + if n != 1 { + t.Fatalf("erwartet 1 eingereihte zustellung, habe %d", n) + } + + dispatcher := NewDispatcher(pool, target.Client()) + processed, err := dispatcher.ProcessDue(ctx) + if err != nil { + t.Fatalf("processdue: %v", err) + } + if processed != 1 { + t.Fatalf("erwartet 1 verarbeitete zustellung, habe %d", processed) + } + + var status string + if err := pool.QueryRow(ctx, `SELECT status FROM webhook_deliveries WHERE subscription_id = $1`, sub.ID).Scan(&status); err != nil { + t.Fatalf("status lesen: %v", err) + } + if status != StatusDelivered { + t.Fatalf("status = %q, want %q", status, StatusDelivered) + } + + expectedPayload, _ := json.Marshal(map[string]string{"file_id": "42"}) + expectedSig := Sign("geheimes-secret", expectedPayload) + if receivedSignature != expectedSig { + t.Fatalf("empfangene signatur = %q, want %q", receivedSignature, expectedSig) + } + if string(receivedBody) != string(expectedPayload) { + t.Fatalf("empfangener payload = %q, want %q", receivedBody, expectedPayload) + } +} + +// Akzeptanzkriterium 2 + Pruefung 2: fehlschlagendes Ziel loest Retry mit +// wachsendem Backoff aus und endet nach der konfigurierten Obergrenze in +// "failed". +func TestProcessDue_RetriesWithBackoffThenMarksFailed(t *testing.T) { + store, pool, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + var callCount int32 + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&callCount, 1) + w.WriteHeader(http.StatusInternalServerError) + })) + defer target.Close() + + eventType := uniqueEventType("mail.send.failed") + sub, err := store.Subscribe(ctx, eventType, target.URL, "secret") + if err != nil { + t.Fatalf("subscribe: %v", err) + } + if _, err := store.Enqueue(ctx, eventType, map[string]string{"x": "y"}); err != nil { + t.Fatalf("enqueue: %v", err) + } + + dispatcher := NewDispatcher(pool, target.Client()) + dispatcher.MaxAttempts = 2 + dispatcher.BaseBackoff = 30 * time.Millisecond + + // 1. Versuch: schlaegt fehl, ist aber noch nicht die Obergrenze. + if _, err := dispatcher.ProcessDue(ctx); err != nil { + t.Fatalf("processdue 1: %v", err) + } + var status string + var attempt int + if err := pool.QueryRow(ctx, `SELECT status, attempt FROM webhook_deliveries WHERE subscription_id = $1`, sub.ID).Scan(&status, &attempt); err != nil { + t.Fatalf("status lesen 1: %v", err) + } + if status != StatusPending || attempt != 1 { + t.Fatalf("nach 1. fehlschlag: status=%q attempt=%d, want pending/1", status, attempt) + } + + // Sofort erneut verarbeiten: Backoff ist noch nicht abgelaufen -> nichts faellig. + processedTooEarly, err := dispatcher.ProcessDue(ctx) + if err != nil { + t.Fatalf("processdue (zu frueh): %v", err) + } + if processedTooEarly != 0 { + t.Fatal("erwartet 0 verarbeitete zustellungen, solange backoff nicht abgelaufen ist") + } + + time.Sleep(dispatcher.backoffFor(1) + 20*time.Millisecond) + + // 2. Versuch: erreicht MaxAttempts=2 -> endgueltig fehlgeschlagen. + if _, err := dispatcher.ProcessDue(ctx); err != nil { + t.Fatalf("processdue 2: %v", err) + } + if err := pool.QueryRow(ctx, `SELECT status, attempt FROM webhook_deliveries WHERE subscription_id = $1`, sub.ID).Scan(&status, &attempt); err != nil { + t.Fatalf("status lesen 2: %v", err) + } + if status != StatusFailed || attempt != 2 { + t.Fatalf("nach 2. fehlschlag: status=%q attempt=%d, want failed/2", status, attempt) + } + if atomic.LoadInt32(&callCount) != 2 { + t.Fatalf("erwartet genau 2 tatsaechliche zustellversuche, habe %d", callCount) + } +} + +// Akzeptanzkriterium 3 + Pruefung 3: Signaturpruefung erkennt eine +// manipulierte Payload zuverlaessig. +func TestVerifySignature_DetectsTamperedPayload(t *testing.T) { + secret := "geteiltes-geheimnis" + payload := []byte(`{"file_id":"42"}`) + signature := Sign(secret, payload) + + if !VerifySignature(secret, payload, signature) { + t.Fatal("erwartet gueltige signatur fuer unveraenderte payload") + } + + tampered := []byte(`{"file_id":"99"}`) + if VerifySignature(secret, tampered, signature) { + t.Fatal("erwartet ungueltige signatur fuer manipulierte payload") + } + + if VerifySignature("falsches-secret", payload, signature) { + t.Fatal("erwartet ungueltige signatur bei falschem secret") + } +} diff --git a/migrations/0003_webhooks.down.sql b/migrations/0003_webhooks.down.sql new file mode 100644 index 0000000..60584e3 --- /dev/null +++ b/migrations/0003_webhooks.down.sql @@ -0,0 +1,2 @@ +DROP TABLE webhook_deliveries; +DROP TABLE webhook_subscriptions; diff --git a/migrations/0003_webhooks.up.sql b/migrations/0003_webhooks.up.sql new file mode 100644 index 0000000..b403330 --- /dev/null +++ b/migrations/0003_webhooks.up.sql @@ -0,0 +1,27 @@ +-- Zentrale Webhook-Registry & Zustellung (API-07, siehe +-- core-kanban/tickets/API-07.md) — Postgres-basierte Jobqueue, kein +-- Redis/AMQP (Ticket-Vorgabe). +CREATE TABLE webhook_subscriptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_type TEXT NOT NULL, + target_url TEXT NOT NULL, + secret TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX webhook_subscriptions_event_type_idx ON webhook_subscriptions (event_type); + +CREATE TABLE webhook_deliveries ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + subscription_id UUID NOT NULL REFERENCES webhook_subscriptions(id), + event_type TEXT NOT NULL, + payload JSONB NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', -- pending|delivered|failed + attempt INT NOT NULL DEFAULT 0, + next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + delivered_at TIMESTAMPTZ +); + +CREATE INDEX webhook_deliveries_due_idx ON webhook_deliveries (status, next_attempt_at); From 34a705390cb29b1088794ebf1ac6bf5bc8c60fa7 Mon Sep 17 00:00:00 2001 From: sysops Date: Fri, 28 Aug 2026 09:06:05 +0200 Subject: [PATCH 2/3] =?UTF-8?q?API-07:=20test-fix=20=E2=80=94=20signaturve?= =?UTF-8?q?rgleich=20gegen=20tatsaechlich=20empfangene=20bytes=20(jsonb-ka?= =?UTF-8?q?nonisierung)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/webhook/webhook_test.go | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/internal/webhook/webhook_test.go b/internal/webhook/webhook_test.go index 5273127..2f78674 100644 --- a/internal/webhook/webhook_test.go +++ b/internal/webhook/webhook_test.go @@ -27,7 +27,6 @@ func setupTest(t *testing.T) (*Store, *pgxpool.Pool, func()) { t.Fatalf("pool: %v", err) } if _, err := pool.Exec(ctx, ` - CREATE EXTENSION IF NOT EXISTS pgcrypto; CREATE TABLE IF NOT EXISTS webhook_subscriptions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), event_type TEXT NOT NULL, target_url TEXT NOT NULL, secret TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() @@ -99,13 +98,21 @@ func TestEnqueueAndProcessDue_DeliversSuccessfully(t *testing.T) { t.Fatalf("status = %q, want %q", status, StatusDelivered) } - expectedPayload, _ := json.Marshal(map[string]string{"file_id": "42"}) - expectedSig := Sign("geheimes-secret", expectedPayload) - if receivedSignature != expectedSig { - t.Fatalf("empfangene signatur = %q, want %q", receivedSignature, expectedSig) + // Postgres' JSONB-Spalte kann die Byte-Repraesentation des Payloads + // gegenueber dem urspruenglichen json.Marshal kanonisieren (z.B. + // Leerzeichen) — das ist unschaedlich, denn der Dispatcher signiert + // IMMER exakt die Bytes, die er auch sendet. Die Pruefung vergleicht + // deshalb Signatur gegen tatsaechlich empfangene Bytes (Selbstkonsistenz), + // nicht gegen eine unabhaengig neu marshalte Referenz. + if !VerifySignature("geheimes-secret", receivedBody, receivedSignature) { + t.Fatalf("empfangene signatur %q passt nicht zum empfangenen payload %q", receivedSignature, receivedBody) } - if string(receivedBody) != string(expectedPayload) { - t.Fatalf("empfangener payload = %q, want %q", receivedBody, expectedPayload) + var decoded map[string]string + if err := json.Unmarshal(receivedBody, &decoded); err != nil { + t.Fatalf("empfangener payload nicht als json lesbar: %v", err) + } + if decoded["file_id"] != "42" { + t.Fatalf("empfangener payload = %v, want file_id=42", decoded) } } From c4840ca55b126245a34c03fba23463bc0c39861d Mon Sep 17 00:00:00 2001 From: sysops Date: Fri, 28 Aug 2026 09:08:06 +0200 Subject: [PATCH 3/3] =?UTF-8?q?API-07:=20fix=20=E2=80=94=20unbenutzten=20p?= =?UTF-8?q?gx-import=20entfernen=20(build-fehler,=20nur=20auf=20testhost?= =?UTF-8?q?=20gepatcht=20gewesen)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/webhook/webhook.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/webhook/webhook.go b/internal/webhook/webhook.go index c0d0a81..f533bee 100644 --- a/internal/webhook/webhook.go +++ b/internal/webhook/webhook.go @@ -18,7 +18,6 @@ import ( "fmt" "time" - "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" )