scheduling functions now works

This commit is contained in:
2025-09-19 21:25:37 -05:00
parent f93b39d7a9
commit a8bbfebd00
7 changed files with 105 additions and 134 deletions

View File

@@ -14,7 +14,7 @@ const nextOccurrenceMs = (hhmm: string, from = new Date()): number => {
}; };
export const LunchReminder = () => { export const LunchReminder = () => {
const setStatus = useMutation(api.statuses.create); const setStatus = useMutation(api.statuses.createLunchStatus);
const timeoutRef = useRef<number | null>(null); const timeoutRef = useRef<number | null>(null);
const user = useQuery(api.auth.getUser); const user = useQuery(api.auth.getUser);
const lunchTime = user?.lunchTime ?? ''; const lunchTime = user?.lunchTime ?? '';
@@ -44,7 +44,7 @@ export const LunchReminder = () => {
description: 'Would you like to set your status to "At lunch"?', description: 'Would you like to set your status to "At lunch"?',
action: { action: {
label: 'Set to lunch', label: 'Set to lunch',
onClick: () => void setStatus({ message: 'At lunch' }), onClick: () => void setStatus(),
}, },
cancel: { cancel: {
label: 'Not now', label: 'Not now',

View File

@@ -12,16 +12,16 @@ import type {
ApiFromModules, ApiFromModules,
FilterApi, FilterApi,
FunctionReference, FunctionReference,
} from 'convex/server'; } from "convex/server";
import type * as auth from '../auth.js'; import type * as auth from "../auth.js";
import type * as crons from '../crons.js'; import type * as crons from "../crons.js";
import type * as custom_auth_index from '../custom/auth/index.js'; import type * as custom_auth_index from "../custom/auth/index.js";
import type * as custom_auth_password_validate from '../custom/auth/password/validate.js'; import type * as custom_auth_password_validate from "../custom/auth/password/validate.js";
import type * as custom_auth_providers_entra from '../custom/auth/providers/entra.js'; import type * as custom_auth_providers_entra from "../custom/auth/providers/entra.js";
import type * as custom_auth_providers_password from '../custom/auth/providers/password.js'; import type * as custom_auth_providers_password from "../custom/auth/providers/password.js";
import type * as files from '../files.js'; import type * as files from "../files.js";
import type * as http from '../http.js'; import type * as http from "../http.js";
import type * as statuses from '../statuses.js'; import type * as statuses from "../statuses.js";
/** /**
* A utility for referencing Convex functions in your app's API. * A utility for referencing Convex functions in your app's API.
@@ -34,19 +34,19 @@ import type * as statuses from '../statuses.js';
declare const fullApi: ApiFromModules<{ declare const fullApi: ApiFromModules<{
auth: typeof auth; auth: typeof auth;
crons: typeof crons; crons: typeof crons;
'custom/auth/index': typeof custom_auth_index; "custom/auth/index": typeof custom_auth_index;
'custom/auth/password/validate': typeof custom_auth_password_validate; "custom/auth/password/validate": typeof custom_auth_password_validate;
'custom/auth/providers/entra': typeof custom_auth_providers_entra; "custom/auth/providers/entra": typeof custom_auth_providers_entra;
'custom/auth/providers/password': typeof custom_auth_providers_password; "custom/auth/providers/password": typeof custom_auth_providers_password;
files: typeof files; files: typeof files;
http: typeof http; http: typeof http;
statuses: typeof statuses; statuses: typeof statuses;
}>; }>;
export declare const api: FilterApi< export declare const api: FilterApi<
typeof fullApi, typeof fullApi,
FunctionReference<any, 'public'> FunctionReference<any, "public">
>; >;
export declare const internal: FilterApi< export declare const internal: FilterApi<
typeof fullApi, typeof fullApi,
FunctionReference<any, 'internal'> FunctionReference<any, "internal">
>; >;

View File

@@ -8,7 +8,7 @@
* @module * @module
*/ */
import { anyApi } from 'convex/server'; import { anyApi } from "convex/server";
/** /**
* A utility for referencing Convex functions in your app's API. * A utility for referencing Convex functions in your app's API.

View File

@@ -13,9 +13,9 @@ import type {
DocumentByName, DocumentByName,
TableNamesInDataModel, TableNamesInDataModel,
SystemTableNames, SystemTableNames,
} from 'convex/server'; } from "convex/server";
import type { GenericId } from 'convex/values'; import type { GenericId } from "convex/values";
import schema from '../schema.js'; import schema from "../schema.js";
/** /**
* The names of all of your Convex tables. * The names of all of your Convex tables.

View File

@@ -18,8 +18,8 @@ import {
GenericQueryCtx, GenericQueryCtx,
GenericDatabaseReader, GenericDatabaseReader,
GenericDatabaseWriter, GenericDatabaseWriter,
} from 'convex/server'; } from "convex/server";
import type { DataModel } from './dataModel.js'; import type { DataModel } from "./dataModel.js";
/** /**
* Define a query in this Convex app's public API. * Define a query in this Convex app's public API.
@@ -29,7 +29,7 @@ import type { DataModel } from './dataModel.js';
* @param func - The query function. It receives a {@link QueryCtx} as its first argument. * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible. * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/ */
export declare const query: QueryBuilder<DataModel, 'public'>; export declare const query: QueryBuilder<DataModel, "public">;
/** /**
* Define a query that is only accessible from other Convex functions (but not from the client). * Define a query that is only accessible from other Convex functions (but not from the client).
@@ -39,7 +39,7 @@ export declare const query: QueryBuilder<DataModel, 'public'>;
* @param func - The query function. It receives a {@link QueryCtx} as its first argument. * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible. * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/ */
export declare const internalQuery: QueryBuilder<DataModel, 'internal'>; export declare const internalQuery: QueryBuilder<DataModel, "internal">;
/** /**
* Define a mutation in this Convex app's public API. * Define a mutation in this Convex app's public API.
@@ -49,7 +49,7 @@ export declare const internalQuery: QueryBuilder<DataModel, 'internal'>;
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/ */
export declare const mutation: MutationBuilder<DataModel, 'public'>; export declare const mutation: MutationBuilder<DataModel, "public">;
/** /**
* Define a mutation that is only accessible from other Convex functions (but not from the client). * Define a mutation that is only accessible from other Convex functions (but not from the client).
@@ -59,7 +59,7 @@ export declare const mutation: MutationBuilder<DataModel, 'public'>;
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/ */
export declare const internalMutation: MutationBuilder<DataModel, 'internal'>; export declare const internalMutation: MutationBuilder<DataModel, "internal">;
/** /**
* Define an action in this Convex app's public API. * Define an action in this Convex app's public API.
@@ -72,7 +72,7 @@ export declare const internalMutation: MutationBuilder<DataModel, 'internal'>;
* @param func - The action. It receives an {@link ActionCtx} as its first argument. * @param func - The action. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped action. Include this as an `export` to name it and make it accessible. * @returns The wrapped action. Include this as an `export` to name it and make it accessible.
*/ */
export declare const action: ActionBuilder<DataModel, 'public'>; export declare const action: ActionBuilder<DataModel, "public">;
/** /**
* Define an action that is only accessible from other Convex functions (but not from the client). * Define an action that is only accessible from other Convex functions (but not from the client).
@@ -80,7 +80,7 @@ export declare const action: ActionBuilder<DataModel, 'public'>;
* @param func - The function. It receives an {@link ActionCtx} as its first argument. * @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Include this as an `export` to name it and make it accessible. * @returns The wrapped function. Include this as an `export` to name it and make it accessible.
*/ */
export declare const internalAction: ActionBuilder<DataModel, 'internal'>; export declare const internalAction: ActionBuilder<DataModel, "internal">;
/** /**
* Define an HTTP action. * Define an HTTP action.

View File

@@ -16,7 +16,7 @@ import {
internalActionGeneric, internalActionGeneric,
internalMutationGeneric, internalMutationGeneric,
internalQueryGeneric, internalQueryGeneric,
} from 'convex/server'; } from "convex/server";
/** /**
* Define a query in this Convex app's public API. * Define a query in this Convex app's public API.

View File

@@ -12,7 +12,6 @@ import type { Doc, Id } from './_generated/dataModel';
import { paginationOptsValidator } from 'convex/server'; import { paginationOptsValidator } from 'convex/server';
type RWCtx = MutationCtx | QueryCtx; type RWCtx = MutationCtx | QueryCtx;
type StatusRow = { type StatusRow = {
user: { user: {
id: Id<'users'>; id: Id<'users'>;
@@ -34,28 +33,24 @@ type Paginated<T> = {
continueCursor: string | null; continueCursor: string | null;
}; };
// CHANGED: typed helpers
const ensureUser = async (ctx: RWCtx, userId: Id<'users'>) => { const ensureUser = async (ctx: RWCtx, userId: Id<'users'>) => {
const user = await ctx.db.get(userId); const user = await ctx.db.get(userId);
if (!user) throw new ConvexError('User not found.'); if (!user) throw new ConvexError('User not found.');
return user; return user;
}; };
const latestStatusForOwner = async (ctx: RWCtx, ownerId: Id<'users'>) => { const getName = (u: Doc<'users'>): string | null =>
const [latest] = await ctx.db 'name' in u && typeof u.name === 'string' ? u.name : null;
.query('statuses')
.withIndex('by_user_updatedAt', (q) => q.eq('userId', ownerId)) const getEmail = (u: Doc<'users'>): string | null =>
.order('desc') 'email' in u && typeof u.email === 'string' ? u.email : null;
.take(1);
return latest as Doc<'statuses'> | 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;
}; };
/**
* 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({ export const create = mutation({
args: { args: {
message: v.string(), message: v.string(),
@@ -64,12 +59,14 @@ export const create = mutation({
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const authUserId = await getAuthUserId(ctx); const authUserId = await getAuthUserId(ctx);
if (!authUserId) throw new ConvexError('Not authenticated.'); if (!args.userId && !authUserId) {
throw new ConvexError('Not authenticated.');
}
const userId = args.userId ?? authUserId!;
const updatedBy = args.updatedBy ?? authUserId!;
const userId = args.userId ?? authUserId;
await ensureUser(ctx, userId); await ensureUser(ctx, userId);
const updatedBy = args.updatedBy ?? authUserId;
await ensureUser(ctx, updatedBy); await ensureUser(ctx, updatedBy);
const message = args.message.trim(); const message = args.message.trim();
@@ -85,16 +82,10 @@ export const create = mutation({
}); });
await ctx.db.patch(userId, { currentStatusId: statusId }); await ctx.db.patch(userId, { currentStatusId: statusId });
return { 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({ export const bulkCreate = mutation({
args: { args: {
message: v.string(), message: v.string(),
@@ -118,91 +109,94 @@ export const bulkCreate = mutation({
const statusIds: Id<'statuses'>[] = []; const statusIds: Id<'statuses'>[] = [];
const now = Date.now(); 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) { for (const userId of args.userIds) {
await ensureUser(ctx, userId); await ensureUser(ctx, userId);
const statusId = await ctx.db.insert('statuses', { const statusId = await ctx.db.insert('statuses', {
message, message,
userId, userId,
updatedBy, updatedBy,
updatedAt: now, updatedAt: now,
}); });
await ctx.db.patch(userId, { currentStatusId: statusId }); await ctx.db.patch(userId, { currentStatusId: statusId });
statusIds.push(statusId); statusIds.push(statusId);
} }
return { statusIds }; return { statusIds };
}, },
}); });
/** // Update all users - simplified
* Update all statuses for all users.
*/
export const updateAllStatuses = mutation({ export const updateAllStatuses = mutation({
args: { message: v.string() }, args: { message: v.string() },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const userIds = await ctx.runQuery(api.auth.getAllUserIds); const users = await ctx.db.query('users').collect();
const updatedAt = Date.now(); const message = args.message.trim();
if (message.length === 0) {
throw new ConvexError('Message cannot be empty.');
}
const statusIds: Id<'statuses'>[] = []; const statusIds: Id<'statuses'>[] = [];
for (const userId of userIds) { const now = Date.now();
await ensureUser(ctx, userId);
for (const user of users) {
const statusId = await ctx.db.insert('statuses', { const statusId = await ctx.db.insert('statuses', {
message: args.message, message,
userId, userId: user._id,
updatedAt, updatedAt: now,
}); });
await ctx.db.patch(userId, { currentStatusId: statusId }); await ctx.db.patch(user._id, { currentStatusId: statusId });
statusIds.push(statusId); statusIds.push(statusId);
} }
return { statusIds }; return { statusIds };
}, },
}); });
/** // Lunch status with automatic return - this should be an action
* Current status for a specific user. export const createLunchStatus = mutation({
* - Uses users.currentStatusId if present, args: {},
* otherwise falls back to latest by index. 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({ export const getCurrentForUser = query({
args: { userId: v.id('users') }, args: { userId: v.id('users') },
handler: async (ctx, { userId }) => { handler: async (ctx, { userId }) => {
const user = await ensureUser(ctx, userId); const user = await ensureUser(ctx, userId);
if (user.currentStatusId) { if (user.currentStatusId) {
const status = await ctx.db.get(user.currentStatusId); const status = await ctx.db.get(user.currentStatusId);
if (status) return status; 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 await latestStatusForOwner(ctx, userId); return latest ?? null;
}, },
}); });
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;
};
/**
* 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({ export const getCurrentForAll = query({
args: {}, args: {},
handler: async (ctx): Promise<StatusRow[]> => { handler: async (ctx): Promise<StatusRow[]> => {
const users = await ctx.db.query('users').collect(); const users = await ctx.db.query('users').collect();
return await Promise.all( return await Promise.all(
users.map(async (u) => { users.map(async (u) => {
// Resolve user's current or latest status
let curStatus: Doc<'statuses'> | null = null; let curStatus: Doc<'statuses'> | null = null;
if ('currentStatusId' in u && u.currentStatusId) { if ('currentStatusId' in u && u.currentStatusId) {
curStatus = await ctx.db.get(u.currentStatusId); curStatus = await ctx.db.get(u.currentStatusId);
@@ -215,18 +209,14 @@ export const getCurrentForAll = query({
.take(1); .take(1);
curStatus = latest ?? null; curStatus = latest ?? null;
} }
// User display + URL
const userImageId = getImageId(u); const userImageId = getImageId(u);
const userImageUrl = userImageId const userImageUrl = userImageId
? await ctx.storage.getUrl(userImageId) ? await ctx.storage.getUrl(userImageId)
: null; : null;
// Updated by (if different) + URL
let updatedByUser: StatusRow['user'] | null = null; let updatedByUser: StatusRow['user'] | null = null;
if (curStatus && curStatus.updatedBy && curStatus.updatedBy !== u._id) { if (curStatus && curStatus.updatedBy && curStatus.updatedBy !== u._id) {
const updater = await ctx.db.get(curStatus.updatedBy); const updater = await ctx.db.get(curStatus.updatedBy);
if (!updater) throw new ConvexError('Updater not found.'); if (updater) {
const updaterImageId = getImageId(updater); const updaterImageId = getImageId(updater);
const updaterImageUrl = updaterImageId const updaterImageUrl = updaterImageId
? await ctx.storage.getUrl(updaterImageId) ? await ctx.storage.getUrl(updaterImageId)
@@ -238,7 +228,7 @@ export const getCurrentForAll = query({
imageUrl: updaterImageUrl, imageUrl: updaterImageUrl,
}; };
} }
}
const status: StatusRow['status'] = curStatus const status: StatusRow['status'] = curStatus
? { ? {
id: curStatus._id, id: curStatus._id,
@@ -247,7 +237,6 @@ export const getCurrentForAll = query({
updatedBy: updatedByUser, updatedBy: updatedByUser,
} }
: null; : null;
return { return {
user: { user: {
id: u._id, id: u._id,
@@ -262,19 +251,13 @@ export const getCurrentForAll = query({
}, },
}); });
/** // Paginated history
* Paginated history for all users or for a specific user.
*/
export const listHistory = query({ export const listHistory = query({
args: { args: {
userId: v.optional(v.id('users')), userId: v.optional(v.id('users')),
paginationOpts: paginationOptsValidator, paginationOpts: paginationOptsValidator,
}, },
handler: async ( handler: async (ctx, { userId, paginationOpts }): Promise<Paginated<StatusRow>> => {
ctx,
{ userId, paginationOpts },
): Promise<Paginated<StatusRow>> => {
// Query statuses newest-first, optionally filtered by user
const result = userId const result = userId
? await ctx.db ? await ctx.db
.query('statuses') .query('statuses')
@@ -282,21 +265,15 @@ export const listHistory = query({
.order('desc') .order('desc')
.paginate(paginationOpts) .paginate(paginationOpts)
: await ctx.db.query('statuses').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 displayCache = new Map<string, StatusRow['user']>();
const getDisplay = async (uid: Id<'users'>): Promise<StatusRow['user']> => { const getDisplay = async (uid: Id<'users'>): Promise<StatusRow['user']> => {
const key = uid as unknown as string; const key = uid as unknown as string;
const cached = displayCache.get(key); const cached = displayCache.get(key);
if (cached) return cached; if (cached) return cached;
const user = await ctx.db.get(uid); const user = await ctx.db.get(uid);
if (!user) throw new ConvexError('User not found.'); if (!user) throw new ConvexError('User not found.');
const imgId = getImageId(user); const imgId = getImageId(user);
const imgUrl = imgId ? await ctx.storage.getUrl(imgId) : null; const imgUrl = imgId ? await ctx.storage.getUrl(imgId) : null;
const display: StatusRow['user'] = { const display: StatusRow['user'] = {
id: user._id, id: user._id,
email: getEmail(user), email: getEmail(user),
@@ -306,7 +283,6 @@ export const listHistory = query({
displayCache.set(key, display); displayCache.set(key, display);
return display; return display;
}; };
const statuses: StatusRow[] = []; const statuses: StatusRow[] = [];
for (const s of result.page) { for (const s of result.page) {
const owner = await getDisplay(s.userId); const owner = await getDisplay(s.userId);
@@ -314,7 +290,6 @@ export const listHistory = query({
s.updatedBy && s.updatedBy !== s.userId s.updatedBy && s.updatedBy !== s.userId
? await getDisplay(s.updatedBy) ? await getDisplay(s.updatedBy)
: null; : null;
statuses.push({ statuses.push({
user: owner, user: owner,
status: { status: {
@@ -325,12 +300,8 @@ export const listHistory = query({
}, },
}); });
} }
const page = statuses.sort(
(a, b) => (b.status?.updatedAt ?? 0) - (a.status?.updatedAt ?? 0),
);
return { return {
page, page: statuses,
isDone: result.isDone, isDone: result.isDone,
continueCursor: result.continueCursor, continueCursor: result.continueCursor,
}; };