Skip to Content
BackendNotification System

Notification System

BattlesBit uses a multi-layer notification system: real-time via Core NATS + GraphQL subscriptions, durable email queue via NATS JetStream, persistent notification center via Ent DB, and mobile push via Firebase Cloud Messaging.

Architecture

Client <-> GraphQL Subscriptions (WebSocket) | Backend -> Core NATS (in-app.*) -> Subscription Resolvers -> Client Backend -> JetStream (notifications.otp_email) -> Email Worker -> SMTP Backend -> Firebase FCM -> Mobile Push Backend -> Ent DB (notification_records) -> Notification Center Queries

When SendInApp() is called, three things happen:

  1. The notification is persisted to NotificationRecord in the database (for the notification center).
  2. The notification is published via Core NATS to the appropriate in-app.* subject for real-time delivery.
  3. Optionally, a push notification is sent via Firebase FCM to registered mobile devices.

Transport Layers

Core NATS (real-time in-app)

All real-time in-app notifications use plain Core NATS (nats.Publish / ChanSubscribe). These are fire-and-forget messages delivered to currently connected clients via GraphQL WebSocket subscriptions. If a client is not connected, the message is not queued --- the persistent notification center covers offline delivery.

Subjects:

Subject PatternDescription
in-app.<userID>All notifications for a specific user
in-app.match.<userID>.<matchID>Match-specific notifications for a user

NATS JetStream (durable email queue)

JetStream is used only for the OTP email queue. Messages are published to the NOTIFICATIONS stream and consumed by an email worker with at-least-once delivery guarantees.

PropertyValue
Stream nameNOTIFICATIONS
Subjectnotifications.otp_email
Max age2 minutes
Max retries2
RetentionLimits policy
Ack policyExplicit

Enums

NotificationTarget

Identifies what category of entity the notification relates to.

enum NotificationTarget { GAME MATCH POSITION WALLET App ACHIEVEMENT }

NotificationType

Severity level of the notification.

enum NotificationType { INFO WARNING ERROR }

NotificationAction

Describes what happened to the target entity. This field is present only in the real-time Notification type, not in NotificationRecord.

enum NotificationAction { CREATED UPDATED CANCELED CLOSED }

NotificationEvent

Specific event that triggered the notification.

enum NotificationEvent { MATCH_CREATED MATCH_CANCELED MATCH_CLOSED POSITION_UPDATED POSITION_CANCELED POSITION_LIQUIDATED POSITION_CLOSED WALLET_BALANCE_UPDATED APP_NOTIFICATION ACHIEVEMENT_UNLOCKED }

Notification Events Reference

EventWhenExample data
MATCH_CREATEDPlayer joins a matchMatch ID
MATCH_CLOSEDMatch finishesMatch ID
MATCH_CANCELEDMatch canceled by adminMatch ID
POSITION_UPDATEDPnL changes on open positionPosition ID, PnL, PnL%
POSITION_CLOSEDPosition closed (manual/SL/TP)Position ID
POSITION_CANCELEDPending position canceledPosition ID
POSITION_LIQUIDATEDPosition force-liquidatedPosition ID
WALLET_BALANCE_UPDATEDWallet balance changesWallet ID
APP_NOTIFICATIONGeneral app notificationCustom data
ACHIEVEMENT_UNLOCKEDAchievement completedAchievement ID

Real-Time Subscriptions

Both subscriptions require authentication. They return the Notification type which includes the action field (not present in the persistent NotificationRecord).

Notification type (real-time)

type Notification { message: String! event: NotificationEvent target: NotificationTarget id: ID type: NotificationType! action: NotificationAction data: JSON createdAt: Time }

NotificationGlobal

Receives all notifications for the authenticated user. Backed by Core NATS subject in-app.<userID>.

subscription { NotificationGlobal { message event target id type action data createdAt } }

NotificationMatch

Receives match-specific notifications for the authenticated user. Backed by Core NATS subject in-app.match.<userID>.<matchID>.

subscription MatchNotifications($matchId: ID!) { NotificationMatch(matchId: $matchId) { message event target id type action data createdAt } }

Notification Center (Persistent)

Notifications are persisted to the NotificationRecord table when SendInApp() is called. This powers the notification center UI for reading past notifications, even if the client was offline when they were sent.

NotificationRecord type

type NotificationRecord implements Node { id: ID! message: String! event: String target: String targetID: UUID type: String! isRead: Boolean! createdAt: Time! updatedAt: Time! }

Note: NotificationRecord does not have an action field. The action field only exists on the real-time Notification type.

Query notifications

query MyNotifications($first: Int, $after: Cursor) { myNotifications(first: $first, after: $after) { edges { node { id message event target targetID type isRead createdAt } } pageInfo { hasNextPage endCursor } totalCount } }

Unread count (for badge)

query { unreadNotificationCount }

Mark as read

mutation MarkRead($id: ID!) { markNotificationRead(id: $id) { id isRead } } mutation MarkAllRead { markAllNotificationsRead }

Push Notifications (Firebase)

Mobile devices register for push notifications via Firebase Cloud Messaging. The backend stores FCM tokens per user and sends push notifications alongside in-app delivery.

Register device

mutation RegisterDevice($input: CreateFCMTokenInput!) { createFCMToken(createFCMTokenInput: $input) { id token platform } }

Input:

input CreateFCMTokenInput { token: String! platform: FCMTokenPlatform }

Platform values: IOS, ANDROID.

Client Integration (URQL)

WebSocket setup

import { subscriptionExchange } from 'urql'; import { createClient as createWSClient } from 'graphql-ws'; const wsClient = createWSClient({ url: import.meta.env.VITE_GRAPHQL_WS_URI, connectionParams: () => ({ authorization: `Bearer ${getAccessToken()}`, }), }); const client = createClient({ url: import.meta.env.VITE_GRAPHQL_URI, exchanges: [ // ... other exchanges subscriptionExchange({ forwardSubscription: (operation) => ({ subscribe: (sink) => ({ unsubscribe: wsClient.subscribe(operation, sink), }), }), }), ], });

React hook usage

import { useSubscription } from 'urql'; const NOTIFICATION_SUB = ` subscription { NotificationGlobal { message event type action data } } `; function useNotifications() { const [result] = useSubscription({ query: NOTIFICATION_SUB }); useEffect(() => { if (result.data?.NotificationGlobal) { const notification = result.data.NotificationGlobal; handleNotification(notification); } }, [result.data]); }

Live PnL updates

const MATCH_PNL_SUB = ` subscription MatchPnL($matchId: ID!) { NotificationMatch(matchId: $matchId) { event data } } `; function useLivePnL(matchId: string) { const [result] = useSubscription({ query: MATCH_PNL_SUB, variables: { matchId }, }); useEffect(() => { if (result.data?.NotificationMatch) { const { event, data } = result.data.NotificationMatch; if (event === 'POSITION_UPDATED') { const pnlData = JSON.parse(data); updatePositionPnL(pnlData.pnl, pnlData['pnl%']); } } }, [result.data]); }

Backend: Sending Notifications

notification := &xent.Notification{ Message: "Your position has been closed", Event: &xent.NotificationEventPositionClosed, Target: &xent.NotificationTargetPosition, ID: &positionID, Type: xent.NotificationTypeInfo, Action: &xent.NotificationActionUpdated, } // Send to user (persists to DB + publishes to Core NATS) // Pass matchID to also publish on the match-specific subject err := u.notifier.SendInApp(ctx, userID, &matchID, notification)
Last updated on