Files
silo-server/internal/notifications/webpush_repo.go
QuickandClaude Fable 5 e5b210589d fix(notifications): address PR #136 review findings
Codex + CodeRabbit review fixes, all verified against current behavior:

- Web Push: single-writer VAPID provisioning via a new conditional
  SetIfAbsent settings write (no split-brain identity across nodes), and
  read/decode failures now surface instead of silently rotating the
  keypair; the eager-provisioning goroutine joins the shutdown WaitGroup
- Web Push: endpoint reassignment purges the previous owner's pending
  attempts inside the upsert transaction, with an ownership re-check at
  send time
- Webhooks: per-profile cap enforced atomically (advisory-locked
  count+insert), typed pgconn unique-violation mapping, create-time
  type/URL mismatch rejection, send-time HTTPS re-check, and Retry-After
  HTTP-date support (shared, clamped parser also used by web push)
- Delivery workers: transient delivery-row lookup errors leave the claim
  to lease expiry instead of permanently failing the attempt
- Interest: history-only imports now feed the index (userstore history
  hooks + completed-history folding in recompute/rebuild), rebuild also
  recomputes existing interest rows so removed sources get cleaned up,
  and failed flush mutations requeue (bounded) instead of dropping
- Retention: read notifications age from read_at, not created_at
- Startup: scan queue workers start only after the availability detector
  is wired, so resumed scans cannot skip availability recording
- mail: settings-store read failures propagate instead of reading as
  "not configured"
- DB: new migration adds episode ordinal/key CHECK constraints
- Web: service worker restricts notification clicks to same-origin URLs,
  preferences popover gets an error+retry state, and the realtime
  profile-rebind backoff grows to 5 minutes to keep shared channels
  stable through notifications-only outages

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:16:27 -04:00

320 lines
11 KiB
Go

package notifications
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// WebPushSubscription is one browser push registration, scoped to a profile.
type WebPushSubscription struct {
ID string
UserID int
ProfileID string
Endpoint string
P256dh string
Auth string
DeviceName string
Enabled bool
ConsecutiveFailures int
LastSuccessAt *time.Time
LastFailureAt *time.Time
LastFailureStatus *int
CreatedAt time.Time
UpdatedAt time.Time
}
// WebPushRepository owns web_push_subscriptions and
// web_push_delivery_attempts.
type WebPushRepository struct {
pool *pgxpool.Pool
}
// NewWebPushRepository creates a WebPushRepository.
func NewWebPushRepository(pool *pgxpool.Pool) *WebPushRepository {
return &WebPushRepository{pool: pool}
}
const webPushColumns = `
id, user_id, profile_id, endpoint, p256dh, auth, device_name, enabled,
consecutive_failures, last_success_at, last_failure_at, last_failure_status,
created_at, updated_at`
func scanWebPushSubscription(row pgx.Row) (*WebPushSubscription, error) {
var sub WebPushSubscription
err := row.Scan(
&sub.ID, &sub.UserID, &sub.ProfileID, &sub.Endpoint, &sub.P256dh, &sub.Auth,
&sub.DeviceName, &sub.Enabled, &sub.ConsecutiveFailures,
&sub.LastSuccessAt, &sub.LastFailureAt, &sub.LastFailureStatus,
&sub.CreatedAt, &sub.UpdatedAt,
)
if err != nil {
return nil, err
}
return &sub, nil
}
func scanWebPushSubscriptions(rows pgx.Rows) ([]WebPushSubscription, error) {
defer rows.Close()
subs := make([]WebPushSubscription, 0, 4)
for rows.Next() {
sub, err := scanWebPushSubscription(rows)
if err != nil {
return nil, fmt.Errorf("scan web push subscription: %w", err)
}
subs = append(subs, *sub)
}
return subs, rows.Err()
}
// Upsert registers a subscription. An existing endpoint is reassigned to the
// caller's (user, profile): one browser endpoint notifies exactly one
// profile, the one that subscribed most recently.
func (r *WebPushRepository) Upsert(ctx context.Context, sub WebPushSubscription) (*WebPushSubscription, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin web push upsert: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
// Reassignment keeps the row id, so undelivered attempts enqueued for the
// previous owner would otherwise be sent to the new owner's browser once
// the retry worker reclaims them. Cancel them before the ownership flips.
if _, err := tx.Exec(ctx, `
DELETE FROM web_push_delivery_attempts
WHERE outcome IN ('pending', 'retrying')
AND subscription_id IN (
SELECT id FROM web_push_subscriptions
WHERE endpoint = $1 AND profile_id <> $2
)`,
sub.Endpoint, sub.ProfileID); err != nil {
return nil, fmt.Errorf("purge reassigned web push attempts: %w", err)
}
row := tx.QueryRow(ctx, `
INSERT INTO web_push_subscriptions
(id, user_id, profile_id, endpoint, p256dh, auth, device_name)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (endpoint) DO UPDATE SET
user_id = EXCLUDED.user_id,
profile_id = EXCLUDED.profile_id,
p256dh = EXCLUDED.p256dh,
auth = EXCLUDED.auth,
device_name = EXCLUDED.device_name,
enabled = true,
consecutive_failures = 0,
updated_at = now()
RETURNING `+webPushColumns,
sub.ID, sub.UserID, sub.ProfileID, sub.Endpoint, sub.P256dh, sub.Auth, sub.DeviceName)
saved, err := scanWebPushSubscription(row)
if err != nil {
return nil, fmt.Errorf("upsert web push subscription: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit web push upsert: %w", err)
}
return saved, nil
}
// ListByProfile returns a profile's subscriptions for the settings UI.
func (r *WebPushRepository) ListByProfile(ctx context.Context, profileID string) ([]WebPushSubscription, error) {
rows, err := r.pool.Query(ctx,
`SELECT `+webPushColumns+` FROM web_push_subscriptions WHERE profile_id = $1 ORDER BY created_at`,
profileID)
if err != nil {
return nil, fmt.Errorf("list web push subscriptions: %w", err)
}
return scanWebPushSubscriptions(rows)
}
// ListEnabledByProfiles loads enabled subscriptions keyed by profile, inside
// the fanout transaction (outbox enqueue).
func (r *WebPushRepository) ListEnabledByProfiles(ctx context.Context, tx pgx.Tx, profileIDs []string) (map[string][]WebPushSubscription, error) {
out := make(map[string][]WebPushSubscription, len(profileIDs))
if len(profileIDs) == 0 {
return out, nil
}
rows, err := tx.Query(ctx,
`SELECT `+webPushColumns+` FROM web_push_subscriptions WHERE profile_id = ANY($1) AND enabled`,
profileIDs)
if err != nil {
return nil, fmt.Errorf("list enabled web push subscriptions: %w", err)
}
subs, err := scanWebPushSubscriptions(rows)
if err != nil {
return nil, err
}
for _, sub := range subs {
out[sub.ProfileID] = append(out[sub.ProfileID], sub)
}
return out, nil
}
func (r *WebPushRepository) getByIDUnscoped(ctx context.Context, id string) (*WebPushSubscription, error) {
sub, err := scanWebPushSubscription(r.pool.QueryRow(ctx,
`SELECT `+webPushColumns+` FROM web_push_subscriptions WHERE id = $1`, id))
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get web push subscription: %w", err)
}
return sub, nil
}
// Delete removes one subscription scoped to the profile. Idempotent.
func (r *WebPushRepository) Delete(ctx context.Context, profileID, id string) error {
_, err := r.pool.Exec(ctx,
`DELETE FROM web_push_subscriptions WHERE profile_id = $1 AND id = $2`, profileID, id)
return err
}
// DeleteByEndpoint removes a subscription by its endpoint (browser
// unsubscribe flow, where the client only knows the endpoint). Scoped to the
// user, not the profile: Upsert reassigns an endpoint across profiles of the
// same account, so a disable issued under one profile must still delete the
// row even when another profile owns it.
func (r *WebPushRepository) DeleteByEndpoint(ctx context.Context, userID int, endpoint string) error {
_, err := r.pool.Exec(ctx,
`DELETE FROM web_push_subscriptions WHERE user_id = $1 AND endpoint = $2`, userID, endpoint)
return err
}
// deleteGone removes a subscription the push service reports as expired or
// revoked (HTTP 404/410). The attempts cascade.
func (r *WebPushRepository) deleteGone(ctx context.Context, id string) error {
_, err := r.pool.Exec(ctx,
`DELETE FROM web_push_subscriptions WHERE id = $1`, id)
return err
}
// DeleteAllForProfile removes a deleted profile's subscriptions.
func (r *WebPushRepository) DeleteAllForProfile(ctx context.Context, profileID string) error {
_, err := r.pool.Exec(ctx,
`DELETE FROM web_push_subscriptions WHERE profile_id = $1`, profileID)
return err
}
// RecordSuccess resets the failure streak after a delivered push.
func (r *WebPushRepository) RecordSuccess(ctx context.Context, id string) error {
_, err := r.pool.Exec(ctx, `
UPDATE web_push_subscriptions
SET last_success_at = now(), consecutive_failures = 0, updated_at = now()
WHERE id = $1`, id)
return err
}
// RecordFailure increments the failure streak.
func (r *WebPushRepository) RecordFailure(ctx context.Context, id string, httpStatus *int) error {
_, err := r.pool.Exec(ctx, `
UPDATE web_push_subscriptions
SET last_failure_at = now(), last_failure_status = $2,
consecutive_failures = consecutive_failures + 1, updated_at = now()
WHERE id = $1`, id, httpStatus)
return err
}
// --- Attempt outbox (mirrors webhook_delivery_attempts semantics) ---
// EnqueueAttempts inserts `pending` outbox rows inside the fanout transaction.
func (r *WebPushRepository) EnqueueAttempts(ctx context.Context, tx pgx.Tx, attempts []DeliveryAttempt) error {
if len(attempts) == 0 {
return nil
}
var sb strings.Builder
sb.WriteString(`
INSERT INTO web_push_delivery_attempts
(id, notification_delivery_id, subscription_id, attempt_number, outcome)
VALUES `)
args := make([]any, 0, len(attempts)*5)
for i, attempt := range attempts {
if i > 0 {
sb.WriteString(", ")
}
base := len(args)
sb.WriteString(fmt.Sprintf("($%d,$%d,$%d,$%d,$%d)", base+1, base+2, base+3, base+4, base+5))
args = append(args, attempt.ID, attempt.NotificationDeliveryID, attempt.TargetID, 0, WebhookOutcomePending)
}
sb.WriteString(" ON CONFLICT DO NOTHING")
if _, err := tx.Exec(ctx, sb.String(), args...); err != nil {
return fmt.Errorf("enqueue web push attempts: %w", err)
}
return nil
}
const webPushAttemptReturning = `
RETURNING id, notification_delivery_id, subscription_id, attempt_number, attempted_at,
next_retry_at, http_status, outcome, failure_message`
// ClaimPendingForDelivery claims a delivery's pending attempts for immediate
// post-commit dispatch (lease-based, like webhooks).
func (r *WebPushRepository) ClaimPendingForDelivery(ctx context.Context, deliveryID string) ([]DeliveryAttempt, error) {
return r.claim(ctx, `
UPDATE web_push_delivery_attempts SET outcome = 'retrying', next_retry_at = now() + $2
WHERE id IN (
SELECT id FROM web_push_delivery_attempts
WHERE notification_delivery_id = $1 AND outcome = 'pending'
FOR UPDATE SKIP LOCKED
)`+webPushAttemptReturning,
deliveryID, webhookClaimLease)
}
// ClaimDue claims due retries plus stale pending rows (outbox recovery).
func (r *WebPushRepository) ClaimDue(ctx context.Context, limit int) ([]DeliveryAttempt, error) {
return r.claim(ctx, `
UPDATE web_push_delivery_attempts SET outcome = 'retrying', next_retry_at = now() + $2
WHERE id IN (
SELECT id FROM web_push_delivery_attempts
WHERE (outcome = 'retrying' AND next_retry_at <= now())
OR (outcome = 'pending' AND attempted_at <= now() - interval '60 seconds')
ORDER BY next_retry_at NULLS FIRST
LIMIT $1
FOR UPDATE SKIP LOCKED
)`+webPushAttemptReturning,
limit, webhookClaimLease)
}
func (r *WebPushRepository) claim(ctx context.Context, query string, args ...any) ([]DeliveryAttempt, error) {
rows, err := r.pool.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("claim web push attempts: %w", err)
}
return scanDeliveryAttempts(rows)
}
// FinalizeAttempt records a send result.
func (r *WebPushRepository) FinalizeAttempt(ctx context.Context, attemptID, outcome string, attemptNumber int, httpStatus *int, failureMessage string, nextRetryAt *time.Time) error {
var message *string
if failureMessage != "" {
message = &failureMessage
}
_, err := r.pool.Exec(ctx, `
UPDATE web_push_delivery_attempts
SET outcome = $2, attempt_number = $3, attempted_at = now(),
http_status = $4, failure_message = left($5, 256), next_retry_at = $6
WHERE id = $1`,
attemptID, outcome, attemptNumber, httpStatus, message, nextRetryAt)
if err != nil {
return fmt.Errorf("finalize web push attempt: %w", err)
}
return nil
}
// DeleteOldAttempts applies retention: delivered past 7 days, failed past 30.
func (r *WebPushRepository) DeleteOldAttempts(ctx context.Context, now time.Time) (int64, error) {
tag, err := r.pool.Exec(ctx, `
DELETE FROM web_push_delivery_attempts
WHERE (outcome = 'delivered' AND attempted_at < $1)
OR (outcome = 'failed' AND attempted_at < $2)`,
now.AddDate(0, 0, -7), now.AddDate(0, 0, -30))
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
}