1584 lines
50 KiB
Go
1584 lines
50 KiB
Go
package handlers
|
|
|
|
import (
|
|
"Yimaru-Backend/internal/domain"
|
|
"Yimaru-Backend/internal/web_server/ws"
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/gorilla/websocket"
|
|
"github.com/resend/resend-go/v2"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
func (h *Handler) ConnectSocket(c *fiber.Ctx) error {
|
|
userID, ok := c.Locals("userID").(int64)
|
|
if !ok || userID == 0 {
|
|
h.mongoLoggerSvc.Info("Invalid user ID in context",
|
|
zap.Int64("userID", userID),
|
|
zap.Int("status_code", fiber.StatusUnauthorized),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
return fiber.NewError(fiber.StatusUnauthorized, "Invalid user identification")
|
|
}
|
|
|
|
// Convert fasthttp request to net/http request for gorilla upgrader
|
|
stdReq := &http.Request{
|
|
Method: http.MethodGet,
|
|
Header: make(http.Header),
|
|
}
|
|
c.Context().Request.Header.VisitAll(func(key, value []byte) {
|
|
stdReq.Header.Set(string(key), string(value))
|
|
})
|
|
stdReq.Host = string(c.Context().Host())
|
|
stdReq.RequestURI = string(c.Context().RequestURI())
|
|
|
|
// Hijack the underlying net.Conn from fasthttp
|
|
// The hijack callback runs after the handler returns, so we must not block.
|
|
c.Context().HijackSetNoResponse(true)
|
|
c.Context().Hijack(func(netConn net.Conn) {
|
|
// Create a hijackable response writer around the raw connection
|
|
hjRW := &hijackResponseWriter{
|
|
conn: netConn,
|
|
brw: bufio.NewReadWriter(bufio.NewReader(netConn), bufio.NewWriter(netConn)),
|
|
h: make(http.Header),
|
|
}
|
|
|
|
wsConn, err := ws.Upgrader.Upgrade(hjRW, stdReq, nil)
|
|
if err != nil {
|
|
h.mongoLoggerSvc.Error("WebSocket upgrade failed",
|
|
zap.Int64("userID", userID),
|
|
zap.Error(err),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
netConn.Close()
|
|
return
|
|
}
|
|
|
|
client := &ws.Client{
|
|
Conn: wsConn,
|
|
RecipientID: userID,
|
|
}
|
|
|
|
h.notificationSvc.Hub.Register <- client
|
|
defer func() {
|
|
h.notificationSvc.Hub.Unregister <- client
|
|
wsConn.Close()
|
|
}()
|
|
|
|
h.mongoLoggerSvc.Info("WebSocket connection established",
|
|
zap.Int64("userID", userID),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
|
|
for {
|
|
_, _, err := wsConn.ReadMessage()
|
|
if err != nil {
|
|
if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
|
|
h.mongoLoggerSvc.Info("WebSocket closed normally",
|
|
zap.Int64("userID", userID),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
} else {
|
|
h.mongoLoggerSvc.Info("Unexpected WebSocket closure",
|
|
zap.Int64("userID", userID),
|
|
zap.Error(err),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
// hijackResponseWriter implements http.ResponseWriter and http.Hijacker
|
|
// so gorilla/websocket can upgrade over a raw net.Conn.
|
|
type hijackResponseWriter struct {
|
|
conn net.Conn
|
|
brw *bufio.ReadWriter
|
|
h http.Header
|
|
}
|
|
|
|
func (w *hijackResponseWriter) Header() http.Header { return w.h }
|
|
func (w *hijackResponseWriter) WriteHeader(statusCode int) {}
|
|
func (w *hijackResponseWriter) Write(b []byte) (int, error) { return w.conn.Write(b) }
|
|
func (w *hijackResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
|
return w.conn, w.brw, nil
|
|
}
|
|
|
|
func (h *Handler) MarkNotificationAsRead(c *fiber.Ctx) error {
|
|
idStr := c.Params("id")
|
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Invalid notification ID",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
|
|
notification, err := h.notificationSvc.MarkNotificationAsRead(context.Background(), id)
|
|
if err != nil {
|
|
h.mongoLoggerSvc.Error("[NotificationHandler.MarkNotificationAsRead] Failed to mark notification as read",
|
|
zap.Int64("notificationID", id),
|
|
zap.Int("status_code", fiber.StatusInternalServerError),
|
|
zap.Error(err),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
|
Message: "Failed to mark notification as read",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
|
|
return c.Status(fiber.StatusOK).JSON(domain.Response{
|
|
Message: "Notification marked as read",
|
|
Success: true,
|
|
StatusCode: fiber.StatusOK,
|
|
Data: notification,
|
|
})
|
|
}
|
|
|
|
func (h *Handler) MarkAllNotificationsAsRead(c *fiber.Ctx) error {
|
|
userID, ok := c.Locals("user_id").(int64)
|
|
if !ok || userID == 0 {
|
|
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
|
|
Message: "Invalid user identification",
|
|
Error: "User ID not found in context",
|
|
})
|
|
}
|
|
|
|
if err := h.notificationSvc.MarkAllUserNotificationsAsRead(context.Background(), userID); err != nil {
|
|
h.mongoLoggerSvc.Error("[NotificationHandler.MarkAllNotificationsAsRead] Failed to mark all notifications as read",
|
|
zap.Int64("userID", userID),
|
|
zap.Int("status_code", fiber.StatusInternalServerError),
|
|
zap.Error(err),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
|
Message: "Failed to mark all notifications as read",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
|
|
return c.Status(fiber.StatusOK).JSON(domain.Response{
|
|
Message: "All notifications marked as read",
|
|
Success: true,
|
|
StatusCode: fiber.StatusOK,
|
|
})
|
|
}
|
|
|
|
func (h *Handler) MarkNotificationAsUnread(c *fiber.Ctx) error {
|
|
idStr := c.Params("id")
|
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Invalid notification ID",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
|
|
notification, err := h.notificationSvc.MarkNotificationAsUnread(context.Background(), id)
|
|
if err != nil {
|
|
h.mongoLoggerSvc.Error("[NotificationHandler.MarkNotificationAsUnread] Failed to mark notification as unread",
|
|
zap.Int64("notificationID", id),
|
|
zap.Int("status_code", fiber.StatusInternalServerError),
|
|
zap.Error(err),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
|
Message: "Failed to mark notification as unread",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
|
|
return c.Status(fiber.StatusOK).JSON(domain.Response{
|
|
Message: "Notification marked as unread",
|
|
Success: true,
|
|
StatusCode: fiber.StatusOK,
|
|
Data: notification,
|
|
})
|
|
}
|
|
|
|
func (h *Handler) MarkAllNotificationsAsUnread(c *fiber.Ctx) error {
|
|
userID, ok := c.Locals("user_id").(int64)
|
|
if !ok || userID == 0 {
|
|
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
|
|
Message: "Invalid user identification",
|
|
Error: "User ID not found in context",
|
|
})
|
|
}
|
|
|
|
if err := h.notificationSvc.MarkAllUserNotificationsAsUnread(context.Background(), userID); err != nil {
|
|
h.mongoLoggerSvc.Error("[NotificationHandler.MarkAllNotificationsAsUnread] Failed to mark all notifications as unread",
|
|
zap.Int64("userID", userID),
|
|
zap.Int("status_code", fiber.StatusInternalServerError),
|
|
zap.Error(err),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
|
Message: "Failed to mark all notifications as unread",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
|
|
return c.Status(fiber.StatusOK).JSON(domain.Response{
|
|
Message: "All notifications marked as unread",
|
|
Success: true,
|
|
StatusCode: fiber.StatusOK,
|
|
})
|
|
}
|
|
|
|
func (h *Handler) DeleteUserNotifications(c *fiber.Ctx) error {
|
|
userID, ok := c.Locals("user_id").(int64)
|
|
if !ok || userID == 0 {
|
|
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
|
|
Message: "Invalid user identification",
|
|
Error: "User ID not found in context",
|
|
})
|
|
}
|
|
|
|
if err := h.notificationSvc.DeleteUserNotifications(context.Background(), userID); err != nil {
|
|
h.mongoLoggerSvc.Error("[NotificationHandler.DeleteUserNotifications] Failed to delete notifications",
|
|
zap.Int64("userID", userID),
|
|
zap.Int("status_code", fiber.StatusInternalServerError),
|
|
zap.Error(err),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
|
Message: "Failed to delete notifications",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
|
|
return c.Status(fiber.StatusOK).JSON(domain.Response{
|
|
Message: "Notifications deleted successfully",
|
|
Success: true,
|
|
StatusCode: fiber.StatusOK,
|
|
})
|
|
}
|
|
|
|
func (h *Handler) CreateAndSendNotification(c *fiber.Ctx) error {
|
|
type Request struct {
|
|
RecipientID int64 `json:"recipient_id" validate:"required_if=DeliveryScheme single"`
|
|
Type domain.NotificationType `json:"type" validate:"required"`
|
|
Level domain.NotificationLevel `json:"level" validate:"required"`
|
|
ErrorSeverity *domain.NotificationErrorSeverity `json:"error_severity"`
|
|
Reciever domain.NotificationRecieverSide `json:"reciever" validate:"required"`
|
|
DeliveryScheme domain.NotificationDeliveryScheme `json:"delivery_scheme" validate:"required"`
|
|
DeliveryChannel domain.DeliveryChannel `json:"delivery_channel" validate:"required"`
|
|
Payload domain.NotificationPayload `json:"payload" validate:"required"`
|
|
Priority int `json:"priority"`
|
|
Metadata json.RawMessage `json:"metadata,omitempty"`
|
|
}
|
|
|
|
var req Request
|
|
if err := c.BodyParser(&req); err != nil {
|
|
h.mongoLoggerSvc.Info("[NotificationSvc.CreateAndSendNotification] Failed to parse request body",
|
|
zap.Int("status_code", fiber.StatusBadRequest),
|
|
zap.Error(err),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
|
|
}
|
|
|
|
// userID, ok := c.Locals("userID").(int64)
|
|
// if !ok || userID == 0 {
|
|
// h.logger.Error("[NotificationSvc.CreateAndSendNotification] Invalid user ID in context")
|
|
// return fiber.NewError(fiber.StatusUnauthorized, "Invalid user identification")
|
|
// }
|
|
|
|
switch req.DeliveryScheme {
|
|
case domain.NotificationDeliverySchemeSingle:
|
|
// if req.Reciever == domain.NotificationRecieverSideCustomer {
|
|
// h.logger.Warn("[NotificationSvc.CreateAndSendNotification] Unauthorized attempt to send notification", "recipientID", req.RecipientID)
|
|
// return fiber.NewError(fiber.StatusForbidden, "Unauthorized to send notification to this recipient")
|
|
// }
|
|
|
|
errorSeverity := domain.NotificationErrorSeverityMedium
|
|
if req.ErrorSeverity != nil {
|
|
errorSeverity = *req.ErrorSeverity
|
|
}
|
|
|
|
notification := &domain.Notification{
|
|
ID: "",
|
|
RecipientID: req.RecipientID,
|
|
ReceiverType: domain.ReceiverTypeFromReciever(req.Reciever),
|
|
Type: req.Type,
|
|
Level: req.Level,
|
|
ErrorSeverity: errorSeverity,
|
|
Reciever: req.Reciever,
|
|
IsRead: false,
|
|
DeliveryStatus: domain.DeliveryStatusPending,
|
|
DeliveryChannel: req.DeliveryChannel,
|
|
Payload: req.Payload,
|
|
Priority: req.Priority,
|
|
Metadata: req.Metadata,
|
|
}
|
|
|
|
if err := h.notificationSvc.SendNotification(context.Background(), notification); err != nil {
|
|
h.mongoLoggerSvc.Error("[NotificationSvc.CreateAndSendNotification] Failed to send single notification",
|
|
zap.Int64("recipientID", req.RecipientID),
|
|
zap.Int("status_code", fiber.StatusInternalServerError),
|
|
zap.Error(err),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to send notification:"+err.Error())
|
|
}
|
|
|
|
h.mongoLoggerSvc.Info("[NotificationSvc.CreateAndSendNotification] Single notification sent successfully",
|
|
zap.Int64("recipientID", req.RecipientID),
|
|
zap.String("type", string(req.Type)),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"message": "Single notification sent successfully", "notification_id": notification.ID})
|
|
|
|
case domain.NotificationDeliverySchemeBulk:
|
|
recipients, _, err := h.userSvc.GetAllUsers(context.Background(), domain.UserFilter{
|
|
Role: string(req.Reciever),
|
|
})
|
|
if err != nil {
|
|
h.mongoLoggerSvc.Error("[NotificationSvc.CreateAndSendNotification] Failed to fetch recipients for bulk notification",
|
|
zap.Int64("RecipientID", req.RecipientID),
|
|
zap.String("Reciever", string(req.Reciever)),
|
|
zap.Int("status_code", fiber.StatusInternalServerError),
|
|
zap.Error(err),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch recipients:"+err.Error())
|
|
}
|
|
|
|
notificationIDs := make([]string, 0, len(recipients))
|
|
for _, user := range recipients {
|
|
errorSeverity := domain.NotificationErrorSeverityMedium
|
|
if req.ErrorSeverity != nil {
|
|
errorSeverity = *req.ErrorSeverity
|
|
}
|
|
|
|
notification := &domain.Notification{
|
|
ID: "",
|
|
RecipientID: user.ID,
|
|
ReceiverType: domain.ReceiverTypeFromReciever(req.Reciever),
|
|
Type: req.Type,
|
|
Level: req.Level,
|
|
ErrorSeverity: errorSeverity,
|
|
Reciever: req.Reciever,
|
|
IsRead: false,
|
|
DeliveryStatus: domain.DeliveryStatusPending,
|
|
DeliveryChannel: req.DeliveryChannel,
|
|
Payload: req.Payload,
|
|
Priority: req.Priority,
|
|
Metadata: req.Metadata,
|
|
}
|
|
|
|
if err := h.notificationSvc.SendNotification(context.Background(), notification); err != nil {
|
|
h.mongoLoggerSvc.Error("[NotificationSvc.CreateAndSendNotification] Failed to send bulk notification",
|
|
zap.Int64("UserID", user.ID),
|
|
zap.Error(err),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
continue
|
|
}
|
|
notificationIDs = append(notificationIDs, notification.ID)
|
|
}
|
|
|
|
h.mongoLoggerSvc.Error("[NotificationSvc.CreateAndSendNotification] Bulk notification sent successfully",
|
|
zap.Int("recipient_count", len(recipients)),
|
|
zap.String("type", string(req.Type)),
|
|
zap.Int("status_code", fiber.StatusCreated),
|
|
zap.Error(err),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
|
|
"message": "Bulk notification sent successfully",
|
|
"recipient_count": len(recipients),
|
|
"notification_ids": notificationIDs,
|
|
})
|
|
|
|
default:
|
|
h.mongoLoggerSvc.Info("[NotificationSvc.CreateAndSendNotification] Invalid delivery scheme",
|
|
zap.String("delivery_scheme", string(req.DeliveryScheme)),
|
|
zap.Int("status_code", fiber.StatusBadRequest),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
return fiber.NewError(fiber.StatusBadRequest, "Invalid delivery scheme")
|
|
}
|
|
}
|
|
|
|
func (h *Handler) GetUserNotification(c *fiber.Ctx) error {
|
|
limitStr := c.Query("limit", "10")
|
|
offsetStr := c.Query("offset", "0")
|
|
|
|
// Convert limit and offset to integers
|
|
limit, err := strconv.Atoi(limitStr)
|
|
if err != nil || limit <= 0 {
|
|
h.mongoLoggerSvc.Info("[NotificationSvc.GetUserNotification] Invalid limit value",
|
|
zap.String("limit", limitStr),
|
|
zap.Int("status_code", fiber.StatusBadRequest),
|
|
zap.Error(err),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
return fiber.NewError(fiber.StatusBadRequest, "Invalid limit value")
|
|
}
|
|
offset, err := strconv.Atoi(offsetStr)
|
|
if err != nil || offset < 0 {
|
|
h.mongoLoggerSvc.Info("[NotificationSvc.GetUserNotification] Invalid offset value",
|
|
zap.String("offset", offsetStr),
|
|
zap.Int("status_code", fiber.StatusBadRequest),
|
|
zap.Error(err),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
return fiber.NewError(fiber.StatusBadRequest, "Invalid offset value")
|
|
}
|
|
|
|
userID, ok := c.Locals("user_id").(int64)
|
|
if !ok || userID == 0 {
|
|
h.mongoLoggerSvc.Error("[NotificationSvc.GetUserNotification] Invalid user ID in context",
|
|
zap.Int64("userID", userID),
|
|
zap.Int("status_code", fiber.StatusInternalServerError),
|
|
zap.Error(err),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Invalid user identification")
|
|
}
|
|
|
|
notifications, total, err := h.notificationSvc.GetUserNotifications(context.Background(), userID, limit, offset)
|
|
if err != nil {
|
|
h.mongoLoggerSvc.Error("[NotificationSvc.GetUserNotification] Failed to fetch notifications",
|
|
zap.Int64("userID", userID),
|
|
zap.Int("status_code", fiber.StatusInternalServerError),
|
|
zap.Error(err),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch notifications:"+err.Error())
|
|
}
|
|
|
|
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
|
"notifications": notifications,
|
|
"total_count": total,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
})
|
|
|
|
}
|
|
|
|
// func (h *Handler) GetAllRecipientIDs(ctx context.Context, receiver domain.NotificationRecieverSide) ([]int64, error) {
|
|
// return h.notificationSvc.ListRecipientIDs(ctx, receiver)
|
|
// }
|
|
|
|
func (h *Handler) CountUnreadNotifications(c *fiber.Ctx) error {
|
|
|
|
userID, ok := c.Locals("user_id").(int64)
|
|
if !ok || userID == 0 {
|
|
h.mongoLoggerSvc.Error("NotificationSvc.GetNotifications] Invalid user ID in context",
|
|
zap.Int64("userID", userID),
|
|
zap.Int("status_code", fiber.StatusInternalServerError),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Invalid user identification")
|
|
}
|
|
|
|
total, err := h.notificationSvc.CountUnreadNotifications(c.Context(), userID)
|
|
|
|
if err != nil {
|
|
h.mongoLoggerSvc.Error("[NotificationSvc.CountUnreadNotifications] Failed to fetch unread notification count",
|
|
zap.Int64("userID", userID),
|
|
zap.Int("status_code", fiber.StatusInternalServerError),
|
|
zap.Error(err),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch notifications:"+err.Error())
|
|
}
|
|
|
|
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
|
"unread": total,
|
|
})
|
|
}
|
|
|
|
func (h *Handler) GetAllNotifications(c *fiber.Ctx) error {
|
|
limitStr := c.Query("limit", "10")
|
|
pageStr := c.Query("page", "1")
|
|
|
|
limit, err := strconv.Atoi(limitStr)
|
|
if err != nil || limit <= 0 {
|
|
return fiber.NewError(fiber.StatusBadRequest, "Invalid limit value")
|
|
}
|
|
page, err := strconv.Atoi(pageStr)
|
|
if err != nil || page <= 0 {
|
|
return fiber.NewError(fiber.StatusBadRequest, "Invalid page value")
|
|
}
|
|
|
|
filter := domain.NotificationFilter{
|
|
Channel: c.Query("channel"),
|
|
Type: c.Query("type"),
|
|
Limit: limit,
|
|
Offset: (page - 1) * limit,
|
|
}
|
|
|
|
if uid := c.Query("user_id"); uid != "" {
|
|
id, err := strconv.ParseInt(uid, 10, 64)
|
|
if err != nil {
|
|
return fiber.NewError(fiber.StatusBadRequest, "Invalid user_id value")
|
|
}
|
|
filter.UserID = &id
|
|
}
|
|
|
|
if isRead := c.Query("is_read"); isRead != "" {
|
|
val := isRead == "true"
|
|
filter.IsRead = &val
|
|
}
|
|
|
|
if after := c.Query("after"); after != "" {
|
|
t, err := time.Parse(time.RFC3339, after)
|
|
if err != nil {
|
|
return fiber.NewError(fiber.StatusBadRequest, "Invalid after date, use RFC3339 format")
|
|
}
|
|
filter.After = &t
|
|
}
|
|
|
|
if before := c.Query("before"); before != "" {
|
|
t, err := time.Parse(time.RFC3339, before)
|
|
if err != nil {
|
|
return fiber.NewError(fiber.StatusBadRequest, "Invalid before date, use RFC3339 format")
|
|
}
|
|
filter.Before = &t
|
|
}
|
|
|
|
notifications, total, err := h.notificationSvc.GetFilteredNotifications(context.Background(), filter)
|
|
if err != nil {
|
|
h.mongoLoggerSvc.Error("[NotificationSvc.GetNotifications] Failed to fetch notifications",
|
|
zap.Int("status_code", fiber.StatusInternalServerError),
|
|
zap.Error(err),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch notifications")
|
|
}
|
|
|
|
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
|
"notifications": notifications,
|
|
"total_count": total,
|
|
"limit": limit,
|
|
"page": page,
|
|
})
|
|
}
|
|
|
|
type SendSingleAfroSMSReq struct {
|
|
Recipient string `json:"recipient" validate:"required" example:"+251912345678"`
|
|
Message string `json:"message" validate:"required" example:"Hello world"`
|
|
}
|
|
|
|
// SendSingleAfroSMS godoc
|
|
// @Summary Send single SMS via AfroMessage
|
|
// @Description Sends an SMS message to a single phone number using AfroMessage
|
|
// @Tags user
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param sendSMS body SendSingleAfroSMSReq true "Send SMS request"
|
|
// @Success 200 {object} response.APIResponse
|
|
// @Failure 400 {object} response.APIResponse
|
|
// @Failure 500 {object} response.APIResponse
|
|
// @Router /api/v1/sendSMS [post]
|
|
func (h *Handler) SendSingleAfroSMS(c *fiber.Ctx) error {
|
|
var req SendSingleAfroSMSReq
|
|
|
|
if err := c.BodyParser(&req); err != nil {
|
|
h.mongoLoggerSvc.Info("Failed to parse SendSingleAfroSMS request",
|
|
zap.Int("status_code", fiber.StatusBadRequest),
|
|
zap.Error(err),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Failed to send SMS",
|
|
Error: "Invalid request body: " + err.Error(),
|
|
})
|
|
}
|
|
|
|
// Validate request
|
|
if valErrs, ok := h.validator.Validate(c, req); !ok {
|
|
var errMsg string
|
|
for field, msg := range valErrs {
|
|
errMsg += fmt.Sprintf("%s: %s; ", field, msg)
|
|
}
|
|
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Failed to send SMS",
|
|
Error: errMsg,
|
|
})
|
|
}
|
|
|
|
// Send SMS via service
|
|
if err := h.notificationSvc.SendAfroMessageSMSTemp(
|
|
c.Context(),
|
|
req.Recipient,
|
|
req.Message,
|
|
nil,
|
|
); err != nil {
|
|
|
|
h.mongoLoggerSvc.Error("Failed to send AfroMessage SMS",
|
|
zap.String("phone_number", req.Recipient),
|
|
zap.Int("status_code", fiber.StatusInternalServerError),
|
|
zap.Error(err),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
|
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
|
Message: "Failed to send SMS",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
|
|
return c.Status(fiber.StatusOK).JSON(domain.Response{
|
|
Message: "SMS sent successfully",
|
|
Success: true,
|
|
StatusCode: fiber.StatusOK,
|
|
Data: req,
|
|
})
|
|
}
|
|
|
|
func (h *Handler) RegisterDeviceToken(c *fiber.Ctx) error {
|
|
type Request struct {
|
|
DeviceToken string `json:"device_token" validate:"required"`
|
|
Platform string `json:"platform" validate:"required,oneof=android ios web"`
|
|
}
|
|
|
|
var req Request
|
|
if err := c.BodyParser(&req); err != nil {
|
|
h.mongoLoggerSvc.Info("[NotificationHandler.RegisterDeviceToken] Failed to parse request body",
|
|
zap.Int("status_code", fiber.StatusBadRequest),
|
|
zap.Error(err),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
|
|
}
|
|
|
|
userID, ok := c.Locals("user_id").(int64)
|
|
if !ok || userID == 0 {
|
|
h.mongoLoggerSvc.Error("[NotificationHandler.RegisterDeviceToken] Invalid user ID in context",
|
|
zap.Int("status_code", fiber.StatusUnauthorized),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
return fiber.NewError(fiber.StatusUnauthorized, "Invalid user identification")
|
|
}
|
|
|
|
if err := h.userSvc.RegisterDevice(c.Context(), userID, req.DeviceToken, req.Platform); err != nil {
|
|
h.mongoLoggerSvc.Error("[NotificationHandler.RegisterDeviceToken] Failed to register device token",
|
|
zap.Int64("userID", userID),
|
|
zap.String("platform", req.Platform),
|
|
zap.Int("status_code", fiber.StatusInternalServerError),
|
|
zap.Error(err),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to register device token: "+err.Error())
|
|
}
|
|
|
|
h.mongoLoggerSvc.Info("[NotificationHandler.RegisterDeviceToken] Device token registered successfully",
|
|
zap.Int64("userID", userID),
|
|
zap.String("platform", req.Platform),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
|
|
return c.Status(fiber.StatusCreated).JSON(domain.Response{
|
|
Message: "Device token registered successfully",
|
|
Success: true,
|
|
StatusCode: fiber.StatusCreated,
|
|
})
|
|
}
|
|
|
|
// SendTestPushNotification sends a test push notification to the authenticated user
|
|
// @Summary Send test push notification
|
|
// @Description Sends a test push notification to all registered devices of the current user
|
|
// @Tags notifications
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param body body object{title=string,message=string} true "Test notification content"
|
|
// @Success 200 {object} domain.Response
|
|
// @Failure 400 {object} domain.ErrorResponse
|
|
// @Failure 401 {object} domain.ErrorResponse
|
|
// @Failure 500 {object} domain.ErrorResponse
|
|
// @Router /api/v1/notifications/test-push [post]
|
|
func (h *Handler) SendTestPushNotification(c *fiber.Ctx) error {
|
|
title := c.FormValue("title", "Test Push Notification")
|
|
message := c.FormValue("message", "This is a test push notification from Yimaru Backend")
|
|
|
|
userID, ok := c.Locals("user_id").(int64)
|
|
if !ok || userID == 0 {
|
|
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
|
|
Message: "Invalid user identification",
|
|
Error: "User ID not found in context",
|
|
})
|
|
}
|
|
|
|
// Get user's device tokens first to provide feedback
|
|
tokens, err := h.userSvc.GetUserDeviceTokens(c.Context(), userID)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
|
Message: "Failed to get device tokens",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
|
|
if len(tokens) == 0 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "No registered devices found",
|
|
Error: "Please register a device token first using POST /devices/register",
|
|
})
|
|
}
|
|
|
|
// Handle optional image upload
|
|
var imageURL string
|
|
if _, err := c.FormFile("file"); err == nil {
|
|
savedPath, saveErr := h.processAndSaveThumbnail(c, "notification_images")
|
|
if saveErr != nil {
|
|
return saveErr
|
|
}
|
|
resolved := h.resolveFileURL(c, savedPath)
|
|
if strings.HasPrefix(resolved, "/") {
|
|
imageURL = c.BaseURL() + resolved
|
|
} else {
|
|
imageURL = resolved
|
|
}
|
|
}
|
|
|
|
// Create test notification
|
|
notification := &domain.Notification{
|
|
RecipientID: userID,
|
|
Type: "system_alert",
|
|
DeliveryChannel: domain.DeliveryChannelPush,
|
|
Image: imageURL,
|
|
Payload: domain.NotificationPayload{
|
|
Headline: title,
|
|
Message: message,
|
|
},
|
|
}
|
|
|
|
// Send push notification
|
|
err = h.notificationSvc.SendPushNotification(c.Context(), notification)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
|
Message: "Failed to send push notification",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
|
|
// Record in DB for history
|
|
h.notificationSvc.RecordNotification(c.Context(), userID, domain.NOTIFICATION_TYPE_SYSTEM_ALERT, domain.DeliveryChannelPush, domain.NotificationLevelInfo, title, message)
|
|
|
|
h.mongoLoggerSvc.Info("[NotificationHandler.SendTestPushNotification] Test push sent",
|
|
zap.Int64("userID", userID),
|
|
zap.Int("deviceCount", len(tokens)),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
|
|
return c.Status(fiber.StatusOK).JSON(domain.Response{
|
|
Message: "Test push notification sent successfully",
|
|
Success: true,
|
|
StatusCode: fiber.StatusOK,
|
|
Data: map[string]interface{}{
|
|
"devices_count": len(tokens),
|
|
"title": title,
|
|
"message": message,
|
|
"image": imageURL,
|
|
},
|
|
})
|
|
}
|
|
|
|
// SendBulkPushNotification sends a push notification to multiple users or all users of a given role.
|
|
// @Summary Send bulk push notification
|
|
// @Description Sends a push notification to specified user IDs or all users matching a role. Optionally schedule for later with scheduled_at (RFC3339).
|
|
// @Tags notifications
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param body body object{title=string,message=string,image=string,user_ids=[]int64,role=string,scheduled_at=string} true "Bulk push content"
|
|
// @Success 200 {object} domain.Response
|
|
// @Failure 400 {object} domain.ErrorResponse
|
|
// @Failure 500 {object} domain.ErrorResponse
|
|
// @Router /api/v1/notifications/bulk-push [post]
|
|
func (h *Handler) SendBulkPushNotification(c *fiber.Ctx) error {
|
|
title := c.FormValue("title")
|
|
message := c.FormValue("message")
|
|
role := c.FormValue("role")
|
|
userIDsRaw := c.FormValue("user_ids")
|
|
scheduledAtRaw := c.FormValue("scheduled_at")
|
|
|
|
if title == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Title is required",
|
|
})
|
|
}
|
|
if message == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Message is required",
|
|
})
|
|
}
|
|
|
|
// Parse user_ids from JSON array string e.g. "[1,2,3]"
|
|
var userIDs []int64
|
|
if userIDsRaw != "" {
|
|
if err := json.Unmarshal([]byte(userIDsRaw), &userIDs); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Invalid user_ids format",
|
|
Error: "user_ids must be a JSON array of integers, e.g. [1,2,3]",
|
|
})
|
|
}
|
|
}
|
|
|
|
if len(userIDs) == 0 && role == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "No target users specified",
|
|
Error: "Provide either user_ids or role",
|
|
})
|
|
}
|
|
|
|
// Schedule for later if scheduled_at is provided
|
|
if scheduledAtRaw != "" {
|
|
scheduledAt, err := time.Parse(time.RFC3339, scheduledAtRaw)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Invalid scheduled_at format, use RFC3339 (e.g. 2025-06-01T10:00:00Z)",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
if scheduledAt.Before(time.Now()) {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "scheduled_at must be in the future",
|
|
})
|
|
}
|
|
|
|
creatorID, _ := c.Locals("user_id").(int64)
|
|
sn := &domain.ScheduledNotification{
|
|
Channel: domain.DeliveryChannelPush,
|
|
Title: title,
|
|
Message: message,
|
|
ScheduledAt: scheduledAt,
|
|
TargetUserIDs: userIDs,
|
|
TargetRole: role,
|
|
CreatedBy: creatorID,
|
|
}
|
|
|
|
created, err := h.notificationSvc.CreateScheduledNotification(c.Context(), sn)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
|
Message: "Failed to schedule push notification",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
|
|
return c.Status(fiber.StatusCreated).JSON(domain.Response{
|
|
Message: "Push notification scheduled",
|
|
Success: true,
|
|
StatusCode: fiber.StatusCreated,
|
|
Data: created,
|
|
})
|
|
}
|
|
|
|
// Determine target user IDs by role if no specific IDs given
|
|
if len(userIDs) == 0 && role != "" {
|
|
users, _, err := h.userSvc.GetAllUsers(context.Background(), domain.UserFilter{Role: role})
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
|
Message: "Failed to fetch users for role",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
for _, u := range users {
|
|
userIDs = append(userIDs, u.ID)
|
|
}
|
|
}
|
|
|
|
if len(userIDs) == 0 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "No target users found",
|
|
})
|
|
}
|
|
|
|
// Handle optional image upload
|
|
var imageURL string
|
|
if _, err := c.FormFile("file"); err == nil {
|
|
savedPath, saveErr := h.processAndSaveThumbnail(c, "notification_images")
|
|
if saveErr != nil {
|
|
return saveErr
|
|
}
|
|
resolved := h.resolveFileURL(c, savedPath)
|
|
if strings.HasPrefix(resolved, "/") {
|
|
imageURL = c.BaseURL() + resolved
|
|
} else {
|
|
imageURL = resolved
|
|
}
|
|
}
|
|
|
|
notification := &domain.Notification{
|
|
Type: "system_alert",
|
|
DeliveryChannel: domain.DeliveryChannelPush,
|
|
Image: imageURL,
|
|
Payload: domain.NotificationPayload{
|
|
Headline: title,
|
|
Message: message,
|
|
},
|
|
}
|
|
|
|
sent, failed, err := h.notificationSvc.SendBulkPushNotification(c.Context(), userIDs, notification)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
|
Message: "Failed to send bulk push notification",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
|
|
// Record in DB for history
|
|
for _, uid := range userIDs {
|
|
h.notificationSvc.RecordNotification(c.Context(), uid, domain.NOTIFICATION_TYPE_SYSTEM_ALERT, domain.DeliveryChannelPush, domain.NotificationLevelInfo, title, message)
|
|
}
|
|
|
|
h.mongoLoggerSvc.Info("[NotificationHandler.SendBulkPushNotification] Bulk push sent",
|
|
zap.Int("targetUsers", len(userIDs)),
|
|
zap.Int("sent", sent),
|
|
zap.Int("failed", failed),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
|
|
return c.Status(fiber.StatusOK).JSON(domain.Response{
|
|
Message: "Bulk push notification sent",
|
|
Success: true,
|
|
StatusCode: fiber.StatusOK,
|
|
Data: map[string]interface{}{
|
|
"target_users": len(userIDs),
|
|
"sent": sent,
|
|
"failed": failed,
|
|
"image": imageURL,
|
|
},
|
|
})
|
|
}
|
|
|
|
// SendBulkSMS sends an SMS to multiple users by user IDs, role, or direct phone numbers.
|
|
// @Summary Send bulk SMS
|
|
// @Description Sends an SMS to specified user IDs, all users of a role, or direct phone numbers. Optionally schedule for later with scheduled_at (RFC3339).
|
|
// @Tags notifications
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param body body object{message=string,user_ids=[]int64,role=string,phone_numbers=[]string,scheduled_at=string} true "Bulk SMS content"
|
|
// @Success 200 {object} domain.Response
|
|
// @Failure 400 {object} domain.ErrorResponse
|
|
// @Failure 500 {object} domain.ErrorResponse
|
|
// @Router /api/v1/notifications/bulk-sms [post]
|
|
func (h *Handler) SendBulkSMS(c *fiber.Ctx) error {
|
|
type Request struct {
|
|
Message string `json:"message" validate:"required"`
|
|
UserIDs []int64 `json:"user_ids"`
|
|
Role string `json:"role"`
|
|
PhoneNumbers []string `json:"phone_numbers"`
|
|
ScheduledAt string `json:"scheduled_at"`
|
|
}
|
|
|
|
var req Request
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Invalid request body",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
|
|
if req.Message == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Message is required",
|
|
})
|
|
}
|
|
|
|
if len(req.UserIDs) == 0 && req.Role == "" && len(req.PhoneNumbers) == 0 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "No recipients specified",
|
|
Error: "Provide user_ids, role, or phone_numbers",
|
|
})
|
|
}
|
|
|
|
// Schedule for later if scheduled_at is provided
|
|
if req.ScheduledAt != "" {
|
|
scheduledAt, err := time.Parse(time.RFC3339, req.ScheduledAt)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Invalid scheduled_at format, use RFC3339 (e.g. 2025-06-01T10:00:00Z)",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
if scheduledAt.Before(time.Now()) {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "scheduled_at must be in the future",
|
|
})
|
|
}
|
|
|
|
creatorID, _ := c.Locals("user_id").(int64)
|
|
|
|
var targetRaw json.RawMessage
|
|
if len(req.PhoneNumbers) > 0 {
|
|
raw := domain.ScheduledNotificationTargetRaw{Phones: req.PhoneNumbers}
|
|
targetRaw, _ = json.Marshal(raw)
|
|
}
|
|
|
|
sn := &domain.ScheduledNotification{
|
|
Channel: domain.DeliveryChannelSMS,
|
|
Message: req.Message,
|
|
ScheduledAt: scheduledAt,
|
|
TargetUserIDs: req.UserIDs,
|
|
TargetRole: req.Role,
|
|
TargetRaw: targetRaw,
|
|
CreatedBy: creatorID,
|
|
}
|
|
|
|
created, err := h.notificationSvc.CreateScheduledNotification(c.Context(), sn)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
|
Message: "Failed to schedule SMS",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
|
|
return c.Status(fiber.StatusCreated).JSON(domain.Response{
|
|
Message: "SMS scheduled",
|
|
Success: true,
|
|
StatusCode: fiber.StatusCreated,
|
|
Data: created,
|
|
})
|
|
}
|
|
|
|
// Collect phone numbers from all sources
|
|
phoneNumbers := make(map[string]struct{})
|
|
|
|
// Add directly provided phone numbers
|
|
for _, p := range req.PhoneNumbers {
|
|
phoneNumbers[p] = struct{}{}
|
|
}
|
|
|
|
// Collect user IDs from role if needed
|
|
userIDs := req.UserIDs
|
|
if len(userIDs) == 0 && req.Role != "" {
|
|
users, _, err := h.userSvc.GetAllUsers(context.Background(), domain.UserFilter{Role: req.Role})
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
|
Message: "Failed to fetch users for role",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
for _, u := range users {
|
|
userIDs = append(userIDs, u.ID)
|
|
}
|
|
}
|
|
|
|
// Resolve user IDs to phone numbers
|
|
for _, uid := range userIDs {
|
|
user, err := h.userSvc.GetUserByID(context.Background(), uid)
|
|
if err != nil {
|
|
h.mongoLoggerSvc.Warn("[NotificationHandler.SendBulkSMS] Failed to get user",
|
|
zap.Int64("userID", uid),
|
|
zap.Error(err),
|
|
)
|
|
continue
|
|
}
|
|
if user.PhoneNumber != "" {
|
|
phoneNumbers[user.PhoneNumber] = struct{}{}
|
|
}
|
|
}
|
|
|
|
if len(phoneNumbers) == 0 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "No recipients found",
|
|
Error: "Provide user_ids, role, or phone_numbers",
|
|
})
|
|
}
|
|
|
|
// Flatten to slice
|
|
recipients := make([]string, 0, len(phoneNumbers))
|
|
for p := range phoneNumbers {
|
|
recipients = append(recipients, p)
|
|
}
|
|
|
|
sent, failed := h.notificationSvc.SendBulkSMS(c.Context(), recipients, req.Message)
|
|
|
|
// Record in DB for history (only for known users)
|
|
for _, uid := range userIDs {
|
|
h.notificationSvc.RecordNotification(c.Context(), uid, domain.NOTIFICATION_TYPE_SYSTEM_ALERT, domain.DeliveryChannelSMS, domain.NotificationLevelInfo, "SMS Notification", req.Message)
|
|
}
|
|
|
|
h.mongoLoggerSvc.Info("[NotificationHandler.SendBulkSMS] Bulk SMS sent",
|
|
zap.Int("totalRecipients", len(recipients)),
|
|
zap.Int("sent", sent),
|
|
zap.Int("failed", failed),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
|
|
return c.Status(fiber.StatusOK).JSON(domain.Response{
|
|
Message: "Bulk SMS sent",
|
|
Success: true,
|
|
StatusCode: fiber.StatusOK,
|
|
Data: map[string]interface{}{
|
|
"total_recipients": len(recipients),
|
|
"sent": sent,
|
|
"failed": failed,
|
|
},
|
|
})
|
|
}
|
|
|
|
// GetScheduledNotification retrieves a single scheduled notification by ID.
|
|
// @Summary Get scheduled notification
|
|
// @Description Returns a single scheduled notification by its ID
|
|
// @Tags notifications
|
|
// @Produce json
|
|
// @Param id path int true "Scheduled Notification ID"
|
|
// @Success 200 {object} domain.Response
|
|
// @Failure 400 {object} domain.ErrorResponse
|
|
// @Failure 500 {object} domain.ErrorResponse
|
|
// @Router /api/v1/notifications/scheduled/{id} [get]
|
|
func (h *Handler) GetScheduledNotification(c *fiber.Ctx) error {
|
|
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Invalid ID",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
|
|
sn, err := h.notificationSvc.GetScheduledNotification(c.Context(), id)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
|
Message: "Failed to get scheduled notification",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
|
|
return c.Status(fiber.StatusOK).JSON(domain.Response{
|
|
Message: "Scheduled notification retrieved",
|
|
Success: true,
|
|
StatusCode: fiber.StatusOK,
|
|
Data: sn,
|
|
})
|
|
}
|
|
|
|
// ListScheduledNotifications lists scheduled notifications with optional filters.
|
|
// @Summary List scheduled notifications
|
|
// @Description Returns paginated scheduled notifications with optional status, channel, and date filters
|
|
// @Tags notifications
|
|
// @Produce json
|
|
// @Param status query string false "Filter by status"
|
|
// @Param channel query string false "Filter by channel"
|
|
// @Param after query string false "Filter after date (RFC3339)"
|
|
// @Param before query string false "Filter before date (RFC3339)"
|
|
// @Param limit query int false "Page size" default(20)
|
|
// @Param page query int false "Page number" default(1)
|
|
// @Success 200 {object} domain.Response
|
|
// @Failure 400 {object} domain.ErrorResponse
|
|
// @Failure 500 {object} domain.ErrorResponse
|
|
// @Router /api/v1/notifications/scheduled [get]
|
|
func (h *Handler) ListScheduledNotifications(c *fiber.Ctx) error {
|
|
limit, err := strconv.Atoi(c.Query("limit", "20"))
|
|
if err != nil || limit <= 0 {
|
|
limit = 20
|
|
}
|
|
page, err := strconv.Atoi(c.Query("page", "1"))
|
|
if err != nil || page <= 0 {
|
|
page = 1
|
|
}
|
|
|
|
filter := domain.ScheduledNotificationFilter{
|
|
Status: c.Query("status"),
|
|
Channel: c.Query("channel"),
|
|
Limit: limit,
|
|
Offset: (page - 1) * limit,
|
|
}
|
|
|
|
if after := c.Query("after"); after != "" {
|
|
t, err := time.Parse(time.RFC3339, after)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Invalid after date, use RFC3339 format",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
filter.After = &t
|
|
}
|
|
|
|
if before := c.Query("before"); before != "" {
|
|
t, err := time.Parse(time.RFC3339, before)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Invalid before date, use RFC3339 format",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
filter.Before = &t
|
|
}
|
|
|
|
notifications, total, err := h.notificationSvc.ListScheduledNotifications(c.Context(), filter)
|
|
if err != nil {
|
|
h.mongoLoggerSvc.Error("[NotificationHandler.ListScheduledNotifications] Failed",
|
|
zap.Error(err),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
|
Message: "Failed to list scheduled notifications",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
|
|
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
|
"scheduled_notifications": notifications,
|
|
"total_count": total,
|
|
"limit": limit,
|
|
"page": page,
|
|
})
|
|
}
|
|
|
|
// CancelScheduledNotification cancels a pending or processing scheduled notification.
|
|
// @Summary Cancel scheduled notification
|
|
// @Description Cancels a scheduled notification if it is still pending or processing
|
|
// @Tags notifications
|
|
// @Produce json
|
|
// @Param id path int true "Scheduled Notification ID"
|
|
// @Success 200 {object} domain.Response
|
|
// @Failure 400 {object} domain.ErrorResponse
|
|
// @Failure 500 {object} domain.ErrorResponse
|
|
// @Router /api/v1/notifications/scheduled/{id}/cancel [post]
|
|
func (h *Handler) CancelScheduledNotification(c *fiber.Ctx) error {
|
|
id, err := strconv.ParseInt(c.Params("id"), 10, 64)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Invalid ID",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
|
|
cancelled, err := h.notificationSvc.CancelScheduledNotification(c.Context(), id)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
|
Message: "Failed to cancel scheduled notification",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
|
|
return c.Status(fiber.StatusOK).JSON(domain.Response{
|
|
Message: "Scheduled notification cancelled",
|
|
Success: true,
|
|
StatusCode: fiber.StatusOK,
|
|
Data: cancelled,
|
|
})
|
|
}
|
|
|
|
// parseEmailAttachment reads an optional "file" from the multipart form and returns a Resend attachment.
|
|
func (h *Handler) parseEmailAttachment(c *fiber.Ctx) []*resend.Attachment {
|
|
fileHeader, err := c.FormFile("file")
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
fh, err := fileHeader.Open()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer fh.Close()
|
|
|
|
data, err := io.ReadAll(fh)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
return []*resend.Attachment{
|
|
{
|
|
Content: data,
|
|
Filename: fileHeader.Filename,
|
|
},
|
|
}
|
|
}
|
|
|
|
// SendSingleEmail sends an email to a single recipient with an optional image attachment.
|
|
// @Summary Send single email
|
|
// @Description Sends an email to a single email address with optional image attachment
|
|
// @Tags notifications
|
|
// @Accept multipart/form-data
|
|
// @Produce json
|
|
// @Success 200 {object} domain.Response
|
|
// @Failure 400 {object} domain.ErrorResponse
|
|
// @Failure 500 {object} domain.ErrorResponse
|
|
// @Router /api/v1/notifications/send-email [post]
|
|
func (h *Handler) SendSingleEmail(c *fiber.Ctx) error {
|
|
recipient := c.FormValue("recipient")
|
|
subject := c.FormValue("subject")
|
|
message := c.FormValue("message")
|
|
html := c.FormValue("html")
|
|
|
|
if recipient == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Recipient is required",
|
|
})
|
|
}
|
|
if subject == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Subject is required",
|
|
})
|
|
}
|
|
if message == "" && html == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Either message or html is required",
|
|
})
|
|
}
|
|
|
|
attachments := h.parseEmailAttachment(c)
|
|
|
|
if err := h.notificationSvc.MessengerSvc().SendEmailWithAttachments(
|
|
c.Context(), recipient, message, html, subject, attachments,
|
|
); err != nil {
|
|
h.mongoLoggerSvc.Error("[NotificationHandler.SendSingleEmail] Failed to send email",
|
|
zap.String("recipient", recipient),
|
|
zap.Error(err),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
|
Message: "Failed to send email",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
|
|
return c.Status(fiber.StatusOK).JSON(domain.Response{
|
|
Message: "Email sent successfully",
|
|
Success: true,
|
|
StatusCode: fiber.StatusOK,
|
|
})
|
|
}
|
|
|
|
// SendBulkEmail sends an email to multiple users by user IDs, role, or direct email addresses.
|
|
// @Summary Send bulk email
|
|
// @Description Sends an email to specified user IDs, all users of a role, or direct email addresses with optional image attachment. Optionally schedule for later with scheduled_at (RFC3339).
|
|
// @Tags notifications
|
|
// @Accept multipart/form-data
|
|
// @Produce json
|
|
// @Success 200 {object} domain.Response
|
|
// @Failure 400 {object} domain.ErrorResponse
|
|
// @Failure 500 {object} domain.ErrorResponse
|
|
// @Router /api/v1/notifications/bulk-email [post]
|
|
func (h *Handler) SendBulkEmail(c *fiber.Ctx) error {
|
|
subject := c.FormValue("subject")
|
|
message := c.FormValue("message")
|
|
html := c.FormValue("html")
|
|
role := c.FormValue("role")
|
|
userIDsRaw := c.FormValue("user_ids")
|
|
emailsRaw := c.FormValue("emails")
|
|
scheduledAtRaw := c.FormValue("scheduled_at")
|
|
|
|
if subject == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Subject is required",
|
|
})
|
|
}
|
|
if message == "" && html == "" {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Either message or html is required",
|
|
})
|
|
}
|
|
|
|
// Parse direct emails
|
|
var directEmails []string
|
|
if emailsRaw != "" {
|
|
if err := json.Unmarshal([]byte(emailsRaw), &directEmails); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Invalid emails format",
|
|
Error: "emails must be a JSON array of strings",
|
|
})
|
|
}
|
|
}
|
|
|
|
// Parse user_ids
|
|
var userIDs []int64
|
|
if userIDsRaw != "" {
|
|
if err := json.Unmarshal([]byte(userIDsRaw), &userIDs); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Invalid user_ids format",
|
|
Error: "user_ids must be a JSON array of integers",
|
|
})
|
|
}
|
|
}
|
|
|
|
if len(userIDs) == 0 && role == "" && len(directEmails) == 0 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "No recipients specified",
|
|
Error: "Provide user_ids, role, or emails",
|
|
})
|
|
}
|
|
|
|
// Schedule for later if scheduled_at is provided
|
|
if scheduledAtRaw != "" {
|
|
scheduledAt, err := time.Parse(time.RFC3339, scheduledAtRaw)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "Invalid scheduled_at format, use RFC3339 (e.g. 2025-06-01T10:00:00Z)",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
if scheduledAt.Before(time.Now()) {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "scheduled_at must be in the future",
|
|
})
|
|
}
|
|
|
|
creatorID, _ := c.Locals("user_id").(int64)
|
|
|
|
var targetRaw json.RawMessage
|
|
if len(directEmails) > 0 {
|
|
raw := domain.ScheduledNotificationTargetRaw{Emails: directEmails}
|
|
targetRaw, _ = json.Marshal(raw)
|
|
}
|
|
|
|
sn := &domain.ScheduledNotification{
|
|
Channel: domain.DeliveryChannelEmail,
|
|
Title: subject,
|
|
Message: message,
|
|
HTML: html,
|
|
ScheduledAt: scheduledAt,
|
|
TargetUserIDs: userIDs,
|
|
TargetRole: role,
|
|
TargetRaw: targetRaw,
|
|
CreatedBy: creatorID,
|
|
}
|
|
|
|
created, err := h.notificationSvc.CreateScheduledNotification(c.Context(), sn)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
|
Message: "Failed to schedule email",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
|
|
return c.Status(fiber.StatusCreated).JSON(domain.Response{
|
|
Message: "Email scheduled",
|
|
Success: true,
|
|
StatusCode: fiber.StatusCreated,
|
|
Data: created,
|
|
})
|
|
}
|
|
|
|
// Immediate send: collect emails from all sources
|
|
emailSet := make(map[string]struct{})
|
|
for _, e := range directEmails {
|
|
emailSet[e] = struct{}{}
|
|
}
|
|
|
|
if len(userIDs) == 0 && role != "" {
|
|
users, _, err := h.userSvc.GetAllUsers(context.Background(), domain.UserFilter{Role: role})
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
|
Message: "Failed to fetch users for role",
|
|
Error: err.Error(),
|
|
})
|
|
}
|
|
for _, u := range users {
|
|
userIDs = append(userIDs, u.ID)
|
|
}
|
|
}
|
|
|
|
for _, uid := range userIDs {
|
|
user, err := h.userSvc.GetUserByID(context.Background(), uid)
|
|
if err != nil {
|
|
h.mongoLoggerSvc.Warn("[NotificationHandler.SendBulkEmail] Failed to get user",
|
|
zap.Int64("userID", uid),
|
|
zap.Error(err),
|
|
)
|
|
continue
|
|
}
|
|
if user.Email != "" {
|
|
emailSet[user.Email] = struct{}{}
|
|
}
|
|
}
|
|
|
|
if len(emailSet) == 0 {
|
|
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
|
Message: "No recipients found",
|
|
Error: "Provide user_ids, role, or emails",
|
|
})
|
|
}
|
|
|
|
recipients := make([]string, 0, len(emailSet))
|
|
for e := range emailSet {
|
|
recipients = append(recipients, e)
|
|
}
|
|
|
|
attachments := h.parseEmailAttachment(c)
|
|
|
|
sent, failed := h.notificationSvc.SendBulkEmail(c.Context(), recipients, subject, message, html, attachments)
|
|
|
|
// Record in DB for history (only for known users)
|
|
for _, uid := range userIDs {
|
|
h.notificationSvc.RecordNotification(c.Context(), uid, domain.NOTIFICATION_TYPE_SYSTEM_ALERT, domain.DeliveryChannelEmail, domain.NotificationLevelInfo, subject, message)
|
|
}
|
|
|
|
h.mongoLoggerSvc.Info("[NotificationHandler.SendBulkEmail] Bulk email sent",
|
|
zap.Int("totalRecipients", len(recipients)),
|
|
zap.Int("sent", sent),
|
|
zap.Int("failed", failed),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
|
|
return c.Status(fiber.StatusOK).JSON(domain.Response{
|
|
Message: "Bulk email sent",
|
|
Success: true,
|
|
StatusCode: fiber.StatusOK,
|
|
Data: map[string]interface{}{
|
|
"total_recipients": len(recipients),
|
|
"sent": sent,
|
|
"failed": failed,
|
|
},
|
|
})
|
|
}
|
|
|
|
func (h *Handler) UnregisterDeviceToken(c *fiber.Ctx) error {
|
|
type Request struct {
|
|
DeviceToken string `json:"device_token" validate:"required"`
|
|
}
|
|
|
|
var req Request
|
|
if err := c.BodyParser(&req); err != nil {
|
|
h.mongoLoggerSvc.Info("[NotificationHandler.UnregisterDeviceToken] Failed to parse request body",
|
|
zap.Int("status_code", fiber.StatusBadRequest),
|
|
zap.Error(err),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
|
|
}
|
|
|
|
userID, ok := c.Locals("user_id").(int64)
|
|
if !ok || userID == 0 {
|
|
h.mongoLoggerSvc.Error("[NotificationHandler.UnregisterDeviceToken] Invalid user ID in context",
|
|
zap.Int("status_code", fiber.StatusUnauthorized),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
return fiber.NewError(fiber.StatusUnauthorized, "Invalid user identification")
|
|
}
|
|
|
|
if err := h.userSvc.DeactivateDevice(c.Context(), userID, req.DeviceToken); err != nil {
|
|
h.mongoLoggerSvc.Error("[NotificationHandler.UnregisterDeviceToken] Failed to unregister device token",
|
|
zap.Int64("userID", userID),
|
|
zap.Int("status_code", fiber.StatusInternalServerError),
|
|
zap.Error(err),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to unregister device token: "+err.Error())
|
|
}
|
|
|
|
h.mongoLoggerSvc.Info("[NotificationHandler.UnregisterDeviceToken] Device token unregistered successfully",
|
|
zap.Int64("userID", userID),
|
|
zap.Time("timestamp", time.Now()),
|
|
)
|
|
|
|
return c.Status(fiber.StatusOK).JSON(domain.Response{
|
|
Message: "Device token unregistered successfully",
|
|
Success: true,
|
|
StatusCode: fiber.StatusOK,
|
|
})
|
|
}
|