309 lines
8.2 KiB
TypeScript
309 lines
8.2 KiB
TypeScript
import { ConvexError, v } from 'convex/values';
|
|
import { getAuthUserId } from '@convex-dev/auth/server';
|
|
import {
|
|
type MutationCtx,
|
|
type QueryCtx,
|
|
mutation,
|
|
query,
|
|
} from './_generated/server';
|
|
import type { Doc, Id } from './_generated/dataModel';
|
|
import { paginationOptsValidator } from 'convex/server';
|
|
|
|
type RWCtx = MutationCtx | QueryCtx
|
|
|
|
type StatusRow = {
|
|
user: {
|
|
id: Id<'users'>;
|
|
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,
|
|
};
|
|
|
|
// CHANGED: typed helpers
|
|
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 latestStatusForOwner = async (ctx: RWCtx, ownerId: Id<'users'>) => {
|
|
const [latest] = await ctx.db
|
|
.query('statuses')
|
|
.withIndex('by_user_updatedAt', (q) => q.eq('userId', ownerId))
|
|
.order('desc')
|
|
.take(1);
|
|
return latest as Doc<'statuses'> | null;
|
|
};
|
|
|
|
/**
|
|
* Create a new status for a single user.
|
|
* - Defaults userId to the caller.
|
|
* - updatedBy defaults to the caller.
|
|
* - Updates the user's currentStatusId pointer.
|
|
*/
|
|
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 (!authUserId) throw new ConvexError('Not authenticated.');
|
|
|
|
const userId = args.userId ?? authUserId;
|
|
await ensureUser(ctx, userId);
|
|
|
|
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 statusId = await ctx.db.insert('statuses', {
|
|
message,
|
|
userId,
|
|
updatedBy,
|
|
updatedAt: Date.now(),
|
|
});
|
|
|
|
await ctx.db.patch(userId, { currentStatusId: statusId });
|
|
|
|
return { statusId };
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Bulk create the same status for many users.
|
|
* - updatedBy defaults to the caller.
|
|
* - Updates each user's currentStatusId pointer.
|
|
*/
|
|
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();
|
|
|
|
// Sequential to keep load predictable; switch to Promise.all
|
|
// if your ownerIds lists are small and bounded.
|
|
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 };
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Current status for a specific user.
|
|
* - Uses users.currentStatusId if present,
|
|
* otherwise falls back to latest by index.
|
|
*/
|
|
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;
|
|
}
|
|
|
|
return await latestStatusForOwner(ctx, userId);
|
|
},
|
|
});
|
|
|
|
const getName = (u: Doc<'users'>): string | null =>
|
|
'name' in u && typeof u.name === 'string' ? u.name : 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;
|
|
};
|
|
|
|
/**
|
|
* Current statuses for all users.
|
|
* - Reads each user's currentStatusId pointer.
|
|
* - Falls back to latest-by-index if pointer is missing.
|
|
*/
|
|
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) => {
|
|
// Resolve user's current or latest status
|
|
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;
|
|
}
|
|
|
|
// User display + URL
|
|
const userImageId = getImageId(u);
|
|
const userImageUrl = userImageId
|
|
? await ctx.storage.getUrl(userImageId)
|
|
: null;
|
|
|
|
// Updated by (if different) + URL
|
|
let updatedByUser: StatusRow['user'] | null = null;
|
|
if (curStatus && curStatus.updatedBy !== u._id) {
|
|
const updater = await ctx.db.get(curStatus.updatedBy);
|
|
if (!updater) throw new ConvexError('Updater not found.');
|
|
const updaterImageId = getImageId(updater);
|
|
const updaterImageUrl = updaterImageId
|
|
? await ctx.storage.getUrl(updaterImageId)
|
|
: null;
|
|
updatedByUser = {
|
|
id: updater._id,
|
|
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,
|
|
name: getName(u),
|
|
imageUrl: userImageUrl,
|
|
},
|
|
status,
|
|
};
|
|
}),
|
|
);
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Paginated history for all users or for a specific user.
|
|
*/
|
|
export const listHistory = query({
|
|
args: {
|
|
userId: v.optional(v.id('users')),
|
|
paginationOpts: paginationOptsValidator,
|
|
},
|
|
handler: async (ctx, { userId, paginationOpts }): Promise<
|
|
Paginated<StatusRow>
|
|
> => {
|
|
// Query statuses newest-first, optionally filtered by user
|
|
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);
|
|
|
|
// Cache user display objects to avoid refetching repeatedly
|
|
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,
|
|
name: getName(user),
|
|
imageUrl: imgUrl,
|
|
};
|
|
displayCache.set(key, display);
|
|
return display;
|
|
};
|
|
|
|
const page: StatusRow[] = [];
|
|
for (const s of result.page) {
|
|
const owner = await getDisplay(s.userId);
|
|
const updatedBy =
|
|
s.updatedBy !== s.userId ? await getDisplay(s.updatedBy) : null;
|
|
|
|
page.push({
|
|
user: owner,
|
|
status: {
|
|
id: s._id,
|
|
message: s.message,
|
|
updatedAt: s.updatedAt,
|
|
updatedBy,
|
|
},
|
|
});
|
|
}
|
|
|
|
return {
|
|
page,
|
|
isDone: result.isDone,
|
|
continueCursor: result.continueCursor,
|
|
};
|
|
},
|
|
});
|
|
|
|
|