This commit is contained in:
Josh 2026-01-09 00:16:46 +02:00
commit d69342b2e9
160 changed files with 28681 additions and 0 deletions

308
internal/tenant/webhooks.go Normal file
View file

@ -0,0 +1,308 @@
package tenant
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"net/http"
"time"
"github.com/google/uuid"
)
type Webhook struct {
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Events []string `json:"events"`
Secret string `json:"secret,omitempty"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
LastTriggeredAt *time.Time `json:"last_triggered_at"`
LastStatus *string `json:"last_status"`
}
type WebhookDelivery struct {
ID int64 `json:"id"`
WebhookID string `json:"webhook_id"`
Event string `json:"event"`
Payload string `json:"payload"`
Status string `json:"status"`
ResponseCode *int `json:"response_code"`
ResponseBody *string `json:"response_body"`
Attempts int `json:"attempts"`
CreatedAt time.Time `json:"created_at"`
}
type WebhookPayload struct {
Event string `json:"event"`
Timestamp time.Time `json:"timestamp"`
Data any `json:"data"`
}
func (q *Queries) CountWebhooks(ctx context.Context) (int, error) {
var count int
err := q.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM webhooks`).Scan(&count)
return count, err
}
func (q *Queries) CreateWebhook(ctx context.Context, name, url string, events []string, secret string) (*Webhook, error) {
id := uuid.New().String()
eventsJSON, _ := json.Marshal(events)
_, err := q.db.ExecContext(ctx, `
INSERT INTO webhooks (id, name, url, events, secret, enabled, created_at)
VALUES (?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP)
`, id, name, url, string(eventsJSON), secret)
if err != nil {
return nil, err
}
return q.GetWebhook(ctx, id)
}
func (q *Queries) GetWebhook(ctx context.Context, id string) (*Webhook, error) {
var w Webhook
var eventsJSON string
var lastTriggeredAt, lastStatus sql.NullString
var createdAtStr string
err := q.db.QueryRowContext(ctx, `
SELECT id, name, url, events, secret, enabled, created_at, last_triggered_at, last_status
FROM webhooks WHERE id = ?
`, id).Scan(&w.ID, &w.Name, &w.URL, &eventsJSON, &w.Secret, &w.Enabled, &createdAtStr, &lastTriggeredAt, &lastStatus)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
json.Unmarshal([]byte(eventsJSON), &w.Events)
w.CreatedAt, _ = time.Parse(time.RFC3339, createdAtStr)
if lastTriggeredAt.Valid {
t, _ := time.Parse(time.RFC3339, lastTriggeredAt.String)
w.LastTriggeredAt = &t
}
if lastStatus.Valid {
w.LastStatus = &lastStatus.String
}
return &w, nil
}
func (q *Queries) ListWebhooks(ctx context.Context) ([]Webhook, error) {
rows, err := q.db.QueryContext(ctx, `
SELECT id, name, url, events, secret, enabled, created_at, last_triggered_at, last_status
FROM webhooks ORDER BY created_at DESC
`)
if err != nil {
return nil, err
}
defer rows.Close()
var webhooks []Webhook
for rows.Next() {
var w Webhook
var eventsJSON string
var lastTriggeredAt, lastStatus sql.NullString
var createdAtStr string
if err := rows.Scan(&w.ID, &w.Name, &w.URL, &eventsJSON, &w.Secret, &w.Enabled, &createdAtStr, &lastTriggeredAt, &lastStatus); err != nil {
return nil, err
}
json.Unmarshal([]byte(eventsJSON), &w.Events)
w.CreatedAt, _ = time.Parse(time.RFC3339, createdAtStr)
if lastTriggeredAt.Valid {
t, _ := time.Parse(time.RFC3339, lastTriggeredAt.String)
w.LastTriggeredAt = &t
}
if lastStatus.Valid {
w.LastStatus = &lastStatus.String
}
webhooks = append(webhooks, w)
}
return webhooks, rows.Err()
}
func (q *Queries) UpdateWebhook(ctx context.Context, id, name, url string, events []string, secret string, enabled bool) error {
eventsJSON, _ := json.Marshal(events)
_, err := q.db.ExecContext(ctx, `
UPDATE webhooks SET name = ?, url = ?, events = ?, secret = ?, enabled = ?
WHERE id = ?
`, name, url, string(eventsJSON), secret, enabled, id)
return err
}
func (q *Queries) DeleteWebhook(ctx context.Context, id string) error {
_, err := q.db.ExecContext(ctx, `DELETE FROM webhooks WHERE id = ?`, id)
return err
}
func (q *Queries) ListWebhooksByEvent(ctx context.Context, event string) ([]Webhook, error) {
webhooks, err := q.ListWebhooks(ctx)
if err != nil {
return nil, err
}
var result []Webhook
for _, w := range webhooks {
if !w.Enabled {
continue
}
for _, e := range w.Events {
if e == event {
result = append(result, w)
break
}
}
}
return result, nil
}
func (q *Queries) TriggerWebhooks(ctx context.Context, event string, data any, baseURL string) {
webhooks, err := q.ListWebhooksByEvent(ctx, event)
if err != nil || len(webhooks) == 0 {
return
}
payload := WebhookPayload{
Event: event,
Timestamp: time.Now().UTC(),
Data: data,
}
payloadJSON, _ := json.Marshal(payload)
for _, w := range webhooks {
go q.deliverWebhook(ctx, w, event, payloadJSON)
}
}
func (q *Queries) deliverWebhook(ctx context.Context, w Webhook, event string, payloadJSON []byte) {
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequestWithContext(ctx, "POST", w.URL, bytes.NewReader(payloadJSON))
if err != nil {
q.logDelivery(ctx, w.ID, event, string(payloadJSON), "failed", nil, stringPtr(err.Error()))
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "WriteKit-Webhook/1.0")
if w.Secret != "" {
mac := hmac.New(sha256.New, []byte(w.Secret))
mac.Write(payloadJSON)
signature := hex.EncodeToString(mac.Sum(nil))
req.Header.Set("X-WriteKit-Signature", signature)
}
resp, err := client.Do(req)
if err != nil {
q.logDelivery(ctx, w.ID, event, string(payloadJSON), "failed", nil, stringPtr(err.Error()))
q.updateWebhookStatus(ctx, w.ID, "failed")
return
}
defer resp.Body.Close()
var respBody string
buf := make([]byte, 1024)
n, _ := resp.Body.Read(buf)
respBody = string(buf[:n])
status := "success"
if resp.StatusCode >= 400 {
status = "failed"
}
q.logDelivery(ctx, w.ID, event, string(payloadJSON), status, &resp.StatusCode, &respBody)
q.updateWebhookStatus(ctx, w.ID, status)
}
func (q *Queries) logDelivery(ctx context.Context, webhookID, event, payload, status string, responseCode *int, responseBody *string) {
q.db.ExecContext(ctx, `
INSERT INTO webhook_deliveries (webhook_id, event, payload, status, response_code, response_body, created_at)
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
`, webhookID, event, truncate(payload, 1024), status, responseCode, truncate(ptrToString(responseBody), 1024))
// Cleanup old deliveries - keep last 50 per webhook
q.db.ExecContext(ctx, `
DELETE FROM webhook_deliveries
WHERE webhook_id = ? AND id NOT IN (
SELECT id FROM webhook_deliveries WHERE webhook_id = ?
ORDER BY created_at DESC LIMIT 50
)
`, webhookID, webhookID)
}
func (q *Queries) updateWebhookStatus(ctx context.Context, webhookID, status string) {
q.db.ExecContext(ctx, `
UPDATE webhooks SET last_triggered_at = CURRENT_TIMESTAMP, last_status = ?
WHERE id = ?
`, status, webhookID)
}
func (q *Queries) ListWebhookDeliveries(ctx context.Context, webhookID string) ([]WebhookDelivery, error) {
rows, err := q.db.QueryContext(ctx, `
SELECT id, webhook_id, event, payload, status, response_code, response_body, attempts, created_at
FROM webhook_deliveries
WHERE webhook_id = ?
ORDER BY created_at DESC
LIMIT 50
`, webhookID)
if err != nil {
return nil, err
}
defer rows.Close()
var deliveries []WebhookDelivery
for rows.Next() {
var d WebhookDelivery
var createdAtStr string
var respCode sql.NullInt64
var respBody sql.NullString
if err := rows.Scan(&d.ID, &d.WebhookID, &d.Event, &d.Payload, &d.Status, &respCode, &respBody, &d.Attempts, &createdAtStr); err != nil {
return nil, err
}
d.CreatedAt, _ = time.Parse(time.RFC3339, createdAtStr)
if respCode.Valid {
code := int(respCode.Int64)
d.ResponseCode = &code
}
if respBody.Valid {
d.ResponseBody = &respBody.String
}
deliveries = append(deliveries, d)
}
return deliveries, rows.Err()
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max]
}
func stringPtr(s string) *string {
return &s
}
func ptrToString(p *string) string {
if p == nil {
return ""
}
return *p
}