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:
@@ -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{}
|
||||
|
||||
@@ -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")
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user