1import { internalQuery, internalMutation } from './_generated/server';
2import { v } from 'convex/values';
3
4// Get user by nickname (internal)
5export const getByNickname = internalQuery({
6 args: { nickname: v.string() },
7 handler: async (ctx, args) => {
8 return await ctx.db
9 .query('users')
10 .withIndex('by_nickname', (q) => q.eq('nickname', args.nickname))
11 .first();
12 }
13});
14
15// Get user by ID (internal)
16export const getById = internalQuery({
17 args: { id: v.id('users') },
18 handler: async (ctx, args) => {
19 return await ctx.db.get(args.id);
20 }
21});
22
23// Get all users (internal)
24export const list = internalQuery({
25 args: {},
26 handler: async (ctx) => {
27 return await ctx.db.query('users').collect();
28 }
29});
30
31// Create a new user (internal)
32export const create = internalMutation({
33 args: {
34 nickname: v.string(),
35 password: v.string()
36 },
37 handler: async (ctx, args) => {
38 // Check if nickname already exists
39 const existing = await ctx.db
40 .query('users')
41 .withIndex('by_nickname', (q) => q.eq('nickname', args.nickname))
42 .first();
43
44 if (existing) {
45 throw new Error('Username already taken');
46 }
47
48 const userId = await ctx.db.insert('users', {
49 nickname: args.nickname,
50 password: args.password,
51 status: 'offline',
52 createdAt: Date.now()
53 });
54
55 return userId;
56 }
57});
58
59// Update user status by ID (internal)
60export const updateStatus = internalMutation({
61 args: {
62 id: v.id('users'),
63 status: v.string()
64 },
65 handler: async (ctx, args) => {
66 const validStatuses = ['online', 'away', 'busy', 'offline', 'idle'];
67 if (!validStatuses.includes(args.status)) {
68 throw new Error('Invalid status');
69 }
70
71 const user = await ctx.db.get(args.id);
72 if (!user) {
73 throw new Error('User not found');
74 }
75
76 // An away message keeps the user away: presence heartbeats only refresh lastSeen.
77 const keepAway = user.awayMessage && args.status !== 'offline';
78 await ctx.db.patch(args.id, {
79 status: keepAway ? 'away' : args.status,
80 lastSeen: Date.now()
81 });
82
83 return await ctx.db.get(args.id);
84 }
85});
86
87// Set all users offline (internal) - used on server startup
88export const setAllOffline = internalMutation({
89 args: {},
90 handler: async (ctx) => {
91 const users = await ctx.db.query('users').collect();
92 for (const user of users) {
93 await ctx.db.patch(user._id, { status: 'offline' });
94 }
95 return { updated: users.length };
96 }
97});
98
99// Update last read mention timestamp (internal)
100export const updateLastReadMentionTimestamp = internalMutation({
101 args: {
102 id: v.id('users'),
103 timestamp: v.number()
104 },
105 handler: async (ctx, args) => {
106 const user = await ctx.db.get(args.id);
107 if (!user) {
108 throw new Error('User not found');
109 }
110
111 const currentTimestamp = user.lastReadMentionTimestamp ?? 0;
112
113 // Only update if the new timestamp is greater (more recent)
114 if (args.timestamp > currentTimestamp) {
115 await ctx.db.patch(args.id, {
116 lastReadMentionTimestamp: args.timestamp
117 });
118 }
119
120 return { success: true };
121 }
122});
123
124// Cleanup stale users who haven't sent a heartbeat in 2 minutes
125export const cleanupStaleUsers = internalMutation({
126 args: {},
127 handler: async (ctx) => {
128 const ONLINE_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes
129 const timeoutThreshold = Date.now() - ONLINE_TIMEOUT_MS;
130
131 const nonOfflineStatuses = ['online', 'away', 'busy', 'idle'];
132 let updated = 0;
133
134 for (const status of nonOfflineStatuses) {
135 const users = await ctx.db
136 .query('users')
137 .withIndex('by_status', (q) => q.eq('status', status))
138 .collect();
139
140 for (const user of users) {
141 const lastSeen = user.lastSeen ?? 0;
142 if (lastSeen < timeoutThreshold) {
143 await ctx.db.patch(user._id, { status: 'offline' });
144 updated++;
145 }
146 }
147 }
148
149 return { updated };
150 }
151});