Add per-user notification time preferences
Allow users to customize when they receive notification reminders: - Add task_due_soon_hour, task_overdue_hour, warranty_expiring_hour fields - Store times in UTC, clients convert to/from local timezone - Worker runs hourly, queries only users scheduled for that hour - Early exit optimization when no users need notifications - Admin UI displays custom notification times 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -4,9 +4,17 @@ import { useState } from 'react';
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { ColumnDef } from '@tanstack/react-table';
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { MoreHorizontal, Trash2 } from 'lucide-react';
|
import { MoreHorizontal, Trash2, Clock } from 'lucide-react';
|
||||||
|
|
||||||
import { notificationPrefsApi, type NotificationPreference } from '@/lib/api';
|
import { notificationPrefsApi, type NotificationPreference } from '@/lib/api';
|
||||||
|
|
||||||
|
// Helper function to format UTC hour for display
|
||||||
|
function formatHour(hour: number | null): string {
|
||||||
|
if (hour === null) return '-';
|
||||||
|
const h = hour % 12 || 12;
|
||||||
|
const ampm = hour < 12 ? 'AM' : 'PM';
|
||||||
|
return `${h}:00 ${ampm}`;
|
||||||
|
}
|
||||||
import { DataTable } from '@/components/data-table';
|
import { DataTable } from '@/components/data-table';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
@@ -166,6 +174,43 @@ export default function NotificationPrefsPage() {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'notification_times',
|
||||||
|
header: () => (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Clock className="h-4 w-4" />
|
||||||
|
<span>Custom Times (UTC)</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const { task_due_soon_hour, task_overdue_hour, warranty_expiring_hour } = row.original;
|
||||||
|
const hasCustomTimes = task_due_soon_hour !== null || task_overdue_hour !== null || warranty_expiring_hour !== null;
|
||||||
|
|
||||||
|
if (!hasCustomTimes) {
|
||||||
|
return <span className="text-muted-foreground text-sm">Default</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-sm space-y-0.5">
|
||||||
|
{task_due_soon_hour !== null && (
|
||||||
|
<div className="text-xs">
|
||||||
|
<span className="text-muted-foreground">Due Soon:</span> {formatHour(task_due_soon_hour)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{task_overdue_hour !== null && (
|
||||||
|
<div className="text-xs">
|
||||||
|
<span className="text-muted-foreground">Overdue:</span> {formatHour(task_overdue_hour)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{warranty_expiring_hour !== null && (
|
||||||
|
<div className="text-xs">
|
||||||
|
<span className="text-muted-foreground">Warranty:</span> {formatHour(warranty_expiring_hour)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
|
|||||||
@@ -599,6 +599,10 @@ export interface NotificationPreference {
|
|||||||
warranty_expiring: boolean;
|
warranty_expiring: boolean;
|
||||||
// Email preferences
|
// Email preferences
|
||||||
email_task_completed: boolean;
|
email_task_completed: boolean;
|
||||||
|
// Custom notification times (UTC hour 0-23, null means use system default)
|
||||||
|
task_due_soon_hour: number | null;
|
||||||
|
task_overdue_hour: number | null;
|
||||||
|
warranty_expiring_hour: number | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,21 +104,20 @@ func main() {
|
|||||||
// Start scheduler for periodic tasks
|
// Start scheduler for periodic tasks
|
||||||
scheduler := asynq.NewScheduler(redisOpt, nil)
|
scheduler := asynq.NewScheduler(redisOpt, nil)
|
||||||
|
|
||||||
// Schedule task reminder notifications
|
// Schedule task reminder notifications (runs every hour to support per-user custom times)
|
||||||
reminderCron := formatCron(cfg.Worker.TaskReminderHour)
|
// The job handler filters users based on their preferred notification hour
|
||||||
if _, err := scheduler.Register(reminderCron, asynq.NewTask(jobs.TypeTaskReminder, nil)); err != nil {
|
if _, err := scheduler.Register("0 * * * *", asynq.NewTask(jobs.TypeTaskReminder, nil)); err != nil {
|
||||||
log.Fatal().Err(err).Msg("Failed to register task reminder job")
|
log.Fatal().Err(err).Msg("Failed to register task reminder job")
|
||||||
}
|
}
|
||||||
log.Info().Str("cron", reminderCron).Msg("Registered task reminder job")
|
log.Info().Str("cron", "0 * * * *").Int("default_hour", cfg.Worker.TaskReminderHour).Msg("Registered task reminder job (runs hourly for per-user times)")
|
||||||
|
|
||||||
// Schedule overdue reminder
|
// Schedule overdue reminder (runs every hour to support per-user custom times)
|
||||||
overdueCron := formatCron(cfg.Worker.OverdueReminderHour)
|
if _, err := scheduler.Register("0 * * * *", asynq.NewTask(jobs.TypeOverdueReminder, nil)); err != nil {
|
||||||
if _, err := scheduler.Register(overdueCron, asynq.NewTask(jobs.TypeOverdueReminder, nil)); err != nil {
|
|
||||||
log.Fatal().Err(err).Msg("Failed to register overdue reminder job")
|
log.Fatal().Err(err).Msg("Failed to register overdue reminder job")
|
||||||
}
|
}
|
||||||
log.Info().Str("cron", overdueCron).Msg("Registered overdue reminder job")
|
log.Info().Str("cron", "0 * * * *").Int("default_hour", cfg.Worker.OverdueReminderHour).Msg("Registered overdue reminder job (runs hourly for per-user times)")
|
||||||
|
|
||||||
// Schedule daily digest
|
// Schedule daily digest (runs at configured hour - no per-user customization yet)
|
||||||
dailyCron := formatCron(cfg.Worker.DailyNotifHour)
|
dailyCron := formatCron(cfg.Worker.DailyNotifHour)
|
||||||
if _, err := scheduler.Register(dailyCron, asynq.NewTask(jobs.TypeDailyDigest, nil)); err != nil {
|
if _, err := scheduler.Register(dailyCron, asynq.NewTask(jobs.TypeDailyDigest, nil)); err != nil {
|
||||||
log.Fatal().Err(err).Msg("Failed to register daily digest job")
|
log.Fatal().Err(err).Msg("Failed to register daily digest job")
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ type NotificationPrefResponse struct {
|
|||||||
// Email preferences
|
// Email preferences
|
||||||
EmailTaskCompleted bool `json:"email_task_completed"`
|
EmailTaskCompleted bool `json:"email_task_completed"`
|
||||||
|
|
||||||
|
// Custom notification times (UTC hour 0-23, null means use system default)
|
||||||
|
TaskDueSoonHour *int `json:"task_due_soon_hour"`
|
||||||
|
TaskOverdueHour *int `json:"task_overdue_hour"`
|
||||||
|
WarrantyExpiringHour *int `json:"warranty_expiring_hour"`
|
||||||
|
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -102,19 +107,22 @@ func (h *AdminNotificationPrefsHandler) List(c *gin.Context) {
|
|||||||
for i, pref := range prefs {
|
for i, pref := range prefs {
|
||||||
user := userMap[pref.UserID]
|
user := userMap[pref.UserID]
|
||||||
responses[i] = NotificationPrefResponse{
|
responses[i] = NotificationPrefResponse{
|
||||||
ID: pref.ID,
|
ID: pref.ID,
|
||||||
UserID: pref.UserID,
|
UserID: pref.UserID,
|
||||||
Username: user.Username,
|
Username: user.Username,
|
||||||
Email: user.Email,
|
Email: user.Email,
|
||||||
TaskDueSoon: pref.TaskDueSoon,
|
TaskDueSoon: pref.TaskDueSoon,
|
||||||
TaskOverdue: pref.TaskOverdue,
|
TaskOverdue: pref.TaskOverdue,
|
||||||
TaskCompleted: pref.TaskCompleted,
|
TaskCompleted: pref.TaskCompleted,
|
||||||
TaskAssigned: pref.TaskAssigned,
|
TaskAssigned: pref.TaskAssigned,
|
||||||
ResidenceShared: pref.ResidenceShared,
|
ResidenceShared: pref.ResidenceShared,
|
||||||
WarrantyExpiring: pref.WarrantyExpiring,
|
WarrantyExpiring: pref.WarrantyExpiring,
|
||||||
EmailTaskCompleted: pref.EmailTaskCompleted,
|
EmailTaskCompleted: pref.EmailTaskCompleted,
|
||||||
CreatedAt: pref.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
TaskDueSoonHour: pref.TaskDueSoonHour,
|
||||||
UpdatedAt: pref.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
TaskOverdueHour: pref.TaskOverdueHour,
|
||||||
|
WarrantyExpiringHour: pref.WarrantyExpiringHour,
|
||||||
|
CreatedAt: pref.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
UpdatedAt: pref.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,19 +151,22 @@ func (h *AdminNotificationPrefsHandler) Get(c *gin.Context) {
|
|||||||
h.db.First(&user, pref.UserID)
|
h.db.First(&user, pref.UserID)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, NotificationPrefResponse{
|
c.JSON(http.StatusOK, NotificationPrefResponse{
|
||||||
ID: pref.ID,
|
ID: pref.ID,
|
||||||
UserID: pref.UserID,
|
UserID: pref.UserID,
|
||||||
Username: user.Username,
|
Username: user.Username,
|
||||||
Email: user.Email,
|
Email: user.Email,
|
||||||
TaskDueSoon: pref.TaskDueSoon,
|
TaskDueSoon: pref.TaskDueSoon,
|
||||||
TaskOverdue: pref.TaskOverdue,
|
TaskOverdue: pref.TaskOverdue,
|
||||||
TaskCompleted: pref.TaskCompleted,
|
TaskCompleted: pref.TaskCompleted,
|
||||||
TaskAssigned: pref.TaskAssigned,
|
TaskAssigned: pref.TaskAssigned,
|
||||||
ResidenceShared: pref.ResidenceShared,
|
ResidenceShared: pref.ResidenceShared,
|
||||||
WarrantyExpiring: pref.WarrantyExpiring,
|
WarrantyExpiring: pref.WarrantyExpiring,
|
||||||
EmailTaskCompleted: pref.EmailTaskCompleted,
|
EmailTaskCompleted: pref.EmailTaskCompleted,
|
||||||
CreatedAt: pref.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
TaskDueSoonHour: pref.TaskDueSoonHour,
|
||||||
UpdatedAt: pref.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
TaskOverdueHour: pref.TaskOverdueHour,
|
||||||
|
WarrantyExpiringHour: pref.WarrantyExpiringHour,
|
||||||
|
CreatedAt: pref.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
UpdatedAt: pref.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +181,11 @@ type UpdateNotificationPrefRequest struct {
|
|||||||
|
|
||||||
// Email preferences
|
// Email preferences
|
||||||
EmailTaskCompleted *bool `json:"email_task_completed"`
|
EmailTaskCompleted *bool `json:"email_task_completed"`
|
||||||
|
|
||||||
|
// Custom notification times (UTC hour 0-23)
|
||||||
|
TaskDueSoonHour *int `json:"task_due_soon_hour"`
|
||||||
|
TaskOverdueHour *int `json:"task_overdue_hour"`
|
||||||
|
WarrantyExpiringHour *int `json:"warranty_expiring_hour"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update handles PUT /api/admin/notification-prefs/:id
|
// Update handles PUT /api/admin/notification-prefs/:id
|
||||||
@@ -219,6 +235,17 @@ func (h *AdminNotificationPrefsHandler) Update(c *gin.Context) {
|
|||||||
pref.EmailTaskCompleted = *req.EmailTaskCompleted
|
pref.EmailTaskCompleted = *req.EmailTaskCompleted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply notification time updates
|
||||||
|
if req.TaskDueSoonHour != nil {
|
||||||
|
pref.TaskDueSoonHour = req.TaskDueSoonHour
|
||||||
|
}
|
||||||
|
if req.TaskOverdueHour != nil {
|
||||||
|
pref.TaskOverdueHour = req.TaskOverdueHour
|
||||||
|
}
|
||||||
|
if req.WarrantyExpiringHour != nil {
|
||||||
|
pref.WarrantyExpiringHour = req.WarrantyExpiringHour
|
||||||
|
}
|
||||||
|
|
||||||
if err := h.db.Save(&pref).Error; err != nil {
|
if err := h.db.Save(&pref).Error; err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update notification preference"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update notification preference"})
|
||||||
return
|
return
|
||||||
@@ -228,19 +255,22 @@ func (h *AdminNotificationPrefsHandler) Update(c *gin.Context) {
|
|||||||
h.db.First(&user, pref.UserID)
|
h.db.First(&user, pref.UserID)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, NotificationPrefResponse{
|
c.JSON(http.StatusOK, NotificationPrefResponse{
|
||||||
ID: pref.ID,
|
ID: pref.ID,
|
||||||
UserID: pref.UserID,
|
UserID: pref.UserID,
|
||||||
Username: user.Username,
|
Username: user.Username,
|
||||||
Email: user.Email,
|
Email: user.Email,
|
||||||
TaskDueSoon: pref.TaskDueSoon,
|
TaskDueSoon: pref.TaskDueSoon,
|
||||||
TaskOverdue: pref.TaskOverdue,
|
TaskOverdue: pref.TaskOverdue,
|
||||||
TaskCompleted: pref.TaskCompleted,
|
TaskCompleted: pref.TaskCompleted,
|
||||||
TaskAssigned: pref.TaskAssigned,
|
TaskAssigned: pref.TaskAssigned,
|
||||||
ResidenceShared: pref.ResidenceShared,
|
ResidenceShared: pref.ResidenceShared,
|
||||||
WarrantyExpiring: pref.WarrantyExpiring,
|
WarrantyExpiring: pref.WarrantyExpiring,
|
||||||
EmailTaskCompleted: pref.EmailTaskCompleted,
|
EmailTaskCompleted: pref.EmailTaskCompleted,
|
||||||
CreatedAt: pref.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
TaskDueSoonHour: pref.TaskDueSoonHour,
|
||||||
UpdatedAt: pref.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
TaskOverdueHour: pref.TaskOverdueHour,
|
||||||
|
WarrantyExpiringHour: pref.WarrantyExpiringHour,
|
||||||
|
CreatedAt: pref.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
UpdatedAt: pref.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,18 +317,21 @@ func (h *AdminNotificationPrefsHandler) GetByUser(c *gin.Context) {
|
|||||||
h.db.First(&user, pref.UserID)
|
h.db.First(&user, pref.UserID)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, NotificationPrefResponse{
|
c.JSON(http.StatusOK, NotificationPrefResponse{
|
||||||
ID: pref.ID,
|
ID: pref.ID,
|
||||||
UserID: pref.UserID,
|
UserID: pref.UserID,
|
||||||
Username: user.Username,
|
Username: user.Username,
|
||||||
Email: user.Email,
|
Email: user.Email,
|
||||||
TaskDueSoon: pref.TaskDueSoon,
|
TaskDueSoon: pref.TaskDueSoon,
|
||||||
TaskOverdue: pref.TaskOverdue,
|
TaskOverdue: pref.TaskOverdue,
|
||||||
TaskCompleted: pref.TaskCompleted,
|
TaskCompleted: pref.TaskCompleted,
|
||||||
TaskAssigned: pref.TaskAssigned,
|
TaskAssigned: pref.TaskAssigned,
|
||||||
ResidenceShared: pref.ResidenceShared,
|
ResidenceShared: pref.ResidenceShared,
|
||||||
WarrantyExpiring: pref.WarrantyExpiring,
|
WarrantyExpiring: pref.WarrantyExpiring,
|
||||||
EmailTaskCompleted: pref.EmailTaskCompleted,
|
EmailTaskCompleted: pref.EmailTaskCompleted,
|
||||||
CreatedAt: pref.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
TaskDueSoonHour: pref.TaskDueSoonHour,
|
||||||
UpdatedAt: pref.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
TaskOverdueHour: pref.TaskOverdueHour,
|
||||||
|
WarrantyExpiringHour: pref.WarrantyExpiringHour,
|
||||||
|
CreatedAt: pref.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
UpdatedAt: pref.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ type NotificationPreference struct {
|
|||||||
|
|
||||||
// Email preferences
|
// Email preferences
|
||||||
EmailTaskCompleted bool `gorm:"column:email_task_completed;default:true" json:"email_task_completed"`
|
EmailTaskCompleted bool `gorm:"column:email_task_completed;default:true" json:"email_task_completed"`
|
||||||
|
|
||||||
|
// Custom notification times (nullable, stored as UTC hour 0-23)
|
||||||
|
// When nil, system defaults from config are used
|
||||||
|
TaskDueSoonHour *int `gorm:"column:task_due_soon_hour" json:"task_due_soon_hour"`
|
||||||
|
TaskOverdueHour *int `gorm:"column:task_overdue_hour" json:"task_overdue_hour"`
|
||||||
|
WarrantyExpiringHour *int `gorm:"column:warranty_expiring_hour" json:"warranty_expiring_hour"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName returns the table name for GORM
|
// TableName returns the table name for GORM
|
||||||
|
|||||||
@@ -195,6 +195,18 @@ func (s *NotificationService) UpdatePreferences(userID uint, req *UpdatePreferen
|
|||||||
prefs.EmailTaskCompleted = *req.EmailTaskCompleted
|
prefs.EmailTaskCompleted = *req.EmailTaskCompleted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update notification times (can be set to nil to use system default)
|
||||||
|
// Note: We update if the field is present in the request (including null values)
|
||||||
|
if req.TaskDueSoonHour != nil {
|
||||||
|
prefs.TaskDueSoonHour = req.TaskDueSoonHour
|
||||||
|
}
|
||||||
|
if req.TaskOverdueHour != nil {
|
||||||
|
prefs.TaskOverdueHour = req.TaskOverdueHour
|
||||||
|
}
|
||||||
|
if req.WarrantyExpiringHour != nil {
|
||||||
|
prefs.WarrantyExpiringHour = req.WarrantyExpiringHour
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.notificationRepo.UpdatePreferences(prefs); err != nil {
|
if err := s.notificationRepo.UpdatePreferences(prefs); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -365,18 +377,26 @@ type NotificationPreferencesResponse struct {
|
|||||||
|
|
||||||
// Email preferences
|
// Email preferences
|
||||||
EmailTaskCompleted bool `json:"email_task_completed"`
|
EmailTaskCompleted bool `json:"email_task_completed"`
|
||||||
|
|
||||||
|
// Custom notification times (UTC hour 0-23, null means use system default)
|
||||||
|
TaskDueSoonHour *int `json:"task_due_soon_hour"`
|
||||||
|
TaskOverdueHour *int `json:"task_overdue_hour"`
|
||||||
|
WarrantyExpiringHour *int `json:"warranty_expiring_hour"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewNotificationPreferencesResponse creates a NotificationPreferencesResponse
|
// NewNotificationPreferencesResponse creates a NotificationPreferencesResponse
|
||||||
func NewNotificationPreferencesResponse(p *models.NotificationPreference) *NotificationPreferencesResponse {
|
func NewNotificationPreferencesResponse(p *models.NotificationPreference) *NotificationPreferencesResponse {
|
||||||
return &NotificationPreferencesResponse{
|
return &NotificationPreferencesResponse{
|
||||||
TaskDueSoon: p.TaskDueSoon,
|
TaskDueSoon: p.TaskDueSoon,
|
||||||
TaskOverdue: p.TaskOverdue,
|
TaskOverdue: p.TaskOverdue,
|
||||||
TaskCompleted: p.TaskCompleted,
|
TaskCompleted: p.TaskCompleted,
|
||||||
TaskAssigned: p.TaskAssigned,
|
TaskAssigned: p.TaskAssigned,
|
||||||
ResidenceShared: p.ResidenceShared,
|
ResidenceShared: p.ResidenceShared,
|
||||||
WarrantyExpiring: p.WarrantyExpiring,
|
WarrantyExpiring: p.WarrantyExpiring,
|
||||||
EmailTaskCompleted: p.EmailTaskCompleted,
|
EmailTaskCompleted: p.EmailTaskCompleted,
|
||||||
|
TaskDueSoonHour: p.TaskDueSoonHour,
|
||||||
|
TaskOverdueHour: p.TaskOverdueHour,
|
||||||
|
WarrantyExpiringHour: p.WarrantyExpiringHour,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,6 +411,12 @@ type UpdatePreferencesRequest struct {
|
|||||||
|
|
||||||
// Email preferences
|
// Email preferences
|
||||||
EmailTaskCompleted *bool `json:"email_task_completed"`
|
EmailTaskCompleted *bool `json:"email_task_completed"`
|
||||||
|
|
||||||
|
// Custom notification times (UTC hour 0-23)
|
||||||
|
// Use a special wrapper to differentiate between "not sent" and "set to null"
|
||||||
|
TaskDueSoonHour *int `json:"task_due_soon_hour"`
|
||||||
|
TaskOverdueHour *int `json:"task_overdue_hour"`
|
||||||
|
WarrantyExpiringHour *int `json:"warranty_expiring_hour"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeviceResponse represents a device in API response
|
// DeviceResponse represents a device in API response
|
||||||
|
|||||||
@@ -61,17 +61,46 @@ func (h *Handler) HandleTaskReminder(ctx context.Context, task *asynq.Task) erro
|
|||||||
log.Info().Msg("Processing task reminder notifications...")
|
log.Info().Msg("Processing task reminder notifications...")
|
||||||
|
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
|
currentHour := now.Hour()
|
||||||
|
systemDefaultHour := h.config.Worker.TaskReminderHour
|
||||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
dayAfterTomorrow := today.AddDate(0, 0, 2)
|
dayAfterTomorrow := today.AddDate(0, 0, 2)
|
||||||
|
|
||||||
// Query tasks due today or tomorrow with full task data for button types
|
log.Info().Int("current_hour", currentHour).Int("system_default_hour", systemDefaultHour).Msg("Task reminder check")
|
||||||
|
|
||||||
|
// Step 1: Find users who should receive notifications THIS hour
|
||||||
|
// Users with custom hour matching current hour, OR users with no custom hour when current hour is system default
|
||||||
|
var eligibleUserIDs []uint
|
||||||
|
err := h.db.Model(&models.NotificationPreference{}).
|
||||||
|
Select("user_id").
|
||||||
|
Where("task_due_soon = true").
|
||||||
|
Where("(task_due_soon_hour = ? OR (task_due_soon_hour IS NULL AND ? = ?))",
|
||||||
|
currentHour, currentHour, systemDefaultHour).
|
||||||
|
Pluck("user_id", &eligibleUserIDs).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to query eligible users for task reminders")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Early exit if no users need notifications this hour
|
||||||
|
if len(eligibleUserIDs) == 0 {
|
||||||
|
log.Debug().Int("hour", currentHour).Msg("No users scheduled for task reminder notifications this hour")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info().Int("eligible_users", len(eligibleUserIDs)).Msg("Found users eligible for task reminders this hour")
|
||||||
|
|
||||||
|
// Step 2: Query tasks due today or tomorrow only for eligible users
|
||||||
var dueSoonTasks []models.Task
|
var dueSoonTasks []models.Task
|
||||||
err := h.db.Preload("Status").Preload("Completions").Preload("Residence").
|
err = h.db.Preload("Status").Preload("Completions").Preload("Residence").
|
||||||
Where("(due_date >= ? AND due_date < ?) OR (next_due_date >= ? AND next_due_date < ?)",
|
Where("(due_date >= ? AND due_date < ?) OR (next_due_date >= ? AND next_due_date < ?)",
|
||||||
today, dayAfterTomorrow, today, dayAfterTomorrow).
|
today, dayAfterTomorrow, today, dayAfterTomorrow).
|
||||||
Where("is_cancelled = false").
|
Where("is_cancelled = false").
|
||||||
Where("is_archived = false").
|
Where("is_archived = false").
|
||||||
Where("NOT EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id AND tc.completed_at >= task_task.due_date)").
|
Where("NOT EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id AND tc.completed_at >= task_task.due_date)").
|
||||||
|
Where("(assigned_to_id IN ? OR residence_id IN (SELECT id FROM residence_residence WHERE owner_id IN ?))",
|
||||||
|
eligibleUserIDs, eligibleUserIDs).
|
||||||
Find(&dueSoonTasks).Error
|
Find(&dueSoonTasks).Error
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -79,7 +108,7 @@ func (h *Handler) HandleTaskReminder(ctx context.Context, task *asynq.Task) erro
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info().Int("count", len(dueSoonTasks)).Msg("Found tasks due today/tomorrow")
|
log.Info().Int("count", len(dueSoonTasks)).Msg("Found tasks due today/tomorrow for eligible users")
|
||||||
|
|
||||||
// Group tasks by user (assigned_to or residence owner)
|
// Group tasks by user (assigned_to or residence owner)
|
||||||
userTasks := make(map[uint][]models.Task)
|
userTasks := make(map[uint][]models.Task)
|
||||||
@@ -92,25 +121,17 @@ func (h *Handler) HandleTaskReminder(ctx context.Context, task *asynq.Task) erro
|
|||||||
} else {
|
} else {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
userTasks[userID] = append(userTasks[userID], t)
|
// Only include if user is in eligible list
|
||||||
|
for _, eligibleID := range eligibleUserIDs {
|
||||||
|
if userID == eligibleID {
|
||||||
|
userTasks[userID] = append(userTasks[userID], t)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send actionable notifications to each user
|
// Step 3: Send notifications (no need to check preferences again - already filtered)
|
||||||
for userID, taskList := range userTasks {
|
for userID, taskList := range userTasks {
|
||||||
// Check user notification preferences
|
|
||||||
var prefs models.NotificationPreference
|
|
||||||
err := h.db.Where("user_id = ?", userID).First(&prefs).Error
|
|
||||||
if err != nil && err != gorm.ErrRecordNotFound {
|
|
||||||
log.Error().Err(err).Uint("user_id", userID).Msg("Failed to get notification preferences")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip if user has disabled task due soon notifications
|
|
||||||
if err == nil && !prefs.TaskDueSoon {
|
|
||||||
log.Debug().Uint("user_id", userID).Msg("User has disabled task due soon notifications")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send individual actionable notification for each task (up to 5)
|
// Send individual actionable notification for each task (up to 5)
|
||||||
maxNotifications := 5
|
maxNotifications := 5
|
||||||
if len(taskList) < maxNotifications {
|
if len(taskList) < maxNotifications {
|
||||||
@@ -136,7 +157,7 @@ func (h *Handler) HandleTaskReminder(ctx context.Context, task *asynq.Task) erro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info().Msg("Task reminder notifications completed")
|
log.Info().Int("users_notified", len(userTasks)).Msg("Task reminder notifications completed")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,16 +166,44 @@ func (h *Handler) HandleOverdueReminder(ctx context.Context, task *asynq.Task) e
|
|||||||
log.Info().Msg("Processing overdue task notifications...")
|
log.Info().Msg("Processing overdue task notifications...")
|
||||||
|
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
|
currentHour := now.Hour()
|
||||||
|
systemDefaultHour := h.config.Worker.OverdueReminderHour
|
||||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
// Query overdue tasks with full task data for button types
|
log.Info().Int("current_hour", currentHour).Int("system_default_hour", systemDefaultHour).Msg("Overdue reminder check")
|
||||||
|
|
||||||
|
// Step 1: Find users who should receive notifications THIS hour
|
||||||
|
// Users with custom hour matching current hour, OR users with no custom hour when current hour is system default
|
||||||
|
var eligibleUserIDs []uint
|
||||||
|
err := h.db.Model(&models.NotificationPreference{}).
|
||||||
|
Select("user_id").
|
||||||
|
Where("task_overdue = true").
|
||||||
|
Where("(task_overdue_hour = ? OR (task_overdue_hour IS NULL AND ? = ?))",
|
||||||
|
currentHour, currentHour, systemDefaultHour).
|
||||||
|
Pluck("user_id", &eligibleUserIDs).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to query eligible users for overdue reminders")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Early exit if no users need notifications this hour
|
||||||
|
if len(eligibleUserIDs) == 0 {
|
||||||
|
log.Debug().Int("hour", currentHour).Msg("No users scheduled for overdue notifications this hour")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info().Int("eligible_users", len(eligibleUserIDs)).Msg("Found users eligible for overdue reminders this hour")
|
||||||
|
|
||||||
|
// Step 2: Query overdue tasks only for eligible users
|
||||||
var overdueTasks []models.Task
|
var overdueTasks []models.Task
|
||||||
err := h.db.Preload("Status").Preload("Completions").Preload("Residence").
|
err = h.db.Preload("Status").Preload("Completions").Preload("Residence").
|
||||||
Joins("JOIN residence_residence r ON task_task.residence_id = r.id").
|
Where("due_date < ? OR next_due_date < ?", today, today).
|
||||||
Where("task_task.due_date < ? OR task_task.next_due_date < ?", today, today).
|
Where("is_cancelled = false").
|
||||||
Where("task_task.is_cancelled = false").
|
Where("is_archived = false").
|
||||||
Where("task_task.is_archived = false").
|
|
||||||
Where("NOT EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id AND tc.completed_at >= task_task.due_date)").
|
Where("NOT EXISTS (SELECT 1 FROM task_taskcompletion tc WHERE tc.task_id = task_task.id AND tc.completed_at >= task_task.due_date)").
|
||||||
|
Where("(assigned_to_id IN ? OR residence_id IN (SELECT id FROM residence_residence WHERE owner_id IN ?))",
|
||||||
|
eligibleUserIDs, eligibleUserIDs).
|
||||||
Find(&overdueTasks).Error
|
Find(&overdueTasks).Error
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -162,7 +211,7 @@ func (h *Handler) HandleOverdueReminder(ctx context.Context, task *asynq.Task) e
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info().Int("count", len(overdueTasks)).Msg("Found overdue tasks")
|
log.Info().Int("count", len(overdueTasks)).Msg("Found overdue tasks for eligible users")
|
||||||
|
|
||||||
// Group tasks by user (assigned_to or residence owner)
|
// Group tasks by user (assigned_to or residence owner)
|
||||||
userTasks := make(map[uint][]models.Task)
|
userTasks := make(map[uint][]models.Task)
|
||||||
@@ -175,25 +224,17 @@ func (h *Handler) HandleOverdueReminder(ctx context.Context, task *asynq.Task) e
|
|||||||
} else {
|
} else {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
userTasks[userID] = append(userTasks[userID], t)
|
// Only include if user is in eligible list
|
||||||
|
for _, eligibleID := range eligibleUserIDs {
|
||||||
|
if userID == eligibleID {
|
||||||
|
userTasks[userID] = append(userTasks[userID], t)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send actionable notifications to each user
|
// Step 3: Send notifications (no need to check preferences again - already filtered)
|
||||||
for userID, taskList := range userTasks {
|
for userID, taskList := range userTasks {
|
||||||
// Check user notification preferences
|
|
||||||
var prefs models.NotificationPreference
|
|
||||||
err := h.db.Where("user_id = ?", userID).First(&prefs).Error
|
|
||||||
if err != nil && err != gorm.ErrRecordNotFound {
|
|
||||||
log.Error().Err(err).Uint("user_id", userID).Msg("Failed to get notification preferences")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip if user has disabled overdue notifications
|
|
||||||
if err == nil && !prefs.TaskOverdue {
|
|
||||||
log.Debug().Uint("user_id", userID).Msg("User has disabled overdue task notifications")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send individual actionable notification for each task (up to 5)
|
// Send individual actionable notification for each task (up to 5)
|
||||||
maxNotifications := 5
|
maxNotifications := 5
|
||||||
if len(taskList) < maxNotifications {
|
if len(taskList) < maxNotifications {
|
||||||
@@ -219,7 +260,7 @@ func (h *Handler) HandleOverdueReminder(ctx context.Context, task *asynq.Task) e
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info().Msg("Overdue task notifications completed")
|
log.Info().Int("users_notified", len(userTasks)).Msg("Overdue task notifications completed")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user