mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-07 08:01:41 +00:00
- webhook_endpoints / webhook_deliveries schema (migration 42) - service: dispatch + endpoint crud + hmac-sha256 signing - delivery worker drains queue using FOR UPDATE SKIP LOCKED so multiple api replicas can run safely without duplicate dispatch - exponential backoff (30s → 1h cap, 8 attempts then abandoned) - REST API under /webhooks: list/create/update/delete/rotate-secret/ list-deliveries. secret only returned at create + rotate - header convention matches stripe-style: X-Warmbly-Signature: t=<unix>,v1=<hex> - legacy header X-Warmbly-Token already renamed; new outbound webhook headers are X-Warmbly-Signature / X-Warmbly-Event / X-Warmbly-Event-Id - wired into email account connect/remove and warmup health transitions; campaign/tracking/deliverability call sites will reuse the same webhookService.Dispatch interface
388 lines
13 KiB
Go
388 lines
13 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
)
|
|
|
|
// WebhookRepository persists webhook endpoints and per-attempt delivery
|
|
// records. The dispatcher polls due deliveries from this repo.
|
|
type WebhookRepository interface {
|
|
// Endpoints
|
|
CreateEndpoint(ctx context.Context, endpoint *models.WebhookEndpoint, secret string) error
|
|
UpdateEndpoint(ctx context.Context, endpoint *models.WebhookEndpoint) error
|
|
RotateSecret(ctx context.Context, orgID, endpointID uuid.UUID, newSecret string) error
|
|
DeleteEndpoint(ctx context.Context, orgID, endpointID uuid.UUID) error
|
|
GetEndpoint(ctx context.Context, orgID, endpointID uuid.UUID) (*models.WebhookEndpoint, error)
|
|
GetEndpointSecret(ctx context.Context, endpointID uuid.UUID) (string, error)
|
|
ListEndpointsForOrg(ctx context.Context, orgID uuid.UUID) ([]models.WebhookEndpoint, error)
|
|
// MatchingEndpoints returns enabled endpoints for the org that subscribe
|
|
// to the given event type (either explicitly or via the wildcard empty filter).
|
|
MatchingEndpoints(ctx context.Context, orgID uuid.UUID, eventType models.WebhookEventType) ([]models.WebhookEndpoint, error)
|
|
|
|
// Deliveries
|
|
EnqueueDelivery(ctx context.Context, delivery *models.WebhookDelivery) error
|
|
ClaimDueDeliveries(ctx context.Context, limit int) ([]models.WebhookDelivery, error)
|
|
MarkDelivered(ctx context.Context, id uuid.UUID, responseStatus int) error
|
|
MarkRetry(ctx context.Context, id uuid.UUID, nextAttemptAt time.Time, responseStatus *int, errorReason string, bodyExcerpt string) error
|
|
MarkAbandoned(ctx context.Context, id uuid.UUID, errorReason string) error
|
|
ListDeliveriesForEndpoint(ctx context.Context, orgID, endpointID uuid.UUID, limit int) ([]models.WebhookDelivery, error)
|
|
UpdateEndpointHealthOnSuccess(ctx context.Context, endpointID uuid.UUID) error
|
|
UpdateEndpointHealthOnFailure(ctx context.Context, endpointID uuid.UUID, reason string) error
|
|
}
|
|
|
|
type webhookRepository struct {
|
|
db *pgxpool.Pool
|
|
}
|
|
|
|
func NewWebhookRepository(db *pgxpool.Pool) WebhookRepository {
|
|
return &webhookRepository{db: db}
|
|
}
|
|
|
|
func (r *webhookRepository) CreateEndpoint(ctx context.Context, endpoint *models.WebhookEndpoint, secret string) error {
|
|
if endpoint.ID == uuid.Nil {
|
|
endpoint.ID = uuid.New()
|
|
}
|
|
endpoint.CreatedAt = time.Now().UTC()
|
|
endpoint.UpdatedAt = endpoint.CreatedAt
|
|
|
|
_, err := r.db.Exec(ctx, `
|
|
INSERT INTO webhook_endpoints (
|
|
id, organization_id, url, description, secret, event_types,
|
|
enabled, created_at, updated_at
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8)
|
|
`,
|
|
endpoint.ID, endpoint.OrganizationID, endpoint.URL, endpoint.Description,
|
|
secret, endpoint.EventTypes, endpoint.Enabled, endpoint.CreatedAt,
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (r *webhookRepository) UpdateEndpoint(ctx context.Context, endpoint *models.WebhookEndpoint) error {
|
|
endpoint.UpdatedAt = time.Now().UTC()
|
|
cmd, err := r.db.Exec(ctx, `
|
|
UPDATE webhook_endpoints
|
|
SET url = $1, description = $2, event_types = $3, enabled = $4, updated_at = $5
|
|
WHERE id = $6 AND organization_id = $7
|
|
`,
|
|
endpoint.URL, endpoint.Description, endpoint.EventTypes, endpoint.Enabled,
|
|
endpoint.UpdatedAt, endpoint.ID, endpoint.OrganizationID,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if cmd.RowsAffected() == 0 {
|
|
return errors.New("webhook endpoint not found")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *webhookRepository) RotateSecret(ctx context.Context, orgID, endpointID uuid.UUID, newSecret string) error {
|
|
cmd, err := r.db.Exec(ctx,
|
|
`UPDATE webhook_endpoints SET secret = $1, updated_at = NOW() WHERE id = $2 AND organization_id = $3`,
|
|
newSecret, endpointID, orgID,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if cmd.RowsAffected() == 0 {
|
|
return errors.New("webhook endpoint not found")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *webhookRepository) DeleteEndpoint(ctx context.Context, orgID, endpointID uuid.UUID) error {
|
|
cmd, err := r.db.Exec(ctx,
|
|
`DELETE FROM webhook_endpoints WHERE id = $1 AND organization_id = $2`,
|
|
endpointID, orgID,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if cmd.RowsAffected() == 0 {
|
|
return errors.New("webhook endpoint not found")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *webhookRepository) GetEndpoint(ctx context.Context, orgID, endpointID uuid.UUID) (*models.WebhookEndpoint, error) {
|
|
endpoint := &models.WebhookEndpoint{}
|
|
err := r.db.QueryRow(ctx, `
|
|
SELECT id, organization_id, url, description, event_types, enabled,
|
|
last_success_at, last_failure_at, last_failure_reason, consecutive_failures,
|
|
created_at, updated_at
|
|
FROM webhook_endpoints WHERE id = $1 AND organization_id = $2
|
|
`, endpointID, orgID).Scan(
|
|
&endpoint.ID, &endpoint.OrganizationID, &endpoint.URL, &endpoint.Description,
|
|
&endpoint.EventTypes, &endpoint.Enabled,
|
|
&endpoint.LastSuccessAt, &endpoint.LastFailureAt, &endpoint.LastFailureReason,
|
|
&endpoint.ConsecutiveFailures,
|
|
&endpoint.CreatedAt, &endpoint.UpdatedAt,
|
|
)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return endpoint, nil
|
|
}
|
|
|
|
func (r *webhookRepository) GetEndpointSecret(ctx context.Context, endpointID uuid.UUID) (string, error) {
|
|
var secret string
|
|
err := r.db.QueryRow(ctx,
|
|
`SELECT secret FROM webhook_endpoints WHERE id = $1`, endpointID,
|
|
).Scan(&secret)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return "", errors.New("webhook endpoint not found")
|
|
}
|
|
return secret, err
|
|
}
|
|
|
|
func (r *webhookRepository) ListEndpointsForOrg(ctx context.Context, orgID uuid.UUID) ([]models.WebhookEndpoint, error) {
|
|
rows, err := r.db.Query(ctx, `
|
|
SELECT id, organization_id, url, description, event_types, enabled,
|
|
last_success_at, last_failure_at, last_failure_reason, consecutive_failures,
|
|
created_at, updated_at
|
|
FROM webhook_endpoints WHERE organization_id = $1 ORDER BY created_at DESC
|
|
`, orgID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []models.WebhookEndpoint
|
|
for rows.Next() {
|
|
var e models.WebhookEndpoint
|
|
if err := rows.Scan(
|
|
&e.ID, &e.OrganizationID, &e.URL, &e.Description,
|
|
&e.EventTypes, &e.Enabled,
|
|
&e.LastSuccessAt, &e.LastFailureAt, &e.LastFailureReason,
|
|
&e.ConsecutiveFailures,
|
|
&e.CreatedAt, &e.UpdatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, e)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (r *webhookRepository) MatchingEndpoints(ctx context.Context, orgID uuid.UUID, eventType models.WebhookEventType) ([]models.WebhookEndpoint, error) {
|
|
// Index-friendly: empty event_types array matches everything, otherwise
|
|
// the GIN index on event_types covers @> lookups.
|
|
rows, err := r.db.Query(ctx, `
|
|
SELECT id, organization_id, url, description, event_types, enabled,
|
|
last_success_at, last_failure_at, last_failure_reason, consecutive_failures,
|
|
created_at, updated_at
|
|
FROM webhook_endpoints
|
|
WHERE organization_id = $1
|
|
AND enabled
|
|
AND (cardinality(event_types) = 0 OR event_types @> ARRAY[$2]::text[])
|
|
`, orgID, string(eventType))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []models.WebhookEndpoint
|
|
for rows.Next() {
|
|
var e models.WebhookEndpoint
|
|
if err := rows.Scan(
|
|
&e.ID, &e.OrganizationID, &e.URL, &e.Description,
|
|
&e.EventTypes, &e.Enabled,
|
|
&e.LastSuccessAt, &e.LastFailureAt, &e.LastFailureReason,
|
|
&e.ConsecutiveFailures,
|
|
&e.CreatedAt, &e.UpdatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, e)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (r *webhookRepository) EnqueueDelivery(ctx context.Context, delivery *models.WebhookDelivery) error {
|
|
if delivery.ID == uuid.Nil {
|
|
delivery.ID = uuid.New()
|
|
}
|
|
if delivery.MaxAttempts == 0 {
|
|
delivery.MaxAttempts = 8
|
|
}
|
|
if delivery.NextAttemptAt.IsZero() {
|
|
delivery.NextAttemptAt = time.Now().UTC()
|
|
}
|
|
if delivery.Status == "" {
|
|
delivery.Status = models.WebhookDeliveryPending
|
|
}
|
|
_, err := r.db.Exec(ctx, `
|
|
INSERT INTO webhook_deliveries (
|
|
id, endpoint_id, organization_id, event_type, event_id, payload,
|
|
status, attempt_count, max_attempts, next_attempt_at, created_at, updated_at
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
|
|
`,
|
|
delivery.ID, delivery.EndpointID, delivery.OrganizationID,
|
|
delivery.EventType, delivery.EventID, json.RawMessage(delivery.Payload),
|
|
string(delivery.Status), delivery.AttemptCount, delivery.MaxAttempts,
|
|
delivery.NextAttemptAt,
|
|
)
|
|
return err
|
|
}
|
|
|
|
// ClaimDueDeliveries atomically picks up to `limit` deliveries due for
|
|
// dispatch, marks them in_flight, and returns them. The SKIP LOCKED clause
|
|
// lets multiple dispatcher workers run concurrently without colliding.
|
|
func (r *webhookRepository) ClaimDueDeliveries(ctx context.Context, limit int) ([]models.WebhookDelivery, error) {
|
|
rows, err := r.db.Query(ctx, `
|
|
WITH claimed AS (
|
|
SELECT id FROM webhook_deliveries
|
|
WHERE status IN ('pending')
|
|
AND next_attempt_at <= NOW()
|
|
ORDER BY next_attempt_at ASC
|
|
FOR UPDATE SKIP LOCKED
|
|
LIMIT $1
|
|
)
|
|
UPDATE webhook_deliveries d
|
|
SET status = 'in_flight',
|
|
attempt_count = attempt_count + 1,
|
|
last_attempt_at = NOW(),
|
|
updated_at = NOW()
|
|
FROM claimed
|
|
WHERE d.id = claimed.id
|
|
RETURNING d.id, d.endpoint_id, d.organization_id, d.event_type, d.event_id,
|
|
d.payload, d.status, d.attempt_count, d.max_attempts,
|
|
d.next_attempt_at, d.last_attempt_at,
|
|
d.response_status, d.response_body_excerpt, d.error_reason,
|
|
d.created_at, d.updated_at
|
|
`, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []models.WebhookDelivery
|
|
for rows.Next() {
|
|
var d models.WebhookDelivery
|
|
var status string
|
|
if err := rows.Scan(
|
|
&d.ID, &d.EndpointID, &d.OrganizationID, &d.EventType, &d.EventID,
|
|
&d.Payload, &status, &d.AttemptCount, &d.MaxAttempts,
|
|
&d.NextAttemptAt, &d.LastAttemptAt,
|
|
&d.ResponseStatus, &d.ResponseBodyExcerpt, &d.ErrorReason,
|
|
&d.CreatedAt, &d.UpdatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
d.Status = models.WebhookDeliveryStatus(status)
|
|
out = append(out, d)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (r *webhookRepository) MarkDelivered(ctx context.Context, id uuid.UUID, responseStatus int) error {
|
|
_, err := r.db.Exec(ctx, `
|
|
UPDATE webhook_deliveries
|
|
SET status = 'delivered',
|
|
response_status = $1,
|
|
error_reason = NULL,
|
|
updated_at = NOW()
|
|
WHERE id = $2
|
|
`, responseStatus, id)
|
|
return err
|
|
}
|
|
|
|
func (r *webhookRepository) MarkRetry(ctx context.Context, id uuid.UUID, nextAttemptAt time.Time, responseStatus *int, errorReason, bodyExcerpt string) error {
|
|
_, err := r.db.Exec(ctx, `
|
|
UPDATE webhook_deliveries
|
|
SET status = 'pending',
|
|
next_attempt_at = $1,
|
|
response_status = $2,
|
|
response_body_excerpt = NULLIF($3, ''),
|
|
error_reason = NULLIF($4, ''),
|
|
updated_at = NOW()
|
|
WHERE id = $5
|
|
`, nextAttemptAt, responseStatus, bodyExcerpt, errorReason, id)
|
|
return err
|
|
}
|
|
|
|
func (r *webhookRepository) MarkAbandoned(ctx context.Context, id uuid.UUID, errorReason string) error {
|
|
_, err := r.db.Exec(ctx, `
|
|
UPDATE webhook_deliveries
|
|
SET status = 'abandoned',
|
|
error_reason = $1,
|
|
updated_at = NOW()
|
|
WHERE id = $2
|
|
`, errorReason, id)
|
|
return err
|
|
}
|
|
|
|
func (r *webhookRepository) ListDeliveriesForEndpoint(ctx context.Context, orgID, endpointID uuid.UUID, limit int) ([]models.WebhookDelivery, error) {
|
|
if limit <= 0 || limit > 200 {
|
|
limit = 50
|
|
}
|
|
rows, err := r.db.Query(ctx, `
|
|
SELECT id, endpoint_id, organization_id, event_type, event_id, payload,
|
|
status, attempt_count, max_attempts, next_attempt_at, last_attempt_at,
|
|
response_status, response_body_excerpt, error_reason,
|
|
created_at, updated_at
|
|
FROM webhook_deliveries
|
|
WHERE organization_id = $1 AND endpoint_id = $2
|
|
ORDER BY created_at DESC
|
|
LIMIT $3
|
|
`, orgID, endpointID, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []models.WebhookDelivery
|
|
for rows.Next() {
|
|
var d models.WebhookDelivery
|
|
var status string
|
|
if err := rows.Scan(
|
|
&d.ID, &d.EndpointID, &d.OrganizationID, &d.EventType, &d.EventID,
|
|
&d.Payload, &status, &d.AttemptCount, &d.MaxAttempts,
|
|
&d.NextAttemptAt, &d.LastAttemptAt,
|
|
&d.ResponseStatus, &d.ResponseBodyExcerpt, &d.ErrorReason,
|
|
&d.CreatedAt, &d.UpdatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
d.Status = models.WebhookDeliveryStatus(status)
|
|
out = append(out, d)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (r *webhookRepository) UpdateEndpointHealthOnSuccess(ctx context.Context, endpointID uuid.UUID) error {
|
|
_, err := r.db.Exec(ctx, `
|
|
UPDATE webhook_endpoints
|
|
SET last_success_at = NOW(),
|
|
consecutive_failures = 0,
|
|
last_failure_reason = NULL,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
`, endpointID)
|
|
return err
|
|
}
|
|
|
|
func (r *webhookRepository) UpdateEndpointHealthOnFailure(ctx context.Context, endpointID uuid.UUID, reason string) error {
|
|
_, err := r.db.Exec(ctx, `
|
|
UPDATE webhook_endpoints
|
|
SET last_failure_at = NOW(),
|
|
last_failure_reason = $1,
|
|
consecutive_failures = consecutive_failures + 1,
|
|
updated_at = NOW()
|
|
WHERE id = $2
|
|
`, reason, endpointID)
|
|
return err
|
|
}
|