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 QueriesWhen SendInApp() is called, three things happen:
- The notification is persisted to
NotificationRecordin the database (for the notification center). - The notification is published via Core NATS to the appropriate
in-app.*subject for real-time delivery. - 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 Pattern | Description |
|---|---|
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.
| Property | Value |
|---|---|
| Stream name | NOTIFICATIONS |
| Subject | notifications.otp_email |
| Max age | 2 minutes |
| Max retries | 2 |
| Retention | Limits policy |
| Ack policy | Explicit |
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
| Event | When | Example data |
|---|---|---|
MATCH_CREATED | Player joins a match | Match ID |
MATCH_CLOSED | Match finishes | Match ID |
MATCH_CANCELED | Match canceled by admin | Match ID |
POSITION_UPDATED | PnL changes on open position | Position ID, PnL, PnL% |
POSITION_CLOSED | Position closed (manual/SL/TP) | Position ID |
POSITION_CANCELED | Pending position canceled | Position ID |
POSITION_LIQUIDATED | Position force-liquidated | Position ID |
WALLET_BALANCE_UPDATED | Wallet balance changes | Wallet ID |
APP_NOTIFICATION | General app notification | Custom data |
ACHIEVEMENT_UNLOCKED | Achievement completed | Achievement 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)