§ unswayed-backend · Platform infrastructure (Phase 1)
Push & realtime
Two delivery channels for notifications, both ported from legacy code that was quietly broken.
Push — FCM v1 (PushPort)
The legacy push never worked, for two reasons that produced no error:
- The body was read from
$notification->message— a property that doesn't exist on the resource — so every push had an empty body. - The endpoint URL embedded
{env('FCM_PROJECT_ID')}, which PHP does not interpolate, so the literal text was sent in the path and every call 404'd.
The FcmPushService fixes both: the body is the notification's description, and the URL is built from typed config — https://fcm.googleapis.com/v1/projects/${fcmProjectId}/messages:send. It mints a Google OAuth token from the service account (scope firebase.messaging) and caches it until just before expiry, instead of minting one per send.
Device tokens
A first-class DeviceToken table (one-to-many to User, token unique) replaces the single overloaded users.device_token column — so a second device no longer evicts the first. sendToUser fans out to all of a user's tokens and prunes any that FCM reports NOT_REGISTERED. The §23 POST /notification/set-token wire is unchanged: registering a token you already have returns 422 "Token Already Set."; a token owned by another user is reassigned; success is 200 "Device Token Set Successfully." When FCM isn't configured, a LogPushService no-op keeps dev flows working (and never logs full token values).
Realtime — Socket.IO gateway
The legacy broadcast surface authorized nothing: every channel callback in routes/channels.php was return true, on public channels — so any authenticated client could subscribe to any user's private notification or chat stream. That hole is the headline fix here.
The RealtimeGateway keeps the exact ralli.* channel names and payloads (they're part of the contract) but enforces real authorization in a pure, exhaustively-tested canAccessChannel function:
ralli.notification.{userId}/ralli.notify.{userId}/ralli.userChats.{participantId}→ requiresocketUserId === channelUserId.ralli.chat.{chatId}→ require verified participation (aChatMembershipCheckerport; a deny-all stub until Phase 6 wires real chats).
The socket's identity comes from verifying the JWT on the handshake; an invalid token disconnects. Events are pure DTOs — no DB writes in constructors (the legacy side-effect that silently failed to re-order chats).
Realtime is opt-in (REALTIME_ENABLED, default off, mirroring the legacy BROADCAST_CONNECTION=null). When off, a NoopRealtimeBroadcaster is injected and NotificationsService simply skips broadcasting — clients fall back to FCM + polling /notifications/count. Because the service depends on the RealtimeBroadcaster port, it doesn't care which is wired.