Files
techtracker/packages/backend/convex/statuses.ts
2025-09-19 21:25:37 -05:00

331 lines
9.4 KiB
TypeScript

import { ConvexError, v } from 'convex/values';
import { getAuthUserId } from '@convex-dev/auth/server';
import {
type MutationCtx,
type QueryCtx,
action,
mutation,
query,
} from './_generated/server';
import { api } from './_generated/api';
import type { Doc, Id } from './_generated/dataModel';
import { paginationOptsValidator } from 'convex/server';
type RWCtx = MutationCtx | QueryCtx;
type StatusRow = {
user: {
id: Id<'users'>;
email: string | null;
name: string | null;
imageUrl: string | null;
};
status: {
id: Id<'statuses'>;
message: string;
updatedAt: number;
updatedBy: StatusRow['user'] | null;
} | null;
};
type Paginated<T> = {
page: T[];
isDone: boolean;
continueCursor: string | null;
};
const ensureUser = async (ctx: RWCtx, userId: Id<'users'>) => {
const user = await ctx.db.get(userId);
if (!user) throw new ConvexError('User not found.');
return user;
};
const getName = (u: Doc<'users'>): string | null =>
'name' in u && typeof u.name === 'string' ? u.name : null;
const getEmail = (u: Doc<'users'>): string | null =>
'email' in u && typeof u.email === 'string' ? u.email : null;
const getImageId = (u: Doc<'users'>): Id<'_storage'> | null => {
if (!('image' in u)) return null;
const img = (u as { image?: unknown }).image as string | undefined;
return img && img.length > 0 ? (img as Id<'_storage'>) : null;
};
export const create = mutation({
args: {
message: v.string(),
userId: v.optional(v.id('users')),
updatedBy: v.optional(v.id('users')),
},
handler: async (ctx, args) => {
const authUserId = await getAuthUserId(ctx);
if (!args.userId && !authUserId) {
throw new ConvexError('Not authenticated.');
}
const userId = args.userId ?? authUserId!;
const updatedBy = args.updatedBy ?? authUserId!;
await ensureUser(ctx, userId);
await ensureUser(ctx, updatedBy);
const message = args.message.trim();
if (message.length === 0) {
throw new ConvexError('Message cannot be empty.');
}
const statusId = await ctx.db.insert('statuses', {
message,
userId,
updatedBy,
updatedAt: Date.now(),
});
await ctx.db.patch(userId, { currentStatusId: statusId });
return { statusId };
},
});
export const bulkCreate = mutation({
args: {
message: v.string(),
userIds: v.array(v.id('users')),
updatedBy: v.optional(v.id('users')),
},
handler: async (ctx, args) => {
const authUserId = await getAuthUserId(ctx);
if (!authUserId) throw new ConvexError('Not authenticated.');
if (args.userIds.length === 0) return { statusIds: [] };
const updatedBy = args.updatedBy ?? authUserId;
await ensureUser(ctx, updatedBy);
const message = args.message.trim();
if (message.length === 0) {
throw new ConvexError('Message cannot be empty.');
}
const statusIds: Id<'statuses'>[] = [];
const now = Date.now();
for (const userId of args.userIds) {
await ensureUser(ctx, userId);
const statusId = await ctx.db.insert('statuses', {
message,
userId,
updatedBy,
updatedAt: now,
});
await ctx.db.patch(userId, { currentStatusId: statusId });
statusIds.push(statusId);
}
return { statusIds };
},
});
// Update all users - simplified
export const updateAllStatuses = mutation({
args: { message: v.string() },
handler: async (ctx, args) => {
const users = await ctx.db.query('users').collect();
const message = args.message.trim();
if (message.length === 0) {
throw new ConvexError('Message cannot be empty.');
}
const statusIds: Id<'statuses'>[] = [];
const now = Date.now();
for (const user of users) {
const statusId = await ctx.db.insert('statuses', {
message,
userId: user._id,
updatedAt: now,
});
await ctx.db.patch(user._id, { currentStatusId: statusId });
statusIds.push(statusId);
}
return { statusIds };
},
});
// Lunch status with automatic return - this should be an action
export const createLunchStatus = mutation({
args: {},
handler: async (ctx) => {
const authUserId = await getAuthUserId(ctx);
if (!authUserId) throw new ConvexError('Not authenticated.');
// Create lunch status
await ctx.runMutation(api.statuses.create, {
message: 'At lunch',
userId: authUserId
});
const oneHour = 60 * 60 * 1000;
console.log('Scheduling return to desk after 1 hour');
await ctx.scheduler.runAfter(oneHour, api.statuses.create, {
message: 'At desk',
userId: authUserId
});
return { success: true };
},
});
export const getCurrentForUser = query({
args: { userId: v.id('users') },
handler: async (ctx, { userId }) => {
const user = await ensureUser(ctx, userId);
if (user.currentStatusId) {
const status = await ctx.db.get(user.currentStatusId);
if (status) return status;
}
const [latest] = await ctx.db
.query('statuses')
.withIndex('by_user_updatedAt', (q) => q.eq('userId', userId))
.order('desc')
.take(1);
return latest ?? null;
},
});
export const getCurrentForAll = query({
args: {},
handler: async (ctx): Promise<StatusRow[]> => {
const users = await ctx.db.query('users').collect();
return await Promise.all(
users.map(async (u) => {
let curStatus: Doc<'statuses'> | null = null;
if ('currentStatusId' in u && u.currentStatusId) {
curStatus = await ctx.db.get(u.currentStatusId);
}
if (!curStatus) {
const [latest] = await ctx.db
.query('statuses')
.withIndex('by_user_updatedAt', (q) => q.eq('userId', u._id))
.order('desc')
.take(1);
curStatus = latest ?? null;
}
const userImageId = getImageId(u);
const userImageUrl = userImageId
? await ctx.storage.getUrl(userImageId)
: null;
let updatedByUser: StatusRow['user'] | null = null;
if (curStatus && curStatus.updatedBy && curStatus.updatedBy !== u._id) {
const updater = await ctx.db.get(curStatus.updatedBy);
if (updater) {
const updaterImageId = getImageId(updater);
const updaterImageUrl = updaterImageId
? await ctx.storage.getUrl(updaterImageId)
: null;
updatedByUser = {
id: updater._id,
email: getEmail(updater),
name: getName(updater),
imageUrl: updaterImageUrl,
};
}
}
const status: StatusRow['status'] = curStatus
? {
id: curStatus._id,
message: curStatus.message,
updatedAt: curStatus.updatedAt,
updatedBy: updatedByUser,
}
: null;
return {
user: {
id: u._id,
email: getEmail(u),
name: getName(u),
imageUrl: userImageUrl,
},
status,
};
}),
);
},
});
// Paginated history
export const listHistory = query({
args: {
userId: v.optional(v.id('users')),
paginationOpts: paginationOptsValidator,
},
handler: async (ctx, { userId, paginationOpts }): Promise<Paginated<StatusRow>> => {
const result = userId
? await ctx.db
.query('statuses')
.withIndex('by_user_updatedAt', (q) => q.eq('userId', userId))
.order('desc')
.paginate(paginationOpts)
: await ctx.db.query('statuses').order('desc').paginate(paginationOpts);
const displayCache = new Map<string, StatusRow['user']>();
const getDisplay = async (uid: Id<'users'>): Promise<StatusRow['user']> => {
const key = uid as unknown as string;
const cached = displayCache.get(key);
if (cached) return cached;
const user = await ctx.db.get(uid);
if (!user) throw new ConvexError('User not found.');
const imgId = getImageId(user);
const imgUrl = imgId ? await ctx.storage.getUrl(imgId) : null;
const display: StatusRow['user'] = {
id: user._id,
email: getEmail(user),
name: getName(user),
imageUrl: imgUrl,
};
displayCache.set(key, display);
return display;
};
const statuses: StatusRow[] = [];
for (const s of result.page) {
const owner = await getDisplay(s.userId);
const updatedBy =
s.updatedBy && s.updatedBy !== s.userId
? await getDisplay(s.updatedBy)
: null;
statuses.push({
user: owner,
status: {
id: s._id,
message: s.message,
updatedAt: s.updatedAt,
updatedBy,
},
});
}
return {
page: statuses,
isDone: result.isDone,
continueCursor: result.continueCursor,
};
},
});
export const endOfShiftUpdate = action({
handler: async (ctx) => {
const now = new Date(
new Date().toLocaleString('en-US', {
timeZone: 'America/Chicago',
}),
);
const day = now.getDay(),
hour = now.getHours(),
minute = now.getMinutes();
if (day == 0 || day === 6) return;
const message = day === 5 ? 'Enjoying the weekend' : 'End of shift';
if (hour === 17) {
await ctx.runMutation(api.statuses.updateAllStatuses, { message });
} else if (hour === 16) {
const ms = ((60 - minute) % 60) * 60 * 1000;
await ctx.scheduler.runAfter(ms, api.statuses.endOfShiftUpdate);
} else return;
},
});