Push Service

Summary

Business logic layer for push notifications: device registration, notification preferences, recipient resolution, preference filtering, send orchestration, token lifecycle management, and retry policy. Sits between domain services (challenge, user, event, score) and push-infra’s PushSender transport layer.

Scope boundary:

  • push-infra = transport to APNs/FCM. Returns normalized PushError variants. No business logic, no DB access.
  • push-service = business intent. Resolves recipients, filters by preferences, builds payloads, calls PushSender, interprets results, updates token state, schedules retries.

Context

Push-infra is complete and provides:

  • PushSender trait with send() / send_batch()
  • Terminal errors (InvalidToken, PayloadTooLarge, Unauthorized, UnsupportedPlatform) and retryable errors (RateLimited, Transient)
  • PushPayload with title, body, category, data (HashMap), badge
  • MultiPushSender routing by platform, NoOpPushSender for dev

Existing contracts:

  • proto/push/v1/push_service.proto defines RegisterDevice(device_token, platform) and UnregisterDevice(device_token)
  • device_registrations table: id, user_id, platform, push_token, created_at
  • Platform proto enum: PLATFORM_IOS, PLATFORM_ANDROID
  • device_platform Postgres enum: ios, android, web

Legacy app had four notification preference flags on the users table: social_notifications, trex_notifications, new_item_notifications, sales_notifications.

Requirements

  • Implement RegisterDevice and UnregisterDevice RPCs (idempotent, conflict-safe)
  • Implement GetNotificationPreferences and UpdateNotificationPreferences RPCs
  • Provide internal PushDispatcher API for domain services to trigger notifications
  • MVP notification categories: social, challenges, events, scores
  • Per-user opt-in/out by category (default: all enabled)
  • Resolve recipients from domain context (e.g., challenge → invited users, friend request → target user)
  • Filter sends by user preferences and token validity
  • Update token health after every send attempt (last_seen_at, last_error_at, invalidated_at)
  • Invalidate tokens immediately on terminal errors (InvalidToken)
  • Retry retryable failures with bounded exponential backoff (max 3 attempts)
  • Prevent duplicate sends via idempotency keys
  • Re-registering an invalidated token restores deliverable state
  • No self-notifications (actor is never notified of their own action)
  • Emit structured tracing events for all send outcomes

Details

Notification categories

Map legacy categories to new transactional categories:

MVP CategoryTriggersLegacy Equivalent
socialFriend request received, friend request acceptedsocial_notifications
challengesChallenge invitation, challenge completedsocial_notifications (partial)
eventsEvent reminder, roster addition(new)
scoresPersonal best, leaderboard position change(new)

Legacy trex_notifications and new_item_notifications map to content-feed push (out of scope — content-feed-api may trigger a push through this service’s dispatcher in the future). Legacy sales_notifications is deferred indefinitely.

Notification payloads

Each category defines a payload builder that produces a PushPayload:

pub enum NotificationIntent {
    // Social
    FriendRequestReceived { from_user_id: Uuid, from_display_name: String },
    FriendRequestAccepted { from_user_id: Uuid, from_display_name: String },
 
    // Challenges
    ChallengeInvite { challenge_id: Uuid, challenge_name: String, from_display_name: String },
    ChallengeCompleted { challenge_id: Uuid, challenge_name: String },
 
    // Events
    EventReminder { event_id: Uuid, event_name: String, starts_in_minutes: u32 },
    RosterAdded { event_id: Uuid, event_name: String },
 
    // Scores
    PersonalBest { drill_id: Uuid, drill_name: String },
}

Each variant maps to:

  • title/body: Human-readable text (e.g., “Challenge Invite”, “{name} invited you to {challenge}”)
  • category: String matching the category name (e.g., "challenges")
  • data: Deep link info as key-value pairs (e.g., {"deep_link": "rangeday://challenges/{id}"})
  • badge: None for MVP (badge count management deferred)

Notifications include a deep_link key in PushPayload.data:

NotificationDeep Link
Friend requestrangeday://friends/requests
Friend acceptedrangeday://friends
Challenge inviterangeday://challenges/{challenge_id}
Challenge completedrangeday://challenges/{challenge_id}
Event reminderrangeday://events/{event_id}
Roster addedrangeday://events/{event_id}
Personal bestrangeday://drills/{drill_id}

Mobile clients parse the scheme and route to the appropriate screen. The URL scheme is the contract between server and mobile — both sides must agree on format.

API surface

Public RPCs (app-facing, requires auth)

Extend proto/push/v1/push_service.proto:

service PushService {
  // Existing
  rpc RegisterDevice(RegisterDeviceRequest) returns (RegisterDeviceResponse);
  rpc UnregisterDevice(UnregisterDeviceRequest) returns (UnregisterDeviceResponse);
 
  // New
  rpc GetNotificationPreferences(GetNotificationPreferencesRequest) returns (GetNotificationPreferencesResponse);
  rpc UpdateNotificationPreferences(UpdateNotificationPreferencesRequest) returns (UpdateNotificationPreferencesResponse);
}
 
message NotificationPreferences {
  bool social = 1;
  bool challenges = 2;
  bool events = 3;
  bool scores = 4;
}
 
message GetNotificationPreferencesRequest {}
message GetNotificationPreferencesResponse {
  NotificationPreferences preferences = 1;
}
 
message UpdateNotificationPreferencesRequest {
  NotificationPreferences preferences = 1;
}
message UpdateNotificationPreferencesResponse {
  NotificationPreferences preferences = 1;
}

Internal API (server-only, not an RPC)

PushDispatcher is injected into domain services as Arc<PushDispatcher>. It is the only entry point for triggering notifications:

pub struct PushDispatcher {
    provider: Arc<PushProvider>,
    sender: Arc<dyn PushSender>,
}
 
impl PushDispatcher {
    /// Send a notification to specific users. Resolves their device tokens,
    /// filters by preferences and token validity, dispatches via PushSender,
    /// and updates token health. `actor_user_id` is the user who triggered
    /// the action — they are always excluded from recipients.
    pub async fn notify(
        &self,
        actor_user_id: Uuid,
        intent: NotificationIntent,
        recipient_user_ids: Vec<Uuid>,
    );
}

Domain services call dispatcher.notify(actor_id, intent, user_ids). The dispatcher is fire-and-forget from the caller’s perspective — it spawns a background task so the caller’s RPC is not blocked by push delivery.

Delivery orchestration flow

When PushDispatcher::notify() is called:

  1. Determine category from NotificationIntent variant
  2. Load notification preferences for all recipient user IDs
  3. Filter out users who have opted out of this category
  4. Filter out the actor (no self-notifications) — actor_user_id passed explicitly by caller
  5. Load valid device registrations for remaining users (WHERE invalidated_at IS NULL)
  6. Build PushPayload from the NotificationIntent
  7. Build Vec<PushTarget> from registrations + payload
  8. Generate idempotency key from {intent_kind}:{entity_id}:{recipient_user_id} — deterministic from stable event identity, no timestamp, so retriggering the same domain event doesn’t produce a new key
  9. Call PushSender::send_batch()
  10. Process results per-target:
    • Success → update last_seen_at
    • Terminal error → update invalidated_at, last_error_at, last_error
    • Retryable error → update last_error_at, last_error, schedule retry
  11. Schedule retries for retryable failures (in-memory, bounded)

Token lifecycle contract

Push-service owns all mutations to device_registrations. Token health columns (added by push-service migration):

ColumnTypeUpdated when
last_seen_atTIMESTAMPTZSuccessful delivery
last_error_atTIMESTAMPTZAny error (terminal or retryable)
last_errorTEXTError variant name (e.g., "InvalidToken", "RateLimited")
invalidated_atTIMESTAMPTZTerminal InvalidToken error received

Per-error behavior:

PushErrorToken actionRetry?
InvalidTokenSet invalidated_at = now()No
PayloadTooLargeLog as bug, no token changeNo (our problem, not the token’s)
UnauthorizedLog as config error, no token changeNo (server credential issue)
UnsupportedPlatformShould never happen (routing bug)No
RateLimitedRecord error, keep token validYes, with backoff
TransientRecord error, keep token validYes, with backoff

Re-registration after invalidation: When RegisterDevice is called with a token that has invalidated_at set, clear invalidated_at, last_error_at, and last_error. This restores the token to deliverable state (the user reinstalled or refreshed their token).

Retry model

MVP uses in-process retry via tokio::spawn. No durable job queue.

  • Max attempts: 3 (initial + 2 retries)
  • Backoff: Exponential with jitter — 2s, 8s, 32s base delays (±25% jitter)
  • Terminal cutoff: Terminal errors are never retried
  • Idempotency: Same key reused across retries (FCM and APNs both support idempotent resend)
  • Scope: Per-target retry, not whole-batch retry (one target’s failure doesn’t affect others)

Known risk: In-memory retries are lost on server restart. Acceptable for MVP — push notifications are best-effort. Durable outbox/job table is a post-MVP enhancement.

async fn retry_send(
    sender: Arc<dyn PushSender>,
    provider: Arc<PushProvider>,
    target: PushTarget,
    idempotency_key: String,
    attempt: u32,
) {
    const MAX_ATTEMPTS: u32 = 3;
    const BASE_DELAYS: [u64; 3] = [2, 8, 32];
 
    if attempt >= MAX_ATTEMPTS { return; }
 
    let base = BASE_DELAYS[attempt as usize];
    let jitter = rand::random::<f64>() * 0.5 + 0.75; // 0.75–1.25
    tokio::time::sleep(Duration::from_secs_f64(base as f64 * jitter)).await;
 
    let result = sender.send(&target.token, target.platform, target.payload.clone()).await;
    // ... update token health, schedule next retry if still retryable
}

Authorization

RPCAuthWho
RegisterDeviceJWT requiredAny authenticated user (registers their own device)
UnregisterDeviceJWT requiredAny authenticated user (unregisters their own device)
GetNotificationPreferencesJWT requiredAny authenticated user (reads their own preferences)
UpdateNotificationPreferencesJWT requiredAny authenticated user (updates their own preferences)

All RPCs are scoped to the calling user’s own data. No admin endpoints for push in MVP.

Internal PushDispatcher::notify() is server-side only — not exposed as an RPC. Domain services hold a reference to the dispatcher and call it directly. There is no way for a client to trigger an arbitrary push.

Error handling

RPC-level errors (RegisterDevice, UnregisterDevice, etc.) use standard ConnectRPC error codes:

  • UNAUTHENTICATED — missing or invalid JWT
  • INVALID_ARGUMENT — empty device token, invalid platform
  • INTERNAL — database errors

Dispatch-level errors are logged but never propagated to the triggering domain service. Push delivery is best-effort — a failed push should never cause a challenge creation or friend request to fail.

Observability

Service-level tracing events (complement push-infra’s transport-level events):

EventTypeTags
push_service.dispatchedcountercategory, recipient_count
push_service.filtered_preferencecountercategory
push_service.filtered_invalid_tokencountercategory
push_service.deliveredcountercategory, platform
push_service.failed_terminalcountercategory, platform, error
push_service.failed_retryablecountercategory, platform, error
push_service.retry_scheduledcountercategory, attempt
push_service.token_invalidatedcounterplatform
push_service.token_registeredcounterplatform
push_service.token_unregisteredcounterplatform

All events are tracing spans/events with structured fields — same pattern as push-infra.

Data model

Migration: add token health columns

ALTER TABLE device_registrations
    ADD COLUMN last_seen_at TIMESTAMPTZ,
    ADD COLUMN last_error_at TIMESTAMPTZ,
    ADD COLUMN last_error TEXT,
    ADD COLUMN invalidated_at TIMESTAMPTZ;
 
-- Idempotent upsert support: a user can only have one registration per token
ALTER TABLE device_registrations
    ADD CONSTRAINT uq_device_registrations_user_token UNIQUE (user_id, push_token);

Migration: notification preferences

CREATE TABLE notification_preferences (
    user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
    social BOOLEAN NOT NULL DEFAULT true,
    challenges BOOLEAN NOT NULL DEFAULT true,
    events BOOLEAN NOT NULL DEFAULT true,
    scores BOOLEAN NOT NULL DEFAULT true,
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

When no row exists for a user, treat all categories as enabled (opt-out model).

Register/Unregister behavior

RegisterDevice:

  1. Validate token is non-empty
  2. Upsert into device_registrations on (user_id, push_token):
    • If new: insert with current timestamp
    • If exists with same platform: update created_at (token refresh)
    • If exists with invalidated_at set: clear invalidated_at, last_error_at, last_error (re-registration)
    • If token belongs to a different user: reassign to current user (device changed hands)
  3. Return success (idempotent)

UnregisterDevice:

  1. Delete from device_registrations WHERE user_id = $1 AND push_token = $2
  2. Return success even if no rows deleted (idempotent)

Module structure

apps/server/src/push/
  mod.rs            — PushSender trait, PushPayload, PushError, PushTarget (existing)
  apns.rs           — ApnsPushSender (existing)
  fcm.rs            — FcmPushSender (existing)
  multi.rs          — MultiPushSender (existing)
  noop.rs           — NoOpPushSender (existing)
  provider.rs       — NEW: DB operations (register, unregister, preferences, token health updates)
  service.rs        — NEW: RPC handler (RegisterDevice, UnregisterDevice, preferences)
  dispatch.rs       — NEW: PushDispatcher (recipient resolution, preference filtering, send orchestration, retry)
  intent.rs         — NEW: NotificationIntent enum, payload builders, category mapping

Integration with domain services

Domain services receive Arc<PushDispatcher> in their constructors (same pattern as jwt_secret). They call dispatcher.notify() at the appropriate moment:

Domain ServiceTriggerIntent
ChallengeServiceImplCreateChallengeactor=creator, ChallengeInvite → invited_user_ids
ChallengeServiceImplCompleteChallengeactor=completer, ChallengeCompleted → all participants
UserServiceImplSendFriendRequestactor=sender, FriendRequestReceived → to_user_id
UserServiceImplAcceptFriendactor=accepter, FriendRequestAccepted → from_user_id
EventServiceImplAddRosterEntryactor=adder, RosterAdded → added user_id

Deferred triggers (require scheduler, post-MVP):

  • Event reminders (30 min before event start) — requires a periodic job to scan upcoming events
  • Score-based notifications (personal best, leaderboard changes) — requires score comparison logic

Test Plan

Unit tests

  1. NotificationIntentPushPayload conversion for each variant (correct title, body, category, deep_link)
  2. Category filtering: verify opted-out categories are excluded
  3. Self-notification filtering: verify actor is excluded from recipients
  4. Token health updates: verify correct column mutations per error class
  5. Retry scheduling: verify backoff timing and max attempt cutoff
  6. Idempotency key generation: verify deterministic from intent_kind + entity_id + recipient_user_id, stable across retriggers

Integration tests (require database)

  1. RegisterDevice: new registration, re-registration after invalidation, device handoff between users
  2. UnregisterDevice: existing token, non-existent token (idempotent)
  3. GetNotificationPreferences: default (no row), after update
  4. UpdateNotificationPreferences: create row, update existing
  5. Full dispatch flow: create user + device registration + trigger notification → verify PushSender called with correct payload (mock PushSender)
  6. Dispatch with preferences: opted-out user not sent to
  7. Token invalidation: send → InvalidToken → verify token invalidated → verify subsequent dispatch skips it
  8. Re-registration: invalidated token → RegisterDevice → verify dispatch sends again

Not tested in CI

  • Real APNs/FCM delivery (requires credentials)
  • Retry timing (timing-dependent tests are flaky)

Implementation Order

  1. Migration — add token health columns and notification_preferences table
  2. Proto — add preference RPCs and messages to push_service.proto, regenerate
  3. Provider — DB operations (register, unregister, preferences CRUD, token health updates, load valid registrations)
  4. Intent — NotificationIntent enum, payload builders, category enum
  5. Service — RPC handlers for RegisterDevice, UnregisterDevice, preferences
  6. Dispatcher — PushDispatcher with notify(), retry logic
  7. Wire up main.rs — create PushDispatcher, inject into domain services
  8. Integration into domain services — add dispatcher calls to challenge, user, event services
  9. Tests — unit + integration

Files

New:

  • apps/server/src/push/provider.rs
  • apps/server/src/push/service.rs
  • apps/server/src/push/dispatch.rs
  • apps/server/src/push/intent.rs
  • apps/server/migrations/NNNN_push_service.up.sql
  • apps/server/migrations/NNNN_push_service.down.sql
  • apps/server/tests/push_service_test.rs

Modified:

  • proto/push/v1/push_service.proto — add preference RPCs
  • apps/server/src/push/mod.rs — add pub mod provider; pub mod service; pub mod dispatch; pub mod intent;
  • apps/server/src/main.rs — create PushDispatcher, inject into domain services
  • apps/server/src/challenge/service.rs — add dispatcher calls
  • apps/server/src/user/service.rs — add dispatcher calls
  • apps/server/src/event/service.rs — add dispatcher calls

Non-goals / Deferred

  • Notification history / inbox — no server-side storage of sent notifications for MVP. Push banners are fire-and-forget.
  • Badge count managementbadge field in PushPayload is None for MVP. Accurate badge counts require server-side tracking of unread state.
  • Quiet hours — no time-of-day suppression for MVP
  • Event reminder scheduling — requires a periodic background job to scan upcoming events. Deferred to post-MVP. Instant notifications (friend request, challenge invite, roster add) are MVP.
  • Score notifications — requires comparison logic (is this a personal best? did leaderboard position change?). Deferred to post-MVP.
  • Durable retry queue — in-memory retries for MVP. DB-backed outbox/job table is a post-MVP reliability improvement.
  • Rate limiting per user — no per-user send throttling for MVP (low volume expected)
  • Content feed push — content-feed-api may trigger pushes in the future via PushDispatcher, but the integration is out of scope for this work item
  • Web pushdevice_platform enum includes web but web push (Service Workers / Web Push API) is post-MVP

Open Questions

  • In-app message center — Resolved: split into transactional notifications (this service) and editorial content (content-feed-api).
  • PushPress-style announcements — Resolved: moved to content-feed-api.
  • Content categories — Resolved: MVP categories are social, challenges, events, scores. Legacy trex_notifications/new_item_notifications map to content-feed. sales_notifications deferred.
  • Deep links — Resolved: rangeday:// URL scheme with path-based routing per notification type.
  • Event reminder scheduling mechanism — when event reminders are added post-MVP, what runs the scheduler? Options: tokio background task with periodic DB scan, or external cron triggering an internal RPC. Leaning toward tokio task for single-binary simplicity.