Enhance notification delivery system with scheduled processing
continuous-integration/drone/push Build is passing

- Introduced a new `NotificationWorker` to promote due scheduled notifications from pending to sent, improving notification management.
- Added `NotificationInterval` configuration to schedule the notification delivery worker, with a default value for flexibility.
- Implemented `MarkDueScheduledAsSent` method in the notification repository to update the status of notifications based on their delivery time.
- Updated the notification service to process due scheduled notifications during relevant operations, ensuring timely delivery.

This update enhances the notification system by automating the delivery of scheduled notifications, improving user engagement and operational efficiency.
This commit is contained in:
Mazyar
2026-06-22 12:53:22 +03:30
parent db14bfe270
commit d486a5e44f
5 changed files with 122 additions and 0 deletions
+23
View File
@@ -6,6 +6,7 @@ import (
"time" "time"
"tm/cmd/worker/workers" "tm/cmd/worker/workers"
"tm/internal/notice" "tm/internal/notice"
notificationDomain "tm/internal/notification"
"tm/internal/tender" "tm/internal/tender"
ai_summarizer "tm/pkg/ai_summarizer" ai_summarizer "tm/pkg/ai_summarizer"
"tm/pkg/config" "tm/pkg/config"
@@ -87,10 +88,12 @@ func InitWorker(config Config, mongoManager *mongo.ConnectionManager, appLogger
"notice_fetch_error_backoff": config.Worker.NoticeFetchErrorBackoff.String(), "notice_fetch_error_backoff": config.Worker.NoticeFetchErrorBackoff.String(),
"translation_enabled": config.Worker.TranslationEnabled, "translation_enabled": config.Worker.TranslationEnabled,
"translation_interval": config.Worker.TranslationInterval, "translation_interval": config.Worker.TranslationInterval,
"notification_interval": config.Worker.NotificationInterval,
}) })
// Initialize repositories // Initialize repositories
noticeRepo := notice.NewRepository(mongoManager, appLogger) noticeRepo := notice.NewRepository(mongoManager, appLogger)
tenderRepo := tender.NewRepository(mongoManager, appLogger) tenderRepo := tender.NewRepository(mongoManager, appLogger)
notificationRepo := notificationDomain.NewRepository(mongoManager, appLogger)
// Create a single shared cron scheduler for all recurring jobs // Create a single shared cron scheduler for all recurring jobs
scheduler := schedule.NewCronScheduler(appLogger, mongoManager, noticeRepo) scheduler := schedule.NewCronScheduler(appLogger, mongoManager, noticeRepo)
@@ -159,6 +162,26 @@ func InitWorker(config Config, mongoManager *mongo.ConnectionManager, appLogger
appLogger.Warn("AI summarizer client not available, tender translation worker is disabled", map[string]interface{}{}) appLogger.Warn("AI summarizer client not available, tender translation worker is disabled", map[string]interface{}{})
} }
notificationInterval := config.Worker.NotificationInterval
if notificationInterval == "" {
notificationInterval = "0 * * * * *"
appLogger.Warn("WORKER_NOTIFICATION_INTERVAL not set, using default schedule", map[string]interface{}{
"interval": notificationInterval,
})
}
scheduler.AddJob(schedule.Job{
Name: "Scheduled Notification Worker Job",
Func: func() {
worker := workers.NewNotificationWorker(notificationRepo, appLogger)
worker.Run()
},
Expr: notificationInterval,
})
appLogger.Info("Scheduled notification delivery worker", map[string]interface{}{
"interval": notificationInterval,
})
// Kick off one notice-processing pass without blocking startup (cron continues on schedule) // Kick off one notice-processing pass without blocking startup (cron continues on schedule)
go func() { go func() {
w := workers.NewNoticeWorker( w := workers.NewNoticeWorker(
+2
View File
@@ -33,6 +33,8 @@ type WorkerConfig struct {
// TranslationEnabled schedules the automatic batch translation cron job on the worker. // TranslationEnabled schedules the automatic batch translation cron job on the worker.
// On-demand translation (admin/public tender endpoints and AI pipeline routes on web) is unaffected. // On-demand translation (admin/public tender endpoints and AI pipeline routes on web) is unaffected.
TranslationEnabled bool `env:"WORKER_TRANSLATION_ENABLED" envDefault:"true"` TranslationEnabled bool `env:"WORKER_TRANSLATION_ENABLED" envDefault:"true"`
// NotificationInterval schedules promotion of due scheduled notifications from pending to sent.
NotificationInterval string `env:"WORKER_NOTIFICATION_INTERVAL" envDefault:"0 * * * * *"`
} }
// AISummarizerConfig holds configuration for the external AI summarizer service. // AISummarizerConfig holds configuration for the external AI summarizer service.
+40
View File
@@ -0,0 +1,40 @@
package workers
import (
"context"
"time"
"tm/internal/notification"
"tm/pkg/logger"
)
// NotificationWorker promotes due scheduled notifications from pending to sent.
type NotificationWorker struct {
Repository notification.Repository
Logger logger.Logger
}
// NewNotificationWorker creates a notification delivery worker.
func NewNotificationWorker(repository notification.Repository, logger logger.Logger) *NotificationWorker {
return &NotificationWorker{
Repository: repository,
Logger: logger,
}
}
// Run marks pending scheduled notifications as sent once their delivery time has passed.
func (w *NotificationWorker) Run() {
count, err := w.Repository.MarkDueScheduledAsSent(context.Background(), time.Now().Unix())
if err != nil {
w.Logger.Error("Scheduled notification worker failed", map[string]interface{}{
"error": err.Error(),
})
return
}
if count > 0 {
w.Logger.Info("Scheduled notification worker completed", map[string]interface{}{
"count": count,
})
}
}
+36
View File
@@ -58,6 +58,7 @@ type Repository interface {
Update(ctx context.Context, notification *Notification) error Update(ctx context.Context, notification *Notification) error
MarkAsSeen(ctx context.Context, notificationID, userID string) error MarkAsSeen(ctx context.Context, notificationID, userID string) error
MarkAllAsSeen(ctx context.Context, userID string) error MarkAllAsSeen(ctx context.Context, userID string) error
MarkDueScheduledAsSent(ctx context.Context, dueBefore int64) (int64, error)
} }
type notificationRepository struct { type notificationRepository struct {
@@ -74,6 +75,11 @@ func NewRepository(mongoManager *orm.ConnectionManager, logger logger.Logger) Re
*orm.NewIndex("status_idx", bson.D{{Key: "status", Value: 1}}), *orm.NewIndex("status_idx", bson.D{{Key: "status", Value: 1}}),
*orm.NewIndex("seen_idx", bson.D{{Key: "seen", Value: 1}}), *orm.NewIndex("seen_idx", bson.D{{Key: "seen", Value: 1}}),
*orm.NewIndex("created_at_idx", bson.D{{Key: "created_at", Value: -1}}), *orm.NewIndex("created_at_idx", bson.D{{Key: "created_at", Value: -1}}),
*orm.NewIndex("scheduled_delivery_idx", bson.D{
{Key: "status", Value: 1},
{Key: "is_scheduled", Value: 1},
{Key: "schedule_at", Value: 1},
}),
} }
err := mongoManager.CreateIndexes("notifications", indexes) err := mongoManager.CreateIndexes("notifications", indexes)
@@ -279,3 +285,33 @@ func (r *notificationRepository) MarkAllAsSeen(ctx context.Context, userID strin
return nil return nil
} }
// MarkDueScheduledAsSent updates pending scheduled notifications whose delivery time has passed.
func (r *notificationRepository) MarkDueScheduledAsSent(ctx context.Context, dueBefore int64) (int64, error) {
filter := bson.M{
"status": string(DeliveryStatusPending),
"is_scheduled": true,
"schedule_at": bson.M{
"$gt": 0,
"$lte": dueBefore,
},
}
update := bson.M{
"$set": bson.M{
"status": string(DeliveryStatusSent),
"updated_at": time.Now().Unix(),
},
}
result, err := r.collection.UpdateMany(ctx, filter, update)
if err != nil {
r.logger.Error("Failed to mark due scheduled notifications as sent", map[string]interface{}{
"error": err.Error(),
"due_before": dueBefore,
})
return 0, err
}
return result.ModifiedCount, nil
}
+21
View File
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"slices" "slices"
"strings" "strings"
"time"
"tm/internal/customer" "tm/internal/customer"
"tm/internal/user" "tm/internal/user"
@@ -186,6 +187,8 @@ func (s *notificationService) persistNotification(ctx context.Context, recipient
} }
func (s *notificationService) GetNotifications(ctx context.Context, req *SearchForm, pagination *response.Pagination) (*NotificationListResponse, error) { func (s *notificationService) GetNotifications(ctx context.Context, req *SearchForm, pagination *response.Pagination) (*NotificationListResponse, error) {
s.processDueScheduled(ctx)
search := req.ResolvedSearch() search := req.ResolvedSearch()
s.logger.Info("Getting notifications", map[string]interface{}{ s.logger.Info("Getting notifications", map[string]interface{}{
@@ -264,6 +267,8 @@ func (s *notificationService) GetNotifications(ctx context.Context, req *SearchF
} }
func (s *notificationService) GetNotification(ctx context.Context, notificationID string) (*NotificationResponse, error) { func (s *notificationService) GetNotification(ctx context.Context, notificationID string) (*NotificationResponse, error) {
s.processDueScheduled(ctx)
s.logger.Info("Getting notification", map[string]interface{}{ s.logger.Info("Getting notification", map[string]interface{}{
"notification_id": notificationID, "notification_id": notificationID,
}) })
@@ -467,3 +472,19 @@ func (s *notificationService) getCustomers(ctx context.Context, values []string,
return recipients, nil return recipients, nil
} }
func (s *notificationService) processDueScheduled(ctx context.Context) {
count, err := s.repository.MarkDueScheduledAsSent(ctx, time.Now().Unix())
if err != nil {
s.logger.Error("Failed to process due scheduled notifications", map[string]interface{}{
"error": err.Error(),
})
return
}
if count > 0 {
s.logger.Info("Processed due scheduled notifications", map[string]interface{}{
"count": count,
})
}
}