1import { query } from './_generated/server';
2import type { QueryCtx } from './_generated/server';
3import { authQuery } from './auth';
4import { v } from 'convex/values';
5import { toPublicUser, assertRoomAccess } from './aim';
6
7// Timeout threshold for marking users as offline (2 minutes)
8const ONLINE_TIMEOUT_MS = 2 * 60 * 1000;
9
10// Helper to compute effective status based on lastSeen timeout.
11// Returns 'online', 'away', 'busy', 'idle', or 'offline'.
12// Note: This is a read-time computation - the database stores the last known status
13// and lastSeen timestamp. The client heartbeat (via /api/status) updates these values.
14// This function computes what to display when a user stops sending heartbeats.
15function computeEffectiveStatus(
16 status: string,
17 lastSeen: number | undefined
18): 'online' | 'away' | 'busy' | 'idle' | 'offline' {
19 if (status === 'offline') return 'offline';
20 const timeoutThreshold = Date.now() - ONLINE_TIMEOUT_MS;
21 const effectiveLastSeen = lastSeen ?? 0;
22 if (effectiveLastSeen < timeoutThreshold) {
23 return 'offline';
24 }
25 return status as 'online' | 'away' | 'busy' | 'idle';
26}
27
28// ============ PUBLIC QUERIES (no auth required) ============
29
30// Visitors (not signed in) only see the latest messages of the default room.
31// Keep in sync with PUBLIC_PREVIEW_MESSAGES in src/lib/validation/message.ts.
32const PUBLIC_MESSAGE_LIMIT = 50;
33const MAX_AUTH_MESSAGE_LIMIT = 200;
34
35// Default room: "General", or the first group room when it does not exist
36async function findDefaultRoom(ctx: QueryCtx) {
37 const rooms = await ctx.db.query('chatRooms').collect();
38 return (
39 rooms.find((r) => r.name === 'General' && r.type === 'group') ??
40 rooms.find((r) => r.type === 'group') ??
41 null
42 );
43}
44
45// Public query: latest messages of the default room (real-time subscription)
46
47export const getMessagesPublic = query({
48 args: {
49 roomId: v.id('chatRooms'),
50 limit: v.optional(v.number())
51 },
52 handler: async (ctx, args) => {
53 // Only the default room is readable without an account
54 const defaultRoom = await findDefaultRoom(ctx);
55 if (!defaultRoom || defaultRoom._id !== args.roomId) return [];
56
57 // Enforce max limit and clamp to a safe positive integer
58 const rawLimit = args.limit ?? PUBLIC_MESSAGE_LIMIT;
59 const limit = Math.max(1, Math.min(PUBLIC_MESSAGE_LIMIT, Math.floor(rawLimit)));
60
61 const messages = await ctx.db
62 .query('messages')
63 .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', args.roomId))
64 .order('desc')
65 .take(limit);
66
67 // Enrich messages with sender info
68 const enrichedMessages = await Promise.all(
69 messages.map(async (msg) => {
70 const sender = await ctx.db.get(msg.senderId);
71 return {
72 id: msg._id,
73 chatRoomId: msg.chatRoomId,
74 senderId: msg.senderId,
75 content: msg.content,
76 type: msg.type,
77 timestamp: msg.timestamp,
78 styleData: msg.styleData,
79 hasFormatting: msg.hasFormatting,
80 sender: sender
81 ? {
82 id: sender._id,
83 nickname: sender.nickname,
84 status: computeEffectiveStatus(sender.status, sender.lastSeen),
85 avatarUrl: sender.avatarUrl
86 }
87 : null
88 };
89 })
90 );
91
92 // Return in chronological order (oldest first)
93 return enrichedMessages.reverse();
94 }
95});
96
97// Authenticated query: older history of a room (pagination is members-only)
98export const getMessagesPage = authQuery({
99 args: {
100 roomId: v.id('chatRooms'),
101 limit: v.optional(v.number()),
102 beforeTimestamp: v.optional(v.number())
103 },
104 handler: async (ctx, args) => {
105 const room = await ctx.db.get(args.roomId);
106 if (!room) return { messages: [], hasMore: false };
107 await assertRoomAccess(ctx, room, ctx.user._id);
108
109 const rawLimit = args.limit ?? 50;
110 const limit = Math.max(1, Math.min(MAX_AUTH_MESSAGE_LIMIT, Math.floor(rawLimit)));
111
112 let query = ctx.db
113 .query('messages')
114 .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', args.roomId))
115 .order('desc');
116
117 const beforeTimestamp = args.beforeTimestamp;
118 if (beforeTimestamp !== undefined) {
119 query = query.filter((q) => q.lt(q.field('timestamp'), beforeTimestamp));
120 }
121
122 const messages = await query.take(limit + 1);
123 const page = messages.slice(0, limit);
124
125 return {
126 messages: page.map((msg) => ({
127 id: msg._id,
128 chatRoomId: msg.chatRoomId,
129 senderId: msg.senderId,
130 content: msg.content,
131 type: msg.type,
132 timestamp: msg.timestamp,
133 styleData: msg.styleData,
134 hasFormatting: msg.hasFormatting
135 })),
136 hasMore: messages.length > limit
137 };
138 }
139});
140
141// Public query: Get all users (real-time subscription for buddy list)
142export const getUsersPublic = query({
143 args: {},
144 handler: async (ctx) => {
145 const users = await ctx.db.query('users').collect();
146
147 // Return safe user data (no passwords) with computed status
148 return users.map(toPublicUser);
149 }
150});
151
152// Public query: Get default room
153export const getDefaultRoomPublic = query({
154 args: {},
155 handler: async (ctx) => {
156 const room = await findDefaultRoom(ctx);
157 return room
158 ? { id: room._id, name: room.name, type: room.type, createdAt: room.createdAt }
159 : null;
160 }
161});
162
163// ============ AUTHENTICATED QUERIES ============
164
165// Authenticated query: Get messages for a chat room (real-time subscription)
166export const getMessages = authQuery({
167 args: {
168 roomId: v.id('chatRooms'),
169 limit: v.optional(v.number())
170 },
171 handler: async (ctx, args) => {
172 const room = await ctx.db.get(args.roomId);
173 if (!room) return [];
174 await assertRoomAccess(ctx, room, ctx.user._id);
175
176 const rawLimit = args.limit ?? 100;
177 const limit = Math.max(1, Math.min(MAX_AUTH_MESSAGE_LIMIT, Math.floor(rawLimit)));
178 const query = ctx.db
179 .query('messages')
180 .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', args.roomId))
181 .order('desc');
182
183 const messages = await query.take(limit);
184
185 // Enrich messages with sender info
186 const enrichedMessages = await Promise.all(
187 messages.map(async (msg) => {
188 const sender = await ctx.db.get(msg.senderId);
189 return {
190 id: msg._id,
191 chatRoomId: msg.chatRoomId,
192 senderId: msg.senderId,
193 content: msg.content,
194 type: msg.type,
195 timestamp: msg.timestamp,
196 styleData: msg.styleData,
197 hasFormatting: msg.hasFormatting,
198 sender: sender
199 ? {
200 id: sender._id,
201 nickname: sender.nickname,
202 status: computeEffectiveStatus(sender.status, sender.lastSeen),
203 avatarUrl: sender.avatarUrl
204 }
205 : null
206 };
207 })
208 );
209
210 // Return in chronological order (oldest first)
211 return enrichedMessages.reverse();
212 }
213});
214
215// Authenticated query: Get all users (real-time subscription for buddy list)
216export const getUsers = authQuery({
217 args: {},
218 handler: async (ctx) => {
219 const users = await ctx.db.query('users').collect();
220
221 // Return safe user data (no passwords) with computed status
222 return users.map(toPublicUser);
223 }
224});
225
226// Authenticated query: Get chat rooms (direct rooms only when the user is a member)
227export const getChatRooms = authQuery({
228 args: {},
229 handler: async (ctx) => {
230 const rooms = (await ctx.db.query('chatRooms').collect()).filter(
231 (room) => room.type !== 'direct' || (room.memberIds ?? []).includes(ctx.user._id)
232 );
233 return rooms.map((room) => ({
234 id: room._id,
235 name: room.name,
236 type: room.type,
237 createdAt: room.createdAt
238 }));
239 }
240});
241
242// Authenticated query: Get default room (or first group room)
243export const getDefaultRoom = authQuery({
244 args: {},
245 handler: async (ctx) => {
246 const rooms = await ctx.db.query('chatRooms').collect();
247 const defaultRoom = rooms.find((r) => r.name === 'General' && r.type === 'group');
248
249 if (defaultRoom) {
250 return {
251 id: defaultRoom._id,
252 name: defaultRoom.name,
253 type: defaultRoom.type,
254 createdAt: defaultRoom.createdAt
255 };
256 }
257
258 // Return first group room if General doesn't exist
259 const firstGroup = rooms.find((r) => r.type === 'group');
260 if (firstGroup) {
261 return {
262 id: firstGroup._id,
263 name: firstGroup.name,
264 type: firstGroup.type,
265 createdAt: firstGroup.createdAt
266 };
267 }
268
269 return null;
270 }
271});
272
273// Authenticated query: Get current user info
274export const getCurrentUser = authQuery({
275 args: {},
276 handler: async (ctx) => {
277 // ctx.user is provided by authQuery
278 return {
279 id: ctx.user._id,
280 nickname: ctx.user.nickname,
281 status: ctx.user.status,
282 avatarUrl: ctx.user.avatarUrl,
283 lastSeen: ctx.user.lastSeen,
284 lastReadMentionTimestamp: ctx.user.lastReadMentionTimestamp
285 };
286 }
287});