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:
Trey t
2025-12-19 23:03:28 -06:00
parent 7a57a902bb
commit 69206c6930
13 changed files with 1733 additions and 28 deletions

View File

@@ -11,6 +11,7 @@ import (
"github.com/treytartt/casera-api/internal/admin/dto"
"github.com/treytartt/casera-api/internal/models"
"github.com/treytartt/casera-api/internal/notifications"
"github.com/treytartt/casera-api/internal/services"
)
@@ -412,10 +413,12 @@ func (h *AdminLookupHandler) DeletePriority(c echo.Context) error {
// ========== Task Frequencies ==========
type TaskFrequencyResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
Days *int `json:"days"`
DisplayOrder int `json:"display_order"`
ID uint `json:"id"`
Name string `json:"name"`
Days *int `json:"days"`
DisplayOrder int `json:"display_order"`
NotificationSchedule string `json:"notification_schedule"`
ReminderDays []int `json:"reminder_days"`
}
type CreateUpdateFrequencyRequest struct {
@@ -432,15 +435,34 @@ func (h *AdminLookupHandler) ListFrequencies(c echo.Context) error {
responses := make([]TaskFrequencyResponse, len(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{
ID: f.ID,
Name: f.Name,
Days: f.Days,
DisplayOrder: f.DisplayOrder,
ID: f.ID,
Name: f.Name,
Days: f.Days,
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 {
@@ -464,11 +486,17 @@ func (h *AdminLookupHandler) CreateFrequency(c echo.Context) error {
// Refresh cache after creating
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{
ID: frequency.ID,
Name: frequency.Name,
Days: frequency.Days,
DisplayOrder: frequency.DisplayOrder,
ID: frequency.ID,
Name: frequency.Name,
Days: frequency.Days,
DisplayOrder: frequency.DisplayOrder,
NotificationSchedule: humanReadable,
ReminderDays: schedule,
})
}
@@ -504,11 +532,17 @@ func (h *AdminLookupHandler) UpdateFrequency(c echo.Context) error {
// Refresh cache after updating
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{
ID: frequency.ID,
Name: frequency.Name,
Days: frequency.Days,
DisplayOrder: frequency.DisplayOrder,
ID: frequency.ID,
Name: frequency.Name,
Days: frequency.Days,
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"})
}
// ========== 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
var _ = dto.PaginationParams{}

View File

@@ -274,6 +274,9 @@ func SetupRoutes(router *echo.Echo, db *gorm.DB, cfg *config.Config, deps *Depen
frequencies.DELETE("/:id", lookupHandler.DeleteFrequency)
}
// Notification Schedules (read-only, shows schedule for each frequency)
protected.GET("/lookups/notification-schedules", lookupHandler.GetNotificationSchedules)
// Residence Types
residenceTypes := protected.Group("/lookups/residence-types")
{