detached 53f93b1

refactor(api): migrate chat/status/users/rooms endpoints to Convex

commit
53f93b1be987a0aef6b2c4aa847778da0f7a2a89
author
bnema · · 2026-01-18 18:34 UTC
parent
a5ef447
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +161 -158
M src/routes/api/chat/messages/+server.ts +65 -96
@@ -1,18 +1,12 @@
1-import { v4 as uuidv4 } from 'uuid';
2-import db from '$lib/db/db.server';
3-import { messages } from '$lib/db/schema';
1+import { messages, chatRooms, users } from '$lib/db/convex.server';
2+import { getDefaultChatRoomId } from '$lib/utils/chat.server';
43 import type { Message } from '$lib/types/chat';
54 import type { SendMessageResponse, GetMessagesResponse } from '$lib/types/payloads';
6-import { and, desc, eq, lt } from 'drizzle-orm/sql';
7-import type { SQL } from 'drizzle-orm/sql';
85 import { error } from '@sveltejs/kit';
9-import { sseEmitter } from '$lib/sse/SSEEventEmitter';
10-import { chatRooms } from '$lib/db/schema';
11-import { users } from '$lib/db/schema';
12-import { DEFAULT_CHAT_ROOM_ID } from '$lib/utils/chat.server';
136 import { createLogger } from '$lib/utils/logger.server';
147 import { sanitizeStyleData } from '$lib/validation/text-formatting';
158 import { sendMessageSchema } from '$lib/validation/message';
9+import type { RequestHandler } from './$types';
1610
1711 const log = createLogger('chat-server');
1812
@@ -31,7 +25,6 @@ function updateUserCooldown(userId: string): { canSend: boolean; retryAfter?: nu
3125 currentCooldown: INITIAL_COOLDOWN
3226 };
3327
34- // Check if enough time has passed since the last message
3528 const timeSinceLastMessage = now - userState.lastMessageTime;
3629 if (timeSinceLastMessage < userState.currentCooldown) {
3730 return {
@@ -40,11 +33,9 @@ function updateUserCooldown(userId: string): { canSend: boolean; retryAfter?: nu
4033 };
4134 }
4235
43- // Reset cooldown if it's been long enough
4436 if (timeSinceLastMessage > userState.currentCooldown * 2) {
4537 userState.currentCooldown = INITIAL_COOLDOWN;
4638 } else {
47- // Exponential backoff
4839 userState.currentCooldown = Math.min(userState.currentCooldown * 2, MAX_COOLDOWN);
4940 }
5041
@@ -54,68 +45,65 @@ function updateUserCooldown(userId: string): { canSend: boolean; retryAfter?: nu
5445 return { canSend: true };
5546 }
5647
57-// GET endpoint: fetch messages directly from DB
58-export async function GET({ request, locals }) {
59- const url = new URL(request.url);
48+// GET endpoint: fetch messages from Convex
49+export const GET: RequestHandler = async ({ url, locals }) => {
6050 const beforeTimestamp = url.searchParams.get('before');
61- const roomId = url.searchParams.get('roomId') || DEFAULT_CHAT_ROOM_ID;
51+ const roomIdParam = url.searchParams.get('roomId');
6252 const isPublic = url.searchParams.get('public') === 'true';
6353
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+
6462 try {
65- log.debug('Fetching messages from database', {
63+ log.debug('Fetching messages from Convex', {
6664 beforeTimestamp,
6765 roomId,
6866 isAuthenticated: !!locals.session,
6967 isPublic
7068 });
7169
72- let conditions = eq(messages.chatRoomId, roomId) as SQL<unknown>;
73-
74- if (beforeTimestamp) {
75- conditions = and(
76- conditions,
77- lt(messages.timestamp, parseInt(beforeTimestamp))
78- ) as SQL<unknown>;
79- }
80-
81- // Fetch messages with a limit based on authentication status
82- // Public requests are limited to 50 messages
83- // Authenticated users get 100 messages per request
8470 const fetchLimit = isPublic || !locals.session ? 50 : 100;
8571
86- const fetchedMessages = await db
87- .select()
88- .from(messages)
89- .where(conditions)
90- .orderBy(desc(messages.timestamp))
91- .limit(fetchLimit)
92- .then((rows) =>
93- rows.map((row) => ({
94- ...row,
95- styleData: row.styleData ?? undefined,
96- hasFormatting: row.hasFormatting ?? false
97- }))
98- );
72+ const result = await messages.getByRoom(
73+ roomId,
74+ fetchLimit,
75+ beforeTimestamp ? parseInt(beforeTimestamp) : undefined
76+ );
77+
78+ // Map Convex messages to our Message type
79+ const fetchedMessages: Message[] = result.messages.map((msg) => ({
80+ id: msg._id,
81+ chatRoomId: msg.chatRoomId,
82+ senderId: msg.senderId,
83+ content: msg.content,
84+ type: msg.type as Message['type'],
85+ timestamp: msg.timestamp,
86+ styleData: msg.styleData,
87+ hasFormatting: msg.hasFormatting ?? false
88+ }));
9989
10090 const response: GetMessagesResponse = {
10191 success: true,
10292 messages: fetchedMessages,
103- hasMore: fetchedMessages.length === fetchLimit // Indicate if there might be more messages
93+ hasMore: result.hasMore
10494 };
10595
10696 return new Response(JSON.stringify(response), {
107- headers: {
108- 'Content-Type': 'application/json'
109- }
97+ headers: { 'Content-Type': 'application/json' }
11098 });
11199 } catch (err) {
112100 log.error('Error fetching messages:', { error: err });
113101 throw error(500, 'Failed to fetch messages');
114102 }
115-}
103+};
116104
117-// POST endpoint: receive a new message, save it in the DB, and broadcast via SSE
118-export async function POST({ request, locals }: { request: Request; locals: App.Locals }) {
105+// POST endpoint: send a new message via Convex
106+export const POST: RequestHandler = async ({ request, locals }) => {
119107 if (!locals.user) {
120108 log.warn('Authentication required');
121109 throw error(401, 'Authentication required');
@@ -170,61 +158,42 @@ export async function POST({ request, locals }: { request: Request; locals: App.
170158 throw error(403, 'Cannot post messages as another user');
171159 }
172160
173- const chatRoomId = data.chatRoomId || DEFAULT_CHAT_ROOM_ID;
174-
175- // Check chat room existence
176- const chatRoom = await db.select().from(chatRooms).where(eq(chatRooms.id, chatRoomId)).get();
161+ // Use default room if not specified
162+ const chatRoomId = data.chatRoomId || getDefaultChatRoomId();
177163
178- if (!chatRoom) {
179- log.warn('Chat room not found', { chatRoomId });
164+ if (!chatRoomId) {
165+ log.error('No chat room ID available');
180166 const errorResponse: SendMessageResponse = {
181167 success: false,
182- error: 'Chat room not found'
168+ error: 'Chat room not configured'
183169 };
184- return new Response(JSON.stringify(errorResponse), { status: 404 });
185- }
186-
187- // Check user existence
188- const user = await db.select().from(users).where(eq(users.id, data.userId)).get();
189-
190- if (!user) {
191- const maskedUserId = `${data.userId.slice(0, 4)}...${data.userId.slice(-4)}`;
192- log.warn('User not found', { maskedUserId });
193- const errorResponse: SendMessageResponse = {
194- success: false,
195- error: 'User not found'
196- };
197- return new Response(JSON.stringify(errorResponse), { status: 404 });
170+ return new Response(JSON.stringify(errorResponse), { status: 500 });
198171 }
199172
200173 const validatedStyleData = sanitizeStyleData(data.styleData);
201174
202- const newMessage: Message = {
203- id: uuidv4(),
175+ // Send message via Convex
176+ const convexMessage = await messages.send(
204177 chatRoomId,
205- senderId: data.userId,
206- content: data.content,
207- type: data.type || 'chat',
208- timestamp: Date.now(),
209- styleData: validatedStyleData ? JSON.stringify(validatedStyleData) : undefined,
210- hasFormatting: Boolean(validatedStyleData)
178+ data.userId,
179+ data.content,
180+ data.type || 'chat',
181+ validatedStyleData ? JSON.stringify(validatedStyleData) : undefined,
182+ Boolean(validatedStyleData)
183+ );
184+
185+ // Map to our Message type
186+ const newMessage: Message = {
187+ id: convexMessage._id,
188+ chatRoomId: convexMessage.chatRoomId,
189+ senderId: convexMessage.senderId,
190+ content: convexMessage.content,
191+ type: convexMessage.type as Message['type'],
192+ timestamp: convexMessage.timestamp,
193+ styleData: convexMessage.styleData,
194+ hasFormatting: convexMessage.hasFormatting ?? false
211195 };
212196
213- // debug style data content at this point
214- log.debug('Style data content', { styleData: validatedStyleData });
215-
216- // Save to DB
217- await db.insert(messages).values(newMessage);
218- log.debug('Message saved in DB', {
219- messageId: newMessage.id,
220- chatRoomId: newMessage.chatRoomId,
221- type: newMessage.type,
222- timestamp: newMessage.timestamp
223- });
224-
225- // Broadcast the new message via SSE
226- sseEmitter.emit('chatMessage', newMessage);
227-
228197 log.debug('Message processed successfully', {
229198 messageId: newMessage.id,
230199 userId: `${newMessage.senderId.slice(0, 4)}...${newMessage.senderId.slice(-4)}`,
@@ -240,9 +209,9 @@ export async function POST({ request, locals }: { request: Request; locals: App.
240209 status: 201,
241210 headers: { 'Content-Type': 'application/json' }
242211 });
243- } catch (error) {
212+ } catch (err) {
244213 log.error('Error processing message', {
245- error: error instanceof Error ? error.message : 'Unknown error'
214+ error: err instanceof Error ? err.message : 'Unknown error'
246215 });
247216 const errorResponse: SendMessageResponse = {
248217 success: false,
@@ -253,4 +222,4 @@ export async function POST({ request, locals }: { request: Request; locals: App.
253222 headers: { 'Content-Type': 'application/json' }
254223 });
255224 }
256-}
225+};
@@ -1,18 +1,12 @@
1-import { v4 as uuidv4 } from 'uuid';1+import { messages, chatRooms, users } from '$lib/db/convex.server';
2-import db from '$lib/db/db.server';2+import { getDefaultChatRoomId } from '$lib/utils/chat.server';
3-import { messages } from '$lib/db/schema';
4 import type { Message } from '$lib/types/chat';3 import type { Message } from '$lib/types/chat';
5 import type { SendMessageResponse, GetMessagesResponse } from '$lib/types/payloads';4 import type { SendMessageResponse, GetMessagesResponse } from '$lib/types/payloads';
6-import { and, desc, eq, lt } from 'drizzle-orm/sql';
7-import type { SQL } from 'drizzle-orm/sql';
8 import { error } from '@sveltejs/kit';5 import { error } from '@sveltejs/kit';
9-import { sseEmitter } from '$lib/sse/SSEEventEmitter';
10-import { chatRooms } from '$lib/db/schema';
11-import { users } from '$lib/db/schema';
12-import { DEFAULT_CHAT_ROOM_ID } from '$lib/utils/chat.server';
13 import { createLogger } from '$lib/utils/logger.server';6 import { createLogger } from '$lib/utils/logger.server';
14 import { sanitizeStyleData } from '$lib/validation/text-formatting';7 import { sanitizeStyleData } from '$lib/validation/text-formatting';
15 import { sendMessageSchema } from '$lib/validation/message';8 import { sendMessageSchema } from '$lib/validation/message';
9+import type { RequestHandler } from './$types';
16 10
17 const log = createLogger('chat-server');11 const log = createLogger('chat-server');
18 12
@@ -31,7 +25,6 @@ function updateUserCooldown(userId: string): { canSend: boolean; retryAfter?: nu
31 currentCooldown: INITIAL_COOLDOWN25 currentCooldown: INITIAL_COOLDOWN
32 };26 };
33 27
34- // Check if enough time has passed since the last message
35 const timeSinceLastMessage = now - userState.lastMessageTime;28 const timeSinceLastMessage = now - userState.lastMessageTime;
36 if (timeSinceLastMessage < userState.currentCooldown) {29 if (timeSinceLastMessage < userState.currentCooldown) {
37 return {30 return {
@@ -40,11 +33,9 @@ function updateUserCooldown(userId: string): { canSend: boolean; retryAfter?: nu
40 };33 };
41 }34 }
42 35
43- // Reset cooldown if it's been long enough
44 if (timeSinceLastMessage > userState.currentCooldown * 2) {36 if (timeSinceLastMessage > userState.currentCooldown * 2) {
45 userState.currentCooldown = INITIAL_COOLDOWN;37 userState.currentCooldown = INITIAL_COOLDOWN;
46 } else {38 } else {
47- // Exponential backoff
48 userState.currentCooldown = Math.min(userState.currentCooldown * 2, MAX_COOLDOWN);39 userState.currentCooldown = Math.min(userState.currentCooldown * 2, MAX_COOLDOWN);
49 }40 }
50 41
@@ -54,68 +45,65 @@ function updateUserCooldown(userId: string): { canSend: boolean; retryAfter?: nu
54 return { canSend: true };45 return { canSend: true };
55 }46 }
56 47
57-// GET endpoint: fetch messages directly from DB48+// GET endpoint: fetch messages from Convex
58-export async function GET({ request, locals }) {49+export const GET: RequestHandler = async ({ url, locals }) => {
59- const url = new URL(request.url);
60 const beforeTimestamp = url.searchParams.get('before');50 const beforeTimestamp = url.searchParams.get('before');
61- const roomId = url.searchParams.get('roomId') || DEFAULT_CHAT_ROOM_ID;51+ const roomIdParam = url.searchParams.get('roomId');
62 const isPublic = url.searchParams.get('public') === 'true';52 const isPublic = url.searchParams.get('public') === 'true';
63 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+
64 try {62 try {
65- log.debug('Fetching messages from database', {63+ log.debug('Fetching messages from Convex', {
66 beforeTimestamp,64 beforeTimestamp,
67 roomId,65 roomId,
68 isAuthenticated: !!locals.session,66 isAuthenticated: !!locals.session,
69 isPublic67 isPublic
70 });68 });
71 69
72- let conditions = eq(messages.chatRoomId, roomId) as SQL<unknown>;
73-
74- if (beforeTimestamp) {
75- conditions = and(
76- conditions,
77- lt(messages.timestamp, parseInt(beforeTimestamp))
78- ) as SQL<unknown>;
79- }
80-
81- // Fetch messages with a limit based on authentication status
82- // Public requests are limited to 50 messages
83- // Authenticated users get 100 messages per request
84 const fetchLimit = isPublic || !locals.session ? 50 : 100;70 const fetchLimit = isPublic || !locals.session ? 50 : 100;
85 71
86- const fetchedMessages = await db72+ const result = await messages.getByRoom(
87- .select()73+ roomId,
88- .from(messages)74+ fetchLimit,
89- .where(conditions)75+ beforeTimestamp ? parseInt(beforeTimestamp) : undefined
90- .orderBy(desc(messages.timestamp))76+ );
91- .limit(fetchLimit)77+
92- .then((rows) =>78+ // Map Convex messages to our Message type
93- rows.map((row) => ({79+ const fetchedMessages: Message[] = result.messages.map((msg) => ({
94- ...row,80+ id: msg._id,
95- styleData: row.styleData ?? undefined,81+ chatRoomId: msg.chatRoomId,
96- hasFormatting: row.hasFormatting ?? false82+ senderId: msg.senderId,
97- }))83+ content: msg.content,
98- );84+ type: msg.type as Message['type'],
85+ timestamp: msg.timestamp,
86+ styleData: msg.styleData,
87+ hasFormatting: msg.hasFormatting ?? false
88+ }));
99 89
100 const response: GetMessagesResponse = {90 const response: GetMessagesResponse = {
101 success: true,91 success: true,
102 messages: fetchedMessages,92 messages: fetchedMessages,
103- hasMore: fetchedMessages.length === fetchLimit // Indicate if there might be more messages93+ hasMore: result.hasMore
104 };94 };
105 95
106 return new Response(JSON.stringify(response), {96 return new Response(JSON.stringify(response), {
107- headers: {97+ headers: { 'Content-Type': 'application/json' }
108- 'Content-Type': 'application/json'
109- }
110 });98 });
111 } catch (err) {99 } catch (err) {
112 log.error('Error fetching messages:', { error: err });100 log.error('Error fetching messages:', { error: err });
113 throw error(500, 'Failed to fetch messages');101 throw error(500, 'Failed to fetch messages');
114 }102 }
115-}103+};
116 104
117-// POST endpoint: receive a new message, save it in the DB, and broadcast via SSE105+// POST endpoint: send a new message via Convex
118-export async function POST({ request, locals }: { request: Request; locals: App.Locals }) {106+export const POST: RequestHandler = async ({ request, locals }) => {
119 if (!locals.user) {107 if (!locals.user) {
120 log.warn('Authentication required');108 log.warn('Authentication required');
121 throw error(401, 'Authentication required');109 throw error(401, 'Authentication required');
@@ -170,61 +158,42 @@ export async function POST({ request, locals }: { request: Request; locals: App.
170 throw error(403, 'Cannot post messages as another user');158 throw error(403, 'Cannot post messages as another user');
171 }159 }
172 160
173- const chatRoomId = data.chatRoomId || DEFAULT_CHAT_ROOM_ID;161+ // Use default room if not specified
174-162+ const chatRoomId = data.chatRoomId || getDefaultChatRoomId();
175- // Check chat room existence
176- const chatRoom = await db.select().from(chatRooms).where(eq(chatRooms.id, chatRoomId)).get();
177 163
178- if (!chatRoom) {164+ if (!chatRoomId) {
179- log.warn('Chat room not found', { chatRoomId });165+ log.error('No chat room ID available');
180 const errorResponse: SendMessageResponse = {166 const errorResponse: SendMessageResponse = {
181 success: false,167 success: false,
182- error: 'Chat room not found'168+ error: 'Chat room not configured'
183 };169 };
184- return new Response(JSON.stringify(errorResponse), { status: 404 });170+ return new Response(JSON.stringify(errorResponse), { status: 500 });
185- }
186-
187- // Check user existence
188- const user = await db.select().from(users).where(eq(users.id, data.userId)).get();
189-
190- if (!user) {
191- const maskedUserId = `${data.userId.slice(0, 4)}...${data.userId.slice(-4)}`;
192- log.warn('User not found', { maskedUserId });
193- const errorResponse: SendMessageResponse = {
194- success: false,
195- error: 'User not found'
196- };
197- return new Response(JSON.stringify(errorResponse), { status: 404 });
198 }171 }
199 172
200 const validatedStyleData = sanitizeStyleData(data.styleData);173 const validatedStyleData = sanitizeStyleData(data.styleData);
201 174
202- const newMessage: Message = {175+ // Send message via Convex
203- id: uuidv4(),176+ const convexMessage = await messages.send(
204 chatRoomId,177 chatRoomId,
205- senderId: data.userId,178+ data.userId,
206- content: data.content,179+ data.content,
207- type: data.type || 'chat',180+ data.type || 'chat',
208- timestamp: Date.now(),181+ validatedStyleData ? JSON.stringify(validatedStyleData) : undefined,
209- styleData: validatedStyleData ? JSON.stringify(validatedStyleData) : undefined,182+ Boolean(validatedStyleData)
210- hasFormatting: Boolean(validatedStyleData)183+ );
184+
185+ // Map to our Message type
186+ const newMessage: Message = {
187+ id: convexMessage._id,
188+ chatRoomId: convexMessage.chatRoomId,
189+ senderId: convexMessage.senderId,
190+ content: convexMessage.content,
191+ type: convexMessage.type as Message['type'],
192+ timestamp: convexMessage.timestamp,
193+ styleData: convexMessage.styleData,
194+ hasFormatting: convexMessage.hasFormatting ?? false
211 };195 };
212 196
213- // debug style data content at this point
214- log.debug('Style data content', { styleData: validatedStyleData });
215-
216- // Save to DB
217- await db.insert(messages).values(newMessage);
218- log.debug('Message saved in DB', {
219- messageId: newMessage.id,
220- chatRoomId: newMessage.chatRoomId,
221- type: newMessage.type,
222- timestamp: newMessage.timestamp
223- });
224-
225- // Broadcast the new message via SSE
226- sseEmitter.emit('chatMessage', newMessage);
227-
228 log.debug('Message processed successfully', {197 log.debug('Message processed successfully', {
229 messageId: newMessage.id,198 messageId: newMessage.id,
230 userId: `${newMessage.senderId.slice(0, 4)}...${newMessage.senderId.slice(-4)}`,199 userId: `${newMessage.senderId.slice(0, 4)}...${newMessage.senderId.slice(-4)}`,
@@ -240,9 +209,9 @@ export async function POST({ request, locals }: { request: Request; locals: App.
240 status: 201,209 status: 201,
241 headers: { 'Content-Type': 'application/json' }210 headers: { 'Content-Type': 'application/json' }
242 });211 });
243- } catch (error) {212+ } catch (err) {
244 log.error('Error processing message', {213 log.error('Error processing message', {
245- error: error instanceof Error ? error.message : 'Unknown error'214+ error: err instanceof Error ? err.message : 'Unknown error'
246 });215 });
247 const errorResponse: SendMessageResponse = {216 const errorResponse: SendMessageResponse = {
248 success: false,217 success: false,
@@ -253,4 +222,4 @@ export async function POST({ request, locals }: { request: Request; locals: App.
253 headers: { 'Content-Type': 'application/json' }222 headers: { 'Content-Type': 'application/json' }
254 });223 });
255 }224 }
256-}225+};
M src/routes/api/rooms/[roomId]/+server.ts +31 -37
@@ -1,16 +1,15 @@
1-import { eq } from 'drizzle-orm/sql';
2-import db from '$lib/db/db.server';
3-import { users } from '$lib/db/schema';
1+import { users } from '$lib/db/convex.server';
42 import type { SafeUser } from '$lib/types/chat';
53 import type { PublicRoomResponse } from '$lib/types/payloads';
64 import { createSafeUser } from '$lib/types/chat';
75 import { createLogger } from '$lib/utils/logger.server';
6+import type { RequestHandler } from './$types';
87
98 const log = createLogger('rooms-server');
109
1110 const ONLINE_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes
1211
13-export async function GET({ params, url, locals }): Promise<Response> {
12+export const GET: RequestHandler = async ({ params, url, locals }) => {
1413 const { roomId } = params;
1514 const isPublic = !locals.user || url.searchParams.get('public') === 'true';
1615
@@ -26,52 +25,47 @@ export async function GET({ params, url, locals }): Promise<Response> {
2625 // Calculate timeout threshold
2726 const timeoutThreshold = Date.now() - ONLINE_TIMEOUT_MS;
2827
29- // Fetch buddy list: get all users
30- const fetchedUsers = await db.select().from(users).execute();
31-
32- // Normalize users to ensure that lastSeen is always a number (using 0 if null)
33- const normalizedUsers = fetchedUsers.map((user: SafeUser & { lastSeen: number | null }) => ({
34- ...user,
35- lastSeen: user.lastSeen ?? 0
36- }));
28+ // Fetch all users from Convex
29+ const fetchedUsers = await users.list();
3730
3831 // Process users to mark them as offline if they've timed out
39- const processedUsers = normalizedUsers.map((user: SafeUser & { lastSeen: number }) => {
40- const shouldBeOffline = user.status !== 'offline' && user.lastSeen < timeoutThreshold;
32+ const processedUsers = fetchedUsers.map((user) => {
33+ const lastSeen = user.lastSeen ?? 0;
34+ const shouldBeOffline = user.status !== 'offline' && lastSeen < timeoutThreshold;
4135 return {
42- ...user,
43- status: shouldBeOffline ? 'offline' : user.status
36+ id: user._id,
37+ nickname: user.nickname,
38+ status: shouldBeOffline ? 'offline' : user.status,
39+ avatarUrl: user.avatarUrl,
40+ lastSeen
4441 };
4542 });
4643
47- // Find users who were originally non-offline but now marked offline
48- const usersToUpdate = processedUsers.filter(
49- (user: SafeUser & { lastSeen: number }) =>
50- user.status === 'offline' &&
51- normalizedUsers.find((u: SafeUser & { lastSeen: number }) => u.id === user.id)?.status !==
52- 'offline'
53- );
44+ // Find users who should be marked offline and update them
45+ const usersToUpdate = processedUsers.filter((user, index) => {
46+ const original = fetchedUsers[index];
47+ return user.status === 'offline' && original.status !== 'offline';
48+ });
5449
50+ // Update timed-out users in Convex
5551 if (usersToUpdate.length > 0) {
56- await Promise.all(
57- usersToUpdate.map((user: SafeUser & { lastSeen: number }) =>
58- db
59- .update(users)
60- .set({ status: 'offline', lastSeen: Date.now() })
61- .where(eq(users.id, user.id))
62- .execute()
63- )
64- );
52+ await Promise.all(usersToUpdate.map((user) => users.updateStatus(user.id, 'offline')));
6553
6654 log.debug('Updated offline status for users', {
6755 count: usersToUpdate.length,
68- userIds: usersToUpdate.map((u: SafeUser & { lastSeen: number }) => u.id)
56+ userIds: usersToUpdate.map((u) => u.id)
6957 });
7058 }
7159
72- // Sanitize user data using createSafeUser
73- const sanitizedUsers = processedUsers.map((user: SafeUser & { lastSeen: number }) =>
74- createSafeUser(user)
60+ // Sanitize user data
61+ const sanitizedUsers: SafeUser[] = processedUsers.map((user) =>
62+ createSafeUser({
63+ id: user.id,
64+ nickname: user.nickname,
65+ status: user.status,
66+ avatarUrl: user.avatarUrl,
67+ lastSeen: user.lastSeen
68+ })
7569 );
7670
7771 const responseData: PublicRoomResponse = {
@@ -104,4 +98,4 @@ export async function GET({ params, url, locals }): Promise<Response> {
10498 headers: { 'Content-Type': 'application/json' }
10599 });
106100 }
107-}
101+};
@@ -1,16 +1,15 @@
1-import { eq } from 'drizzle-orm/sql';1+import { users } from '$lib/db/convex.server';
2-import db from '$lib/db/db.server';
3-import { users } from '$lib/db/schema';
4 import type { SafeUser } from '$lib/types/chat';2 import type { SafeUser } from '$lib/types/chat';
5 import type { PublicRoomResponse } from '$lib/types/payloads';3 import type { PublicRoomResponse } from '$lib/types/payloads';
6 import { createSafeUser } from '$lib/types/chat';4 import { createSafeUser } from '$lib/types/chat';
7 import { createLogger } from '$lib/utils/logger.server';5 import { createLogger } from '$lib/utils/logger.server';
6+import type { RequestHandler } from './$types';
8 7
9 const log = createLogger('rooms-server');8 const log = createLogger('rooms-server');
10 9
11 const ONLINE_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes10 const ONLINE_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes
12 11
13-export async function GET({ params, url, locals }): Promise<Response> {12+export const GET: RequestHandler = async ({ params, url, locals }) => {
14 const { roomId } = params;13 const { roomId } = params;
15 const isPublic = !locals.user || url.searchParams.get('public') === 'true';14 const isPublic = !locals.user || url.searchParams.get('public') === 'true';
16 15
@@ -26,52 +25,47 @@ export async function GET({ params, url, locals }): Promise<Response> {
26 // Calculate timeout threshold25 // Calculate timeout threshold
27 const timeoutThreshold = Date.now() - ONLINE_TIMEOUT_MS;26 const timeoutThreshold = Date.now() - ONLINE_TIMEOUT_MS;
28 27
29- // Fetch buddy list: get all users28+ // Fetch all users from Convex
30- const fetchedUsers = await db.select().from(users).execute();29+ const fetchedUsers = await users.list();
31-
32- // Normalize users to ensure that lastSeen is always a number (using 0 if null)
33- const normalizedUsers = fetchedUsers.map((user: SafeUser & { lastSeen: number | null }) => ({
34- ...user,
35- lastSeen: user.lastSeen ?? 0
36- }));
37 30
38 // Process users to mark them as offline if they've timed out31 // Process users to mark them as offline if they've timed out
39- const processedUsers = normalizedUsers.map((user: SafeUser & { lastSeen: number }) => {32+ const processedUsers = fetchedUsers.map((user) => {
40- const shouldBeOffline = user.status !== 'offline' && user.lastSeen < timeoutThreshold;33+ const lastSeen = user.lastSeen ?? 0;
34+ const shouldBeOffline = user.status !== 'offline' && lastSeen < timeoutThreshold;
41 return {35 return {
42- ...user,36+ id: user._id,
43- status: shouldBeOffline ? 'offline' : user.status37+ nickname: user.nickname,
38+ status: shouldBeOffline ? 'offline' : user.status,
39+ avatarUrl: user.avatarUrl,
40+ lastSeen
44 };41 };
45 });42 });
46 43
47- // Find users who were originally non-offline but now marked offline44+ // Find users who should be marked offline and update them
48- const usersToUpdate = processedUsers.filter(45+ const usersToUpdate = processedUsers.filter((user, index) => {
49- (user: SafeUser & { lastSeen: number }) =>46+ const original = fetchedUsers[index];
50- user.status === 'offline' &&47+ return user.status === 'offline' && original.status !== 'offline';
51- normalizedUsers.find((u: SafeUser & { lastSeen: number }) => u.id === user.id)?.status !==48+ });
52- 'offline'
53- );
54 49
50+ // Update timed-out users in Convex
55 if (usersToUpdate.length > 0) {51 if (usersToUpdate.length > 0) {
56- await Promise.all(52+ await Promise.all(usersToUpdate.map((user) => users.updateStatus(user.id, 'offline')));
57- usersToUpdate.map((user: SafeUser & { lastSeen: number }) =>
58- db
59- .update(users)
60- .set({ status: 'offline', lastSeen: Date.now() })
61- .where(eq(users.id, user.id))
62- .execute()
63- )
64- );
65 53
66 log.debug('Updated offline status for users', {54 log.debug('Updated offline status for users', {
67 count: usersToUpdate.length,55 count: usersToUpdate.length,
68- userIds: usersToUpdate.map((u: SafeUser & { lastSeen: number }) => u.id)56+ userIds: usersToUpdate.map((u) => u.id)
69 });57 });
70 }58 }
71 59
72- // Sanitize user data using createSafeUser60+ // Sanitize user data
73- const sanitizedUsers = processedUsers.map((user: SafeUser & { lastSeen: number }) =>61+ const sanitizedUsers: SafeUser[] = processedUsers.map((user) =>
74- createSafeUser(user)62+ createSafeUser({
63+ id: user.id,
64+ nickname: user.nickname,
65+ status: user.status,
66+ avatarUrl: user.avatarUrl,
67+ lastSeen: user.lastSeen
68+ })
75 );69 );
76 70
77 const responseData: PublicRoomResponse = {71 const responseData: PublicRoomResponse = {
@@ -104,4 +98,4 @@ export async function GET({ params, url, locals }): Promise<Response> {
104 headers: { 'Content-Type': 'application/json' }98 headers: { 'Content-Type': 'application/json' }
105 });99 });
106 }100 }
107-}101+};
A src/routes/api/rooms/default/+server.ts +42 -0
@@ -0,0 +1,42 @@
1+import { chatRooms } from '$lib/db/convex.server';
2+import { createLogger } from '$lib/utils/logger.server';
3+import type { RequestHandler } from './$types';
4+
5+const log = createLogger('rooms-default-server');
6+
7+export const GET: RequestHandler = async () => {
8+ try {
9+ log.debug('Fetching default room');
10+
11+ const room = await chatRooms.getOrCreateDefault();
12+
13+ log.debug('Successfully fetched default room', {
14+ roomId: room._id
15+ });
16+
17+ return new Response(
18+ JSON.stringify({
19+ success: true,
20+ roomId: room._id
21+ }),
22+ {
23+ status: 200,
24+ headers: { 'Content-Type': 'application/json' }
25+ }
26+ );
27+ } catch (error) {
28+ log.error('Error fetching default room', {
29+ error: error instanceof Error ? error.message : 'Unknown error'
30+ });
31+ return new Response(
32+ JSON.stringify({
33+ success: false,
34+ error: 'Failed to fetch default room'
35+ }),
36+ {
37+ status: 500,
38+ headers: { 'Content-Type': 'application/json' }
39+ }
40+ );
41+ }
42+};
@@ -0,0 +1,42 @@
1+import { chatRooms } from '$lib/db/convex.server';
2+import { createLogger } from '$lib/utils/logger.server';
3+import type { RequestHandler } from './$types';
4+
5+const log = createLogger('rooms-default-server');
6+
7+export const GET: RequestHandler = async () => {
8+ try {
9+ log.debug('Fetching default room');
10+
11+ const room = await chatRooms.getOrCreateDefault();
12+
13+ log.debug('Successfully fetched default room', {
14+ roomId: room._id
15+ });
16+
17+ return new Response(
18+ JSON.stringify({
19+ success: true,
20+ roomId: room._id
21+ }),
22+ {
23+ status: 200,
24+ headers: { 'Content-Type': 'application/json' }
25+ }
26+ );
27+ } catch (error) {
28+ log.error('Error fetching default room', {
29+ error: error instanceof Error ? error.message : 'Unknown error'
30+ });
31+ return new Response(
32+ JSON.stringify({
33+ success: false,
34+ error: 'Failed to fetch default room'
35+ }),
36+ {
37+ status: 500,
38+ headers: { 'Content-Type': 'application/json' }
39+ }
40+ );
41+ }
42+};
M src/routes/api/status/+server.ts +14 -20
@@ -1,11 +1,8 @@
1-import db from '$lib/db/db.server';
2-import { users } from '$lib/db/schema';
3-import { eq } from 'drizzle-orm/sql';
1+import { users } from '$lib/db/convex.server';
42 import { validateSessionToken, generateSessionToken, createSession } from '$lib/api/session.server';
53 import { setSessionTokenCookie } from '$lib/api/session.cookie';
64 import { createSafeUser } from '$lib/types/chat';
75 import { createLogger } from '$lib/utils/logger.server';
8-import { buddyListCache } from '$lib/buddyListCache';
96 import type { RequestHandler } from './$types';
107
118 const log = createLogger('status-server');
@@ -36,21 +33,9 @@ export const POST: RequestHandler = async ({ request, cookies, locals }) => {
3633
3734 const now = Date.now();
3835
39- // Update the user status in the database
40- // Always update lastSeen to support timeout detection
41- const updatedUser = await db
42- .update(users)
43- .set({
44- status,
45- lastSeen: now // Always update lastSeen for proper timeout detection
46- })
47- .where(eq(users.id, userId))
48- .returning()
49- .get();
50- log.debug('Database updated successfully', { userId: maskedUserId, status });
51-
52- // Invalidate buddy list cache when status changes
53- buddyListCache.invalidate();
36+ // Update the user status in Convex
37+ const updatedUser = await users.updateStatus(userId, status);
38+ log.debug('Convex updated successfully', { userId: maskedUserId, status });
5439
5540 // Renew session if status is online and session expires in less than 1 day
5641 if (status === 'online') {
@@ -76,7 +61,13 @@ export const POST: RequestHandler = async ({ request, cookies, locals }) => {
7661 return new Response(
7762 JSON.stringify({
7863 success: true,
79- user: createSafeUser(updatedUser)
64+ user: createSafeUser({
65+ id: updatedUser._id,
66+ nickname: updatedUser.nickname,
67+ status: updatedUser.status,
68+ avatarUrl: updatedUser.avatarUrl,
69+ lastSeen: updatedUser.lastSeen
70+ })
8071 }),
8172 {
8273 status: 200,
@@ -84,6 +75,9 @@ export const POST: RequestHandler = async ({ request, cookies, locals }) => {
8475 }
8576 );
8677 } catch (error) {
78+ log.error('Error updating status', {
79+ error: error instanceof Error ? error.message : 'Unknown error'
80+ });
8781 return new Response(
8882 JSON.stringify({
8983 success: false,
@@ -1,11 +1,8 @@
1-import db from '$lib/db/db.server';1+import { users } from '$lib/db/convex.server';
2-import { users } from '$lib/db/schema';
3-import { eq } from 'drizzle-orm/sql';
4 import { validateSessionToken, generateSessionToken, createSession } from '$lib/api/session.server';2 import { validateSessionToken, generateSessionToken, createSession } from '$lib/api/session.server';
5 import { setSessionTokenCookie } from '$lib/api/session.cookie';3 import { setSessionTokenCookie } from '$lib/api/session.cookie';
6 import { createSafeUser } from '$lib/types/chat';4 import { createSafeUser } from '$lib/types/chat';
7 import { createLogger } from '$lib/utils/logger.server';5 import { createLogger } from '$lib/utils/logger.server';
8-import { buddyListCache } from '$lib/buddyListCache';
9 import type { RequestHandler } from './$types';6 import type { RequestHandler } from './$types';
10 7
11 const log = createLogger('status-server');8 const log = createLogger('status-server');
@@ -36,21 +33,9 @@ export const POST: RequestHandler = async ({ request, cookies, locals }) => {
36 33
37 const now = Date.now();34 const now = Date.now();
38 35
39- // Update the user status in the database36+ // Update the user status in Convex
40- // Always update lastSeen to support timeout detection37+ const updatedUser = await users.updateStatus(userId, status);
41- const updatedUser = await db38+ log.debug('Convex updated successfully', { userId: maskedUserId, status });
42- .update(users)
43- .set({
44- status,
45- lastSeen: now // Always update lastSeen for proper timeout detection
46- })
47- .where(eq(users.id, userId))
48- .returning()
49- .get();
50- log.debug('Database updated successfully', { userId: maskedUserId, status });
51-
52- // Invalidate buddy list cache when status changes
53- buddyListCache.invalidate();
54 39
55 // Renew session if status is online and session expires in less than 1 day40 // Renew session if status is online and session expires in less than 1 day
56 if (status === 'online') {41 if (status === 'online') {
@@ -76,7 +61,13 @@ export const POST: RequestHandler = async ({ request, cookies, locals }) => {
76 return new Response(61 return new Response(
77 JSON.stringify({62 JSON.stringify({
78 success: true,63 success: true,
79- user: createSafeUser(updatedUser)64+ user: createSafeUser({
65+ id: updatedUser._id,
66+ nickname: updatedUser.nickname,
67+ status: updatedUser.status,
68+ avatarUrl: updatedUser.avatarUrl,
69+ lastSeen: updatedUser.lastSeen
70+ })
80 }),71 }),
81 {72 {
82 status: 200,73 status: 200,
@@ -84,6 +75,9 @@ export const POST: RequestHandler = async ({ request, cookies, locals }) => {
84 }75 }
85 );76 );
86 } catch (error) {77 } catch (error) {
78+ log.error('Error updating status', {
79+ error: error instanceof Error ? error.message : 'Unknown error'
80+ });
87 return new Response(81 return new Response(
88 JSON.stringify({82 JSON.stringify({
89 success: false,83 success: false,
M src/routes/api/users/[userId]/+server.ts +9 -5
@@ -1,7 +1,5 @@
11 import { error, json } from '@sveltejs/kit';
2-import db from '$lib/db/db.server';
3-import { users } from '$lib/db/schema';
4-import { eq } from 'drizzle-orm';
2+import { users } from '$lib/db/convex.server';
53 import type { RequestHandler } from './$types';
64 import { createSafeUser } from '$lib/types/chat';
75 import { createLogger } from '$lib/utils/logger.server';
@@ -13,14 +11,20 @@ export const GET: RequestHandler = async ({ params }) => {
1311 log.debug('Fetching user data', { userId: maskedUserId });
1412
1513 try {
16- const user = await db.select().from(users).where(eq(users.id, params.userId)).get();
14+ const user = await users.getById(params.userId);
1715
1816 if (!user) {
1917 log.warn('User not found', { userId: maskedUserId });
2018 throw error(404, 'User not found');
2119 }
2220
23- const safeUser = createSafeUser(user);
21+ const safeUser = createSafeUser({
22+ id: user._id,
23+ nickname: user.nickname,
24+ status: user.status,
25+ avatarUrl: user.avatarUrl,
26+ lastSeen: user.lastSeen
27+ });
2428
2529 log.debug('User data retrieved', {
2630 userId: maskedUserId,
@@ -1,7 +1,5 @@
1 import { error, json } from '@sveltejs/kit';1 import { error, json } from '@sveltejs/kit';
2-import db from '$lib/db/db.server';2+import { users } from '$lib/db/convex.server';
3-import { users } from '$lib/db/schema';
4-import { eq } from 'drizzle-orm';
5 import type { RequestHandler } from './$types';3 import type { RequestHandler } from './$types';
6 import { createSafeUser } from '$lib/types/chat';4 import { createSafeUser } from '$lib/types/chat';
7 import { createLogger } from '$lib/utils/logger.server';5 import { createLogger } from '$lib/utils/logger.server';
@@ -13,14 +11,20 @@ export const GET: RequestHandler = async ({ params }) => {
13 log.debug('Fetching user data', { userId: maskedUserId });11 log.debug('Fetching user data', { userId: maskedUserId });
14 12
15 try {13 try {
16- const user = await db.select().from(users).where(eq(users.id, params.userId)).get();14+ const user = await users.getById(params.userId);
17 15
18 if (!user) {16 if (!user) {
19 log.warn('User not found', { userId: maskedUserId });17 log.warn('User not found', { userId: maskedUserId });
20 throw error(404, 'User not found');18 throw error(404, 'User not found');
21 }19 }
22 20
23- const safeUser = createSafeUser(user);21+ const safeUser = createSafeUser({
22+ id: user._id,
23+ nickname: user.nickname,
24+ status: user.status,
25+ avatarUrl: user.avatarUrl,
26+ lastSeen: user.lastSeen
27+ });
24 28
25 log.debug('User data retrieved', {29 log.debug('User data retrieved', {
26 userId: maskedUserId,30 userId: maskedUserId,

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 8

↑↓ moveenter openesc close