1import { messages, chatRooms } from '$lib/db/convex.server';
2import { getDefaultChatRoomId } from '$lib/utils/chat.server';
3import type { Message } from '$lib/types/chat';
4import type { SendMessageResponse, GetMessagesResponse } from '$lib/types/payloads';
5import { error, isHttpError } from '@sveltejs/kit';
6import { createLogger } from '$lib/utils/logger.server';
7import { sanitizeStyleData } from '$lib/validation/text-formatting';
8import { sendMessageSchema, PUBLIC_PREVIEW_MESSAGES } from '$lib/validation/message';
9import type { RequestHandler } from './$types';
10
11const log = createLogger('chat-server');
12
13// Rate limiting configuration
14const INITIAL_COOLDOWN = 1000; // 1 second
15const MAX_COOLDOWN = 30000; // 30 seconds
16const userMessageTimestamps = new Map<
17 string,
18 { lastMessageTime: number; currentCooldown: number }
19>();
20
21function updateUserCooldown(userId: string): { canSend: boolean; retryAfter?: number } {
22 const now = Date.now();
23 const userState = userMessageTimestamps.get(userId) || {
24 lastMessageTime: 0,
25 currentCooldown: INITIAL_COOLDOWN
26 };
27
28 const timeSinceLastMessage = now - userState.lastMessageTime;
29 if (timeSinceLastMessage < userState.currentCooldown) {
30 return {
31 canSend: false,
32 retryAfter: userState.currentCooldown - timeSinceLastMessage
33 };
34 }
35
36 if (timeSinceLastMessage > userState.currentCooldown * 2) {
37 userState.currentCooldown = INITIAL_COOLDOWN;
38 } else {
39 userState.currentCooldown = Math.min(userState.currentCooldown * 2, MAX_COOLDOWN);
40 }
41
42 userState.lastMessageTime = now;
43 userMessageTimestamps.set(userId, userState);
44
45 return { canSend: true };
46}
47
48// GET endpoint: fetch messages from Convex
49export const GET: RequestHandler = async ({ url, locals }) => {
50 const beforeTimestamp = url.searchParams.get('before');
51 const roomIdParam = url.searchParams.get('roomId');
52 const isPublic = url.searchParams.get('public') === 'true' || !locals.session;
53
54 // Use default room if not specified
55 const roomId = roomIdParam || getDefaultChatRoomId();
56
57 if (!roomId) {
58 log.error('No room ID available');
59 throw error(500, 'Chat room not configured');
60 }
61
62 try {
63 // Direct (IM) rooms are private to their two members
64 const room = await chatRooms.getById(roomId);
65 if (!room) {
66 throw error(404, 'Chat room not found');
67 }
68 if (room.type === 'direct') {
69 if (!locals.user || !(room.memberIds ?? []).includes(locals.user.id)) {
70 throw error(403, 'Not a member of this conversation');
71 }
72 }
73 // Visitors only get a preview of the default room
74 if (isPublic && roomId !== getDefaultChatRoomId()) {
75 throw error(403, 'Sign in to read this room');
76 }
77
78 log.debug('Fetching messages from Convex', {
79 beforeTimestamp,
80 roomId,
81 isAuthenticated: !!locals.session,
82 isPublic
83 });
84
85 if (isPublic && beforeTimestamp) {
86 const response: GetMessagesResponse = {
87 success: true,
88 messages: [],
89 hasMore: false
90 };
91
92 return new Response(JSON.stringify(response), {
93 headers: { 'Content-Type': 'application/json' }
94 });
95 }
96
97 const fetchLimit = isPublic ? PUBLIC_PREVIEW_MESSAGES : 100;
98
99 const result = await messages.getByRoom(
100 roomId,
101 fetchLimit,
102 beforeTimestamp ? parseInt(beforeTimestamp) : undefined
103 );
104
105 // Map Convex messages to our Message type
106 const fetchedMessages: Message[] = result.messages.map((msg) => ({
107 id: msg._id,
108 chatRoomId: msg.chatRoomId,
109 senderId: msg.senderId,
110 content: msg.content,
111 type: msg.type as Message['type'],
112 timestamp: msg.timestamp,
113 styleData: msg.styleData,
114 hasFormatting: msg.hasFormatting ?? false
115 }));
116
117 const response: GetMessagesResponse = {
118 success: true,
119 messages: fetchedMessages,
120 hasMore: result.hasMore
121 };
122
123 return new Response(JSON.stringify(response), {
124 headers: { 'Content-Type': 'application/json' }
125 });
126 } catch (err) {
127 if (isHttpError(err)) throw err;
128 log.error('Error fetching messages:', { error: err });
129 throw error(500, 'Failed to fetch messages');
130 }
131};
132
133// POST endpoint: send a new message via Convex
134export const POST: RequestHandler = async ({ request, locals }) => {
135 if (!locals.user) {
136 log.warn('Authentication required');
137 throw error(401, 'Authentication required');
138 }
139
140 log.debug('New message received');
141 try {
142 // Check rate limiting
143 const { canSend, retryAfter } = updateUserCooldown(locals.user.id);
144 if (!canSend) {
145 const maskedUserId = `${locals.user.id.slice(0, 4)}...${locals.user.id.slice(-4)}`;
146 log.warn('Rate limited', { userId: maskedUserId, retryAfter });
147 const errorResponse: SendMessageResponse = {
148 success: false,
149 error: 'Please wait before sending another message',
150 retryAfter,
151 isRateLimited: true
152 };
153 return new Response(JSON.stringify(errorResponse), {
154 status: 429,
155 headers: {
156 'Content-Type': 'application/json',
157 'Retry-After': Math.ceil(retryAfter! / 1000).toString()
158 }
159 });
160 }
161
162 const rawData = await request.json();
163
164 // Validate with Zod schema
165 const parseResult = sendMessageSchema.safeParse(rawData);
166 if (!parseResult.success) {
167 const errorMessage = parseResult.error.issues[0]?.message || 'Invalid message payload';
168 log.warn('Message validation failed', {
169 error: errorMessage,
170 issues: parseResult.error.issues
171 });
172 const errorResponse: SendMessageResponse = {
173 success: false,
174 error: errorMessage
175 };
176 return new Response(JSON.stringify(errorResponse), { status: 400 });
177 }
178
179 const data = parseResult.data;
180
181 if (data.userId !== locals.user.id) {
182 log.warn('Unauthorized message attempt', {
183 requestedUserId: data.userId,
184 actualUserId: `${locals.user.id.slice(0, 4)}...${locals.user.id.slice(-4)}`
185 });
186 throw error(403, 'Cannot post messages as another user');
187 }
188
189 // Use default room if not specified
190 const chatRoomId = data.chatRoomId || getDefaultChatRoomId();
191
192 if (!chatRoomId) {
193 log.error('No chat room ID available');
194 const errorResponse: SendMessageResponse = {
195 success: false,
196 error: 'Chat room not configured'
197 };
198 return new Response(JSON.stringify(errorResponse), { status: 500 });
199 }
200
201 const validatedStyleData = sanitizeStyleData(data.styleData);
202
203 // Send message via Convex
204 const convexMessage = await messages.send(
205 chatRoomId,
206 data.userId,
207 data.content,
208 data.type || 'chat',
209 validatedStyleData ? JSON.stringify(validatedStyleData) : undefined,
210 Boolean(validatedStyleData)
211 );
212
213 // Map to our Message type
214 const newMessage: Message = {
215 id: convexMessage._id,
216 chatRoomId: convexMessage.chatRoomId,
217 senderId: convexMessage.senderId,
218 content: convexMessage.content,
219 type: convexMessage.type as Message['type'],
220 timestamp: convexMessage.timestamp,
221 styleData: convexMessage.styleData,
222 hasFormatting: convexMessage.hasFormatting ?? false
223 };
224
225 log.debug('Message processed successfully', {
226 messageId: newMessage.id,
227 userId: `${newMessage.senderId.slice(0, 4)}...${newMessage.senderId.slice(-4)}`,
228 roomId: newMessage.chatRoomId
229 });
230
231 const successResponse: SendMessageResponse = {
232 success: true,
233 message: newMessage
234 };
235
236 return new Response(JSON.stringify(successResponse), {
237 status: 201,
238 headers: { 'Content-Type': 'application/json' }
239 });
240 } catch (err) {
241 log.error('Error processing message', {
242 error: err instanceof Error ? err.message : 'Unknown error'
243 });
244 const errorResponse: SendMessageResponse = {
245 success: false,
246 error: 'Failed to save message'
247 };
248 return new Response(JSON.stringify(errorResponse), {
249 status: 500,
250 headers: { 'Content-Type': 'application/json' }
251 });
252 }
253};