* Add push notifications support * fix(notifications): address push notification review findings - Gate the capability endpoint's apple_push availability on the admin delivery toggle, matching web push: Available now means setup will actually deliver. - Reject direct admin writes to push_relay_deployment_id/api_key; the relay issues them as a pair during registration and a lone write desyncs them (and poisons the next rotation request). - Purge a device's registrations under other profiles when it re-registers, so a profile switch on a shared device stops the old profile's pushes (attempts cascade); adds a DB-backed test. - Extract the shared channelDispatcher core + retry sweep and rebuild the webhook/web push/Apple push dispatchers on it instead of keeping three copies of the worker-pool/retry loop. - Deduplicate relay URL validation (admin setting + register flow) and the push outbox attempt-building loops behind shared helpers. - Cap free-text decline reasons in notification display bodies. - Fix TestHandleApplePushDisplayDB expectations to match the shared display copy (test previously failed under SILO_TEST_DATABASE_URL). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(notifications): route push relay URL writes through registration only Direct writes to notifications.push_relay_url via the admin settings endpoint bypassed the relay registration flow, letting the stored URL drift out of sync with the deployment id / API key pair the relay minted for it. Reject the URL alongside the deployment id and API key in the settings handler; POST /admin/notifications/push/relay/register remains the only path that persists all three together. The admin UI's Relay URL field now edits local draft state and is applied by the Register/Rotate action instead of the settings save. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
74 lines
2.2 KiB
Go
74 lines
2.2 KiB
Go
package notifications
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
webhookDispatchWorkers = 16
|
|
webhookDispatchQueue = 256
|
|
webhookRetryInterval = 30 * time.Second
|
|
webhookRetryClaimLimit = 50
|
|
)
|
|
|
|
// WebhookDispatcher implements the channel Dispatcher interface for outbound
|
|
// webhooks on top of the shared channelDispatcher core. Retry/recovery runs in
|
|
// the standalone WebhookRetryWorker.
|
|
type WebhookDispatcher struct {
|
|
core channelDispatcher[DeliveryAttempt]
|
|
}
|
|
|
|
func newWebhookDispatcher(sender *webhookSender) *WebhookDispatcher {
|
|
return &WebhookDispatcher{core: channelDispatcher[DeliveryAttempt]{
|
|
channel: "webhook",
|
|
queue: make(chan string, webhookDispatchQueue),
|
|
logger: slog.Default().With("component", "notifications.webhooks.dispatch"),
|
|
claimPending: sender.webhooks.ClaimPendingForDelivery,
|
|
process: sender.processAttempt,
|
|
}}
|
|
}
|
|
|
|
// Dispatch queues the delivery's webhook attempts for immediate send.
|
|
func (d *WebhookDispatcher) Dispatch(_ context.Context, delivery DeliveryRow) error {
|
|
if d == nil {
|
|
return nil
|
|
}
|
|
if delivery.Type == DeliveryTypeWebhookAutoDisabled {
|
|
// Type deny list: an auto-disable notice must never re-dispatch as a
|
|
// webhook, or a broken webhook would loop forever.
|
|
return nil
|
|
}
|
|
d.core.dispatch(delivery.ID)
|
|
return nil
|
|
}
|
|
|
|
// Run consumes the dispatch queue with a bounded worker pool until ctx is
|
|
// canceled. One slow destination cannot block other deliveries.
|
|
func (d *WebhookDispatcher) Run(ctx context.Context) {
|
|
d.core.run(ctx)
|
|
}
|
|
|
|
// WebhookRetryWorker drains due retries and recovers stale pending outbox
|
|
// rows whose post-commit dispatch never ran (process crash between the fanout
|
|
// commit and dispatch).
|
|
type WebhookRetryWorker struct {
|
|
sender *webhookSender
|
|
logger *slog.Logger
|
|
}
|
|
|
|
func newWebhookRetryWorker(sender *webhookSender) *WebhookRetryWorker {
|
|
return &WebhookRetryWorker{
|
|
sender: sender,
|
|
logger: slog.Default().With("component", "notifications.webhooks.retry"),
|
|
}
|
|
}
|
|
|
|
// Run polls for due attempts until ctx is canceled.
|
|
func (w *WebhookRetryWorker) Run(ctx context.Context) {
|
|
runRetrySweep(ctx, "webhook", w.logger,
|
|
w.sender.settings.WebhooksEnabled, w.sender.webhooks.ClaimDue,
|
|
webhookRetryClaimLimit, w.sender.processAttempt)
|
|
}
|