* 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>
110 lines
3.8 KiB
Go
110 lines
3.8 KiB
Go
package notifications
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"github.com/oklog/ulid/v2"
|
|
)
|
|
|
|
// OperationalDispatch describes how one operational delivery (a non-fanout
|
|
// notice such as webhook.auto_disabled or request.fulfilled) reaches the
|
|
// per-target channels. WebhookFilter selects which of the profile's enabled
|
|
// webhooks receive it; nil means the type must not reach webhooks at all
|
|
// (e.g. the auto-disable notice's loop guard). Web push has no per-type
|
|
// filter: profile-level gating happens before dispatch.
|
|
type OperationalDispatch struct {
|
|
WebhookFilter func(Webhook) bool
|
|
}
|
|
|
|
// DispatchOperational durably creates one operational delivery. The inbox row
|
|
// and the per-target webhook / web push / Apple push outbox rows commit in a
|
|
// single transaction — a crash afterwards delays channel sends instead of dropping
|
|
// them, because the retry workers recover pending outbox rows — then realtime
|
|
// and channel dispatch run post-commit. Returns nil when the delivery deduped
|
|
// away (the partial unique indexes make operational notices idempotent).
|
|
func (s *System) DispatchOperational(ctx context.Context, delivery Delivery, opts OperationalDispatch) (*InsertedDelivery, error) {
|
|
if s == nil {
|
|
return nil, nil
|
|
}
|
|
tx, err := s.pool.Begin(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("begin operational dispatch tx: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
|
|
inserted, err := s.Deliveries.BulkInsert(ctx, tx, []Delivery{delivery})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(inserted) == 0 {
|
|
return nil, nil
|
|
}
|
|
row := inserted[0]
|
|
|
|
if opts.WebhookFilter != nil && s.webhookRepo != nil && s.Settings.WebhooksEnabled(ctx) {
|
|
hooksByProfile, err := s.webhookRepo.ListEnabledByProfiles(ctx, tx, []string{delivery.ProfileID})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
attempts := make([]DeliveryAttempt, 0, 2)
|
|
for _, hook := range hooksByProfile[delivery.ProfileID] {
|
|
if !opts.WebhookFilter(hook) {
|
|
continue
|
|
}
|
|
attempts = append(attempts, DeliveryAttempt{
|
|
ID: ulid.Make().String(),
|
|
NotificationDeliveryID: row.ID,
|
|
TargetID: hook.ID,
|
|
})
|
|
}
|
|
if err := s.webhookRepo.EnqueueAttempts(ctx, tx, attempts); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if s.webPushRepo != nil && s.Settings.WebPushEnabled(ctx) {
|
|
subsByProfile, err := s.webPushRepo.ListEnabledByProfiles(ctx, tx, []string{delivery.ProfileID})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
attempts := make([]DeliveryAttempt, 0, 2)
|
|
for _, sub := range subsByProfile[delivery.ProfileID] {
|
|
attempts = append(attempts, DeliveryAttempt{
|
|
ID: ulid.Make().String(),
|
|
NotificationDeliveryID: row.ID,
|
|
TargetID: sub.ID,
|
|
})
|
|
}
|
|
if err := s.webPushRepo.EnqueueAttempts(ctx, tx, attempts); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if s.pushDeviceRepo != nil && s.Settings.ApplePushDeliveryEnabled(ctx) {
|
|
devicesByProfile, err := s.pushDeviceRepo.ListEnabledAppleByProfiles(ctx, tx, []string{delivery.ProfileID})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
attempts := newPushDeliveryAttempts(row.ID, devicesByProfile[delivery.ProfileID])
|
|
if err := s.pushDeviceRepo.EnqueuePushAttempts(ctx, tx, attempts); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return nil, fmt.Errorf("commit operational dispatch: %w", err)
|
|
}
|
|
|
|
// Post-commit dispatch is best-effort: the durable inbox row covers
|
|
// websocket reconnect, and the retry workers recover the outbox rows.
|
|
full, err := s.Deliveries.GetRowByID(ctx, row.ID)
|
|
if err != nil || full == nil {
|
|
s.logger.Warn("operational delivery reload failed",
|
|
"delivery_id", row.ID, "error", err)
|
|
return &row, nil
|
|
}
|
|
if err := s.dispatcher.Dispatch(ctx, *full); err != nil {
|
|
s.logger.Warn("operational delivery dispatch failed",
|
|
"delivery_id", row.ID, "error", err)
|
|
}
|
|
return &row, nil
|
|
}
|