1import { v4 as uuidv4 } from 'uuid';
2import db from '$lib/db/db.server';
3import { messages } from '$lib/db/schema';
4import type { Message } from '$lib/types/chat';
5import type {
6 SendMessageRequest,
7 SendMessageResponse,
8 GetMessagesResponse
9} from '$lib/types/payloads';
10import { and, desc, eq, lt } from 'drizzle-orm/sql';
11import type { SQL } from 'drizzle-orm/sql';
12import { error } from '@sveltejs/kit';
13import { sseEmitter } from '$lib/sseEmitter';
14import { chatRooms } from '$lib/db/schema';
15import { users } from '$lib/db/schema';
16import { DEFAULT_CHAT_ROOM_ID } from '$lib/utils/chat.server';
17import { createLogger } from '$lib/utils/logger.server';
18
19const log = createLogger('chat-server');
20
21// Rate limiting configuration
22const INITIAL_COOLDOWN = 1000; // 1 second
23const MAX_COOLDOWN = 30000; // 30 seconds
24const userMessageTimestamps = new Map<string, { lastMessageTime: number; currentCooldown: number }>();
25
26function updateUserCooldown(userId: string): { canSend: boolean; retryAfter?: number } {
27 const now = Date.now();
28 const userState = userMessageTimestamps.get(userId) || { lastMessageTime: 0, currentCooldown: INITIAL_COOLDOWN };
29
30 // Check if enough time has passed since the last message
31 const timeSinceLastMessage = now - userState.lastMessageTime;
32 if (timeSinceLastMessage < userState.currentCooldown) {
33 return {
34 canSend: false,
35 retryAfter: userState.currentCooldown - timeSinceLastMessage
36 };
37 }
38
39 // Reset cooldown if it's been long enough
40 if (timeSinceLastMessage > userState.currentCooldown * 2) {
41 userState.currentCooldown = INITIAL_COOLDOWN;
42 } else {
43 // Exponential backoff
44 userState.currentCooldown = Math.min(userState.currentCooldown * 2, MAX_COOLDOWN);
45 }
46
47 userState.lastMessageTime = now;
48 userMessageTimestamps.set(userId, userState);
49
50 return { canSend: true };
51}
52
53// GET endpoint: fetch messages directly from DB
54export async function GET({ request, locals }) {
55 const url = new URL(request.url);
56 const beforeTimestamp = url.searchParams.get('before');
57 const roomId = url.searchParams.get('roomId') || DEFAULT_CHAT_ROOM_ID;
58 const isPublic = url.searchParams.get('public') === 'true';
59
60 try {
61 log.debug('Fetching messages from database', {
62 beforeTimestamp,
63 roomId,
64 isAuthenticated: !!locals.session,
65 isPublic
66 });
67
68 let conditions = eq(messages.chatRoomId, roomId) as SQL<unknown>;
69
70 if (beforeTimestamp) {
71 conditions = and(
72 conditions,
73 lt(messages.timestamp, parseInt(beforeTimestamp))
74 ) as SQL<unknown>;
75 }
76
77 // Fetch messages with a limit based on authentication status
78 // Public requests are limited to 50 messages
79 // Authenticated users get 100 messages per request
80 const fetchLimit = (isPublic || !locals.session) ? 50 : 100;
81
82 const fetchedMessages = await db.select()
83 .from(messages)
84 .where(conditions)
85 .orderBy(desc(messages.timestamp))
86 .limit(fetchLimit);
87
88 const response: GetMessagesResponse = {
89 success: true,
90 messages: fetchedMessages,
91 hasMore: fetchedMessages.length === fetchLimit // Indicate if there might be more messages
92 };
93
94 return new Response(JSON.stringify(response), {
95 headers: {
96 'Content-Type': 'application/json'
97 }
98 });
99 } catch (err) {
100 log.error('Error fetching messages:', { error: err });
101 throw error(500, 'Failed to fetch messages');
102 }
103}
104
105// POST endpoint: receive a new message, save it in the DB, and broadcast via SSE
106export async function POST({ request, locals }: { request: Request, locals: App.Locals }) {
107 if (!locals.user) {
108 log.warn('Authentication required');
109 throw error(401, 'Authentication required');
110 }
111
112 log.debug('New message received');
113 try {
114 // Check rate limiting
115 const { canSend, retryAfter } = updateUserCooldown(locals.user.id);
116 if (!canSend) {
117 const maskedUserId = `${locals.user.id.slice(0, 4)}...${locals.user.id.slice(-4)}`;
118 log.warn('Rate limited', { userId: maskedUserId, retryAfter });
119 const errorResponse: SendMessageResponse = {
120 success: false,
121 error: 'Please wait before sending another message',
122 retryAfter,
123 isRateLimited: true
124 };
125 return new Response(JSON.stringify(errorResponse), {
126 status: 429,
127 headers: {
128 'Content-Type': 'application/json',
129 'Retry-After': Math.ceil(retryAfter! / 1000).toString()
130 }
131 });
132 }
133
134 const data = await request.json() as SendMessageRequest;
135 if (!data.content || !data.userId) {
136 log.warn('Invalid payload received', { hasContent: Boolean(data.content), hasUserId: Boolean(data.userId) });
137 const errorResponse: SendMessageResponse = {
138 success: false,
139 error: 'Invalid message payload'
140 };
141 return new Response(JSON.stringify(errorResponse), { status: 400 });
142 }
143
144 if (data.userId !== locals.user.id) {
145 log.warn('Unauthorized message attempt', {
146 requestedUserId: data.userId,
147 actualUserId: `${locals.user.id.slice(0, 4)}...${locals.user.id.slice(-4)}`
148 });
149 throw error(403, 'Cannot post messages as another user');
150 }
151
152 const chatRoomId = data.chatRoomId || DEFAULT_CHAT_ROOM_ID;
153
154 // Check chat room existence
155 const chatRoom = await db.select()
156 .from(chatRooms)
157 .where(eq(chatRooms.id, chatRoomId))
158 .get();
159
160 if (!chatRoom) {
161 log.warn('Chat room not found', { chatRoomId });
162 const errorResponse: SendMessageResponse = {
163 success: false,
164 error: 'Chat room not found'
165 };
166 return new Response(JSON.stringify(errorResponse), { status: 404 });
167 }
168
169 // Check user existence
170 const user = await db.select()
171 .from(users)
172 .where(eq(users.id, data.userId))
173 .get();
174
175 if (!user) {
176 const maskedUserId = `${data.userId.slice(0, 4)}...${data.userId.slice(-4)}`;
177 log.warn('User not found', { maskedUserId });
178 const errorResponse: SendMessageResponse = {
179 success: false,
180 error: 'User not found'
181 };
182 return new Response(JSON.stringify(errorResponse), { status: 404 });
183 }
184
185 const newMessage: Message = {
186 id: uuidv4(),
187 chatRoomId,
188 senderId: data.userId,
189 content: data.content,
190 type: data.type || 'chat',
191 timestamp: Date.now()
192 };
193
194 // Save to DB
195 await db.insert(messages).values(newMessage);
196 log.debug('Message saved in DB', { messageId: newMessage.id, chatRoomId: newMessage.chatRoomId, type: newMessage.type, timestamp: newMessage.timestamp });
197
198 // Broadcast the new message via SSE
199 sseEmitter.emit('sse', { type: 'chatMessage', data: newMessage });
200
201 log.debug('Message processed successfully', { messageId: newMessage.id, userId: `${newMessage.senderId.slice(0, 4)}...${newMessage.senderId.slice(-4)}`, roomId: newMessage.chatRoomId });
202
203 const successResponse: SendMessageResponse = {
204 success: true,
205 message: newMessage
206 };
207
208 return new Response(
209 JSON.stringify(successResponse),
210 {
211 status: 201,
212 headers: { 'Content-Type': 'application/json' }
213 }
214 );
215 } catch (error) {
216 log.error('Error processing message', { error: error instanceof Error ? error.message : 'Unknown error' });
217 const errorResponse: SendMessageResponse = {
218 success: false,
219 error: 'Failed to save message'
220 };
221 return new Response(
222 JSON.stringify(errorResponse),
223 {
224 status: 500,
225 headers: { 'Content-Type': 'application/json' }
226 }
227 );
228 }
229}