Add smart notification reminder system with frequency-aware scheduling
Replaces one-size-fits-all "2 days before" reminders with intelligent
scheduling based on task frequency. Infrequent tasks (annual) get 30-day
advance notice while frequent tasks (weekly) only get day-of reminders.
Key features:
- Frequency-aware pre-reminders: annual (30d, 14d, 7d), quarterly (7d, 3d),
monthly (3d), bi-weekly (1d), daily/weekly/once (day-of only)
- Overdue tapering: daily for 3 days, then every 3 days, stops after 14 days
- Reminder log table prevents duplicate notifications per due date/stage
- Admin endpoint displays notification schedules for all frequencies
- Comprehensive test suite (100 random tasks, 61 days each, 10 test functions)
New files:
- internal/notifications/reminder_config.go - Editable schedule configuration
- internal/notifications/reminder_schedule.go - Schedule lookup logic
- internal/notifications/reminder_schedule_test.go - Dynamic test suite
- internal/models/reminder_log.go - TaskReminderLog model
- internal/repositories/reminder_repo.go - Reminder log repository
- migrations/010_add_task_reminder_log.{up,down}.sql
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Plus, Pencil, Trash2, GripVertical, X, Check } from 'lucide-react';
|
import { Plus, Pencil, Trash2, GripVertical, X, Check, Bell, Info } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import { lookupsApi, type LookupItem, type CreateLookupRequest, type UpdateLookupRequest } from '@/lib/api';
|
import { lookupsApi, type LookupItem, type CreateLookupRequest, type UpdateLookupRequest, type FrequencyItem, type OverduePolicy } from '@/lib/api';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
@@ -34,11 +34,18 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip';
|
||||||
|
|
||||||
const lookupTabs = [
|
const lookupTabs = [
|
||||||
{ key: 'categories', label: 'Task Categories', api: lookupsApi.categories },
|
{ key: 'categories', label: 'Task Categories', api: lookupsApi.categories },
|
||||||
{ key: 'priorities', label: 'Task Priorities', api: lookupsApi.priorities },
|
{ key: 'priorities', label: 'Task Priorities', api: lookupsApi.priorities },
|
||||||
{ key: 'frequencies', label: 'Task Frequencies', api: lookupsApi.frequencies },
|
{ key: 'frequencies', label: 'Task Frequencies', api: null }, // Handled separately
|
||||||
{ key: 'residenceTypes', label: 'Residence Types', api: lookupsApi.residenceTypes },
|
{ key: 'residenceTypes', label: 'Residence Types', api: lookupsApi.residenceTypes },
|
||||||
{ key: 'specialties', label: 'Contractor Specialties', api: lookupsApi.specialties },
|
{ key: 'specialties', label: 'Contractor Specialties', api: lookupsApi.specialties },
|
||||||
];
|
];
|
||||||
@@ -305,6 +312,311 @@ function LookupTable({ lookupKey, api }: LookupTableProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Specialized table for frequencies that shows notification schedules
|
||||||
|
function FrequenciesTable() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
|
const [editingName, setEditingName] = useState('');
|
||||||
|
const [editingOrder, setEditingOrder] = useState(0);
|
||||||
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
|
const [newName, setNewName] = useState('');
|
||||||
|
const [newOrder, setNewOrder] = useState(0);
|
||||||
|
const [deleteItem, setDeleteItem] = useState<FrequencyItem | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['lookups', 'frequencies'],
|
||||||
|
queryFn: () => lookupsApi.frequencies.listWithPolicy(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = data?.data ?? [];
|
||||||
|
const overduePolicy = data?.overdue_policy;
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: CreateLookupRequest) => lookupsApi.frequencies.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['lookups', 'frequencies'] });
|
||||||
|
setIsAdding(false);
|
||||||
|
setNewName('');
|
||||||
|
setNewOrder(0);
|
||||||
|
toast.success('Frequency created successfully');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Failed to create frequency');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: number; data: UpdateLookupRequest }) => lookupsApi.frequencies.update(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['lookups', 'frequencies'] });
|
||||||
|
setEditingId(null);
|
||||||
|
toast.success('Frequency updated successfully');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Failed to update frequency');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => lookupsApi.frequencies.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['lookups', 'frequencies'] });
|
||||||
|
setDeleteItem(null);
|
||||||
|
toast.success('Frequency deleted successfully');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Failed to delete frequency');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleEdit = (item: FrequencyItem) => {
|
||||||
|
setEditingId(item.id);
|
||||||
|
setEditingName(item.name);
|
||||||
|
setEditingOrder(item.display_order);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!editingId) return;
|
||||||
|
updateMutation.mutate({
|
||||||
|
id: editingId,
|
||||||
|
data: {
|
||||||
|
name: editingName,
|
||||||
|
display_order: editingOrder,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
if (!newName.trim()) return;
|
||||||
|
createMutation.mutate({
|
||||||
|
name: newName,
|
||||||
|
display_order: newOrder,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortedItems = [...items].sort((a, b) => a.display_order - b.display_order);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="flex items-center justify-center h-32 text-muted-foreground">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Overdue Policy Info */}
|
||||||
|
{overduePolicy && (
|
||||||
|
<div className="p-4 bg-muted/50 rounded-lg border">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Bell className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="font-medium text-sm">Overdue Reminder Policy</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{overduePolicy.description}</p>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
|
{overduePolicy.overdue_days.map((day) => (
|
||||||
|
<Badge key={day} variant="secondary" className="text-xs">
|
||||||
|
Day {day}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={() => setIsAdding(true)} disabled={isAdding}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Add Item
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-12">#</TableHead>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Notification Schedule</TableHead>
|
||||||
|
<TableHead className="w-24">Order</TableHead>
|
||||||
|
<TableHead className="w-32">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{isAdding && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>
|
||||||
|
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Input
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
placeholder="Enter name..."
|
||||||
|
className="h-8"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span className="text-muted-foreground text-sm">Auto-assigned based on interval</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={newOrder}
|
||||||
|
onChange={(e) => setNewOrder(parseInt(e.target.value) || 0)}
|
||||||
|
className="h-8 w-16"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={createMutation.isPending || !newName.trim()}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4 text-green-600" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => {
|
||||||
|
setIsAdding(false);
|
||||||
|
setNewName('');
|
||||||
|
setNewOrder(0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4 text-red-600" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{sortedItems.map((item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell>
|
||||||
|
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{editingId === item.id ? (
|
||||||
|
<Input
|
||||||
|
value={editingName}
|
||||||
|
onChange={(e) => setEditingName(e.target.value)}
|
||||||
|
className="h-8"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{item.name}</div>
|
||||||
|
{item.days !== null && (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{item.days} day{item.days !== 1 ? 's' : ''} interval
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Bell className="h-3 w-3 text-muted-foreground" />
|
||||||
|
<span className="text-sm">{item.notification_schedule}</span>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p className="text-xs">Reminder days before due: {item.reminder_days.join(', ') || 'Day-of only'}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{editingId === item.id ? (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={editingOrder}
|
||||||
|
onChange={(e) => setEditingOrder(parseInt(e.target.value) || 0)}
|
||||||
|
className="h-8 w-16"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
item.display_order
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{editingId === item.id ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={updateMutation.isPending}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4 text-green-600" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => setEditingId(null)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4 text-red-600" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => handleEdit(item)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => setDeleteItem(item)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-red-600" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{sortedItems.length === 0 && !isAdding && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">
|
||||||
|
No frequencies found. Click "Add Item" to create one.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<AlertDialog open={!!deleteItem} onOpenChange={(open) => !open && setDeleteItem(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Frequency</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete "{deleteItem?.name}"? This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => deleteItem && deleteMutation.mutate(deleteItem.id)}
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
>
|
||||||
|
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function LookupsPage() {
|
export default function LookupsPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -333,7 +645,11 @@ export default function LookupsPage() {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
{lookupTabs.map((tab) => (
|
{lookupTabs.map((tab) => (
|
||||||
<TabsContent key={tab.key} value={tab.key} className="mt-4">
|
<TabsContent key={tab.key} value={tab.key} className="mt-4">
|
||||||
<LookupTable lookupKey={tab.key} api={tab.api} />
|
{tab.key === 'frequencies' ? (
|
||||||
|
<FrequenciesTable />
|
||||||
|
) : (
|
||||||
|
<LookupTable lookupKey={tab.key} api={tab.api!} />
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
))}
|
))}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@@ -473,6 +473,29 @@ export interface LookupItem {
|
|||||||
color?: string;
|
color?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Frequency Item with notification schedule
|
||||||
|
export interface FrequencyItem extends LookupItem {
|
||||||
|
days: number | null;
|
||||||
|
notification_schedule: string;
|
||||||
|
reminder_days: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overdue Policy
|
||||||
|
export interface OverduePolicy {
|
||||||
|
daily_reminder_days: number;
|
||||||
|
taper_interval_days: number;
|
||||||
|
max_overdue_days: number;
|
||||||
|
description: string;
|
||||||
|
overdue_days: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Frequencies List Response
|
||||||
|
export interface FrequenciesListResponse {
|
||||||
|
data: FrequencyItem[];
|
||||||
|
total: number;
|
||||||
|
overdue_policy: OverduePolicy;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateLookupRequest {
|
export interface CreateLookupRequest {
|
||||||
name: string;
|
name: string;
|
||||||
display_order?: number;
|
display_order?: number;
|
||||||
@@ -517,7 +540,27 @@ const createLookupApi = (endpoint: string) => ({
|
|||||||
export const lookupsApi = {
|
export const lookupsApi = {
|
||||||
categories: createLookupApi('categories'),
|
categories: createLookupApi('categories'),
|
||||||
priorities: createLookupApi('priorities'),
|
priorities: createLookupApi('priorities'),
|
||||||
frequencies: createLookupApi('frequencies'),
|
frequencies: {
|
||||||
|
list: async (): Promise<FrequencyItem[]> => {
|
||||||
|
const response = await api.get<FrequenciesListResponse>('/lookups/frequencies');
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
listWithPolicy: async (): Promise<FrequenciesListResponse> => {
|
||||||
|
const response = await api.get<FrequenciesListResponse>('/lookups/frequencies');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
create: async (data: CreateLookupRequest): Promise<FrequencyItem> => {
|
||||||
|
const response = await api.post<FrequencyItem>('/lookups/frequencies', data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
update: async (id: number, data: UpdateLookupRequest): Promise<FrequencyItem> => {
|
||||||
|
const response = await api.put<FrequencyItem>(`/lookups/frequencies/${id}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
delete: async (id: number): Promise<void> => {
|
||||||
|
await api.delete(`/lookups/frequencies/${id}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
residenceTypes: createLookupApi('residence-types'),
|
residenceTypes: createLookupApi('residence-types'),
|
||||||
specialties: createLookupApi('specialties'),
|
specialties: createLookupApi('specialties'),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"github.com/treytartt/casera-api/internal/admin/dto"
|
"github.com/treytartt/casera-api/internal/admin/dto"
|
||||||
"github.com/treytartt/casera-api/internal/models"
|
"github.com/treytartt/casera-api/internal/models"
|
||||||
|
"github.com/treytartt/casera-api/internal/notifications"
|
||||||
"github.com/treytartt/casera-api/internal/services"
|
"github.com/treytartt/casera-api/internal/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -412,10 +413,12 @@ func (h *AdminLookupHandler) DeletePriority(c echo.Context) error {
|
|||||||
// ========== Task Frequencies ==========
|
// ========== Task Frequencies ==========
|
||||||
|
|
||||||
type TaskFrequencyResponse struct {
|
type TaskFrequencyResponse struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Days *int `json:"days"`
|
Days *int `json:"days"`
|
||||||
DisplayOrder int `json:"display_order"`
|
DisplayOrder int `json:"display_order"`
|
||||||
|
NotificationSchedule string `json:"notification_schedule"`
|
||||||
|
ReminderDays []int `json:"reminder_days"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateUpdateFrequencyRequest struct {
|
type CreateUpdateFrequencyRequest struct {
|
||||||
@@ -432,15 +435,34 @@ func (h *AdminLookupHandler) ListFrequencies(c echo.Context) error {
|
|||||||
|
|
||||||
responses := make([]TaskFrequencyResponse, len(frequencies))
|
responses := make([]TaskFrequencyResponse, len(frequencies))
|
||||||
for i, f := range frequencies {
|
for i, f := range frequencies {
|
||||||
|
// Get notification schedule for this frequency
|
||||||
|
schedule := notifications.GetScheduleForFrequency(f.Days)
|
||||||
|
humanReadable := notifications.GetHumanReadableSchedule(f.Days)
|
||||||
|
|
||||||
responses[i] = TaskFrequencyResponse{
|
responses[i] = TaskFrequencyResponse{
|
||||||
ID: f.ID,
|
ID: f.ID,
|
||||||
Name: f.Name,
|
Name: f.Name,
|
||||||
Days: f.Days,
|
Days: f.Days,
|
||||||
DisplayOrder: f.DisplayOrder,
|
DisplayOrder: f.DisplayOrder,
|
||||||
|
NotificationSchedule: humanReadable,
|
||||||
|
ReminderDays: schedule,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, map[string]interface{}{"data": responses, "total": len(responses)})
|
// Include overdue policy in response
|
||||||
|
overduePolicy := map[string]interface{}{
|
||||||
|
"daily_reminder_days": notifications.OverdueConfig.DailyReminderDays,
|
||||||
|
"taper_interval_days": notifications.OverdueConfig.TaperIntervalDays,
|
||||||
|
"max_overdue_days": notifications.OverdueConfig.MaxOverdueDays,
|
||||||
|
"description": "Daily for first " + strconv.Itoa(notifications.OverdueConfig.DailyReminderDays) + " days, then every " + strconv.Itoa(notifications.OverdueConfig.TaperIntervalDays) + " days, stops after " + strconv.Itoa(notifications.OverdueConfig.MaxOverdueDays) + " days",
|
||||||
|
"overdue_days": notifications.GetOverdueReminderDays(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"data": responses,
|
||||||
|
"total": len(responses),
|
||||||
|
"overdue_policy": overduePolicy,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AdminLookupHandler) CreateFrequency(c echo.Context) error {
|
func (h *AdminLookupHandler) CreateFrequency(c echo.Context) error {
|
||||||
@@ -464,11 +486,17 @@ func (h *AdminLookupHandler) CreateFrequency(c echo.Context) error {
|
|||||||
// Refresh cache after creating
|
// Refresh cache after creating
|
||||||
h.refreshFrequenciesCache(c.Request().Context())
|
h.refreshFrequenciesCache(c.Request().Context())
|
||||||
|
|
||||||
|
// Get notification schedule for this frequency
|
||||||
|
schedule := notifications.GetScheduleForFrequency(frequency.Days)
|
||||||
|
humanReadable := notifications.GetHumanReadableSchedule(frequency.Days)
|
||||||
|
|
||||||
return c.JSON(http.StatusCreated, TaskFrequencyResponse{
|
return c.JSON(http.StatusCreated, TaskFrequencyResponse{
|
||||||
ID: frequency.ID,
|
ID: frequency.ID,
|
||||||
Name: frequency.Name,
|
Name: frequency.Name,
|
||||||
Days: frequency.Days,
|
Days: frequency.Days,
|
||||||
DisplayOrder: frequency.DisplayOrder,
|
DisplayOrder: frequency.DisplayOrder,
|
||||||
|
NotificationSchedule: humanReadable,
|
||||||
|
ReminderDays: schedule,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -504,11 +532,17 @@ func (h *AdminLookupHandler) UpdateFrequency(c echo.Context) error {
|
|||||||
// Refresh cache after updating
|
// Refresh cache after updating
|
||||||
h.refreshFrequenciesCache(c.Request().Context())
|
h.refreshFrequenciesCache(c.Request().Context())
|
||||||
|
|
||||||
|
// Get notification schedule for this frequency
|
||||||
|
schedule := notifications.GetScheduleForFrequency(frequency.Days)
|
||||||
|
humanReadable := notifications.GetHumanReadableSchedule(frequency.Days)
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, TaskFrequencyResponse{
|
return c.JSON(http.StatusOK, TaskFrequencyResponse{
|
||||||
ID: frequency.ID,
|
ID: frequency.ID,
|
||||||
Name: frequency.Name,
|
Name: frequency.Name,
|
||||||
Days: frequency.Days,
|
Days: frequency.Days,
|
||||||
DisplayOrder: frequency.DisplayOrder,
|
DisplayOrder: frequency.DisplayOrder,
|
||||||
|
NotificationSchedule: humanReadable,
|
||||||
|
ReminderDays: schedule,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -770,5 +804,73 @@ func (h *AdminLookupHandler) DeleteSpecialty(c echo.Context) error {
|
|||||||
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Specialty deleted successfully"})
|
return c.JSON(http.StatusOK, map[string]interface{}{"message": "Specialty deleted successfully"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== Notification Schedules ==========
|
||||||
|
|
||||||
|
// FrequencyScheduleResponse represents a frequency with its notification schedule
|
||||||
|
type FrequencyScheduleResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Days *int `json:"days"`
|
||||||
|
NotificationSchedule string `json:"notification_schedule"`
|
||||||
|
ReminderDays []int `json:"reminder_days"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OverduePolicyResponse represents the overdue reminder policy
|
||||||
|
type OverduePolicyResponse struct {
|
||||||
|
DailyReminderDays int `json:"daily_reminder_days"`
|
||||||
|
TaperIntervalDays int `json:"taper_interval_days"`
|
||||||
|
MaxOverdueDays int `json:"max_overdue_days"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
OverdueDays []int `json:"overdue_days"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotificationSchedulesResponse is the full response for notification schedules
|
||||||
|
type NotificationSchedulesResponse struct {
|
||||||
|
Frequencies []FrequencyScheduleResponse `json:"frequencies"`
|
||||||
|
OverduePolicy OverduePolicyResponse `json:"overdue_policy"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNotificationSchedules handles GET /api/admin/lookups/notification-schedules
|
||||||
|
// Returns the notification schedule configuration for all frequencies
|
||||||
|
func (h *AdminLookupHandler) GetNotificationSchedules(c echo.Context) error {
|
||||||
|
// Get all frequencies from database
|
||||||
|
var frequencies []models.TaskFrequency
|
||||||
|
if err := h.db.Order("display_order ASC, name ASC").Find(&frequencies).Error; err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": "Failed to fetch frequencies"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build frequency schedules with notification info
|
||||||
|
frequencyResponses := make([]FrequencyScheduleResponse, len(frequencies))
|
||||||
|
for i, f := range frequencies {
|
||||||
|
// Get the notification schedule for this frequency's days
|
||||||
|
schedule := notifications.GetScheduleForFrequency(f.Days)
|
||||||
|
humanReadable := notifications.GetHumanReadableSchedule(f.Days)
|
||||||
|
|
||||||
|
frequencyResponses[i] = FrequencyScheduleResponse{
|
||||||
|
ID: f.ID,
|
||||||
|
Name: f.Name,
|
||||||
|
Days: f.Days,
|
||||||
|
NotificationSchedule: humanReadable,
|
||||||
|
ReminderDays: schedule,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build overdue policy response
|
||||||
|
overduePolicy := OverduePolicyResponse{
|
||||||
|
DailyReminderDays: notifications.OverdueConfig.DailyReminderDays,
|
||||||
|
TaperIntervalDays: notifications.OverdueConfig.TaperIntervalDays,
|
||||||
|
MaxOverdueDays: notifications.OverdueConfig.MaxOverdueDays,
|
||||||
|
Description: "Daily for first " + strconv.Itoa(notifications.OverdueConfig.DailyReminderDays) + " days, then every " + strconv.Itoa(notifications.OverdueConfig.TaperIntervalDays) + " days, stops after " + strconv.Itoa(notifications.OverdueConfig.MaxOverdueDays) + " days",
|
||||||
|
OverdueDays: notifications.GetOverdueReminderDays(),
|
||||||
|
}
|
||||||
|
|
||||||
|
response := NotificationSchedulesResponse{
|
||||||
|
Frequencies: frequencyResponses,
|
||||||
|
OverduePolicy: overduePolicy,
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure dto import is used
|
// Ensure dto import is used
|
||||||
var _ = dto.PaginationParams{}
|
var _ = dto.PaginationParams{}
|
||||||
|
|||||||
@@ -274,6 +274,9 @@ func SetupRoutes(router *echo.Echo, db *gorm.DB, cfg *config.Config, deps *Depen
|
|||||||
frequencies.DELETE("/:id", lookupHandler.DeleteFrequency)
|
frequencies.DELETE("/:id", lookupHandler.DeleteFrequency)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notification Schedules (read-only, shows schedule for each frequency)
|
||||||
|
protected.GET("/lookups/notification-schedules", lookupHandler.GetNotificationSchedules)
|
||||||
|
|
||||||
// Residence Types
|
// Residence Types
|
||||||
residenceTypes := protected.Group("/lookups/residence-types")
|
residenceTypes := protected.Group("/lookups/residence-types")
|
||||||
{
|
{
|
||||||
|
|||||||
91
internal/models/reminder_log.go
Normal file
91
internal/models/reminder_log.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReminderStage represents the stage of a task reminder
|
||||||
|
type ReminderStage string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Pre-due date reminders (days before)
|
||||||
|
ReminderStage30Days ReminderStage = "reminder_30d"
|
||||||
|
ReminderStage14Days ReminderStage = "reminder_14d"
|
||||||
|
ReminderStage7Days ReminderStage = "reminder_7d"
|
||||||
|
ReminderStage3Days ReminderStage = "reminder_3d"
|
||||||
|
ReminderStage1Day ReminderStage = "reminder_1d"
|
||||||
|
ReminderStageDayOf ReminderStage = "day_of"
|
||||||
|
|
||||||
|
// Overdue reminders (days after)
|
||||||
|
ReminderStageOverdue1 ReminderStage = "overdue_1"
|
||||||
|
ReminderStageOverdue2 ReminderStage = "overdue_2"
|
||||||
|
ReminderStageOverdue3 ReminderStage = "overdue_3"
|
||||||
|
ReminderStageOverdue4 ReminderStage = "overdue_4"
|
||||||
|
ReminderStageOverdue7 ReminderStage = "overdue_7"
|
||||||
|
ReminderStageOverdue10 ReminderStage = "overdue_10"
|
||||||
|
ReminderStageOverdue13 ReminderStage = "overdue_13"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetReminderStageForDaysBefore returns the reminder stage for days before due date
|
||||||
|
func GetReminderStageForDaysBefore(days int) ReminderStage {
|
||||||
|
switch days {
|
||||||
|
case 30:
|
||||||
|
return ReminderStage30Days
|
||||||
|
case 14:
|
||||||
|
return ReminderStage14Days
|
||||||
|
case 7:
|
||||||
|
return ReminderStage7Days
|
||||||
|
case 3:
|
||||||
|
return ReminderStage3Days
|
||||||
|
case 1:
|
||||||
|
return ReminderStage1Day
|
||||||
|
case 0:
|
||||||
|
return ReminderStageDayOf
|
||||||
|
default:
|
||||||
|
// For any other number of days, create a custom stage
|
||||||
|
return ReminderStage(fmt.Sprintf("reminder_%dd", days))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetReminderStageForDaysOverdue returns the reminder stage for days overdue
|
||||||
|
func GetReminderStageForDaysOverdue(days int) ReminderStage {
|
||||||
|
switch days {
|
||||||
|
case 1:
|
||||||
|
return ReminderStageOverdue1
|
||||||
|
case 2:
|
||||||
|
return ReminderStageOverdue2
|
||||||
|
case 3:
|
||||||
|
return ReminderStageOverdue3
|
||||||
|
case 4:
|
||||||
|
return ReminderStageOverdue4
|
||||||
|
case 7:
|
||||||
|
return ReminderStageOverdue7
|
||||||
|
case 10:
|
||||||
|
return ReminderStageOverdue10
|
||||||
|
case 13:
|
||||||
|
return ReminderStageOverdue13
|
||||||
|
default:
|
||||||
|
// For any other overdue day, format as overdue_N
|
||||||
|
return ReminderStage(fmt.Sprintf("overdue_%d", days))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskReminderLog tracks which reminders have been sent to prevent duplicates
|
||||||
|
type TaskReminderLog struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
TaskID uint `gorm:"column:task_id;not null;index:idx_reminderlog_task_user_date" json:"task_id"`
|
||||||
|
Task *Task `gorm:"foreignKey:TaskID" json:"-"`
|
||||||
|
UserID uint `gorm:"column:user_id;not null;index:idx_reminderlog_task_user_date" json:"user_id"`
|
||||||
|
User *User `gorm:"foreignKey:UserID" json:"-"`
|
||||||
|
DueDate time.Time `gorm:"column:due_date;type:date;not null;index:idx_reminderlog_task_user_date" json:"due_date"`
|
||||||
|
ReminderStage ReminderStage `gorm:"column:reminder_stage;size:20;not null" json:"reminder_stage"`
|
||||||
|
SentAt time.Time `gorm:"column:sent_at;default:CURRENT_TIMESTAMP;index:idx_reminderlog_sent_at" json:"sent_at"`
|
||||||
|
NotificationID *uint `gorm:"column:notification_id" json:"notification_id"`
|
||||||
|
Notification *Notification `gorm:"foreignKey:NotificationID" json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName returns the table name for GORM
|
||||||
|
func (TaskReminderLog) TableName() string {
|
||||||
|
return "task_reminderlog"
|
||||||
|
}
|
||||||
63
internal/notifications/reminder_config.go
Normal file
63
internal/notifications/reminder_config.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package notifications
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// REMINDER CONFIGURATION
|
||||||
|
// Edit these values to adjust notification behavior
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// OverdueConfig controls when and how often overdue reminders are sent
|
||||||
|
var OverdueConfig = struct {
|
||||||
|
DailyReminderDays int // Send daily reminders for first N days overdue
|
||||||
|
TaperIntervalDays int // After daily period, remind every N days
|
||||||
|
MaxOverdueDays int // Stop reminding after N days overdue
|
||||||
|
}{
|
||||||
|
DailyReminderDays: 3, // Daily for days 1-3
|
||||||
|
TaperIntervalDays: 3, // Then every 3 days (4, 7, 10, 13)
|
||||||
|
MaxOverdueDays: 14, // Stop after 14 days
|
||||||
|
}
|
||||||
|
|
||||||
|
// FrequencySchedules - EXPLICIT entry for each of the 9 seeded frequencies
|
||||||
|
// Key: interval days (matches TaskFrequency.days in DB, 0 = Once/null)
|
||||||
|
// Value: array of days before due date to send reminders (0 = day-of)
|
||||||
|
//
|
||||||
|
// To add a reminder: append the number of days before to the slice
|
||||||
|
// Example: FrequencySchedules[30] = []int{7, 3, 0} adds 7-day warning to Monthly
|
||||||
|
var FrequencySchedules = map[int][]int{
|
||||||
|
0: {0}, // 1. Once (null/0): day-of only
|
||||||
|
1: {0}, // 2. Daily: day-of only
|
||||||
|
7: {0}, // 3. Weekly: day-of only
|
||||||
|
14: {1, 0}, // 4. Bi-Weekly: 1 day before, day-of
|
||||||
|
30: {3, 0}, // 5. Monthly: 3 days before, day-of
|
||||||
|
90: {7, 3, 0}, // 6. Quarterly: 7d, 3d, day-of
|
||||||
|
180: {14, 7, 0}, // 7. Semi-Annually: 14d, 7d, day-of
|
||||||
|
365: {30, 14, 7, 0}, // 8. Annually: 30d, 14d, 7d, day-of
|
||||||
|
}
|
||||||
|
|
||||||
|
// HumanReadableSchedule returns admin-friendly description for each frequency
|
||||||
|
// Key: interval days (matches FrequencySchedules keys)
|
||||||
|
// Value: human-readable description of the reminder schedule
|
||||||
|
var HumanReadableSchedule = map[int]string{
|
||||||
|
0: "Day-of → Overdue (tapering)",
|
||||||
|
1: "Day-of → Overdue (tapering)",
|
||||||
|
7: "Day-of → Overdue (tapering)",
|
||||||
|
14: "1 day before → Day-of → Overdue",
|
||||||
|
30: "3 days before → Day-of → Overdue",
|
||||||
|
90: "7d → 3d → Day-of → Overdue",
|
||||||
|
180: "14d → 7d → Day-of → Overdue",
|
||||||
|
365: "30d → 14d → 7d → Day-of → Overdue",
|
||||||
|
}
|
||||||
|
|
||||||
|
// FrequencyNames maps interval days to frequency names for display
|
||||||
|
var FrequencyNames = map[int]string{
|
||||||
|
0: "Once",
|
||||||
|
1: "Daily",
|
||||||
|
7: "Weekly",
|
||||||
|
14: "Bi-Weekly",
|
||||||
|
30: "Monthly",
|
||||||
|
90: "Quarterly",
|
||||||
|
180: "Semi-Annually",
|
||||||
|
365: "Annually",
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrderedFrequencies defines the display order for frequencies
|
||||||
|
var OrderedFrequencies = []int{0, 1, 7, 14, 30, 90, 180, 365}
|
||||||
198
internal/notifications/reminder_schedule.go
Normal file
198
internal/notifications/reminder_schedule.go
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
package notifications
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetScheduleForFrequency returns the reminder schedule (days before due date)
|
||||||
|
// based on the task's frequency interval days.
|
||||||
|
// Returns nil or 0 interval as Once schedule.
|
||||||
|
func GetScheduleForFrequency(intervalDays *int) []int {
|
||||||
|
if intervalDays == nil || *intervalDays == 0 {
|
||||||
|
return FrequencySchedules[0] // Once schedule
|
||||||
|
}
|
||||||
|
|
||||||
|
days := *intervalDays
|
||||||
|
|
||||||
|
// Check for exact match first
|
||||||
|
if schedule, exists := FrequencySchedules[days]; exists {
|
||||||
|
return schedule
|
||||||
|
}
|
||||||
|
|
||||||
|
// For Custom frequencies, find the nearest tier
|
||||||
|
return GetScheduleForCustomInterval(days)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetScheduleForCustomInterval looks up schedule for Custom frequency
|
||||||
|
// based on CustomIntervalDays value - finds nearest tier
|
||||||
|
func GetScheduleForCustomInterval(days int) []int {
|
||||||
|
// Ordered tiers from smallest to largest
|
||||||
|
tiers := []int{1, 7, 14, 30, 90, 180, 365}
|
||||||
|
|
||||||
|
for _, tier := range tiers {
|
||||||
|
if days <= tier {
|
||||||
|
return FrequencySchedules[tier]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to annual schedule for intervals > 365 days
|
||||||
|
return FrequencySchedules[365]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShouldSendOverdueReminder checks if we should send an overdue reminder
|
||||||
|
// for a task that is daysOverdue days past its due date.
|
||||||
|
//
|
||||||
|
// Returns true if:
|
||||||
|
// - Days 1 to DailyReminderDays: every day
|
||||||
|
// - Days DailyReminderDays+1 to MaxOverdueDays: every TaperIntervalDays
|
||||||
|
// - After MaxOverdueDays: never
|
||||||
|
func ShouldSendOverdueReminder(daysOverdue int) bool {
|
||||||
|
if daysOverdue <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 1: Daily reminders for first N days
|
||||||
|
if daysOverdue <= OverdueConfig.DailyReminderDays {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: Tapered reminders (every N days)
|
||||||
|
if daysOverdue <= OverdueConfig.MaxOverdueDays {
|
||||||
|
// Calculate which day in the taper phase
|
||||||
|
daysSinceDaily := daysOverdue - OverdueConfig.DailyReminderDays
|
||||||
|
// Send on days that are multiples of TaperIntervalDays after daily phase
|
||||||
|
// e.g., with DailyReminderDays=3, TaperIntervalDays=3:
|
||||||
|
// Day 4 (daysSinceDaily=1): 1 % 3 = 1 (no)
|
||||||
|
// Day 5 (daysSinceDaily=2): 2 % 3 = 2 (no)
|
||||||
|
// Day 6 (daysSinceDaily=3): 3 % 3 = 0 (yes) -> but we want day 4, 7, 10, 13
|
||||||
|
// Correcting: we want to send on daysSinceDaily = 1, 4, 7, 10...
|
||||||
|
// So (daysSinceDaily - 1) % TaperIntervalDays == 0
|
||||||
|
return (daysSinceDaily-1)%OverdueConfig.TaperIntervalDays == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 3: No more reminders
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOverdueReminderDays returns all the days on which overdue reminders
|
||||||
|
// should be sent, based on the current OverdueConfig.
|
||||||
|
func GetOverdueReminderDays() []int {
|
||||||
|
var days []int
|
||||||
|
|
||||||
|
// Add daily reminder days
|
||||||
|
for i := 1; i <= OverdueConfig.DailyReminderDays; i++ {
|
||||||
|
days = append(days, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add tapered reminder days
|
||||||
|
for i := OverdueConfig.DailyReminderDays + 1; i <= OverdueConfig.MaxOverdueDays; i++ {
|
||||||
|
if ShouldSendOverdueReminder(i) {
|
||||||
|
days = append(days, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return days
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetReminderStageForToday determines which reminder stage applies today
|
||||||
|
// for a task with the given due date and frequency.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - "reminder_Nd" if task is N days away and in schedule
|
||||||
|
// - "day_of" if task is due today
|
||||||
|
// - "overdue_N" if task is N days overdue and should be reminded
|
||||||
|
// - empty string if no reminder should be sent today
|
||||||
|
func GetReminderStageForToday(dueDate time.Time, frequencyDays *int, today time.Time) string {
|
||||||
|
// Normalize to date only (midnight)
|
||||||
|
dueDate = time.Date(dueDate.Year(), dueDate.Month(), dueDate.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
|
today = time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
// Calculate days difference
|
||||||
|
diff := int(dueDate.Sub(today).Hours() / 24)
|
||||||
|
|
||||||
|
if diff > 0 {
|
||||||
|
// Task is in the future - check pre-due reminders
|
||||||
|
schedule := GetScheduleForFrequency(frequencyDays)
|
||||||
|
if slices.Contains(schedule, diff) {
|
||||||
|
return formatDaysBeforeStage(diff)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
} else if diff == 0 {
|
||||||
|
// Task is due today
|
||||||
|
return "day_of"
|
||||||
|
} else {
|
||||||
|
// Task is overdue
|
||||||
|
daysOverdue := -diff
|
||||||
|
if ShouldSendOverdueReminder(daysOverdue) {
|
||||||
|
return formatOverdueStage(daysOverdue)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatDaysBeforeStage returns the stage string for days before due date
|
||||||
|
func formatDaysBeforeStage(days int) string {
|
||||||
|
if days == 0 {
|
||||||
|
return "day_of"
|
||||||
|
}
|
||||||
|
return "reminder_" + itoa(days) + "d"
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatOverdueStage returns the stage string for days overdue
|
||||||
|
func formatOverdueStage(days int) string {
|
||||||
|
return "overdue_" + itoa(days)
|
||||||
|
}
|
||||||
|
|
||||||
|
// itoa is a simple int to string helper
|
||||||
|
func itoa(i int) string {
|
||||||
|
if i == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
if i < 0 {
|
||||||
|
return "-" + itoa(-i)
|
||||||
|
}
|
||||||
|
s := ""
|
||||||
|
for i > 0 {
|
||||||
|
s = string(rune('0'+i%10)) + s
|
||||||
|
i /= 10
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHumanReadableSchedule returns a human-readable description of the
|
||||||
|
// notification schedule for a given frequency interval.
|
||||||
|
func GetHumanReadableSchedule(intervalDays *int) string {
|
||||||
|
days := 0
|
||||||
|
if intervalDays != nil {
|
||||||
|
days = *intervalDays
|
||||||
|
}
|
||||||
|
|
||||||
|
if desc, exists := HumanReadableSchedule[days]; exists {
|
||||||
|
return desc
|
||||||
|
}
|
||||||
|
|
||||||
|
// For custom intervals, find the nearest tier
|
||||||
|
tiers := []int{1, 7, 14, 30, 90, 180, 365}
|
||||||
|
for _, tier := range tiers {
|
||||||
|
if days <= tier {
|
||||||
|
return HumanReadableSchedule[tier] + " (custom)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return HumanReadableSchedule[365] + " (custom)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFrequencyName returns the human-readable name for a frequency interval
|
||||||
|
func GetFrequencyName(intervalDays *int) string {
|
||||||
|
days := 0
|
||||||
|
if intervalDays != nil {
|
||||||
|
days = *intervalDays
|
||||||
|
}
|
||||||
|
|
||||||
|
if name, exists := FrequencyNames[days]; exists {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Custom"
|
||||||
|
}
|
||||||
524
internal/notifications/reminder_schedule_test.go
Normal file
524
internal/notifications/reminder_schedule_test.go
Normal file
@@ -0,0 +1,524 @@
|
|||||||
|
package notifications
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestReminderScheduleWith100RandomTasks creates 100 tasks with random frequencies
|
||||||
|
// and validates that notifications are sent on exactly the right days according to
|
||||||
|
// the current FrequencySchedules and OverdueConfig.
|
||||||
|
//
|
||||||
|
// This test is dynamic - it reads from the config, so if the config changes,
|
||||||
|
// the test will still validate the correct behavior.
|
||||||
|
func TestReminderScheduleWith100RandomTasks(t *testing.T) {
|
||||||
|
// Seed random for reproducibility in tests (use current time for variety)
|
||||||
|
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||||
|
|
||||||
|
// Available frequency intervals (from OrderedFrequencies)
|
||||||
|
// We'll also add some custom intervals to test the tier mapping
|
||||||
|
frequencyIntervals := []int{0, 1, 7, 14, 30, 90, 180, 365}
|
||||||
|
customIntervals := []int{5, 10, 20, 45, 120, 200, 400} // Custom intervals to test tier mapping
|
||||||
|
|
||||||
|
allIntervals := append(frequencyIntervals, customIntervals...)
|
||||||
|
|
||||||
|
// Base date for tests - use a fixed date for deterministic behavior
|
||||||
|
baseDate := time.Date(2025, 6, 15, 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
// Track test statistics
|
||||||
|
tasksCreated := 0
|
||||||
|
daysSimulated := 0
|
||||||
|
notificationsSent := 0
|
||||||
|
notificationsExpected := 0
|
||||||
|
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
// Random frequency
|
||||||
|
intervalIdx := rng.Intn(len(allIntervals))
|
||||||
|
intervalDays := allIntervals[intervalIdx]
|
||||||
|
|
||||||
|
// Random due date offset (-30 to +30 days from base date)
|
||||||
|
dueDateOffset := rng.Intn(61) - 30
|
||||||
|
dueDate := baseDate.AddDate(0, 0, dueDateOffset)
|
||||||
|
|
||||||
|
// Create frequency pointer (nil for "Once" which is interval 0)
|
||||||
|
var frequencyDays *int
|
||||||
|
if intervalDays > 0 {
|
||||||
|
frequencyDays = &intervalDays
|
||||||
|
}
|
||||||
|
|
||||||
|
tasksCreated++
|
||||||
|
|
||||||
|
// Get the expected schedule for this frequency
|
||||||
|
expectedSchedule := GetScheduleForFrequency(frequencyDays)
|
||||||
|
expectedOverdueDays := GetOverdueReminderDays()
|
||||||
|
|
||||||
|
// Simulate each day from 45 days before to 15 days after due date
|
||||||
|
for dayOffset := -45; dayOffset <= 15; dayOffset++ {
|
||||||
|
today := dueDate.AddDate(0, 0, -dayOffset) // dayOffset is days until due, so we subtract
|
||||||
|
daysSimulated++
|
||||||
|
|
||||||
|
// Get the reminder stage for today
|
||||||
|
stage := GetReminderStageForToday(dueDate, frequencyDays, today)
|
||||||
|
|
||||||
|
// Calculate what we expect
|
||||||
|
expectedStage := calculateExpectedStage(dayOffset, expectedSchedule, expectedOverdueDays)
|
||||||
|
|
||||||
|
if stage != expectedStage {
|
||||||
|
t.Errorf("Task %d (interval=%d, dueDate=%s): on %s (dayOffset=%d), got stage=%q, expected=%q",
|
||||||
|
i+1, intervalDays, dueDate.Format("2006-01-02"),
|
||||||
|
today.Format("2006-01-02"), dayOffset, stage, expectedStage)
|
||||||
|
}
|
||||||
|
|
||||||
|
if stage != "" {
|
||||||
|
notificationsSent++
|
||||||
|
}
|
||||||
|
if expectedStage != "" {
|
||||||
|
notificationsExpected++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Test Summary:")
|
||||||
|
t.Logf(" Tasks created: %d", tasksCreated)
|
||||||
|
t.Logf(" Days simulated: %d", daysSimulated)
|
||||||
|
t.Logf(" Notifications sent: %d", notificationsSent)
|
||||||
|
t.Logf(" Notifications expected: %d", notificationsExpected)
|
||||||
|
|
||||||
|
if notificationsSent != notificationsExpected {
|
||||||
|
t.Errorf("Notification count mismatch: sent=%d, expected=%d", notificationsSent, notificationsExpected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateExpectedStage determines what stage should be returned based on the config
|
||||||
|
func calculateExpectedStage(dayOffset int, preReminderDays []int, overdueDays []int) string {
|
||||||
|
if dayOffset > 0 {
|
||||||
|
// Task is in the future (dayOffset days until due)
|
||||||
|
if slices.Contains(preReminderDays, dayOffset) {
|
||||||
|
return formatDaysBeforeStage(dayOffset)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
} else if dayOffset == 0 {
|
||||||
|
// Task is due today
|
||||||
|
return "day_of"
|
||||||
|
} else {
|
||||||
|
// Task is overdue (-dayOffset days overdue)
|
||||||
|
daysOverdue := -dayOffset
|
||||||
|
if slices.Contains(overdueDays, daysOverdue) {
|
||||||
|
return formatOverdueStage(daysOverdue)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEachFrequencySchedule validates each frequency's schedule explicitly
|
||||||
|
func TestEachFrequencySchedule(t *testing.T) {
|
||||||
|
dueDate := time.Date(2025, 6, 15, 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
// Test each configured frequency
|
||||||
|
for intervalDays, expectedSchedule := range FrequencySchedules {
|
||||||
|
t.Run(fmt.Sprintf("Interval_%d_days", intervalDays), func(t *testing.T) {
|
||||||
|
var frequencyDays *int
|
||||||
|
if intervalDays > 0 {
|
||||||
|
frequencyDays = &intervalDays
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all expected reminder days trigger notifications
|
||||||
|
for _, reminderDay := range expectedSchedule {
|
||||||
|
today := dueDate.AddDate(0, 0, -reminderDay)
|
||||||
|
stage := GetReminderStageForToday(dueDate, frequencyDays, today)
|
||||||
|
|
||||||
|
expectedStage := formatDaysBeforeStage(reminderDay)
|
||||||
|
if stage != expectedStage {
|
||||||
|
t.Errorf("Reminder %d days before: got %q, expected %q", reminderDay, stage, expectedStage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify days NOT in the schedule don't trigger notifications (before due date)
|
||||||
|
for day := 1; day <= 45; day++ {
|
||||||
|
if !slices.Contains(expectedSchedule, day) {
|
||||||
|
today := dueDate.AddDate(0, 0, -day)
|
||||||
|
stage := GetReminderStageForToday(dueDate, frequencyDays, today)
|
||||||
|
|
||||||
|
if stage != "" {
|
||||||
|
t.Errorf("Day %d before due should NOT send notification for interval %d, but got %q",
|
||||||
|
day, intervalDays, stage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOverdueReminderSchedule validates the overdue tapering logic
|
||||||
|
func TestOverdueReminderSchedule(t *testing.T) {
|
||||||
|
dueDate := time.Date(2025, 6, 15, 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
// Use nil frequency (Once) - overdue logic is the same for all frequencies
|
||||||
|
var frequencyDays *int
|
||||||
|
|
||||||
|
// Get expected overdue days from config
|
||||||
|
expectedOverdueDays := GetOverdueReminderDays()
|
||||||
|
|
||||||
|
t.Logf("OverdueConfig: DailyReminderDays=%d, TaperIntervalDays=%d, MaxOverdueDays=%d",
|
||||||
|
OverdueConfig.DailyReminderDays, OverdueConfig.TaperIntervalDays, OverdueConfig.MaxOverdueDays)
|
||||||
|
t.Logf("Expected overdue reminder days: %v", expectedOverdueDays)
|
||||||
|
|
||||||
|
// Test each day from 1 to MaxOverdueDays + 5
|
||||||
|
for daysOverdue := 1; daysOverdue <= OverdueConfig.MaxOverdueDays+5; daysOverdue++ {
|
||||||
|
today := dueDate.AddDate(0, 0, daysOverdue)
|
||||||
|
stage := GetReminderStageForToday(dueDate, frequencyDays, today)
|
||||||
|
|
||||||
|
shouldSend := slices.Contains(expectedOverdueDays, daysOverdue)
|
||||||
|
expectedStage := ""
|
||||||
|
if shouldSend {
|
||||||
|
expectedStage = formatOverdueStage(daysOverdue)
|
||||||
|
}
|
||||||
|
|
||||||
|
if stage != expectedStage {
|
||||||
|
t.Errorf("Day %d overdue: got %q, expected %q (shouldSend=%v)",
|
||||||
|
daysOverdue, stage, expectedStage, shouldSend)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCustomIntervalTierMapping validates that custom intervals map to correct tiers
|
||||||
|
func TestCustomIntervalTierMapping(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
customInterval int
|
||||||
|
expectedTier int
|
||||||
|
expectedMessage string
|
||||||
|
}{
|
||||||
|
{2, 7, "2 days should map to Weekly (7) tier"},
|
||||||
|
{5, 7, "5 days should map to Weekly (7) tier"},
|
||||||
|
{8, 14, "8 days should map to Bi-Weekly (14) tier"},
|
||||||
|
{10, 14, "10 days should map to Bi-Weekly (14) tier"},
|
||||||
|
{20, 30, "20 days should map to Monthly (30) tier"},
|
||||||
|
{45, 90, "45 days should map to Quarterly (90) tier"},
|
||||||
|
{100, 180, "100 days should map to Semi-Annually (180) tier"},
|
||||||
|
{200, 365, "200 days should map to Annually (365) tier"},
|
||||||
|
{500, 365, "500 days should map to Annually (365) tier - fallback"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(fmt.Sprintf("CustomInterval_%d", tc.customInterval), func(t *testing.T) {
|
||||||
|
schedule := GetScheduleForCustomInterval(tc.customInterval)
|
||||||
|
expectedSchedule := FrequencySchedules[tc.expectedTier]
|
||||||
|
|
||||||
|
if !slices.Equal(schedule, expectedSchedule) {
|
||||||
|
t.Errorf("%s: got schedule %v, expected %v (tier %d)",
|
||||||
|
tc.expectedMessage, schedule, expectedSchedule, tc.expectedTier)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDayOfNotification ensures day-of notifications always fire
|
||||||
|
func TestDayOfNotification(t *testing.T) {
|
||||||
|
dueDate := time.Date(2025, 6, 15, 0, 0, 0, 0, time.UTC)
|
||||||
|
today := dueDate // Same day
|
||||||
|
|
||||||
|
// Test all frequency types - day-of should always fire
|
||||||
|
for intervalDays := range FrequencySchedules {
|
||||||
|
t.Run(fmt.Sprintf("DayOf_Interval_%d", intervalDays), func(t *testing.T) {
|
||||||
|
var frequencyDays *int
|
||||||
|
if intervalDays > 0 {
|
||||||
|
frequencyDays = &intervalDays
|
||||||
|
}
|
||||||
|
|
||||||
|
stage := GetReminderStageForToday(dueDate, frequencyDays, today)
|
||||||
|
if stage != "day_of" {
|
||||||
|
t.Errorf("Day-of notification for interval %d: got %q, expected \"day_of\"",
|
||||||
|
intervalDays, stage)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNoNotificationAfterMaxOverdue ensures no notifications after MaxOverdueDays
|
||||||
|
func TestNoNotificationAfterMaxOverdue(t *testing.T) {
|
||||||
|
dueDate := time.Date(2025, 6, 15, 0, 0, 0, 0, time.UTC)
|
||||||
|
var frequencyDays *int // Once frequency
|
||||||
|
|
||||||
|
// Test days beyond MaxOverdueDays
|
||||||
|
for daysOverdue := OverdueConfig.MaxOverdueDays + 1; daysOverdue <= OverdueConfig.MaxOverdueDays+30; daysOverdue++ {
|
||||||
|
today := dueDate.AddDate(0, 0, daysOverdue)
|
||||||
|
stage := GetReminderStageForToday(dueDate, frequencyDays, today)
|
||||||
|
|
||||||
|
if stage != "" {
|
||||||
|
t.Errorf("Day %d overdue (beyond MaxOverdueDays=%d): should not send notification, but got %q",
|
||||||
|
daysOverdue, OverdueConfig.MaxOverdueDays, stage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNoNotificationTooFarInFuture ensures no notifications for tasks far in the future
|
||||||
|
func TestNoNotificationTooFarInFuture(t *testing.T) {
|
||||||
|
dueDate := time.Date(2025, 6, 15, 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
// Get the maximum pre-reminder days from all schedules
|
||||||
|
maxPreReminderDays := 0
|
||||||
|
for _, schedule := range FrequencySchedules {
|
||||||
|
for _, days := range schedule {
|
||||||
|
if days > maxPreReminderDays {
|
||||||
|
maxPreReminderDays = days
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test for each frequency type - days beyond their schedule should not trigger
|
||||||
|
for intervalDays, schedule := range FrequencySchedules {
|
||||||
|
t.Run(fmt.Sprintf("FarFuture_Interval_%d", intervalDays), func(t *testing.T) {
|
||||||
|
var frequencyDays *int
|
||||||
|
if intervalDays > 0 {
|
||||||
|
frequencyDays = &intervalDays
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the max reminder day for this frequency
|
||||||
|
maxForFrequency := 0
|
||||||
|
for _, d := range schedule {
|
||||||
|
if d > maxForFrequency {
|
||||||
|
maxForFrequency = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test days beyond the max reminder day for this frequency
|
||||||
|
for daysBefore := maxForFrequency + 1; daysBefore <= maxPreReminderDays+10; daysBefore++ {
|
||||||
|
// Skip if this day is actually in the schedule
|
||||||
|
if slices.Contains(schedule, daysBefore) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
today := dueDate.AddDate(0, 0, -daysBefore)
|
||||||
|
stage := GetReminderStageForToday(dueDate, frequencyDays, today)
|
||||||
|
|
||||||
|
if stage != "" {
|
||||||
|
t.Errorf("Day %d before due (beyond schedule max=%d for interval %d): should not send, but got %q",
|
||||||
|
daysBefore, maxForFrequency, intervalDays, stage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfigConsistency validates that the configuration is internally consistent
|
||||||
|
func TestConfigConsistency(t *testing.T) {
|
||||||
|
// Verify GetOverdueReminderDays matches ShouldSendOverdueReminder
|
||||||
|
overdueDays := GetOverdueReminderDays()
|
||||||
|
|
||||||
|
for day := 1; day <= OverdueConfig.MaxOverdueDays+5; day++ {
|
||||||
|
shouldSend := ShouldSendOverdueReminder(day)
|
||||||
|
isInList := slices.Contains(overdueDays, day)
|
||||||
|
|
||||||
|
if shouldSend != isInList {
|
||||||
|
t.Errorf("Inconsistency at day %d: ShouldSendOverdueReminder=%v, but day in GetOverdueReminderDays=%v",
|
||||||
|
day, shouldSend, isInList)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all frequencies have 0 (day-of) in their schedule
|
||||||
|
for intervalDays, schedule := range FrequencySchedules {
|
||||||
|
if !slices.Contains(schedule, 0) {
|
||||||
|
t.Errorf("Frequency %d days is missing day-of (0) in schedule: %v", intervalDays, schedule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify schedules are sorted in descending order (most days first)
|
||||||
|
for intervalDays, schedule := range FrequencySchedules {
|
||||||
|
for i := 0; i < len(schedule)-1; i++ {
|
||||||
|
if schedule[i] < schedule[i+1] {
|
||||||
|
t.Errorf("Frequency %d schedule is not in descending order: %v", intervalDays, schedule)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStageFormatting validates the stage string formatting
|
||||||
|
func TestStageFormatting(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
daysBefore int
|
||||||
|
expectedStage string
|
||||||
|
}{
|
||||||
|
{0, "day_of"},
|
||||||
|
{1, "reminder_1d"},
|
||||||
|
{3, "reminder_3d"},
|
||||||
|
{7, "reminder_7d"},
|
||||||
|
{14, "reminder_14d"},
|
||||||
|
{30, "reminder_30d"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(fmt.Sprintf("Format_%d_days_before", tc.daysBefore), func(t *testing.T) {
|
||||||
|
stage := formatDaysBeforeStage(tc.daysBefore)
|
||||||
|
if stage != tc.expectedStage {
|
||||||
|
t.Errorf("formatDaysBeforeStage(%d) = %q, expected %q", tc.daysBefore, stage, tc.expectedStage)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test overdue formatting
|
||||||
|
overdueTestCases := []struct {
|
||||||
|
daysOverdue int
|
||||||
|
expectedStage string
|
||||||
|
}{
|
||||||
|
{1, "overdue_1"},
|
||||||
|
{3, "overdue_3"},
|
||||||
|
{7, "overdue_7"},
|
||||||
|
{14, "overdue_14"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range overdueTestCases {
|
||||||
|
t.Run(fmt.Sprintf("Format_%d_days_overdue", tc.daysOverdue), func(t *testing.T) {
|
||||||
|
stage := formatOverdueStage(tc.daysOverdue)
|
||||||
|
if stage != tc.expectedStage {
|
||||||
|
t.Errorf("formatOverdueStage(%d) = %q, expected %q", tc.daysOverdue, stage, tc.expectedStage)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkGetReminderStageForToday benchmarks the main function
|
||||||
|
func BenchmarkGetReminderStageForToday(b *testing.B) {
|
||||||
|
dueDate := time.Date(2025, 6, 15, 0, 0, 0, 0, time.UTC)
|
||||||
|
today := time.Date(2025, 6, 12, 0, 0, 0, 0, time.UTC) // 3 days before
|
||||||
|
intervalDays := 30
|
||||||
|
frequencyDays := &intervalDays
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
GetReminderStageForToday(dueDate, frequencyDays, today)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReminderScheduleTableDriven uses the config to generate a comprehensive
|
||||||
|
// table-driven test that validates all expected notification days
|
||||||
|
func TestReminderScheduleTableDriven(t *testing.T) {
|
||||||
|
dueDate := time.Date(2025, 6, 15, 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
// Build test cases from config
|
||||||
|
for intervalDays, preSchedule := range FrequencySchedules {
|
||||||
|
var frequencyDays *int
|
||||||
|
if intervalDays > 0 {
|
||||||
|
frequencyDays = &intervalDays
|
||||||
|
}
|
||||||
|
|
||||||
|
freqName := FrequencyNames[intervalDays]
|
||||||
|
if freqName == "" {
|
||||||
|
freqName = fmt.Sprintf("Custom_%d", intervalDays)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test pre-due reminders
|
||||||
|
for _, daysBefore := range preSchedule {
|
||||||
|
t.Run(fmt.Sprintf("%s_PreReminder_%dd", freqName, daysBefore), func(t *testing.T) {
|
||||||
|
today := dueDate.AddDate(0, 0, -daysBefore)
|
||||||
|
stage := GetReminderStageForToday(dueDate, frequencyDays, today)
|
||||||
|
expected := formatDaysBeforeStage(daysBefore)
|
||||||
|
|
||||||
|
if stage != expected {
|
||||||
|
t.Errorf("got %q, expected %q", stage, expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test overdue reminders (same for all frequencies)
|
||||||
|
overdueDays := GetOverdueReminderDays()
|
||||||
|
for _, daysOverdue := range overdueDays {
|
||||||
|
t.Run(fmt.Sprintf("%s_Overdue_%dd", freqName, daysOverdue), func(t *testing.T) {
|
||||||
|
today := dueDate.AddDate(0, 0, daysOverdue)
|
||||||
|
stage := GetReminderStageForToday(dueDate, frequencyDays, today)
|
||||||
|
expected := formatOverdueStage(daysOverdue)
|
||||||
|
|
||||||
|
if stage != expected {
|
||||||
|
t.Errorf("got %q, expected %q", stage, expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFullSimulation100Tasks is the main comprehensive test that simulates
|
||||||
|
// 100 random tasks over the full date range
|
||||||
|
func TestFullSimulation100Tasks(t *testing.T) {
|
||||||
|
// Use a fixed seed for reproducible tests
|
||||||
|
rng := rand.New(rand.NewSource(42))
|
||||||
|
|
||||||
|
// All available frequencies including custom
|
||||||
|
frequencies := []int{0, 1, 7, 14, 30, 90, 180, 365, 5, 10, 20, 45, 100, 200, 500}
|
||||||
|
|
||||||
|
baseDate := time.Date(2025, 6, 15, 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
// Statistics
|
||||||
|
totalDays := 0
|
||||||
|
totalNotifications := 0
|
||||||
|
notificationsByStage := make(map[string]int)
|
||||||
|
|
||||||
|
for taskIdx := 0; taskIdx < 100; taskIdx++ {
|
||||||
|
// Random frequency
|
||||||
|
intervalDays := frequencies[rng.Intn(len(frequencies))]
|
||||||
|
|
||||||
|
// Random due date (within 60-day range around base)
|
||||||
|
dueDateOffset := rng.Intn(61) - 30
|
||||||
|
dueDate := baseDate.AddDate(0, 0, dueDateOffset)
|
||||||
|
|
||||||
|
var frequencyDays *int
|
||||||
|
if intervalDays > 0 {
|
||||||
|
frequencyDays = &intervalDays
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get expected schedules from config
|
||||||
|
preSchedule := GetScheduleForFrequency(frequencyDays)
|
||||||
|
overdueDays := GetOverdueReminderDays()
|
||||||
|
|
||||||
|
// Simulate 45 days before to 15 days after due date
|
||||||
|
for dayOffset := -45; dayOffset <= 15; dayOffset++ {
|
||||||
|
// dayOffset: negative = future (before due), positive = past (overdue)
|
||||||
|
// So if dayOffset = -10, we're 10 days BEFORE due date
|
||||||
|
// If dayOffset = 5, we're 5 days AFTER due date (overdue)
|
||||||
|
today := dueDate.AddDate(0, 0, dayOffset)
|
||||||
|
totalDays++
|
||||||
|
|
||||||
|
stage := GetReminderStageForToday(dueDate, frequencyDays, today)
|
||||||
|
|
||||||
|
// Calculate expected stage
|
||||||
|
var expectedStage string
|
||||||
|
if dayOffset < 0 {
|
||||||
|
// Before due date: check pre-reminders
|
||||||
|
daysBefore := -dayOffset
|
||||||
|
if slices.Contains(preSchedule, daysBefore) {
|
||||||
|
expectedStage = formatDaysBeforeStage(daysBefore)
|
||||||
|
}
|
||||||
|
} else if dayOffset == 0 {
|
||||||
|
// Day of
|
||||||
|
expectedStage = "day_of"
|
||||||
|
} else {
|
||||||
|
// Overdue
|
||||||
|
if slices.Contains(overdueDays, dayOffset) {
|
||||||
|
expectedStage = formatOverdueStage(dayOffset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if stage != expectedStage {
|
||||||
|
t.Errorf("Task %d (freq=%d, due=%s): day %s (offset=%d): got %q, expected %q",
|
||||||
|
taskIdx+1, intervalDays, dueDate.Format("2006-01-02"),
|
||||||
|
today.Format("2006-01-02"), dayOffset, stage, expectedStage)
|
||||||
|
}
|
||||||
|
|
||||||
|
if stage != "" {
|
||||||
|
totalNotifications++
|
||||||
|
notificationsByStage[stage]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Full Simulation Results:")
|
||||||
|
t.Logf(" Total tasks: 100")
|
||||||
|
t.Logf(" Total days simulated: %d", totalDays)
|
||||||
|
t.Logf(" Total notifications: %d", totalNotifications)
|
||||||
|
t.Logf(" Notifications by stage:")
|
||||||
|
for stage, count := range notificationsByStage {
|
||||||
|
t.Logf(" %s: %d", stage, count)
|
||||||
|
}
|
||||||
|
}
|
||||||
125
internal/repositories/reminder_repo.go
Normal file
125
internal/repositories/reminder_repo.go
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/treytartt/casera-api/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReminderRepository handles database operations for task reminder logs
|
||||||
|
type ReminderRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewReminderRepository creates a new reminder repository
|
||||||
|
func NewReminderRepository(db *gorm.DB) *ReminderRepository {
|
||||||
|
return &ReminderRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasSentReminder checks if a reminder has already been sent for the given
|
||||||
|
// task, user, due date, and reminder stage.
|
||||||
|
func (r *ReminderRepository) HasSentReminder(taskID, userID uint, dueDate time.Time, stage models.ReminderStage) (bool, error) {
|
||||||
|
// Normalize to date only
|
||||||
|
dueDateOnly := time.Date(dueDate.Year(), dueDate.Month(), dueDate.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
err := r.db.Model(&models.TaskReminderLog{}).
|
||||||
|
Where("task_id = ? AND user_id = ? AND due_date = ? AND reminder_stage = ?",
|
||||||
|
taskID, userID, dueDateOnly, stage).
|
||||||
|
Count(&count).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return count > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogReminder records that a reminder was sent.
|
||||||
|
// Returns the created log entry or an error if the reminder was already sent
|
||||||
|
// (unique constraint violation).
|
||||||
|
func (r *ReminderRepository) LogReminder(taskID, userID uint, dueDate time.Time, stage models.ReminderStage, notificationID *uint) (*models.TaskReminderLog, error) {
|
||||||
|
// Normalize to date only
|
||||||
|
dueDateOnly := time.Date(dueDate.Year(), dueDate.Month(), dueDate.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
log := &models.TaskReminderLog{
|
||||||
|
TaskID: taskID,
|
||||||
|
UserID: userID,
|
||||||
|
DueDate: dueDateOnly,
|
||||||
|
ReminderStage: stage,
|
||||||
|
SentAt: time.Now().UTC(),
|
||||||
|
NotificationID: notificationID,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := r.db.Create(log).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return log, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSentRemindersForTask returns all reminder logs for a specific task and user.
|
||||||
|
func (r *ReminderRepository) GetSentRemindersForTask(taskID, userID uint) ([]models.TaskReminderLog, error) {
|
||||||
|
var logs []models.TaskReminderLog
|
||||||
|
err := r.db.Where("task_id = ? AND user_id = ?", taskID, userID).
|
||||||
|
Order("sent_at DESC").
|
||||||
|
Find(&logs).Error
|
||||||
|
return logs, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSentRemindersForDueDate returns all reminder logs for a specific task,
|
||||||
|
// user, and due date.
|
||||||
|
func (r *ReminderRepository) GetSentRemindersForDueDate(taskID, userID uint, dueDate time.Time) ([]models.TaskReminderLog, error) {
|
||||||
|
dueDateOnly := time.Date(dueDate.Year(), dueDate.Month(), dueDate.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
var logs []models.TaskReminderLog
|
||||||
|
err := r.db.Where("task_id = ? AND user_id = ? AND due_date = ?",
|
||||||
|
taskID, userID, dueDateOnly).
|
||||||
|
Order("sent_at DESC").
|
||||||
|
Find(&logs).Error
|
||||||
|
return logs, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupOldLogs removes reminder logs older than the specified number of days.
|
||||||
|
// This helps keep the table from growing indefinitely.
|
||||||
|
func (r *ReminderRepository) CleanupOldLogs(daysOld int) (int64, error) {
|
||||||
|
cutoff := time.Now().UTC().AddDate(0, 0, -daysOld)
|
||||||
|
|
||||||
|
result := r.db.Where("sent_at < ?", cutoff).
|
||||||
|
Delete(&models.TaskReminderLog{})
|
||||||
|
|
||||||
|
return result.RowsAffected, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRecentReminderStats returns statistics about recent reminders sent.
|
||||||
|
// Useful for admin/monitoring purposes.
|
||||||
|
func (r *ReminderRepository) GetRecentReminderStats(sinceHours int) (map[string]int64, error) {
|
||||||
|
since := time.Now().UTC().Add(-time.Duration(sinceHours) * time.Hour)
|
||||||
|
|
||||||
|
stats := make(map[string]int64)
|
||||||
|
|
||||||
|
// Count by stage
|
||||||
|
rows, err := r.db.Model(&models.TaskReminderLog{}).
|
||||||
|
Select("reminder_stage, COUNT(*) as count").
|
||||||
|
Where("sent_at >= ?", since).
|
||||||
|
Group("reminder_stage").
|
||||||
|
Rows()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var stage string
|
||||||
|
var count int64
|
||||||
|
if err := rows.Scan(&stage, &count); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stats[stage] = count
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
@@ -50,6 +50,7 @@ type TaskFilterOptions struct {
|
|||||||
PreloadAssignedTo bool
|
PreloadAssignedTo bool
|
||||||
PreloadResidence bool
|
PreloadResidence bool
|
||||||
PreloadCompletions bool // Minimal: just id, task_id, completed_at
|
PreloadCompletions bool // Minimal: just id, task_id, completed_at
|
||||||
|
PreloadFrequency bool // For smart notifications
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyFilterOptions applies the filter options to a query.
|
// applyFilterOptions applies the filter options to a query.
|
||||||
@@ -88,6 +89,9 @@ func (r *TaskRepository) applyFilterOptions(query *gorm.DB, opts TaskFilterOptio
|
|||||||
return db.Select("id", "task_id", "completed_at")
|
return db.Select("id", "task_id", "completed_at")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if opts.PreloadFrequency {
|
||||||
|
query = query.Preload("Frequency")
|
||||||
|
}
|
||||||
|
|
||||||
return query
|
return query
|
||||||
}
|
}
|
||||||
@@ -209,6 +213,32 @@ func (r *TaskRepository) GetCancelledTasks(opts TaskFilterOptions) ([]models.Tas
|
|||||||
return tasks, err
|
return tasks, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetActiveTasksForUsers returns all active, non-completed tasks for the specified users.
|
||||||
|
// This is used by the smart notification system to evaluate all tasks for potential reminders.
|
||||||
|
// It includes tasks that are overdue, due soon, or upcoming - the caller determines
|
||||||
|
// which reminders to send based on the task's frequency and due date.
|
||||||
|
func (r *TaskRepository) GetActiveTasksForUsers(now time.Time, opts TaskFilterOptions) ([]models.Task, error) {
|
||||||
|
var tasks []models.Task
|
||||||
|
|
||||||
|
// Get all active, non-completed tasks
|
||||||
|
query := r.db.Model(&models.Task{}).
|
||||||
|
Scopes(task.ScopeActive, task.ScopeNotCompleted)
|
||||||
|
|
||||||
|
// Include in-progress tasks if specified
|
||||||
|
if !opts.IncludeInProgress {
|
||||||
|
query = query.Scopes(task.ScopeNotInProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply filters and preloads
|
||||||
|
query = r.applyFilterOptions(query, opts)
|
||||||
|
|
||||||
|
// Order by due date for consistent processing
|
||||||
|
query = query.Order("COALESCE(next_due_date, due_date) ASC NULLS LAST")
|
||||||
|
|
||||||
|
err := query.Find(&tasks).Error
|
||||||
|
return tasks, err
|
||||||
|
}
|
||||||
|
|
||||||
// === Task CRUD ===
|
// === Task CRUD ===
|
||||||
|
|
||||||
// FindByID finds a task by ID with preloaded relations
|
// FindByID finds a task by ID with preloaded relations
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"github.com/treytartt/casera-api/internal/config"
|
"github.com/treytartt/casera-api/internal/config"
|
||||||
"github.com/treytartt/casera-api/internal/models"
|
"github.com/treytartt/casera-api/internal/models"
|
||||||
|
"github.com/treytartt/casera-api/internal/notifications"
|
||||||
"github.com/treytartt/casera-api/internal/push"
|
"github.com/treytartt/casera-api/internal/push"
|
||||||
"github.com/treytartt/casera-api/internal/repositories"
|
"github.com/treytartt/casera-api/internal/repositories"
|
||||||
"github.com/treytartt/casera-api/internal/services"
|
"github.com/treytartt/casera-api/internal/services"
|
||||||
@@ -19,18 +20,21 @@ import (
|
|||||||
|
|
||||||
// Task types
|
// Task types
|
||||||
const (
|
const (
|
||||||
TypeTaskReminder = "notification:task_reminder"
|
TypeTaskReminder = "notification:task_reminder"
|
||||||
TypeOverdueReminder = "notification:overdue_reminder"
|
TypeOverdueReminder = "notification:overdue_reminder"
|
||||||
TypeDailyDigest = "notification:daily_digest"
|
TypeSmartReminder = "notification:smart_reminder" // Frequency-aware reminders
|
||||||
TypeSendEmail = "email:send"
|
TypeDailyDigest = "notification:daily_digest"
|
||||||
TypeSendPush = "push:send"
|
TypeSendEmail = "email:send"
|
||||||
TypeOnboardingEmails = "email:onboarding"
|
TypeSendPush = "push:send"
|
||||||
|
TypeOnboardingEmails = "email:onboarding"
|
||||||
|
TypeReminderLogCleanup = "maintenance:reminder_log_cleanup"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handler handles background job processing
|
// Handler handles background job processing
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
taskRepo *repositories.TaskRepository
|
taskRepo *repositories.TaskRepository
|
||||||
|
reminderRepo *repositories.ReminderRepository
|
||||||
pushClient *push.Client
|
pushClient *push.Client
|
||||||
emailService *services.EmailService
|
emailService *services.EmailService
|
||||||
notificationService *services.NotificationService
|
notificationService *services.NotificationService
|
||||||
@@ -49,6 +53,7 @@ func NewHandler(db *gorm.DB, pushClient *push.Client, emailService *services.Ema
|
|||||||
return &Handler{
|
return &Handler{
|
||||||
db: db,
|
db: db,
|
||||||
taskRepo: repositories.NewTaskRepository(db),
|
taskRepo: repositories.NewTaskRepository(db),
|
||||||
|
reminderRepo: repositories.NewReminderRepository(db),
|
||||||
pushClient: pushClient,
|
pushClient: pushClient,
|
||||||
emailService: emailService,
|
emailService: emailService,
|
||||||
notificationService: notificationService,
|
notificationService: notificationService,
|
||||||
@@ -553,3 +558,179 @@ func (h *Handler) HandleOnboardingEmails(ctx context.Context, task *asynq.Task)
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleSmartReminder processes frequency-aware task reminders.
|
||||||
|
// Unlike the old HandleTaskReminder and HandleOverdueReminder, this handler:
|
||||||
|
// 1. Uses frequency-based schedules (weekly = day-of only, annual = 30d, 14d, 7d, day-of)
|
||||||
|
// 2. Tracks sent reminders to prevent duplicates
|
||||||
|
// 3. Tapers off overdue reminders (daily for 3 days, then every 3 days, stop after 14)
|
||||||
|
func (h *Handler) HandleSmartReminder(ctx context.Context, task *asynq.Task) error {
|
||||||
|
log.Info().Msg("Processing smart task reminders...")
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
|
currentHour := now.Hour()
|
||||||
|
|
||||||
|
// Use the task_due_soon hour setting for smart reminders
|
||||||
|
systemDefaultHour := h.config.Worker.TaskReminderHour
|
||||||
|
|
||||||
|
log.Info().
|
||||||
|
Int("current_hour", currentHour).
|
||||||
|
Int("system_default_hour", systemDefaultHour).
|
||||||
|
Msg("Smart reminder check")
|
||||||
|
|
||||||
|
// Step 1: Find users who should receive notifications THIS hour
|
||||||
|
var eligibleUserIDs []uint
|
||||||
|
|
||||||
|
query := h.db.Model(&models.NotificationPreference{}).
|
||||||
|
Select("user_id").
|
||||||
|
Where("task_due_soon = true OR task_overdue = true")
|
||||||
|
|
||||||
|
if currentHour == systemDefaultHour {
|
||||||
|
query = query.Where("task_due_soon_hour IS NULL OR task_due_soon_hour = ?", currentHour)
|
||||||
|
} else {
|
||||||
|
query = query.Where("task_due_soon_hour = ?", currentHour)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := query.Pluck("user_id", &eligibleUserIDs).Error; err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to query eligible users for smart reminders")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(eligibleUserIDs) == 0 {
|
||||||
|
log.Debug().Int("hour", currentHour).Msg("No users scheduled for smart reminders this hour")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info().Int("eligible_users", len(eligibleUserIDs)).Msg("Found users eligible for smart reminders")
|
||||||
|
|
||||||
|
// Step 2: Query all active tasks for eligible users
|
||||||
|
opts := repositories.TaskFilterOptions{
|
||||||
|
UserIDs: eligibleUserIDs,
|
||||||
|
IncludeInProgress: true,
|
||||||
|
PreloadResidence: true,
|
||||||
|
PreloadCompletions: true,
|
||||||
|
PreloadFrequency: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all active, non-completed tasks
|
||||||
|
activeTasks, err := h.taskRepo.GetActiveTasksForUsers(now, opts)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to query active tasks")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info().Int("count", len(activeTasks)).Msg("Found active tasks for eligible users")
|
||||||
|
|
||||||
|
// Step 3: Process each task
|
||||||
|
var sentCount, skippedCount int
|
||||||
|
|
||||||
|
for _, t := range activeTasks {
|
||||||
|
// Determine which user to notify
|
||||||
|
var userID uint
|
||||||
|
if t.AssignedToID != nil {
|
||||||
|
userID = *t.AssignedToID
|
||||||
|
} else if t.Residence.ID != 0 {
|
||||||
|
userID = t.Residence.OwnerID
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is in eligible list
|
||||||
|
eligible := false
|
||||||
|
for _, eligibleID := range eligibleUserIDs {
|
||||||
|
if userID == eligibleID {
|
||||||
|
eligible = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !eligible {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the effective due date (NextDueDate takes precedence for recurring tasks)
|
||||||
|
var effectiveDate time.Time
|
||||||
|
if t.NextDueDate != nil {
|
||||||
|
effectiveDate = *t.NextDueDate
|
||||||
|
} else if t.DueDate != nil {
|
||||||
|
effectiveDate = *t.DueDate
|
||||||
|
} else {
|
||||||
|
// No due date, skip
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get frequency interval days
|
||||||
|
var frequencyDays *int
|
||||||
|
if t.Frequency != nil && t.Frequency.Days != nil {
|
||||||
|
days := int(*t.Frequency.Days)
|
||||||
|
frequencyDays = &days
|
||||||
|
} else if t.CustomIntervalDays != nil {
|
||||||
|
days := int(*t.CustomIntervalDays)
|
||||||
|
frequencyDays = &days
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which reminder stage applies today
|
||||||
|
stage := notifications.GetReminderStageForToday(effectiveDate, frequencyDays, today)
|
||||||
|
if stage == "" {
|
||||||
|
continue // No reminder needed today
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert stage string to ReminderStage type
|
||||||
|
reminderStage := models.ReminderStage(stage)
|
||||||
|
|
||||||
|
// Check if already sent
|
||||||
|
alreadySent, err := h.reminderRepo.HasSentReminder(t.ID, userID, effectiveDate, reminderStage)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Uint("task_id", t.ID).Msg("Failed to check reminder log")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if alreadySent {
|
||||||
|
skippedCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine notification type based on stage
|
||||||
|
var notificationType models.NotificationType
|
||||||
|
if stage == "day_of" || (len(stage) >= 8 && stage[:8] == "reminder") {
|
||||||
|
notificationType = models.NotificationTaskDueSoon
|
||||||
|
} else {
|
||||||
|
notificationType = models.NotificationTaskOverdue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send notification
|
||||||
|
if err := h.notificationService.CreateAndSendTaskNotification(ctx, userID, notificationType, &t); err != nil {
|
||||||
|
log.Error().Err(err).Uint("user_id", userID).Uint("task_id", t.ID).Str("stage", stage).Msg("Failed to send smart reminder")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the reminder
|
||||||
|
if _, err := h.reminderRepo.LogReminder(t.ID, userID, effectiveDate, reminderStage, nil); err != nil {
|
||||||
|
log.Error().Err(err).Uint("task_id", t.ID).Str("stage", stage).Msg("Failed to log reminder")
|
||||||
|
}
|
||||||
|
|
||||||
|
sentCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info().
|
||||||
|
Int("sent", sentCount).
|
||||||
|
Int("skipped_duplicates", skippedCount).
|
||||||
|
Msg("Smart reminder notifications completed")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleReminderLogCleanup cleans up old reminder logs to prevent table bloat
|
||||||
|
func (h *Handler) HandleReminderLogCleanup(ctx context.Context, task *asynq.Task) error {
|
||||||
|
log.Info().Msg("Processing reminder log cleanup...")
|
||||||
|
|
||||||
|
// Clean up logs older than 90 days
|
||||||
|
deleted, err := h.reminderRepo.CleanupOldLogs(90)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to cleanup old reminder logs")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info().Int64("deleted", deleted).Msg("Reminder log cleanup completed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
5
migrations/010_add_task_reminder_log.down.sql
Normal file
5
migrations/010_add_task_reminder_log.down.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
-- Rollback: Smart Notification Reminder System
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_reminderlog_sent_at;
|
||||||
|
DROP INDEX IF EXISTS idx_reminderlog_task_user_date;
|
||||||
|
DROP TABLE IF EXISTS task_reminderlog;
|
||||||
24
migrations/010_add_task_reminder_log.up.sql
Normal file
24
migrations/010_add_task_reminder_log.up.sql
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
-- Smart Notification Reminder System
|
||||||
|
-- Tracks which reminders have been sent to prevent duplicates
|
||||||
|
|
||||||
|
CREATE TABLE task_reminderlog (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
task_id INTEGER NOT NULL REFERENCES task_task(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES auth_user(id) ON DELETE CASCADE,
|
||||||
|
due_date DATE NOT NULL, -- Which occurrence this is for
|
||||||
|
reminder_stage VARCHAR(20) NOT NULL, -- e.g., "reminder_30d", "reminder_7d", "day_of", "overdue_1", "overdue_4"
|
||||||
|
sent_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
notification_id INTEGER REFERENCES notifications_notification(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
-- Prevent duplicate reminders for same task/user/date/stage
|
||||||
|
UNIQUE(task_id, user_id, due_date, reminder_stage)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Index for quick lookup when checking if reminder was already sent
|
||||||
|
CREATE INDEX idx_reminderlog_task_user_date ON task_reminderlog(task_id, user_id, due_date);
|
||||||
|
|
||||||
|
-- Index for cleanup job (delete old logs)
|
||||||
|
CREATE INDEX idx_reminderlog_sent_at ON task_reminderlog(sent_at);
|
||||||
|
|
||||||
|
COMMENT ON TABLE task_reminderlog IS 'Tracks sent task reminders to prevent duplicate notifications';
|
||||||
|
COMMENT ON COLUMN task_reminderlog.reminder_stage IS 'Stage of reminder: reminder_30d, reminder_14d, reminder_7d, reminder_3d, reminder_1d, day_of, overdue_N';
|
||||||
Reference in New Issue
Block a user