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 }