Add Next.js admin panel and implement background worker jobs
- Add full Next.js admin panel with: - User, residence, task, contractor, document management - Notifications and notification preferences management - Subscriptions and auth token management - Dashboard with stats - Lookup tables management (categories, priorities, statuses, etc.) - Admin user management - Implement background worker job handlers: - HandleTaskReminder: sends push notifications for tasks due within 24h - HandleOverdueReminder: sends push notifications for overdue tasks - HandleDailyDigest: sends daily summary of pending tasks - HandleSendEmail: processes email sending jobs - HandleSendPush: processes push notification jobs - Make worker job schedules configurable via environment variables: - TASK_REMINDER_HOUR, TASK_REMINDER_MINUTE (default: 20:00 UTC) - OVERDUE_REMINDER_HOUR (default: 09:00 UTC) - DAILY_DIGEST_HOUR (default: 11:00 UTC) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -3,48 +3,176 @@ package jobs
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/rs/zerolog/log"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/treytartt/mycrib-api/internal/config"
|
||||
"github.com/treytartt/mycrib-api/internal/models"
|
||||
"github.com/treytartt/mycrib-api/internal/push"
|
||||
"github.com/treytartt/mycrib-api/internal/services"
|
||||
)
|
||||
|
||||
// Task types
|
||||
const (
|
||||
TypeTaskReminder = "notification:task_reminder"
|
||||
TypeTaskReminder = "notification:task_reminder"
|
||||
TypeOverdueReminder = "notification:overdue_reminder"
|
||||
TypeDailyDigest = "notification:daily_digest"
|
||||
TypeSendEmail = "email:send"
|
||||
TypeSendPush = "push:send"
|
||||
TypeDailyDigest = "notification:daily_digest"
|
||||
TypeSendEmail = "email:send"
|
||||
TypeSendPush = "push:send"
|
||||
)
|
||||
|
||||
// Handler handles background job processing
|
||||
type Handler struct {
|
||||
db *gorm.DB
|
||||
pushClient *push.GorushClient
|
||||
config *config.Config
|
||||
db *gorm.DB
|
||||
pushClient *push.GorushClient
|
||||
emailService *services.EmailService
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
// NewHandler creates a new job handler
|
||||
func NewHandler(db *gorm.DB, pushClient *push.GorushClient, cfg *config.Config) *Handler {
|
||||
func NewHandler(db *gorm.DB, pushClient *push.GorushClient, emailService *services.EmailService, cfg *config.Config) *Handler {
|
||||
return &Handler{
|
||||
db: db,
|
||||
pushClient: pushClient,
|
||||
config: cfg,
|
||||
db: db,
|
||||
pushClient: pushClient,
|
||||
emailService: emailService,
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTaskReminder processes task reminder notifications
|
||||
// TaskReminderData represents a task due soon for reminder notifications
|
||||
type TaskReminderData struct {
|
||||
TaskID uint
|
||||
TaskTitle string
|
||||
DueDate time.Time
|
||||
UserID uint
|
||||
UserEmail string
|
||||
UserName string
|
||||
ResidenceName string
|
||||
}
|
||||
|
||||
// HandleTaskReminder processes task reminder notifications for tasks due today or tomorrow
|
||||
func (h *Handler) HandleTaskReminder(ctx context.Context, task *asynq.Task) error {
|
||||
log.Info().Msg("Processing task reminder notifications...")
|
||||
|
||||
// TODO: Implement task reminder logic
|
||||
// 1. Query tasks due today or tomorrow
|
||||
// 2. Get user device tokens
|
||||
// 3. Send push notifications via Gorush
|
||||
now := time.Now().UTC()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
tomorrow := today.AddDate(0, 0, 1)
|
||||
dayAfterTomorrow := today.AddDate(0, 0, 2)
|
||||
|
||||
// Query tasks due today or tomorrow that are not completed, cancelled, or archived
|
||||
var tasks []struct {
|
||||
TaskID uint
|
||||
TaskTitle string
|
||||
DueDate time.Time
|
||||
UserID uint
|
||||
ResidenceName string
|
||||
}
|
||||
|
||||
err := h.db.Raw(`
|
||||
SELECT DISTINCT
|
||||
t.id as task_id,
|
||||
t.title as task_title,
|
||||
t.due_date,
|
||||
COALESCE(t.assigned_to_id, r.owner_id) as user_id,
|
||||
r.name as residence_name
|
||||
FROM task_task t
|
||||
JOIN residence_residence r ON t.residence_id = r.id
|
||||
LEFT JOIN task_taskcompletion tc ON t.id = tc.task_id
|
||||
WHERE t.due_date >= ? AND t.due_date < ?
|
||||
AND t.is_cancelled = false
|
||||
AND t.is_archived = false
|
||||
AND tc.id IS NULL
|
||||
`, today, dayAfterTomorrow).Scan(&tasks).Error
|
||||
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to query tasks due soon")
|
||||
return err
|
||||
}
|
||||
|
||||
log.Info().Int("count", len(tasks)).Msg("Found tasks due today/tomorrow")
|
||||
|
||||
// Group by user and check preferences
|
||||
userTasks := make(map[uint][]struct {
|
||||
TaskID uint
|
||||
TaskTitle string
|
||||
DueDate time.Time
|
||||
ResidenceName string
|
||||
})
|
||||
|
||||
for _, t := range tasks {
|
||||
userTasks[t.UserID] = append(userTasks[t.UserID], struct {
|
||||
TaskID uint
|
||||
TaskTitle string
|
||||
DueDate time.Time
|
||||
ResidenceName string
|
||||
}{t.TaskID, t.TaskTitle, t.DueDate, t.ResidenceName})
|
||||
}
|
||||
|
||||
// Send notifications to each user
|
||||
for userID, userTaskList := 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
|
||||
}
|
||||
|
||||
// Build notification message
|
||||
var title, body string
|
||||
if len(userTaskList) == 1 {
|
||||
t := userTaskList[0]
|
||||
dueText := "today"
|
||||
if t.DueDate.After(tomorrow) {
|
||||
dueText = "tomorrow"
|
||||
}
|
||||
title = fmt.Sprintf("Task Due %s", dueText)
|
||||
body = fmt.Sprintf("%s at %s is due %s", t.TaskTitle, t.ResidenceName, dueText)
|
||||
} else {
|
||||
todayCount := 0
|
||||
tomorrowCount := 0
|
||||
for _, t := range userTaskList {
|
||||
if t.DueDate.Before(tomorrow) {
|
||||
todayCount++
|
||||
} else {
|
||||
tomorrowCount++
|
||||
}
|
||||
}
|
||||
title = "Tasks Due Soon"
|
||||
body = fmt.Sprintf("You have %d task(s) due today and %d task(s) due tomorrow", todayCount, tomorrowCount)
|
||||
}
|
||||
|
||||
// Send push notification
|
||||
if err := h.sendPushToUser(ctx, userID, title, body, map[string]string{
|
||||
"type": "task_reminder",
|
||||
}); err != nil {
|
||||
log.Error().Err(err).Uint("user_id", userID).Msg("Failed to send task reminder push")
|
||||
}
|
||||
|
||||
// Create in-app notification record
|
||||
for _, t := range userTaskList {
|
||||
notification := &models.Notification{
|
||||
UserID: userID,
|
||||
NotificationType: models.NotificationTaskDueSoon,
|
||||
Title: title,
|
||||
Body: body,
|
||||
TaskID: &t.TaskID,
|
||||
}
|
||||
if err := h.db.Create(notification).Error; err != nil {
|
||||
log.Error().Err(err).Msg("Failed to create notification record")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Info().Msg("Task reminder notifications completed")
|
||||
return nil
|
||||
@@ -54,23 +182,184 @@ func (h *Handler) HandleTaskReminder(ctx context.Context, task *asynq.Task) erro
|
||||
func (h *Handler) HandleOverdueReminder(ctx context.Context, task *asynq.Task) error {
|
||||
log.Info().Msg("Processing overdue task notifications...")
|
||||
|
||||
// TODO: Implement overdue reminder logic
|
||||
// 1. Query overdue tasks
|
||||
// 2. Get user device tokens
|
||||
// 3. Send push notifications via Gorush
|
||||
now := time.Now().UTC()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
|
||||
// Query overdue tasks that are not completed, cancelled, or archived
|
||||
var tasks []struct {
|
||||
TaskID uint
|
||||
TaskTitle string
|
||||
DueDate time.Time
|
||||
DaysOverdue int
|
||||
UserID uint
|
||||
ResidenceName string
|
||||
}
|
||||
|
||||
err := h.db.Raw(`
|
||||
SELECT DISTINCT
|
||||
t.id as task_id,
|
||||
t.title as task_title,
|
||||
t.due_date,
|
||||
EXTRACT(DAY FROM ? - t.due_date)::int as days_overdue,
|
||||
COALESCE(t.assigned_to_id, r.owner_id) as user_id,
|
||||
r.name as residence_name
|
||||
FROM task_task t
|
||||
JOIN residence_residence r ON t.residence_id = r.id
|
||||
LEFT JOIN task_taskcompletion tc ON t.id = tc.task_id
|
||||
WHERE t.due_date < ?
|
||||
AND t.is_cancelled = false
|
||||
AND t.is_archived = false
|
||||
AND tc.id IS NULL
|
||||
ORDER BY t.due_date ASC
|
||||
`, today, today).Scan(&tasks).Error
|
||||
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to query overdue tasks")
|
||||
return err
|
||||
}
|
||||
|
||||
log.Info().Int("count", len(tasks)).Msg("Found overdue tasks")
|
||||
|
||||
// Group by user
|
||||
userTasks := make(map[uint][]struct {
|
||||
TaskID uint
|
||||
TaskTitle string
|
||||
DaysOverdue int
|
||||
ResidenceName string
|
||||
})
|
||||
|
||||
for _, t := range tasks {
|
||||
userTasks[t.UserID] = append(userTasks[t.UserID], struct {
|
||||
TaskID uint
|
||||
TaskTitle string
|
||||
DaysOverdue int
|
||||
ResidenceName string
|
||||
}{t.TaskID, t.TaskTitle, t.DaysOverdue, t.ResidenceName})
|
||||
}
|
||||
|
||||
// Send notifications to each user
|
||||
for userID, userTaskList := 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
|
||||
}
|
||||
|
||||
// Build notification message
|
||||
var title, body string
|
||||
if len(userTaskList) == 1 {
|
||||
t := userTaskList[0]
|
||||
title = "Overdue Task"
|
||||
if t.DaysOverdue == 1 {
|
||||
body = fmt.Sprintf("%s at %s is 1 day overdue", t.TaskTitle, t.ResidenceName)
|
||||
} else {
|
||||
body = fmt.Sprintf("%s at %s is %d days overdue", t.TaskTitle, t.ResidenceName, t.DaysOverdue)
|
||||
}
|
||||
} else {
|
||||
title = "Overdue Tasks"
|
||||
body = fmt.Sprintf("You have %d overdue tasks that need attention", len(userTaskList))
|
||||
}
|
||||
|
||||
// Send push notification
|
||||
if err := h.sendPushToUser(ctx, userID, title, body, map[string]string{
|
||||
"type": "overdue_reminder",
|
||||
}); err != nil {
|
||||
log.Error().Err(err).Uint("user_id", userID).Msg("Failed to send overdue reminder push")
|
||||
}
|
||||
|
||||
// Create in-app notification record
|
||||
notification := &models.Notification{
|
||||
UserID: userID,
|
||||
NotificationType: models.NotificationTaskOverdue,
|
||||
Title: title,
|
||||
Body: body,
|
||||
}
|
||||
if err := h.db.Create(notification).Error; err != nil {
|
||||
log.Error().Err(err).Msg("Failed to create notification record")
|
||||
}
|
||||
}
|
||||
|
||||
log.Info().Msg("Overdue task notifications completed")
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleDailyDigest processes daily digest notifications
|
||||
// HandleDailyDigest processes daily digest notifications with task statistics
|
||||
func (h *Handler) HandleDailyDigest(ctx context.Context, task *asynq.Task) error {
|
||||
log.Info().Msg("Processing daily digest notifications...")
|
||||
|
||||
// TODO: Implement daily digest logic
|
||||
// 1. Aggregate task statistics per user
|
||||
// 2. Get user device tokens
|
||||
// 3. Send push notifications via Gorush
|
||||
now := time.Now().UTC()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
nextWeek := today.AddDate(0, 0, 7)
|
||||
|
||||
// Get all users with their task statistics
|
||||
var userStats []struct {
|
||||
UserID uint
|
||||
TotalTasks int
|
||||
OverdueTasks int
|
||||
DueThisWeek int
|
||||
}
|
||||
|
||||
err := h.db.Raw(`
|
||||
SELECT
|
||||
u.id as user_id,
|
||||
COUNT(DISTINCT t.id) as total_tasks,
|
||||
COUNT(DISTINCT CASE WHEN t.due_date < ? AND tc.id IS NULL THEN t.id END) as overdue_tasks,
|
||||
COUNT(DISTINCT CASE WHEN t.due_date >= ? AND t.due_date < ? AND tc.id IS NULL THEN t.id END) as due_this_week
|
||||
FROM user_user u
|
||||
JOIN residence_residence r ON r.owner_id = u.id OR r.id IN (
|
||||
SELECT residence_id FROM residence_residence_users WHERE user_id = u.id
|
||||
)
|
||||
JOIN task_task t ON t.residence_id = r.id
|
||||
AND t.is_cancelled = false
|
||||
AND t.is_archived = false
|
||||
LEFT JOIN task_taskcompletion tc ON t.id = tc.task_id
|
||||
WHERE u.is_active = true
|
||||
GROUP BY u.id
|
||||
HAVING COUNT(DISTINCT t.id) > 0
|
||||
`, today, today, nextWeek).Scan(&userStats).Error
|
||||
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to query user task statistics")
|
||||
return err
|
||||
}
|
||||
|
||||
log.Info().Int("users", len(userStats)).Msg("Processing daily digest for users")
|
||||
|
||||
for _, stats := range userStats {
|
||||
// Skip users with no actionable items
|
||||
if stats.OverdueTasks == 0 && stats.DueThisWeek == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Build notification message
|
||||
title := "Daily Task Summary"
|
||||
var body string
|
||||
|
||||
if stats.OverdueTasks > 0 && stats.DueThisWeek > 0 {
|
||||
body = fmt.Sprintf("You have %d overdue task(s) and %d task(s) due this week", stats.OverdueTasks, stats.DueThisWeek)
|
||||
} else if stats.OverdueTasks > 0 {
|
||||
body = fmt.Sprintf("You have %d overdue task(s) that need attention", stats.OverdueTasks)
|
||||
} else {
|
||||
body = fmt.Sprintf("You have %d task(s) due this week", stats.DueThisWeek)
|
||||
}
|
||||
|
||||
// Send push notification
|
||||
if err := h.sendPushToUser(ctx, stats.UserID, title, body, map[string]string{
|
||||
"type": "daily_digest",
|
||||
"overdue": fmt.Sprintf("%d", stats.OverdueTasks),
|
||||
"due_this_week": fmt.Sprintf("%d", stats.DueThisWeek),
|
||||
}); err != nil {
|
||||
log.Error().Err(err).Uint("user_id", stats.UserID).Msg("Failed to send daily digest push")
|
||||
}
|
||||
}
|
||||
|
||||
log.Info().Msg("Daily digest notifications completed")
|
||||
return nil
|
||||
@@ -78,17 +367,17 @@ func (h *Handler) HandleDailyDigest(ctx context.Context, task *asynq.Task) error
|
||||
|
||||
// EmailPayload represents the payload for email tasks
|
||||
type EmailPayload struct {
|
||||
To string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
Body string `json:"body"`
|
||||
IsHTML bool `json:"is_html"`
|
||||
To string `json:"to"`
|
||||
Subject string `json:"subject"`
|
||||
HTMLBody string `json:"html_body"`
|
||||
TextBody string `json:"text_body"`
|
||||
}
|
||||
|
||||
// HandleSendEmail processes email sending tasks
|
||||
func (h *Handler) HandleSendEmail(ctx context.Context, task *asynq.Task) error {
|
||||
var payload EmailPayload
|
||||
if err := json.Unmarshal(task.Payload(), &payload); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("failed to unmarshal payload: %w", err)
|
||||
}
|
||||
|
||||
log.Info().
|
||||
@@ -96,7 +385,16 @@ func (h *Handler) HandleSendEmail(ctx context.Context, task *asynq.Task) error {
|
||||
Str("subject", payload.Subject).
|
||||
Msg("Sending email...")
|
||||
|
||||
// TODO: Implement email sending via EmailService
|
||||
if h.emailService == nil {
|
||||
log.Warn().Msg("Email service not configured, skipping email")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use the email service to send the email
|
||||
if err := h.emailService.SendEmail(payload.To, payload.Subject, payload.HTMLBody, payload.TextBody); err != nil {
|
||||
log.Error().Err(err).Str("to", payload.To).Msg("Failed to send email")
|
||||
return err
|
||||
}
|
||||
|
||||
log.Info().Str("to", payload.To).Msg("Email sent successfully")
|
||||
return nil
|
||||
@@ -114,7 +412,7 @@ type PushPayload struct {
|
||||
func (h *Handler) HandleSendPush(ctx context.Context, task *asynq.Task) error {
|
||||
var payload PushPayload
|
||||
if err := json.Unmarshal(task.Payload(), &payload); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("failed to unmarshal payload: %w", err)
|
||||
}
|
||||
|
||||
log.Info().
|
||||
@@ -122,24 +420,62 @@ func (h *Handler) HandleSendPush(ctx context.Context, task *asynq.Task) error {
|
||||
Str("title", payload.Title).
|
||||
Msg("Sending push notification...")
|
||||
|
||||
if h.pushClient == nil {
|
||||
log.Warn().Msg("Push client not configured, skipping notification")
|
||||
return nil
|
||||
if err := h.sendPushToUser(ctx, payload.UserID, payload.Title, payload.Message, payload.Data); err != nil {
|
||||
log.Error().Err(err).Uint("user_id", payload.UserID).Msg("Failed to send push notification")
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO: Get user device tokens and send via Gorush
|
||||
|
||||
log.Info().Uint("user_id", payload.UserID).Msg("Push notification sent successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendPushToUser sends a push notification to all of a user's devices
|
||||
func (h *Handler) sendPushToUser(ctx context.Context, userID uint, title, message string, data map[string]string) error {
|
||||
if h.pushClient == nil {
|
||||
log.Warn().Msg("Push client not configured, skipping notification")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get iOS device tokens
|
||||
var iosTokens []string
|
||||
err := h.db.Model(&models.APNSDevice{}).
|
||||
Where("user_id = ? AND active = ?", userID, true).
|
||||
Pluck("registration_id", &iosTokens).Error
|
||||
if err != nil {
|
||||
log.Error().Err(err).Uint("user_id", userID).Msg("Failed to get iOS tokens")
|
||||
}
|
||||
|
||||
// Get Android device tokens
|
||||
var androidTokens []string
|
||||
err = h.db.Model(&models.GCMDevice{}).
|
||||
Where("user_id = ? AND active = ?", userID, true).
|
||||
Pluck("registration_id", &androidTokens).Error
|
||||
if err != nil {
|
||||
log.Error().Err(err).Uint("user_id", userID).Msg("Failed to get Android tokens")
|
||||
}
|
||||
|
||||
if len(iosTokens) == 0 && len(androidTokens) == 0 {
|
||||
log.Debug().Uint("user_id", userID).Msg("No device tokens found for user")
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Uint("user_id", userID).
|
||||
Int("ios_tokens", len(iosTokens)).
|
||||
Int("android_tokens", len(androidTokens)).
|
||||
Msg("Sending push to user devices")
|
||||
|
||||
// Send to all devices
|
||||
return h.pushClient.SendToAll(ctx, iosTokens, androidTokens, title, message, data)
|
||||
}
|
||||
|
||||
// NewSendEmailTask creates a new email sending task
|
||||
func NewSendEmailTask(to, subject, body string, isHTML bool) (*asynq.Task, error) {
|
||||
func NewSendEmailTask(to, subject, htmlBody, textBody string) (*asynq.Task, error) {
|
||||
payload, err := json.Marshal(EmailPayload{
|
||||
To: to,
|
||||
Subject: subject,
|
||||
Body: body,
|
||||
IsHTML: isHTML,
|
||||
To: to,
|
||||
Subject: subject,
|
||||
HTMLBody: htmlBody,
|
||||
TextBody: textBody,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
Reference in New Issue
Block a user