Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { MetricsModule } from "./metrics/metrics.module";
import { HealthModule } from "./health/health.module";
import { VersionModule } from "./version/version.module";
import { ArtistStatusModule } from "./artist-status/artist-status.module";
import { WebSocketModule } from "./websocket/websocket.module";
import { CustomThrottlerRedisStorage } from "./custom-throttler-storage-redis";
import { VaryAcceptEncodingMiddleware } from "./common/middleware/vary-accept-encoding.middleware";

Expand Down Expand Up @@ -107,6 +108,7 @@ import { VaryAcceptEncodingMiddleware } from "./common/middleware/vary-accept-en
HealthModule,
VersionModule,
ArtistStatusModule,
WebSocketModule,
],
controllers: [],
providers: [
Expand Down
44 changes: 43 additions & 1 deletion backend/src/tips/create-tips.dto.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,40 @@
import { IsUUID, IsOptional, IsString, MaxLength } from 'class-validator';
import { IsUUID, IsOptional, IsString, MaxLength, ValidateNested } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { SanitiseAsPlainText } from '../common/utils/sanitise.util';

export class TipMetadataDto {
@ApiPropertyOptional({ description: 'Source of the tip', example: 'mobile_app' })
@IsOptional()
@IsString()
@MaxLength(50)
source?: string;

@ApiPropertyOptional({ description: 'Campaign ID', example: 'summer2023' })
@IsOptional()
@IsString()
@MaxLength(100)
campaign?: string;

@ApiPropertyOptional({ description: 'Platform (web/ios/android)', example: 'android' })
@IsOptional()
@IsString()
@MaxLength(20)
platform?: string;

@ApiPropertyOptional({ description: 'Operating System', example: 'Android 13' })
@IsOptional()
@IsString()
@MaxLength(50)
os?: string;

@ApiPropertyOptional({ description: 'Client App Version', example: '2.1.0' })
@IsOptional()
@IsString()
@MaxLength(20)
version?: string;
Comment on lines +6 to +35
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Sanitize nested metadata before persisting it.

These fields are length/type checked, but unlike message they never run through @SanitiseAsPlainText(). Line 182 of backend/src/tips/tips.service.ts then serializes them directly, so HTML/control characters still reach storage.

Suggested fix
   `@IsOptional`()
   `@IsString`()
+  `@SanitiseAsPlainText`()
   `@MaxLength`(50)
   source?: string;
@@
   `@IsOptional`()
   `@IsString`()
+  `@SanitiseAsPlainText`()
   `@MaxLength`(100)
   campaign?: string;
@@
   `@IsOptional`()
   `@IsString`()
+  `@SanitiseAsPlainText`()
   `@MaxLength`(20)
   platform?: string;
@@
   `@IsOptional`()
   `@IsString`()
+  `@SanitiseAsPlainText`()
   `@MaxLength`(50)
   os?: string;
@@
   `@IsOptional`()
   `@IsString`()
+  `@SanitiseAsPlainText`()
   `@MaxLength`(20)
   version?: string;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export class TipMetadataDto {
@ApiPropertyOptional({ description: 'Source of the tip', example: 'mobile_app' })
@IsOptional()
@IsString()
@MaxLength(50)
source?: string;
@ApiPropertyOptional({ description: 'Campaign ID', example: 'summer2023' })
@IsOptional()
@IsString()
@MaxLength(100)
campaign?: string;
@ApiPropertyOptional({ description: 'Platform (web/ios/android)', example: 'android' })
@IsOptional()
@IsString()
@MaxLength(20)
platform?: string;
@ApiPropertyOptional({ description: 'Operating System', example: 'Android 13' })
@IsOptional()
@IsString()
@MaxLength(50)
os?: string;
@ApiPropertyOptional({ description: 'Client App Version', example: '2.1.0' })
@IsOptional()
@IsString()
@MaxLength(20)
version?: string;
export class TipMetadataDto {
`@ApiPropertyOptional`({ description: 'Source of the tip', example: 'mobile_app' })
`@IsOptional`()
`@IsString`()
`@SanitiseAsPlainText`()
`@MaxLength`(50)
source?: string;
`@ApiPropertyOptional`({ description: 'Campaign ID', example: 'summer2023' })
`@IsOptional`()
`@IsString`()
`@SanitiseAsPlainText`()
`@MaxLength`(100)
campaign?: string;
`@ApiPropertyOptional`({ description: 'Platform (web/ios/android)', example: 'android' })
`@IsOptional`()
`@IsString`()
`@SanitiseAsPlainText`()
`@MaxLength`(20)
platform?: string;
`@ApiPropertyOptional`({ description: 'Operating System', example: 'Android 13' })
`@IsOptional`()
`@IsString`()
`@SanitiseAsPlainText`()
`@MaxLength`(50)
os?: string;
`@ApiPropertyOptional`({ description: 'Client App Version', example: '2.1.0' })
`@IsOptional`()
`@IsString`()
`@SanitiseAsPlainText`()
`@MaxLength`(20)
version?: string;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/tips/create-tips.dto.ts` around lines 6 - 35, TipMetadataDto's
nested fields (source, campaign, platform, os, version) are not sanitized before
persistence; add the same sanitization used for message by applying the
`@SanitiseAsPlainText`() decorator to each of these properties in TipMetadataDto
so they are cleaned during validation, or alternatively ensure tips.service.ts
serializes only after passing them through the existing sanitiser function used
for message (the code path around the serialization call in tips.service.ts
where metadata is JSON.stringified). Reference TipMetadataDto and the
serialization point in tips.service.ts and apply the sanitiser consistently to
prevent HTML/control characters from reaching storage.

}

export class CreateTipDto {
@ApiProperty({ description: 'Artist ID receiving the tip', example: '550e8400-e29b-41d4-a716-446655440001' })
@IsUUID()
Expand Down Expand Up @@ -35,4 +68,13 @@ export class CreateTipDto {
@IsString()
@MaxLength(128)
idempotencyKey?: string;

@ApiPropertyOptional({
description: 'Additional structured metadata',
type: TipMetadataDto,
})
@IsOptional()
@ValidateNested()
@Type(() => TipMetadataDto)
metadata?: TipMetadataDto;
}
3 changes: 2 additions & 1 deletion backend/src/tips/tips.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export class TipsService {
) {}

async create(userId: string, createTipDto: CreateTipDto): Promise<Tip> {
const { artistId, trackId, stellarTxHash, message, idempotencyKey } = createTipDto;
const { artistId, trackId, stellarTxHash, message, idempotencyKey, metadata } = createTipDto;

// --- Idempotency key check: replay the original response if key already seen ---
if (idempotencyKey) {
Expand Down Expand Up @@ -179,6 +179,7 @@ export class TipsService {
status: TipStatus.VERIFIED,
verifiedAt: new Date(),
stellarTimestamp: new Date(txDetails.created_at),
metadata: metadata ? JSON.stringify(metadata) : null,
...(idempotencyKey ? { idempotencyKey } : {}),
});

Expand Down
67 changes: 67 additions & 0 deletions backend/src/websocket/event-store.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { Injectable, Logger } from '@nestjs/common';

export interface StoredEvent {
sequenceId: number;
type: string;
data: any;
rooms: string[];
timestamp: Date;
}

@Injectable()
export class EventStoreService {
private readonly logger = new Logger(EventStoreService.name);
private events: StoredEvent[] = [];
private nextSequenceId = 1;
private readonly maxWindowSize = 1000; // Store up to 1000 recent events
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Detect stale cursors instead of silently replaying a partial backlog.

Once the 1000-event window rolls over, older events are evicted. If a client reconnects with a sequenceId older than the first retained event, getEventsAfter() returns only the tail and gives no signal that earlier events were lost. Clients will think replay completed successfully when it did not.

Also applies to: 33-35, 44-50

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/websocket/event-store.service.ts` at line 16, The code silently
returns a truncated tail when a client-supplied sequenceId precedes the oldest
retained event (maxWindowSize), so update the logic in the event-store code
(e.g., inside getEventsAfter and the reconnection/replay path that consumes
sequenceId) to detect stale cursors: compare the requested sequenceId against
the earliest retained sequence (compute from current store head and
maxWindowSize) and, when the request is older, return an explicit
error/indicator (or special response) instead of returning a partial backlog;
ensure the error includes the requested sequenceId and the earliest available
sequence to allow the client to decide whether to request a full snapshot or
resync.


/**
* Store a new event and assign a sequence ID
*/
storeEvent(type: string, data: any, rooms: string[]): StoredEvent {
const event: StoredEvent = {
sequenceId: this.nextSequenceId++,
type,
data,
rooms,
timestamp: new Date(),
};

this.events.push(event);

// Keep only the sliding window of events
if (this.events.length > this.maxWindowSize) {
this.events.shift();
}

this.logger.debug(`Stored event ${event.sequenceId} (${type}) for rooms: ${rooms.join(', ')}`);
return event;
}

/**
* Get missed events for a client after a specific sequence ID
*/
getEventsAfter(sequenceId: number, targetRooms: string[]): StoredEvent[] {
// Only return events that the client should have access to based on their joined rooms
return this.events.filter(
(event) =>
event.sequenceId > sequenceId &&
event.rooms.some((room) => targetRooms.includes(room))
);
}

/**
* Get the latest sequence ID
*/
getLatestSequenceId(): number {
return this.nextSequenceId - 1;
}

/**
* Clear the event store (useful for testing)
*/
clear(): void {
this.events = [];
this.nextSequenceId = 1;
}
}
115 changes: 92 additions & 23 deletions backend/src/websocket/websocket.gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,14 @@ import {
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Tip } from '../tips/entities/tip.entity';
import { EventStoreService } from './event-store.service';
import { TipVerifiedEvent } from '../tips/events/tip-verified.event';

export interface TipNotificationData {
type: 'tip_received';
sequenceId: number;
data: {
tipId: string;
artistId: string;
Expand Down Expand Up @@ -43,15 +47,26 @@ export class WebSocketGateway implements OnGatewayConnection, OnGatewayDisconnec
private readonly logger = new Logger(WebSocketGateway.name);
private connectedClients: Map<string, Socket> = new Map();

handleConnection(client: Socket): void {
constructor(private readonly eventStore: EventStoreService) {}

async handleConnection(client: Socket): Promise<void> {
this.logger.log(`Client connected: ${client.id}`);
this.connectedClients.set(client.id, client);

// Send welcome message
client.emit('connected', {
message: 'Connected to TipTune WebSocket',
clientId: client.id,
latestSequenceId: this.eventStore.getLatestSequenceId(),
});

// Handle initial catch-up if requested
const lastSequenceId = parseInt(client.handshake.query.lastSequenceId as string);
if (!isNaN(lastSequenceId)) {
this.logger.log(`Client ${client.id} requested catch-up from sequence: ${lastSequenceId}`);
// Replay missed events will be handled after rooms are joined or based on past rooms if stored
// For now, we'll allow an explicit 'request_missed_events' message or handle it post-authentication
}
}

handleDisconnect(client: Socket): void {
Expand All @@ -62,13 +77,18 @@ export class WebSocketGateway implements OnGatewayConnection, OnGatewayDisconnec
@SubscribeMessage('join_artist_room')
handleJoinArtistRoom(
@ConnectedSocket() client: Socket,
@MessageBody() data: { artistId: string },
@MessageBody() data: { artistId: string; lastSequenceId?: number },
): void {
const room = `artist_${data.artistId}`;
client.join(room);
this.logger.log(`Client ${client.id} joined room: ${room}`);

client.emit('joined_room', { room, artistId: data.artistId });

// Catch up if lastSequenceId provided
if (data.lastSequenceId !== undefined) {
this.replayMissedEvents(client, data.lastSequenceId, [room]);
}
}

@SubscribeMessage('leave_artist_room')
Expand All @@ -86,13 +106,18 @@ export class WebSocketGateway implements OnGatewayConnection, OnGatewayDisconnec
@SubscribeMessage('join_track_room')
handleJoinTrackRoom(
@ConnectedSocket() client: Socket,
@MessageBody() data: { trackId: string },
@MessageBody() data: { trackId: string; lastSequenceId?: number },
): void {
const room = `track_${data.trackId}`;
client.join(room);
this.logger.log(`Client ${client.id} joined room: ${room}`);

client.emit('joined_room', { room, trackId: data.trackId });

// Catch up if lastSequenceId provided
if (data.lastSequenceId !== undefined) {
this.replayMissedEvents(client, data.lastSequenceId, [room]);
}
}

@SubscribeMessage('leave_track_room')
Expand All @@ -107,54 +132,98 @@ export class WebSocketGateway implements OnGatewayConnection, OnGatewayDisconnec
client.emit('left_room', { room, trackId: data.trackId });
}

@SubscribeMessage('ack')
handleAck(
@ConnectedSocket() client: Socket,
@MessageBody() data: { sequenceId: number },
): void {
this.logger.debug(`Client ${client.id} acknowledged sequence ID: ${data.sequenceId}`);
}

@SubscribeMessage('ping')
handlePing(@ConnectedSocket() client: Socket): void {
client.emit('pong', { timestamp: new Date() });
}

/**
* Send tip notification to relevant rooms
* Listen for tip.verified events from TipsService
*/
@OnEvent('tip.verified')
async handleTipVerifiedEvent(event: TipVerifiedEvent): Promise<void> {
this.logger.log(`Received tip.verified event for tip ${event.tip.id}`);
await this.sendTipNotification(event.tip);
}

/**
* Send tip notification to relevant rooms and store it
*/
async sendTipNotification(tip: Tip): Promise<void> {
try {
const artistRoom = `artist_${tip.artistId}`;
const trackRoom = tip.trackId ? `track_${tip.trackId}` : null;
const rooms = [artistRoom, 'global'];
if (trackRoom) rooms.push(trackRoom);

const payload = {
tipId: tip.id,
artistId: tip.artistId,
trackId: tip.trackId,
amount: tip.amount,
asset: tip.asset,
message: tip.message,
senderAddress: tip.isAnonymous ? undefined : tip.senderAddress,
isAnonymous: tip.isAnonymous,
createdAt: tip.createdAt,
artist: tip.artist,
track: tip.track,
};
Comment on lines +167 to +179
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Send assetCode in the notification payload.

Lines 152-177 of backend/src/tips/tips.service.ts populate assetCode, not asset, so this emits asset: undefined for newly verified tips. Non-XLM tips will be mislabeled by clients that fall back to XLM.

Suggested fix
-        asset: tip.asset,
+        asset: tip.assetCode ?? tip.asset,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/websocket/websocket.gateway.ts` around lines 167 - 179, The
notification payload in websocket.gateway.ts currently sets asset: tip.asset
which is undefined for newly verified tips because tips.service.ts populates
assetCode; update the payload construction (the payload object near where tip is
read) to include the correct asset identifier by using tip.assetCode (or set
assetCode: tip.assetCode and/or replace asset with assetCode) so clients receive
the non-XLM asset code instead of undefined.


// Store event first to get sequence ID
const storedEvent = this.eventStore.storeEvent('tip_received', payload, rooms);

const notificationData: TipNotificationData = {
type: 'tip_received',
data: {
tipId: tip.id,
artistId: tip.artistId,
trackId: tip.trackId,
amount: tip.amount,
asset: tip.asset,
message: tip.message,
senderAddress: tip.isAnonymous ? undefined : tip.senderAddress,
isAnonymous: tip.isAnonymous,
createdAt: tip.createdAt,
artist: tip.artist,
track: tip.track,
},
sequenceId: storedEvent.sequenceId,
data: payload,
};

// Send to artist room
const artistRoom = `artist_${tip.artistId}`;
this.server.to(artistRoom).emit('tip_notification', notificationData);
this.logger.log(`Sent tip notification to room: ${artistRoom}`);
this.logger.log(`Sent tip notification ${storedEvent.sequenceId} to room: ${artistRoom}`);

// Send to track room if track is specified
if (tip.trackId) {
const trackRoom = `track_${tip.trackId}`;
if (trackRoom) {
this.server.to(trackRoom).emit('tip_notification', notificationData);
this.logger.log(`Sent tip notification to room: ${trackRoom}`);
this.logger.log(`Sent tip notification ${storedEvent.sequenceId} to room: ${trackRoom}`);
}
Comment on lines 190 to 198
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "websocket.gateway.ts"

Repository: OlufunbiIK/tip-tune

Length of output: 107


🏁 Script executed:

wc -l ./backend/src/websocket/websocket.gateway.ts

Repository: OlufunbiIK/tip-tune

Length of output: 111


🏁 Script executed:

sed -n '150,220p' ./backend/src/websocket/websocket.gateway.ts

Repository: OlufunbiIK/tip-tune

Length of output: 2678


🏁 Script executed:

grep -n "socket.join\|client.join" ./backend/src/websocket/websocket.gateway.ts

Repository: OlufunbiIK/tip-tune

Length of output: 115


🏁 Script executed:

sed -n '75,125p' ./backend/src/websocket/websocket.gateway.ts

Repository: OlufunbiIK/tip-tune

Length of output: 1651


🏁 Script executed:

head -30 ./backend/src/websocket/websocket.gateway.ts

Repository: OlufunbiIK/tip-tune

Length of output: 879


🏁 Script executed:

cat > /tmp/test_socketio.js << 'EOF'
// Testing Socket.io room behavior
// Documenting Socket.io API

// According to Socket.io documentation:
// server.to(room1).to(room2).emit() - emits to the INTERSECTION (rooms that have both)
// To emit to UNION, you need to emit separately or use different approach

console.log("Socket.io room chaining behavior:");
console.log("server.to(room1).emit() - emits to all sockets in room1");
console.log("server.to(room1).to(room2).emit() - emits only to sockets in BOTH room1 AND room2 (intersection)");
console.log("");
console.log("If a socket is in artist_1 and track_5:");
console.log("- Current code: emits to artist_1, then emits to track_5 -> socket receives twice");
console.log("- Suggested fix with chaining: would only emit to sockets in BOTH rooms");
console.log("");
console.log("To solve the duplicate without losing messages:");
console.log("- Option 1: Track socket IDs and emit once per socket");
console.log("- Option 2: Use namespace or user-based approach");
console.log("- Option 3: Emit to union and deduplicate at client");
EOF
cat /tmp/test_socketio.js

Repository: OlufunbiIK/tip-tune

Length of output: 1110


🌐 Web query:

socket.io server.to(room1).to(room2).emit behavior intersection union

💡 Result:

The behavior of server.to(room1).to(room2).emit(...) in Socket.IO is a union of the rooms, not an intersection. Every socket that is in at least one of the specified rooms receives the event exactly once, even if it belongs to multiple rooms. This is explicitly stated in the official Socket.IO documentation across multiple versions: - "In that case, a union is performed: every socket that is at least in one of the rooms will get the event once (even if the socket is in two or more rooms)." (Rooms docs v3 and v4) - Emit cheatsheet example: io.to("room1").to(["room2", "room3"]).except("room4").emit("hello"); describes targeting clients "in at least one of the rooms". The to method chains by accumulating room names in the socket's _rooms array, and the broadcast targets the union via the adapter. There is no intersection behavior; except is used for exclusion instead.

Citations:


🏁 Script executed:

sed -n '188,220p' ./backend/src/websocket/websocket.gateway.ts

Repository: OlufunbiIK/tip-tune

Length of output: 1415


Broadcast once to the room union.

A socket can be in both artist_* and track_* rooms. The current code emits separately to each room, sending duplicate tip_notification events to clients subscribed to both. Use Socket.io's room chaining to emit to the union of rooms in a single broadcast:

Suggested fix
-      this.server.to(artistRoom).emit('tip_notification', notificationData);
-      this.logger.log(`Sent tip notification ${storedEvent.sequenceId} to room: ${artistRoom}`);
-
-      // Send to track room if track is specified
-      if (trackRoom) {
-        this.server.to(trackRoom).emit('tip_notification', notificationData);
-        this.logger.log(`Sent tip notification ${storedEvent.sequenceId} to room: ${trackRoom}`);
-      }
+      if (trackRoom) {
+        this.server
+          .to(artistRoom)
+          .to(trackRoom)
+          .emit('tip_notification', notificationData);
+        this.logger.log(
+          `Sent tip notification ${storedEvent.sequenceId} to rooms: ${artistRoom}, ${trackRoom}`,
+        );
+      } else {
+        this.server.to(artistRoom).emit('tip_notification', notificationData);
+        this.logger.log(`Sent tip notification ${storedEvent.sequenceId} to room: ${artistRoom}`);
+      }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Send to artist room
const artistRoom = `artist_${tip.artistId}`;
this.server.to(artistRoom).emit('tip_notification', notificationData);
this.logger.log(`Sent tip notification to room: ${artistRoom}`);
this.logger.log(`Sent tip notification ${storedEvent.sequenceId} to room: ${artistRoom}`);
// Send to track room if track is specified
if (tip.trackId) {
const trackRoom = `track_${tip.trackId}`;
if (trackRoom) {
this.server.to(trackRoom).emit('tip_notification', notificationData);
this.logger.log(`Sent tip notification to room: ${trackRoom}`);
this.logger.log(`Sent tip notification ${storedEvent.sequenceId} to room: ${trackRoom}`);
}
if (trackRoom) {
this.server
.to(artistRoom)
.to(trackRoom)
.emit('tip_notification', notificationData);
this.logger.log(
`Sent tip notification ${storedEvent.sequenceId} to rooms: ${artistRoom}, ${trackRoom}`,
);
} else {
this.server.to(artistRoom).emit('tip_notification', notificationData);
this.logger.log(`Sent tip notification ${storedEvent.sequenceId} to room: ${artistRoom}`);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/websocket/websocket.gateway.ts` around lines 190 - 198, Currently
the gateway emits 'tip_notification' twice when a socket is in both rooms by
calling this.server.to(artistRoom).emit(...) and again
this.server.to(trackRoom).emit(...); change this to emit once to the union of
rooms by chaining room targets on the same server broadcast (use
this.server.to(artistRoom).to(trackRoom).emit(...) when trackRoom exists) so
clients in both rooms receive a single event; also collapse the two logger calls
into one single log after the emit (include storedEvent.sequenceId and the list
of target rooms like artistRoom and trackRoom) and keep the fallback to emit
only to artistRoom when trackRoom is falsy.


// Send to all connected clients for global notifications
this.server.emit('global_tip_notification', notificationData);
this.logger.log(`Sent global tip notification`);
this.logger.log(`Sent global tip notification ${storedEvent.sequenceId}`);

} catch (error) {
this.logger.error(`Failed to send tip notification: ${error.message}`);
}
}

/**
* Replay missed events for a client
*/
private replayMissedEvents(client: Socket, lastSequenceId: number, rooms: string[]): void {
const missedEvents = this.eventStore.getEventsAfter(lastSequenceId, rooms);
if (missedEvents.length > 0) {
this.logger.log(`Replaying ${missedEvents.length} missed events for client ${client.id}`);
for (const event of missedEvents) {
const notificationData: TipNotificationData = {
type: 'tip_received',
sequenceId: event.sequenceId,
data: event.data,
};
client.emit('tip_notification', notificationData);
}
}
}

/**
* Send general notification
*/
Expand Down
5 changes: 3 additions & 2 deletions backend/src/websocket/websocket.module.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Module } from '@nestjs/common';
import { WebSocketGateway } from './websocket.gateway';
import { EventStoreService } from './event-store.service';

@Module({
providers: [WebSocketGateway],
exports: [WebSocketGateway],
providers: [WebSocketGateway, EventStoreService],
exports: [WebSocketGateway, EventStoreService],
})
export class WebSocketModule {}
Loading