Files
tm_back/internal/notification/service.go
T
Mazyar aafae2a26f Enhance notification entity and service for improved delivery channel handling
- Introduced new event types for notifications, including email and push, to support a broader range of delivery methods.
- Added a `persistedEventType` function to map delivery channels to their corresponding event types, improving clarity and maintainability.
- Updated the `sendToRecipient` method to ensure in-app notifications are always persisted, enhancing the reliability of notification records.
- Improved error logging for in-app notification persistence failures, providing better context for debugging.

This update enhances the notification system's flexibility and reliability by supporting multiple delivery channels and improving error handling.
2026-07-11 21:07:37 +03:30

516 lines
14 KiB
Go

package notification
import (
"context"
"errors"
"slices"
"strings"
"time"
"tm/internal/customer"
"tm/internal/user"
"tm/pkg/logger"
"tm/pkg/notification"
"tm/pkg/response"
)
// NotificationService defines the interface for notification business logic
type Service interface {
Send(ctx context.Context, req *NotificationRequest) error
GetNotifications(ctx context.Context, req *SearchForm, pagination *response.Pagination) (*NotificationListResponse, error)
GetNotification(ctx context.Context, notificationID string) (*NotificationResponse, error)
MarkSeen(ctx context.Context, notificationID, userID string) error
AllMarkSeen(ctx context.Context, userID string) error
getUsers(ctx context.Context, userIDs []string) ([]notificationRecipient, error)
getCustomers(ctx context.Context, values []string, target string) ([]notificationRecipient, error)
}
// notificationService implements NotificationService interface
type notificationService struct {
sdk notification.SDK
repository Repository
userService user.Service
customerService customer.Service
logger logger.Logger
}
// NewService creates a new notification service
func NewService(
sdk notification.SDK,
repository Repository,
userService user.Service,
customerService customer.Service,
logger logger.Logger,
) Service {
return &notificationService{
sdk: sdk,
repository: repository,
userService: userService,
customerService: customerService,
logger: logger,
}
}
// Send sends a multiple notification
func (s *notificationService) Send(ctx context.Context, req *NotificationRequest) error {
image := ""
if req.Image != nil {
image = *req.Image
}
link := ""
if req.Link != nil {
link = *req.Link
}
s.logger.Info("Sending notification", map[string]interface{}{
"recipient": req.Recipient,
"type": req.Type,
"priority": req.Priority,
"link": link,
"image": image,
})
if req.Target == TargetAudienceAllUsers || req.Target == TargetAudienceSpecificUsers {
recipients, err := s.getUsers(ctx, req.Recipient)
if err != nil {
return err
}
for _, recipient := range recipients {
s.sendToRecipient(ctx, recipient, req, link, image)
}
} else {
recipients, err := s.getCustomers(ctx, req.Recipient, string(req.Target))
if err != nil {
return err
}
for _, recipient := range recipients {
s.sendToRecipient(ctx, recipient, req, link, image)
}
}
s.logger.Info("Notification sent", map[string]interface{}{
"recipient": req.Recipient,
"channels": req.Channels,
"target": req.Target,
})
return nil
}
func (s *notificationService) sendToRecipient(ctx context.Context, recipient notificationRecipient, req *NotificationRequest, link, image string) {
// Always persist an in-app record so every send appears in the notification center.
if err := s.persistNotification(ctx, recipient, req, link, image, DeliveryChannelInApp); err != nil {
s.logger.Error("Failed to persist in-app notification", map[string]interface{}{
"error": err.Error(),
"user_id": recipient.UserID,
})
}
for _, channel := range req.Channels {
if channel == DeliveryChannelInApp {
continue
}
if err := s.persistNotification(ctx, recipient, req, link, image, channel); err != nil {
s.logger.Error("Failed to persist notification", map[string]interface{}{
"error": err.Error(),
"user_id": recipient.UserID,
"channel": channel,
})
}
}
metadata := map[string]any{
"tender": strings.TrimSpace(req.Tender),
}
if slices.Contains(req.Channels, DeliveryChannelPush) {
for _, v := range recipient.DeviceTokens {
s.sdk.SendNotification(ctx, &notification.NotificationRequest{
UserID: recipient.UserID,
Title: req.Title,
Message: req.Description,
Type: string(req.Type),
Priority: string(req.Priority),
EventType: notification.EventTypePush,
Link: link,
Image: image,
Metadata: metadata,
Methods: notification.NotificationMethods{
Push: v,
},
ScheduledAt: req.ScheduleAt,
})
}
}
if slices.Contains(req.Channels, DeliveryChannelEmail) && recipient.Email != "" {
s.sdk.SendNotification(ctx, &notification.NotificationRequest{
UserID: recipient.UserID,
Title: req.Title,
Message: req.Description,
Type: string(req.Type),
Priority: string(req.Priority),
EventType: notification.EventTypeEmail,
Link: link,
Image: image,
Metadata: metadata,
Methods: notification.NotificationMethods{
Email: recipient.Email,
},
ScheduledAt: req.ScheduleAt,
})
}
}
func (s *notificationService) persistNotification(ctx context.Context, recipient notificationRecipient, req *NotificationRequest, link, image string, channel DeliveryChannel) error {
eventType, ok := persistedEventType(channel)
if !ok {
return nil
}
methods := map[string]string{}
switch channel {
case DeliveryChannelEmail:
if recipient.Email == "" {
return nil
}
methods["email"] = recipient.Email
case DeliveryChannelPush:
if len(recipient.DeviceTokens) == 0 {
return nil
}
methods["push"] = recipient.DeviceTokens[0]
case DeliveryChannelInApp:
methods["in_app"] = "true"
}
status := string(DeliveryStatusSent)
if req.ScheduleAt > 0 {
status = string(DeliveryStatusPending)
}
return s.repository.Create(ctx, &Notification{
UserID: recipient.UserID,
Title: req.Title,
Message: req.Description,
Link: link,
Image: image,
Priority: string(req.Priority),
Type: string(req.Type),
EventType: eventType,
Status: status,
Methods: methods,
Metadata: map[string]any{
"tender": strings.TrimSpace(req.Tender),
},
ScheduleAt: req.ScheduleAt,
ScheduledAt: req.ScheduleAt,
IsScheduled: req.ScheduleAt > 0,
Seen: false,
})
}
func (s *notificationService) GetNotifications(ctx context.Context, req *SearchForm, pagination *response.Pagination) (*NotificationListResponse, error) {
s.processDueScheduled(ctx)
search := req.ResolvedSearch()
s.logger.Info("Getting notifications", map[string]interface{}{
"search": search,
"status": req.Status,
"method": req.Method,
"event_type": req.EventType,
"type": req.Type,
"recipient": req.ResolvedRecipients(),
"seen": req.Seen,
"priority": req.Priority,
"limit": pagination.Limit,
"offset": pagination.Offset,
})
page, err := s.repository.GetByUserID(ctx, req.ResolvedRecipients(), search, req.Status, req.Seen, req.Priority, req.EventType, req.Type, pagination)
if err != nil {
s.logger.Error("Failed to get notifications", map[string]interface{}{
"error": err.Error(),
})
return nil, err
}
notificationsResponse := make([]*NotificationResponse, 0)
for _, notification := range page.Items {
usr, _ := s.userService.GetUserByID(ctx, notification.UserID)
cust, _ := s.customerService.GetByID(ctx, notification.UserID)
var recipient any
if usr != nil {
recipient = usr
} else if cust != nil {
recipient = cust
}
// Handle Link pointer - return nil if empty
var linkPtr *string
if notification.Link != "" {
linkPtr = &notification.Link
}
// Handle Image pointer - return nil if empty
var imagePtr *string
if notification.Image != "" {
imagePtr = &notification.Image
}
notificationsResponse = append(notificationsResponse, &NotificationResponse{
ID: notification.GetID(),
UserID: notification.UserID,
Recipient: recipient,
Title: notification.Title,
Message: notification.Message,
Link: linkPtr,
Priority: notification.Priority,
Type: notification.Type,
EventType: notification.EventType,
Status: notification.Status,
Methods: notification.Methods,
Metadata: notification.Metadata,
ScheduleAt: notification.ScheduleAt,
IsScheduled: notification.IsScheduled,
Image: imagePtr,
Seen: notification.Seen,
SeenAt: notification.SeenAt,
ScheduledAt: notification.ScheduledAt,
CreatedAt: notification.CreatedAt,
UpdatedAt: notification.UpdatedAt,
})
}
return &NotificationListResponse{
Notifications: notificationsResponse,
Meta: pagination.ListMeta(page.TotalCount, page.NextCursor, page.HasMore, page.PageOffset),
}, nil
}
func (s *notificationService) GetNotification(ctx context.Context, notificationID string) (*NotificationResponse, error) {
s.processDueScheduled(ctx)
s.logger.Info("Getting notification", map[string]interface{}{
"notification_id": notificationID,
})
notification, err := s.repository.GetByID(ctx, notificationID)
if err != nil {
s.logger.Error("Failed to get notification", map[string]interface{}{
"error": err.Error(),
"notification_id": notificationID,
})
return nil, err
}
// Convert to detailed response format
var linkPtr *string
if notification.Link != "" {
linkPtr = &notification.Link
}
var imagePtr *string
if notification.Image != "" {
imagePtr = &notification.Image
}
usr, _ := s.userService.GetUserByID(ctx, notification.UserID)
cust, _ := s.customerService.GetByID(ctx, notification.UserID)
var recipient any
if usr != nil {
recipient = usr
} else if cust != nil {
recipient = cust
}
detailedResponse := &NotificationResponse{
ID: notification.GetID(),
UserID: notification.UserID,
Recipient: recipient,
Title: notification.Title,
Message: notification.Message,
Link: linkPtr,
Image: imagePtr,
Type: notification.Type,
Priority: notification.Priority,
EventType: notification.EventType,
CreatedAt: notification.CreatedAt,
UpdatedAt: notification.UpdatedAt,
Methods: notification.Methods,
Metadata: notification.Metadata,
Status: notification.Status,
Seen: notification.Seen,
SeenAt: notification.SeenAt,
ScheduledAt: notification.ScheduledAt,
IsScheduled: notification.IsScheduled,
}
return detailedResponse, nil
}
func (s *notificationService) MarkSeen(ctx context.Context, notificationID, userID string) error {
s.logger.Info("Marking notification as seen", map[string]interface{}{
"notification_id": notificationID,
})
err := s.repository.MarkAsSeen(ctx, notificationID, userID)
if err != nil {
s.logger.Error("Failed to mark notification as seen", map[string]interface{}{
"error": err.Error(),
"notification_id": notificationID,
})
return err
}
return nil
}
func (s *notificationService) AllMarkSeen(ctx context.Context, userID string) error {
s.logger.Info("Marking all notifications as seen", map[string]interface{}{
"user_id": userID,
})
err := s.repository.MarkAllAsSeen(ctx, userID)
if err != nil {
s.logger.Error("Failed to mark all notifications as seen", map[string]interface{}{
"error": err.Error(),
"user_id": userID,
})
return err
}
return nil
}
func (s *notificationService) getUsers(ctx context.Context, userIDs []string) ([]notificationRecipient, error) {
recipients := make([]notificationRecipient, 0)
offset := 0
for {
var users *user.UserListResponse
var err error
if len(userIDs) > 0 {
users, err = s.userService.GetUsersByIDs(ctx, userIDs)
if err != nil {
return nil, err
}
} else {
users, err = s.userService.Search(ctx, &user.SearchUsersForm{}, &response.Pagination{Limit: 100, Offset: offset})
if err != nil {
return nil, err
}
offset += 100
if len(users.Users) == 0 {
break
}
}
for _, user := range users.Users {
recipients = append(recipients, notificationRecipient{
UserID: user.ID,
Email: user.Email,
DeviceTokens: user.DeviceToken,
})
}
if len(userIDs) > 0 || len(users.Users) == 0 {
break
}
}
if len(recipients) == 0 {
return nil, errors.New("no users found")
}
return recipients, nil
}
func (s *notificationService) getCustomers(ctx context.Context, values []string, target string) ([]notificationRecipient, error) {
recipients := make([]notificationRecipient, 0)
if target == string(TargetAudienceSpecificRole) {
if len(values) == 0 {
return nil, errors.New("no role provided")
}
customers, err := s.customerService.GetCustomersByRole(ctx, values[0])
if err != nil {
return nil, err
}
for _, customer := range customers {
recipients = append(recipients, notificationRecipient{
UserID: customer.ID,
Email: customer.Email,
DeviceTokens: customer.DeviceToken,
})
}
} else if target == string(TargetAudienceSpecificCompany) {
customers, err := s.customerService.GetCustomersByCompanies(ctx, values)
if err != nil {
return nil, err
}
for _, customer := range customers {
recipients = append(recipients, notificationRecipient{
UserID: customer.ID,
Email: customer.Email,
DeviceTokens: customer.DeviceToken,
})
}
} else if target == string(TargetAudienceSpecificCustomer) {
customers, err := s.customerService.GetCustomersByIDs(ctx, values)
if err != nil {
return nil, err
}
for _, customer := range customers {
recipients = append(recipients, notificationRecipient{
UserID: customer.ID,
Email: customer.Email,
DeviceTokens: customer.DeviceToken,
})
}
} else {
offset := 0
for {
customers, err := s.customerService.SearchNotification(ctx, &customer.SearchCustomersForm{}, &response.Pagination{Limit: 100, Offset: offset})
if err != nil {
return nil, err
}
for _, customer := range customers {
recipients = append(recipients, notificationRecipient{
UserID: customer.ID,
Email: customer.Email,
DeviceTokens: customer.DeviceToken,
})
}
offset += 100
if len(customers) == 0 {
break
}
}
}
if len(recipients) == 0 {
return nil, errors.New("no customers found")
}
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,
})
}
}