From 4fd61a6add1c381c524d37cb9f05151c380bf6c2 Mon Sep 17 00:00:00 2001 From: Matt Rabe Date: Sun, 25 Jan 2026 12:28:20 -1000 Subject: [PATCH] Push Notification days of the week. Closes #130 --- .../src/endpoints/cron/sendChoreReminders.ts | 15 ++ .../src/endpoints/trpc/auth/createAccount.ts | 9 + apps/api/src/endpoints/trpc/auth/deleteMe.ts | 12 +- apps/api/src/endpoints/trpc/auth/me.ts | 10 + .../src/endpoints/trpc/auth/updatePassword.ts | 7 + .../src/endpoints/trpc/auth/updateProfile.ts | 7 + .../updatePushNotificationPreferences.ts | 49 ++++ .../updatePushNotificationsEnabled.ts | 19 -- apps/api/src/models/User.ts | 21 ++ apps/api/src/routers/trpc/settings.ts | 4 +- apps/api/src/trpc/index.ts | 15 +- apps/mobile/src/app/(tabs)/settings.tsx | 76 ++++++- apps/mobile/src/app/_layout.tsx | 7 + apps/mobile/src/app/edit-password.tsx | 2 +- apps/mobile/src/app/edit-profile.tsx | 2 +- .../src/app/notification-preferences.tsx | 213 ++++++++++++++++++ 16 files changed, 430 insertions(+), 38 deletions(-) create mode 100644 apps/api/src/endpoints/trpc/settings/updatePushNotificationPreferences.ts delete mode 100644 apps/api/src/endpoints/trpc/settings/updatePushNotificationsEnabled.ts create mode 100644 apps/mobile/src/app/notification-preferences.tsx diff --git a/apps/api/src/endpoints/cron/sendChoreReminders.ts b/apps/api/src/endpoints/cron/sendChoreReminders.ts index aadcc88..070c76e 100644 --- a/apps/api/src/endpoints/cron/sendChoreReminders.ts +++ b/apps/api/src/endpoints/cron/sendChoreReminders.ts @@ -25,8 +25,23 @@ export const sendChoreReminders = async (req: Request, res: Response): Promise { + const user = ctx.user + + if (!user) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'User not found', + }) + } + const userId = ctx.userId // Get all plants for this user @@ -55,7 +65,7 @@ export const deleteMe = authProcedure }) // Finally, delete the user - await ctx.user.deleteOne() + await user.deleteOne() return { success: true } }) diff --git a/apps/api/src/endpoints/trpc/auth/me.ts b/apps/api/src/endpoints/trpc/auth/me.ts index 628ee4f..1ed5f73 100644 --- a/apps/api/src/endpoints/trpc/auth/me.ts +++ b/apps/api/src/endpoints/trpc/auth/me.ts @@ -1,9 +1,18 @@ +import { TRPCError } from '@trpc/server' + import { authProcedure } from '../../../procedures/authProcedure' export const me = authProcedure .query(async ({ ctx }) => { const user = ctx.user + if (!user) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'User not found', + }) + } + return { _id: user._id.toString(), name: user.name, @@ -11,6 +20,7 @@ export const me = authProcedure phone: user.phone, timezone: user.timezone, pushNotificationsEnabled: user.pushNotificationsEnabled ?? true, + pushNotificationDays: user.pushNotificationDays, } }) diff --git a/apps/api/src/endpoints/trpc/auth/updatePassword.ts b/apps/api/src/endpoints/trpc/auth/updatePassword.ts index 595e0ae..fd96a41 100644 --- a/apps/api/src/endpoints/trpc/auth/updatePassword.ts +++ b/apps/api/src/endpoints/trpc/auth/updatePassword.ts @@ -12,6 +12,13 @@ export const updatePassword = authProcedure .mutation(async ({ input, ctx }) => { const user = ctx.user + if (!user) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'User not found', + }) + } + // Verify current password const isValidPassword = await bcrypt.compare(input.currentPassword, user.password) diff --git a/apps/api/src/endpoints/trpc/auth/updateProfile.ts b/apps/api/src/endpoints/trpc/auth/updateProfile.ts index 1e30219..7b46ef5 100644 --- a/apps/api/src/endpoints/trpc/auth/updateProfile.ts +++ b/apps/api/src/endpoints/trpc/auth/updateProfile.ts @@ -14,6 +14,13 @@ export const updateProfile = authProcedure .mutation(async ({ input, ctx }) => { const user = ctx.user + if (!user) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'User not found', + }) + } + // Check if email is already taken by another user if (input.email.toLowerCase().trim() !== user.email.toLowerCase().trim()) { const existingUser = await User.findOne({ email: input.email.toLowerCase().trim() }) diff --git a/apps/api/src/endpoints/trpc/settings/updatePushNotificationPreferences.ts b/apps/api/src/endpoints/trpc/settings/updatePushNotificationPreferences.ts new file mode 100644 index 0000000..0e69391 --- /dev/null +++ b/apps/api/src/endpoints/trpc/settings/updatePushNotificationPreferences.ts @@ -0,0 +1,49 @@ +import { z } from 'zod' + +import { User } from '../../../models' + +import { authProcedure } from '../../../procedures/authProcedure' + +const pushNotificationDaysSchema = z.object({ + Mon: z.boolean(), + Tue: z.boolean(), + Wed: z.boolean(), + Thu: z.boolean(), + Fri: z.boolean(), + Sat: z.boolean(), + Sun: z.boolean(), +}) + +export const updatePushNotificationPreferences = authProcedure + .input(z.object({ + days: pushNotificationDaysSchema.optional(), + enabled: z.boolean().optional(), + })) + .mutation(async ({ input, ctx }) => { + const userId = ctx.userId + + const updateData: { + pushNotificationDays?: { + Mon: boolean, + Tue: boolean, + Wed: boolean, + Thu: boolean, + Fri: boolean, + Sat: boolean, + Sun: boolean, + }, + pushNotificationsEnabled?: boolean, + } = {} + + if (input.enabled !== undefined) { + updateData.pushNotificationsEnabled = input.enabled + } + + if (input.days !== undefined) { + updateData.pushNotificationDays = input.days + } + + await User.findByIdAndUpdate(userId, updateData) + + return { success: true } + }) diff --git a/apps/api/src/endpoints/trpc/settings/updatePushNotificationsEnabled.ts b/apps/api/src/endpoints/trpc/settings/updatePushNotificationsEnabled.ts deleted file mode 100644 index b84b08e..0000000 --- a/apps/api/src/endpoints/trpc/settings/updatePushNotificationsEnabled.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { z } from 'zod' - -import { User } from '../../../models' - -import { authProcedure } from '../../../procedures/authProcedure' - -export const updatePushNotificationsEnabled = authProcedure - .input(z.object({ - enabled: z.boolean(), - })) - .mutation(async ({ input, ctx }) => { - const userId = ctx.userId - - await User.findByIdAndUpdate(userId, { - pushNotificationsEnabled: input.enabled, - }) - - return { success: true } - }) diff --git a/apps/api/src/models/User.ts b/apps/api/src/models/User.ts index da90ad4..079fa36 100644 --- a/apps/api/src/models/User.ts +++ b/apps/api/src/models/User.ts @@ -9,6 +9,15 @@ export interface IUser { timezone?: string, pushNotificationToken?: string, pushNotificationsEnabled?: boolean, + pushNotificationDays?: { + Mon: boolean, + Tue: boolean, + Wed: boolean, + Thu: boolean, + Fri: boolean, + Sat: boolean, + Sun: boolean, + }, createdAt: Date, updatedAt: Date, } @@ -55,6 +64,18 @@ export const userSchema = new mongoose.Schema({ type: Boolean, default: true, }, + pushNotificationDays: { + type: { + Mon: Boolean, + Tue: Boolean, + Wed: Boolean, + Thu: Boolean, + Fri: Boolean, + Sat: Boolean, + Sun: Boolean, + }, + required: false, + }, }, { collation: { locale: 'en', strength: 2 }, timestamps: true, diff --git a/apps/api/src/routers/trpc/settings.ts b/apps/api/src/routers/trpc/settings.ts index 90a78c6..df5f4df 100644 --- a/apps/api/src/routers/trpc/settings.ts +++ b/apps/api/src/routers/trpc/settings.ts @@ -1,11 +1,11 @@ import { router } from '../../trpc' -import { updatePushNotificationsEnabled } from '../../endpoints/trpc/settings/updatePushNotificationsEnabled' +import { updatePushNotificationPreferences } from '../../endpoints/trpc/settings/updatePushNotificationPreferences' import { updatePushNotificationToken } from '../../endpoints/trpc/settings/updatePushNotificationToken' import { updateTimezone } from '../../endpoints/trpc/settings/updateTimezone' export const settingsRouter = router({ - updatePushNotificationsEnabled, + updatePushNotificationPreferences, updatePushNotificationToken, updateTimezone, }) diff --git a/apps/api/src/trpc/index.ts b/apps/api/src/trpc/index.ts index 8b4966b..038004d 100644 --- a/apps/api/src/trpc/index.ts +++ b/apps/api/src/trpc/index.ts @@ -2,11 +2,18 @@ import { initTRPC } from '@trpc/server' import superjson from 'superjson' import type { IncomingMessage } from 'http' +import type { IUser } from '../models/User' +import type { Document } from 'mongoose' + export interface Context { - req?: IncomingMessage - userId?: string - user?: any -} + req?: IncomingMessage, + userId?: string, + user?: (Document & IUser & Required<{ + _id: string; + }> & { + __v: number; + }) | null, + } export const tRPCContext = initTRPC .context() diff --git a/apps/mobile/src/app/(tabs)/settings.tsx b/apps/mobile/src/app/(tabs)/settings.tsx index 7ebd983..2249a15 100644 --- a/apps/mobile/src/app/(tabs)/settings.tsx +++ b/apps/mobile/src/app/(tabs)/settings.tsx @@ -30,7 +30,11 @@ export function SettingsScreen() { const [isDeleteAccountExpanded, setIsDeleteAccountExpanded] = useState(false) // Get user settings - const { data: userData } = trpc.auth.me.useQuery() + const { + data: userData, + isRefetching, + refetch, + } = trpc.auth.me.useQuery() useEffect(() => { if (!userData) { @@ -45,7 +49,7 @@ export function SettingsScreen() { return } - setCurrentTimezone(userData.timezone) + setCurrentTimezone(userData.timezone || null) }, [ userData?.timezone ]) const updateTimezoneMutation = trpc.settings.updateTimezone.useMutation({ @@ -58,7 +62,7 @@ export function SettingsScreen() { }, }) - const updatePushNotificationsMutation = trpc.settings.updatePushNotificationsEnabled.useMutation({ + const updatePushNotificationPreferencesMutation = trpc.settings.updatePushNotificationPreferences.useMutation({ onSuccess: () => { queryClient.invalidateQueries() }, @@ -75,7 +79,45 @@ export function SettingsScreen() { const handlePushNotificationsToggle = (enabled: boolean) => { setPushNotificationsEnabled(enabled) - updatePushNotificationsMutation.mutate({ enabled }) + updatePushNotificationPreferencesMutation.mutate({ enabled }) + } + + const getReminderDescription = (): string => { + const description = ' at 8am when chores are due.' + + const days = userData?.pushNotificationDays || { + Mon: true, + Tue: true, + Wed: true, + Thu: true, + Fri: true, + Sat: true, + Sun: true, + } + + const enabledDays = Object.entries(days) + .filter(([_, enabled]) => enabled) + .map(([day]) => day !== '_id' && day) + .filter(Boolean) + + // Check if all days are selected + if (enabledDays.length === 7) { + return 'Receive reminders every day' + description + } + + // Check if only weekends are selected + if (enabledDays.length === 2 && enabledDays.includes('Sat') && enabledDays.includes('Sun')) { + return 'Receive reminders on weekends' + description + } + + // Check if only weekdays are selected + const weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'] + if (enabledDays.length === 5 && weekdays.every(day => enabledDays.includes(day))) { + return 'Receive reminders on weekdays' + description + } + + // Otherwise, list the selected days + return `Receive reminders on ${enabledDays.join(', ').replace(/,([^,]*)$/g, ' and$1')}` + description } const deleteMeMutation = trpc.auth.deleteMe.useMutation({ @@ -137,8 +179,10 @@ export function SettingsScreen() { } return ( - - Settings + + + Settings + {user && ( @@ -174,12 +218,9 @@ export function SettingsScreen() { Notifications - + Push Notifications - - Receive daily reminders at 8am when chores are due - + {pushNotificationsEnabled && ( + + + + {getReminderDescription()} + + + router.push('/notification-preferences')} + > + Edit + + + )} diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 95d0ee0..fe96f21 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -168,6 +168,13 @@ function RootRouter() { title: 'Edit Profile', }} /> + Change Password - Update your account password + Update your account password. Current Password Edit Profile - Update your account information + Update your account information. Name = [ + { key: 'Mon', label: 'Monday' }, + { key: 'Tue', label: 'Tuesday' }, + { key: 'Wed', label: 'Wednesday' }, + { key: 'Thu', label: 'Thursday' }, + { key: 'Fri', label: 'Friday' }, + { key: 'Sat', label: 'Saturday' }, + { key: 'Sun', label: 'Sunday' }, +] + +export default function NotificationPreferencesScreen() { + const router = useRouter() + + const queryClient = useQueryClient() + + const { alert } = useAlert() + + const [pushNotificationDays, setPushNotificationDays] = useState({ + Mon: true, + Tue: true, + Wed: true, + Thu: true, + Fri: true, + Sat: true, + Sun: true, + }) + + // Get current user data + const { data: userData } = trpc.auth.me.useQuery() + + useEffect(() => { + if (userData?.pushNotificationDays) { + setPushNotificationDays(userData.pushNotificationDays) + } + }, [userData?.pushNotificationDays]) + + const updatePreferencesMutation = trpc.settings.updatePushNotificationPreferences.useMutation({ + onSuccess: () => { + queryClient.invalidateQueries() + router.back() + }, + onError: (error) => { + alert('Error', error.message || 'Failed to update notification preferences') + }, + }) + + const handleDayToggle = (day: keyof PushNotificationDays) => { + setPushNotificationDays((prev) => ({ + ...prev, + [day]: !prev[day], + })) + } + + const handleSave = () => { + updatePreferencesMutation.mutate({ + days: pushNotificationDays, + }) + } + + const handleCancel = () => { + router.back() + } + + return ( + + + + Notification Preferences + Select which days you want to receive reminders. + + + {DAYS_OF_WEEK.map(({ key, label }) => ( + handleDayToggle(key)} + activeOpacity={0.7} + > + {label} + + + ))} + + + {updatePreferencesMutation.error && ( + + {updatePreferencesMutation.error.message} + + )} + + + {updatePreferencesMutation.isPending ? ( + + ) : ( + Save + )} + + + + Cancel + + + + + ) +} + +const localStyles = StyleSheet.create({ + scrollView: { + flex: 1, + }, + form: { + width: '100%', + maxWidth: 400, + padding: 20, + alignSelf: 'center', + }, + title: { + fontSize: 32, + fontWeight: 'bold', + color: palette.brandPrimary, + textAlign: 'center', + marginBottom: 8, + }, + subtitle: { + fontSize: 16, + color: '#666', + textAlign: 'center', + marginBottom: 32, + }, + daysContainer: { + backgroundColor: '#fff', + borderRadius: 8, + borderWidth: 1, + borderColor: '#ddd', + marginBottom: 24, + overflow: 'hidden', + }, + dayRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: 16, + borderBottomWidth: 1, + borderBottomColor: '#eee', + }, + dayLabel: { + fontSize: 16, + color: '#333', + }, + button: { + borderRadius: 8, + padding: 16, + alignItems: 'center', + marginBottom: 12, + }, + primaryButton: { + backgroundColor: palette.brandPrimary, + }, + secondaryButton: { + backgroundColor: 'transparent', + borderWidth: 1, + borderColor: palette.brandPrimary, + }, + buttonText: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, + secondaryButtonText: { + color: palette.brandPrimary, + fontSize: 16, + fontWeight: '600', + }, + errorText: { + fontSize: 14, + color: palette.danger, + marginBottom: 16, + textAlign: 'center', + }, +})