From 1dc506792ca0da655d4616ea5d78376b9cc989ac Mon Sep 17 00:00:00 2001 From: Test User Date: Mon, 27 Apr 2026 00:35:08 +0100 Subject: [PATCH 1/2] feat: Support user-defined reminder lead times - Create reminder_settings table to store user-specific reminder timings - Update ReminderEngine to use user settings instead of hardcoded defaults - Add backend API endpoints for managing reminder settings - Add frontend UI component for users to configure reminder days - Integrate reminder settings into notification settings page Acceptance Criteria: Reminders trigger exactly at the user-defined intervals. --- backend/src/index.ts | 2 + backend/src/routes/reminder-settings.ts | 58 +++++++ backend/src/services/reminder-engine.ts | 4 +- .../src/services/reminder-settings-service.ts | 101 +++++++++++ backend/src/types/reminder.ts | 7 + client/app/settings/notifications/page.tsx | 6 + .../components/settings/ReminderSettings.tsx | 161 ++++++++++++++++++ client/lib/api/reminder-settings.ts | 22 +++ ...0260427000000_create_reminder_settings.sql | 42 +++++ 9 files changed, 402 insertions(+), 1 deletion(-) create mode 100644 backend/src/routes/reminder-settings.ts create mode 100644 backend/src/services/reminder-settings-service.ts create mode 100644 client/components/settings/ReminderSettings.tsx create mode 100644 client/lib/api/reminder-settings.ts create mode 100644 supabase/migrations/20260427000000_create_reminder_settings.sql diff --git a/backend/src/index.ts b/backend/src/index.ts index 1664922f..8caf62ae 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -35,6 +35,7 @@ import complianceRoutes from './routes/compliance'; import tagsRoutes from './routes/tags'; import userRoutes from './routes/user'; import userPreferencesRoutes from './routes/user-preferences'; +import reminderSettingsRoutes from './routes/reminder-settings'; import apiKeysRoutes from './routes/api-keys'; import digestRoutes from './routes/digest'; import mfaRoutes from './routes/mfa'; @@ -130,6 +131,7 @@ app.use('/api/compliance', complianceRoutes); app.use('/api/tags', tagsRoutes); app.use('/api/user', userRoutes); app.use('/api/user-preferences', authenticate, userPreferencesRoutes); +app.use('/api/reminder-settings', authenticate, reminderSettingsRoutes); app.use('/api/digest', digestRoutes); app.use('/api/mfa', mfaRoutes); app.use('/api/notifications/push', pushNotificationRoutes); diff --git a/backend/src/routes/reminder-settings.ts b/backend/src/routes/reminder-settings.ts new file mode 100644 index 00000000..c1d7571d --- /dev/null +++ b/backend/src/routes/reminder-settings.ts @@ -0,0 +1,58 @@ +import { Router, Response } from 'express'; +import { AuthenticatedRequest } from '../middleware/auth'; +import { validateRequest } from '../utils/validation'; +import { reminderSettingsService } from '../services/reminder-settings-service'; +import logger from '../config/logger'; +import { z } from 'zod'; + +const reminderSettingsUpdateSchema = z.object({ + reminder_days_before: z.array(z.number().int().min(1).max(365)).optional(), +}); + +const router = Router(); + +/** + * GET /api/reminder-settings + * Get current reminder settings + */ +router.get('/', async (req: AuthenticatedRequest, res: Response) => { + try { + const settings = await reminderSettingsService.getSettings(req.user!.id); + res.json({ success: true, data: settings }); + } catch (error) { + logger.error('Error fetching reminder settings:', error); + res.status(500).json({ + success: false, + error: 'Failed to fetch reminder settings' + }); + } +}); + +/** + * PATCH /api/reminder-settings + * Update reminder settings + */ +router.patch('/', async (req: AuthenticatedRequest, res: Response) => { + try { + const validatedData = validateRequest(reminderSettingsUpdateSchema, req.body); + + const updatedSettings = await reminderSettingsService.updateSettings( + req.user!.id, + validatedData + ); + + res.json({ + success: true, + data: updatedSettings, + message: 'Reminder settings updated successfully' + }); + } catch (error) { + logger.error('Error updating reminder settings:', error); + res.status(500).json({ + success: false, + error: 'Failed to update reminder settings' + }); + } +}); + +export default router; \ No newline at end of file diff --git a/backend/src/services/reminder-engine.ts b/backend/src/services/reminder-engine.ts index 0031bbe0..eccf6542 100644 --- a/backend/src/services/reminder-engine.ts +++ b/backend/src/services/reminder-engine.ts @@ -13,6 +13,7 @@ import { import { calculateBackoffDelay } from '../utils/retry'; import { userPreferenceService } from './user-preference-service'; import { notificationPreferenceService } from './notification-preference-service'; +import { reminderSettingsService } from './reminder-settings-service'; import { quietHoursService } from './quiet-hours-service'; import { delayedNotificationService } from './delayed-notification-service'; @@ -639,8 +640,9 @@ export class ReminderEngine { // 2. User global settings try { const userPrefs = await userPreferenceService.getPreferences(userId); + const reminderSettings = await reminderSettingsService.getSettings(userId); return { - reminder_days_before: userPrefs.reminder_timing ?? this.defaultDaysBefore, + reminder_days_before: reminderSettings.reminder_days_before, channels: userPrefs.notification_channels ?? ['email'], muted: false, }; diff --git a/backend/src/services/reminder-settings-service.ts b/backend/src/services/reminder-settings-service.ts new file mode 100644 index 00000000..928ecc87 --- /dev/null +++ b/backend/src/services/reminder-settings-service.ts @@ -0,0 +1,101 @@ +import { supabase } from '../config/database'; +import logger from '../config/logger'; + +export interface ReminderSettings { + user_id: string; + reminder_days_before: number[]; + created_at: string; + updated_at: string; +} + +export interface PartialReminderSettings { + reminder_days_before?: number[]; +} + +export class ReminderSettingsService { + private readonly defaultSettings: Omit = { + reminder_days_before: [7, 3, 1], + }; + + /** + * Get reminder settings, returning defaults if not found + */ + async getSettings(userId: string): Promise { + try { + const { data, error } = await supabase + .from('reminder_settings') + .select('*') + .eq('user_id', userId) + .single(); + + if (error && error.code !== 'PGRST116') { + // PGRST116 is "no rows returned" + logger.error(`Error fetching reminder settings for user ${userId}:`, error); + throw error; + } + + if (!data) { + return { + user_id: userId, + ...this.defaultSettings, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + } + + return data as ReminderSettings; + } catch (error) { + logger.error(`Unexpected error fetching reminder settings for user ${userId}:`, error); + return { + user_id: userId, + ...this.defaultSettings, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + } + } + + /** + * Update reminder settings partially + */ + async updateSettings( + userId: string, + updates: PartialReminderSettings + ): Promise { + try { + // Fetch current settings to ensure safe merging + const current = await this.getSettings(userId); + + const merged: Partial = { + ...current, + ...updates, + }; + + // Remove keys that shouldn't be updated directly + delete merged.user_id; + delete (merged as any).created_at; + delete (merged as any).updated_at; + + const { data, error } = await supabase + .from('reminder_settings') + .upsert({ + user_id: userId, + ...merged, + }) + .select() + .single(); + + if (error) { + logger.error(`Error updating reminder settings for user ${userId}:`, error); + throw error; + } + + return data as ReminderSettings; + } catch (error) { + logger.error(`Unexpected error updating reminder settings for user ${userId}:`, error); + throw error; + } + } +} + +export const reminderSettingsService = new ReminderSettingsService(); \ No newline at end of file diff --git a/backend/src/types/reminder.ts b/backend/src/types/reminder.ts index edb86e1a..f3999249 100644 --- a/backend/src/types/reminder.ts +++ b/backend/src/types/reminder.ts @@ -108,5 +108,12 @@ export interface UserPreferences { updated_at: string; } +export interface ReminderSettings { + user_id: string; + reminder_days_before: number[]; + created_at: string; + updated_at: string; +} + export type PartialUserPreferences = Partial>; diff --git a/client/app/settings/notifications/page.tsx b/client/app/settings/notifications/page.tsx index 8994dc3f..5b6d034f 100644 --- a/client/app/settings/notifications/page.tsx +++ b/client/app/settings/notifications/page.tsx @@ -1,6 +1,7 @@ import { redirect } from "next/navigation" import { createClient } from "@/lib/supabase/server" import QuietHoursSettings from "@/components/settings/QuietHoursSettings" +import ReminderSettings from "@/components/settings/ReminderSettings" import Link from "next/link" export default async function NotificationSettingsPage() { @@ -59,6 +60,11 @@ export default async function NotificationSettingsPage() { + {/* Reminder Settings */} +
+ +
+ {/* Quiet Hours Settings */} diff --git a/client/components/settings/ReminderSettings.tsx b/client/components/settings/ReminderSettings.tsx new file mode 100644 index 00000000..f137ec72 --- /dev/null +++ b/client/components/settings/ReminderSettings.tsx @@ -0,0 +1,161 @@ +"use client" + +import { useState, useEffect } from "react" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Badge } from "@/components/ui/badge" +import { Plus, X, Clock } from "lucide-react" +import { toast } from "sonner" +import { fetchReminderSettings, updateReminderSettings, type ReminderSettings } from "@/lib/api/reminder-settings" + +export default function ReminderSettings() { + const [settings, setSettings] = useState(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [daysBefore, setDaysBefore] = useState([7, 3, 1]) + const [newDay, setNewDay] = useState('') + + useEffect(() => { + loadSettings() + }, []) + + const loadSettings = async () => { + try { + const data = await fetchReminderSettings() + setSettings(data) + setDaysBefore(data.reminder_days_before) + } catch (error) { + console.error('Failed to load reminder settings:', error) + toast.error('Failed to load reminder settings') + } finally { + setLoading(false) + } + } + + const handleAddDay = () => { + const day = parseInt(newDay) + if (isNaN(day) || day < 1 || day > 365) { + toast.error('Please enter a valid number of days (1-365)') + return + } + if (daysBefore.includes(day)) { + toast.error('This reminder day already exists') + return + } + setDaysBefore([...daysBefore, day].sort((a, b) => b - a)) // Sort descending + setNewDay('') + } + + const handleRemoveDay = (day: number) => { + setDaysBefore(daysBefore.filter(d => d !== day)) + } + + const handleSave = async () => { + if (daysBefore.length === 0) { + toast.error('You must have at least one reminder day') + return + } + setSaving(true) + try { + const updated = await updateReminderSettings({ + reminder_days_before: daysBefore, + }) + setSettings(updated) + toast.success('Reminder settings updated successfully') + } catch (error) { + console.error('Failed to update reminder settings:', error) + toast.error('Failed to update reminder settings') + } finally { + setSaving(false) + } + } + + if (loading) { + return ( + + + + + Reminder Timing + + + Configure when you want to be reminded before your subscriptions renew. + + + +
+
+
+
+
+ ) + } + + return ( + + + + + Reminder Timing + + + Configure when you want to be reminded before your subscriptions renew. + Reminders will be sent at the specified number of days before renewal. + + + + {/* Current reminder days */} +
+ +
+ {daysBefore.map((day) => ( + + {day} day{day !== 1 ? 's' : ''} before + + + ))} +
+
+ + {/* Add new day */} +
+ +
+ setNewDay(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && handleAddDay()} + min="1" + max="365" + className="w-32" + /> + +
+

+ Enter the number of days before renewal (1-365) +

+
+ + {/* Save button */} +
+ +
+
+
+ ) +} \ No newline at end of file diff --git a/client/lib/api/reminder-settings.ts b/client/lib/api/reminder-settings.ts new file mode 100644 index 00000000..58fb2d9a --- /dev/null +++ b/client/lib/api/reminder-settings.ts @@ -0,0 +1,22 @@ +import { apiGet, apiPatch } from "@/lib/api" + +export interface ReminderSettings { + user_id: string + reminder_days_before: number[] + created_at: string + updated_at: string +} + +export interface ReminderSettingsUpdateInput { + reminder_days_before?: number[] +} + +export const fetchReminderSettings = async (): Promise => { + const response = await apiGet('/api/reminder-settings') + return response.data +} + +export const updateReminderSettings = async (updates: ReminderSettingsUpdateInput): Promise => { + const response = await apiPatch('/api/reminder-settings', updates) + return response.data +} \ No newline at end of file diff --git a/supabase/migrations/20260427000000_create_reminder_settings.sql b/supabase/migrations/20260427000000_create_reminder_settings.sql new file mode 100644 index 00000000..c0f5a47a --- /dev/null +++ b/supabase/migrations/20260427000000_create_reminder_settings.sql @@ -0,0 +1,42 @@ +-- Create reminder_settings table +create table if not exists public.reminder_settings ( + user_id uuid primary key references auth.users(id) on delete cascade, + reminder_days_before integer[] not null default '{7, 3, 1}', + created_at timestamp with time zone default timezone('utc'::text, now()) not null, + updated_at timestamp with time zone default timezone('utc'::text, now()) not null +); + +-- Enable RLS +alter table public.reminder_settings enable row level security; + +-- RLS Policies +create policy "reminder_settings_select_own" + on public.reminder_settings for select + using (auth.uid() = user_id); + +create policy "reminder_settings_insert_own" + on public.reminder_settings for insert + with check (auth.uid() = user_id); + +create policy "reminder_settings_update_own" + on public.reminder_settings for update + using (auth.uid() = user_id); + +-- Trigger for updated_at +create or replace function public.handle_updated_at() +returns trigger as $$ +begin + new.updated_at = now(); + return new; +end; +$$ language plpgsql; + +create trigger set_updated_at_reminder_settings + before update on public.reminder_settings + for each row + execute function public.handle_updated_at(); + +-- Add default settings for existing users +insert into public.reminder_settings (user_id) +select id from auth.users +on conflict (user_id) do nothing; \ No newline at end of file From 9b52e2926ed7b90f6dba9b5a883be2bc5e0c957f Mon Sep 17 00:00:00 2001 From: Test User Date: Mon, 27 Apr 2026 00:35:46 +0100 Subject: [PATCH 2/2] fix: Update test to use reminder_settings table instead of user_preferences --- backend/tests/reminder-engine-batch.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/tests/reminder-engine-batch.test.ts b/backend/tests/reminder-engine-batch.test.ts index 5768e95b..55204989 100644 --- a/backend/tests/reminder-engine-batch.test.ts +++ b/backend/tests/reminder-engine-batch.test.ts @@ -37,8 +37,8 @@ describe('ReminderEngine Batch Optimization', () => { ]; const mockPreferences = [ - { user_id: 'user1', reminder_timing: [7, 3] }, - { user_id: 'user2', reminder_timing: [1] }, + { user_id: 'user1', reminder_days_before: [7, 3] }, + { user_id: 'user2', reminder_days_before: [1] }, ]; // Setup mocks @@ -54,7 +54,7 @@ describe('ReminderEngine Batch Optimization', () => { }), }; } - if (table === 'user_preferences') { + if (table === 'reminder_settings') { return { select: () => ({ in: () => Promise.resolve({ data: mockPreferences, error: null }), @@ -71,7 +71,7 @@ describe('ReminderEngine Batch Optimization', () => { await engine.scheduleReminders([7, 3, 1]); // Verify batch fetch of preferences - expect(supabase.from).toHaveBeenCalledWith('user_preferences'); + expect(supabase.from).toHaveBeenCalledWith('reminder_settings'); // Verify batch upsert expect(supabase.from).toHaveBeenCalledWith('reminder_schedules');