Files
warmbly/internal/repository/pg_webhook.go
T
Matthew Meszaros ab445e2207 feat: customer webhook subscriptions with hmac signing and retry
- 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
2026-05-25 16:03:13 +00:00

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
}