diff --git a/apps/next/src/components/layout/profile/avatar-upload.tsx b/apps/next/src/components/layout/profile/avatar-upload.tsx index f5454e3..c2802dd 100644 --- a/apps/next/src/components/layout/profile/avatar-upload.tsx +++ b/apps/next/src/components/layout/profile/avatar-upload.tsx @@ -1,6 +1,5 @@ 'use client'; -import Image from 'next/image'; import { type ChangeEvent, useRef, useState } from 'react'; import { type Preloaded, @@ -49,7 +48,7 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => { const inputRef = useRef(null); const generateUploadUrl = useMutation(api.files.generateUploadUrl); - const updateUserImage = useMutation(api.auth.updateUserImage); + const updateUser = useMutation(api.auth.updateUser); const currentImageUrl = useQuery( api.files.getImageUrl, @@ -98,7 +97,7 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => { storageId: Id<'_storage'>; }; - await updateUserImage({ storageId: uploadResponse.storageId }); + await updateUser({ image: uploadResponse.storageId }); toast.success('Profile picture updated.'); handleReset(); diff --git a/apps/next/src/components/layout/profile/user-info.tsx b/apps/next/src/components/layout/profile/user-info.tsx index c89e0c6..bb161d4 100644 --- a/apps/next/src/components/layout/profile/user-info.tsx +++ b/apps/next/src/components/layout/profile/user-info.tsx @@ -51,12 +51,7 @@ export const UserInfoForm = ({ preloadedUser }: UserInfoFormProps) => { const user = usePreloadedQuery(preloadedUser); const [loading, setLoading] = useState(false); - const updateUserName = useMutation(api.auth.updateUserName); - const updateUserEmail = useMutation(api.auth.updateUserEmail); - const updateUserLunchtime = useMutation(api.auth.updateUserLunchtime); - const updateUserAutomaticLunch = useMutation( - api.auth.updateUserAutomaticLunch, - ); + const updateUser = useMutation(api.auth.updateUser); const initialValues = useMemo>( () => ({ @@ -74,22 +69,29 @@ export const UserInfoForm = ({ preloadedUser }: UserInfoFormProps) => { }); const handleSubmit = async (values: z.infer) => { - const ops: Promise[] = []; const name = values.name.trim(); const email = values.email.trim().toLowerCase(); const lunchTime = values.lunchTime.trim(); const automaticLunch = values.automaticLunch; - if (name !== (user?.name ?? '')) ops.push(updateUserName({ name })); - if (email !== (user?.email ?? '')) ops.push(updateUserEmail({ email })); - if (lunchTime !== (user?.lunchTime ?? '')) - ops.push(updateUserLunchtime({ lunchTime })); - if (automaticLunch !== user?.automaticLunch) - ops.push(updateUserAutomaticLunch({ automaticLunch })); - if (ops.length === 0) return; + const patch: Partial<{ + name: string; + email: string; + lunchTime: string; + automaticLunch: boolean; + }> = {}; + if (name !== (user?.name ?? '') && name !== undefined) + patch.name = name; + if (email !== (user?.email ?? '') && email !== undefined) + patch.email = email; + if (lunchTime !== (user?.lunchTime && '') && lunchTime !== undefined) + patch.lunchTime = lunchTime; + if (automaticLunch !== user?.automaticLunch && automaticLunch !== undefined) + patch.automaticLunch = automaticLunch; + if (Object.keys(patch).length === 0) return; setLoading(true); try { - await Promise.all(ops); - form.reset({ name, email, lunchTime }); + await updateUser(patch); + form.reset(patch); toast.success('Profile updated successfully.'); } catch (error) { console.error(error); diff --git a/apps/next/src/components/providers/lunch-reminder.tsx b/apps/next/src/components/providers/lunch-reminder.tsx index 2743f9c..6e1ef12 100644 --- a/apps/next/src/components/providers/lunch-reminder.tsx +++ b/apps/next/src/components/providers/lunch-reminder.tsx @@ -16,12 +16,19 @@ const nextOccurrenceMs = (hhmm: string, from = new Date()): number => { export const LunchReminder = () => { const setStatus = useMutation(api.statuses.createLunchStatus); const timeoutRef = useRef(null); - const user = useQuery(api.auth.getUser); + const user = useQuery(api.auth.getUser, {}); const lunchTime = user?.lunchTime ?? ''; + const automaticLunch = user?.automaticLunch ?? false; useEffect(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + if (!lunchTime || automaticLunch) { + return; + } const schedule = () => { - if (!lunchTime) return; const ms = nextOccurrenceMs(lunchTime); console.log('Ms = ', ms); if (timeoutRef.current) clearTimeout(timeoutRef.current); @@ -44,7 +51,7 @@ export const LunchReminder = () => { description: 'Would you like to set your status to "At lunch"?', action: { label: 'Set to lunch', - onClick: () => void setStatus(), + onClick: () => void setStatus({}), }, cancel: { label: 'Not now', @@ -59,9 +66,11 @@ export const LunchReminder = () => { schedule(); return () => { - if (timeoutRef.current) clearTimeout(timeoutRef.current); + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null + } }; - }, [lunchTime, setStatus]); - + }, [automaticLunch, lunchTime, setStatus]); return null; }; diff --git a/apps/next/src/components/providers/notification-permission.tsx b/apps/next/src/components/providers/notification-permission.tsx index 01f7bbe..70de8fa 100644 --- a/apps/next/src/components/providers/notification-permission.tsx +++ b/apps/next/src/components/providers/notification-permission.tsx @@ -1,6 +1,5 @@ 'use client'; -import { permission } from 'process'; import { useEffect } from 'react'; import { toast } from 'sonner'; diff --git a/packages/backend/convex/auth.ts b/packages/backend/convex/auth.ts index 688f2e8..c3e6a4c 100644 --- a/packages/backend/convex/auth.ts +++ b/packages/backend/convex/auth.ts @@ -7,7 +7,13 @@ import { } from '@convex-dev/auth/server'; import { api } from './_generated/api'; import { type Id } from './_generated/dataModel'; -import { action, mutation, query } from './_generated/server'; +import { + action, + mutation, + query, + type MutationCtx, + type QueryCtx, +} from './_generated/server'; import Authentik from '@auth/core/providers/authentik'; import { Entra, Password, validatePassword } from './custom/auth'; @@ -24,127 +30,116 @@ export const PASSWORD_MAX = 100; export const PASSWORD_REGEX = /^(?=.{8,100}$)(?!.*\s)(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[\p{P}\p{S}]).*$/u; -export const getUser = query(async (ctx) => { - const userId = await getAuthUserId(ctx); - if (!userId) return null; +type RWCtx = MutationCtx | QueryCtx; +type User = { + id: Id<'users'>; + email: string | null; + name: string | null; + image: Id<'_storage'> | null; + lunchTime: string | null; + automaticLunch: boolean; + provider: string | null; +}; + +const getUserData = async (ctx: RWCtx, userId: Id<'users'>): Promise => { const user = await ctx.db.get(userId); if (!user) throw new ConvexError('User not found.'); + const image: Id<'_storage'> | null = typeof user.image === 'string' && user.image.length > 0 ? (user.image as Id<'_storage'>) : null; - const authAccount = await ctx.db + + const authAccount = await getUserAuthAccountData(ctx, userId); + + return { + id: user._id, + email: user.email ?? null, + name: user.name ?? null, + image, + lunchTime: user.lunchTime ?? null, + automaticLunch: user.automaticLunch ?? false, + provider: authAccount?.provider ?? null, + }; +}; + +const getUserAuthAccountData = async (ctx: RWCtx, userId: Id<'users'>) => { + const user = await ctx.db.get(userId); + if (!user) throw new ConvexError('User not found.'); + const authAccountData = await ctx.db .query('authAccounts') .withIndex('userIdAndProvider', (q) => q.eq('userId', userId)) .first(); - return { - id: user._id, - email: user?.email, - name: user?.name, - image, - lunchTime: user?.lunchTime, - automaticLunch: user.automaticLunch, - provider: authAccount?.provider, - }; + return authAccountData; +}; + +export const getUser = query({ + args: { userId: v.optional(v.id('users')) }, + handler: async (ctx, args) => { + const userId = args.userId ?? await getAuthUserId(ctx); + if (!userId) return null; + return getUserData(ctx, userId); + }, }); export const getUserAuthAccount = query(async (ctx) => { const userId = await getAuthUserId(ctx); if (!userId) return null; - const authAccount = await ctx.db - .query('authAccounts') - .withIndex('userIdAndProvider', (q) => q.eq('userId', userId)) - .first(); - return authAccount; + return getUserAuthAccountData(ctx, userId); }); export const getAllUsers = query(async (ctx) => { const users = await ctx.db.query('users').collect(); - return users.map((u) => ({ - id: u._id, - email: u.email ?? null, - name: u.name ?? null, - image: u.image ?? null, - lunchTime: u.lunchTime ?? null, - automaticLUnch: u.automaticLunch ?? (false as boolean), - })); + return Promise.all(users.map((u) => getUserData(ctx, u._id))); }); export const getAllUserIds = query(async (ctx) => { const users = await ctx.db.query('users').collect(); - const userIds = users.map((u) => u._id); - return userIds; + return users.map((u) => u._id); }); -export const updateUserName = mutation({ +export const updateUser = mutation({ args: { - name: v.string(), + name: v.optional(v.string()), + email: v.optional(v.string()), + image: v.optional(v.id('_storage')), + lunchTime: v.optional(v.string()), + automaticLunch: v.optional(v.boolean()), }, - handler: async (ctx, { name }) => { + handler: async (ctx, args) => { const userId = await getAuthUserId(ctx); if (!userId) throw new ConvexError('Not authenticated.'); const user = await ctx.db.get(userId); if (!user) throw new ConvexError('User not found.'); - await ctx.db.patch(userId, { name }); - return { success: true }; - }, -}); - -export const updateUserEmail = mutation({ - args: { - email: v.string(), - }, - handler: async (ctx, { email }) => { - const userId = await getAuthUserId(ctx); - if (!userId) throw new ConvexError('Not authenticated.'); - const user = await ctx.db.get(userId); - if (!user) throw new ConvexError('User not found.'); - await ctx.db.patch(userId, { email }); - return { success: true }; - }, -}); - -export const updateUserImage = mutation({ - args: { - storageId: v.id('_storage'), - }, - handler: async (ctx, { storageId }) => { - const userId = await getAuthUserId(ctx); - if (!userId) throw new ConvexError('Not authenticated.'); - const user = await ctx.db.get(userId); - if (!user) throw new ConvexError('User not found.'); - const oldImage = user.image as Id<'_storage'> | undefined; - await ctx.db.patch(userId, { image: storageId }); - if (oldImage && oldImage !== storageId) await ctx.storage.delete(oldImage); - return { success: true }; - }, -}); - -export const updateUserLunchtime = mutation({ - args: { - lunchTime: v.string(), - }, - handler: async (ctx, { lunchTime }) => { - const userId = await getAuthUserId(ctx); - if (!userId) throw new ConvexError('Not authenticated.'); - const user = await ctx.db.get(userId); - if (!user) throw new ConvexError('User not found.'); - if (!lunchTime.includes(':')) + if (args.lunchTime !== undefined && !args.lunchTime.includes(':')) { throw new ConvexError('Lunch time is invalid.'); - await ctx.db.patch(userId, { lunchTime }); - return { success: true }; - }, -}); + } + const patch: Partial<{ + name: string; + email: string; + image: Id<'_storage'>; + lunchTime: string; + automaticLunch: boolean; + }> = {}; + + if (args.name !== undefined) patch.name = args.name; + if (args.email !== undefined) patch.email = args.email; + if (args.lunchTime !== undefined) patch.lunchTime = args.lunchTime; + if (args.automaticLunch !== undefined) + patch.automaticLunch = args.automaticLunch; + + if (args.image !== undefined) { + const oldImage = user.image as Id<'_storage'> | undefined; + patch.image = args.image; + if (oldImage && oldImage !== args.image) { + await ctx.storage.delete(oldImage); + } + } + + if (Object.keys(patch).length > 0) { + await ctx.db.patch(userId, patch); + } -export const updateUserAutomaticLunch = mutation({ - args: { automaticLunch: v.boolean() }, - handler: async (ctx, { automaticLunch }) => { - const userId = await getAuthUserId(ctx); - if (!userId) throw new ConvexError('Not authenticated.'); - const user = await ctx.db.get(userId); - if (!user) throw new ConvexError('User not found.'); - if (user.automaticLunch === automaticLunch) return { success: true }; - await ctx.db.patch(userId, { automaticLunch }); return { success: true }; }, }); @@ -157,7 +152,7 @@ export const updateUserPassword = action({ handler: async (ctx, { currentPassword, newPassword }) => { const userId = await getAuthUserId(ctx); if (!userId) throw new ConvexError('Not authenticated.'); - const user = await ctx.runQuery(api.auth.getUser); + const user = await ctx.runQuery(api.auth.getUser, { userId }); if (!user?.email) throw new ConvexError('User not found.'); const verified = await retrieveAccount(ctx, { provider: 'password', diff --git a/packages/backend/convex/crons.ts b/packages/backend/convex/crons.ts index 84f5839..89fb7b7 100644 --- a/packages/backend/convex/crons.ts +++ b/packages/backend/convex/crons.ts @@ -4,10 +4,26 @@ import { api } from './_generated/api'; const crons = cronJobs(); -// Runs at 5:00 PM America/Chicago, Monday–Friday. -// Convex will handle DST if your project version supports `timeZone`. +crons.cron( + // Run at 7:00 AM CST / 8:00 AM CDT + // Only on weekdays + 'Schedule Automatic Lunches', + '0 13 * * 1-5', + api.statuses.automaticLunch, +); + +crons.cron( + // Run at 7:00 AM CST / 8:00 AM CDT + // Only on weekdays + 'Schedule Automatic Lunches Test', + '25 18 * * 1-5', + api.statuses.automaticLunch, +); + crons.cron( 'End of shift (weekdays 5pm CT)', + // Run at 4:00 PM CST / 5:00 PM CDT + // Only on weekdays '0 22 * * 1-5', api.statuses.endOfShiftUpdate, ); diff --git a/packages/backend/convex/statuses.ts b/packages/backend/convex/statuses.ts index 47232f4..caf163a 100644 --- a/packages/backend/convex/statuses.ts +++ b/packages/backend/convex/statuses.ts @@ -156,23 +156,41 @@ export const updateAllStatuses = mutation({ }); export const createLunchStatus = mutation({ - args: {}, - handler: async (ctx) => { + args: { userId: v.optional(v.id('users'))}, + handler: async (ctx, args) => { const authUserId = await getAuthUserId(ctx); - if (!authUserId) throw new ConvexError('Not authenticated.'); + const lunchUserId = args.userId ?? authUserId + if (!lunchUserId) throw new ConvexError('Not authenticated.'); await ctx.runMutation(api.statuses.create, { message: 'At lunch', - userId: authUserId, + userId: lunchUserId, }); const oneHour = 60 * 60 * 1000; - await ctx.scheduler.runAfter(oneHour, api.statuses.create, { - message: 'At desk', - userId: authUserId, + await ctx.scheduler.runAfter(oneHour, api.statuses.backFromLunchStatus, { + userId: lunchUserId, }); return { success: true }; }, }); +export const backFromLunchStatus = mutation({ + args: { userId: v.optional(v.id('users')) }, + handler: async (ctx, args) => { + const authUserId = await getAuthUserId(ctx); + const lunchUserId = args.userId ?? authUserId + if (!lunchUserId) throw new ConvexError('Not authenticated.'); + const user = await ensureUser(ctx, lunchUserId); + if (!user.currentStatusId) throw new ConvexError('User has no current status.'); + const currentStatus = await ctx.db.get(user.currentStatusId); + if (currentStatus?.message === 'At lunch') { + await ctx.runMutation(api.statuses.create, { + message: 'At desk', + userId: lunchUserId, + }); + } + }, +}); + export const getCurrentForUser = query({ args: { userId: v.id('users') }, handler: async (ctx, { userId }) => { @@ -331,3 +349,36 @@ export const endOfShiftUpdate = action({ } else return; }, }); + +export const automaticLunch = action({ + handler: async (ctx) => { + const now = new Date( + new Date().toLocaleString('en-US', { + timeZone: 'America/Chicago', + }), + ); + const users = await ctx.runQuery(api.auth.getAllUsers); + await Promise.all( + users.map(async (user) => { + if (user.automaticLunch && user.lunchTime) { + const [hours, minutes] = user.lunchTime.split(':').map(Number); + const userLunchTime = new Date(now); + userLunchTime.setHours(hours, minutes, 0, 0); + const diffInMs = userLunchTime.getTime() - now.getTime(); + // Only schedule if lunch is in the future today + if (diffInMs > 0) { + await ctx.scheduler.runAfter( + diffInMs, + api.statuses.createLunchStatus, + { userId: user.id }, + ); + } else { + console.warn( + `Skipped ${user.name} - lunch time ${user.lunchTime} already passed.` + ); + } + } + }) + ); + }, +});