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
PushErrorvariants. 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:
PushSendertrait withsend()/send_batch()- Terminal errors (
InvalidToken,PayloadTooLarge,Unauthorized,UnsupportedPlatform) and retryable errors (RateLimited,Transient) PushPayloadwith title, body, category, data (HashMap), badgeMultiPushSenderrouting by platform,NoOpPushSenderfor dev
Existing contracts:
proto/push/v1/push_service.protodefinesRegisterDevice(device_token, platform)andUnregisterDevice(device_token)device_registrationstable: id, user_id, platform, push_token, created_atPlatformproto enum:PLATFORM_IOS,PLATFORM_ANDROIDdevice_platformPostgres 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
RegisterDeviceandUnregisterDeviceRPCs (idempotent, conflict-safe) - Implement
GetNotificationPreferencesandUpdateNotificationPreferencesRPCs - Provide internal
PushDispatcherAPI 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 Category | Triggers | Legacy Equivalent |
|---|---|---|
social | Friend request received, friend request accepted | social_notifications |
challenges | Challenge invitation, challenge completed | social_notifications (partial) |
events | Event reminder, roster addition | (new) |
scores | Personal 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:
Nonefor MVP (badge count management deferred)
Deep link scheme
Notifications include a deep_link key in PushPayload.data:
| Notification | Deep Link |
|---|---|
| Friend request | rangeday://friends/requests |
| Friend accepted | rangeday://friends |
| Challenge invite | rangeday://challenges/{challenge_id} |
| Challenge completed | rangeday://challenges/{challenge_id} |
| Event reminder | rangeday://events/{event_id} |
| Roster added | rangeday://events/{event_id} |
| Personal best | rangeday://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:
- Determine category from
NotificationIntentvariant - Load notification preferences for all recipient user IDs
- Filter out users who have opted out of this category
- Filter out the actor (no self-notifications) —
actor_user_idpassed explicitly by caller - Load valid device registrations for remaining users (WHERE
invalidated_at IS NULL) - Build
PushPayloadfrom theNotificationIntent - Build
Vec<PushTarget>from registrations + payload - 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 - Call
PushSender::send_batch() - 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
- Success → update
- 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):
| Column | Type | Updated when |
|---|---|---|
last_seen_at | TIMESTAMPTZ | Successful delivery |
last_error_at | TIMESTAMPTZ | Any error (terminal or retryable) |
last_error | TEXT | Error variant name (e.g., "InvalidToken", "RateLimited") |
invalidated_at | TIMESTAMPTZ | Terminal InvalidToken error received |
Per-error behavior:
| PushError | Token action | Retry? |
|---|---|---|
InvalidToken | Set invalidated_at = now() | No |
PayloadTooLarge | Log as bug, no token change | No (our problem, not the token’s) |
Unauthorized | Log as config error, no token change | No (server credential issue) |
UnsupportedPlatform | Should never happen (routing bug) | No |
RateLimited | Record error, keep token valid | Yes, with backoff |
Transient | Record error, keep token valid | Yes, 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
| RPC | Auth | Who |
|---|---|---|
RegisterDevice | JWT required | Any authenticated user (registers their own device) |
UnregisterDevice | JWT required | Any authenticated user (unregisters their own device) |
GetNotificationPreferences | JWT required | Any authenticated user (reads their own preferences) |
UpdateNotificationPreferences | JWT required | Any 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 JWTINVALID_ARGUMENT— empty device token, invalid platformINTERNAL— 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):
| Event | Type | Tags |
|---|---|---|
push_service.dispatched | counter | category, recipient_count |
push_service.filtered_preference | counter | category |
push_service.filtered_invalid_token | counter | category |
push_service.delivered | counter | category, platform |
push_service.failed_terminal | counter | category, platform, error |
push_service.failed_retryable | counter | category, platform, error |
push_service.retry_scheduled | counter | category, attempt |
push_service.token_invalidated | counter | platform |
push_service.token_registered | counter | platform |
push_service.token_unregistered | counter | platform |
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:
- Validate token is non-empty
- Upsert into
device_registrationson(user_id, push_token):- If new: insert with current timestamp
- If exists with same platform: update
created_at(token refresh) - If exists with
invalidated_atset: clearinvalidated_at,last_error_at,last_error(re-registration) - If token belongs to a different user: reassign to current user (device changed hands)
- Return success (idempotent)
UnregisterDevice:
- Delete from
device_registrationsWHEREuser_id = $1 AND push_token = $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 Service | Trigger | Intent |
|---|---|---|
ChallengeServiceImpl | CreateChallenge | actor=creator, ChallengeInvite → invited_user_ids |
ChallengeServiceImpl | CompleteChallenge | actor=completer, ChallengeCompleted → all participants |
UserServiceImpl | SendFriendRequest | actor=sender, FriendRequestReceived → to_user_id |
UserServiceImpl | AcceptFriend | actor=accepter, FriendRequestAccepted → from_user_id |
EventServiceImpl | AddRosterEntry | actor=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
NotificationIntent→PushPayloadconversion for each variant (correct title, body, category, deep_link)- Category filtering: verify opted-out categories are excluded
- Self-notification filtering: verify actor is excluded from recipients
- Token health updates: verify correct column mutations per error class
- Retry scheduling: verify backoff timing and max attempt cutoff
- Idempotency key generation: verify deterministic from intent_kind + entity_id + recipient_user_id, stable across retriggers
Integration tests (require database)
- RegisterDevice: new registration, re-registration after invalidation, device handoff between users
- UnregisterDevice: existing token, non-existent token (idempotent)
- GetNotificationPreferences: default (no row), after update
- UpdateNotificationPreferences: create row, update existing
- Full dispatch flow: create user + device registration + trigger notification → verify PushSender called with correct payload (mock PushSender)
- Dispatch with preferences: opted-out user not sent to
- Token invalidation: send → InvalidToken → verify token invalidated → verify subsequent dispatch skips it
- 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
- Migration — add token health columns and notification_preferences table
- Proto — add preference RPCs and messages to push_service.proto, regenerate
- Provider — DB operations (register, unregister, preferences CRUD, token health updates, load valid registrations)
- Intent — NotificationIntent enum, payload builders, category enum
- Service — RPC handlers for RegisterDevice, UnregisterDevice, preferences
- Dispatcher — PushDispatcher with notify(), retry logic
- Wire up main.rs — create PushDispatcher, inject into domain services
- Integration into domain services — add dispatcher calls to challenge, user, event services
- Tests — unit + integration
Files
New:
apps/server/src/push/provider.rsapps/server/src/push/service.rsapps/server/src/push/dispatch.rsapps/server/src/push/intent.rsapps/server/migrations/NNNN_push_service.up.sqlapps/server/migrations/NNNN_push_service.down.sqlapps/server/tests/push_service_test.rs
Modified:
proto/push/v1/push_service.proto— add preference RPCsapps/server/src/push/mod.rs— addpub mod provider; pub mod service; pub mod dispatch; pub mod intent;apps/server/src/main.rs— create PushDispatcher, inject into domain servicesapps/server/src/challenge/service.rs— add dispatcher callsapps/server/src/user/service.rs— add dispatcher callsapps/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 management —
badgefield in PushPayload isNonefor 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 push —
device_platformenum includeswebbut 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 aresocial,challenges,events,scores. Legacytrex_notifications/new_item_notificationsmap to content-feed.sales_notificationsdeferred. -
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.