package v1 import ( "context" "encoding/json" "fmt" "net/http" "strconv" "strings" "time" "github.com/gin-gonic/gin" "gorm.io/gorm" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/service" "github.com/gochat/gochat/internal/ws" applogger "github.com/gochat/gochat/pkg/logger" "github.com/gochat/gochat/pkg/pagination" "github.com/gochat/gochat/pkg/response" ) // NotificationHandler handles Notification CRUD. // Reference: Chatwoot app/controllers/api/v1/accounts/notifications_controller.rb type NotificationHandler struct { notificationService *service.NotificationService eventPublisher *ws.EventPublisher } // NewNotificationHandler creates a new Notification handler with injected service. func NewNotificationHandler(notificationService *service.NotificationService) *NotificationHandler { return &NotificationHandler{notificationService: notificationService} } func (h *NotificationHandler) WithEventPublisher(publisher *ws.EventPublisher) *NotificationHandler { h.eventPublisher = publisher return h } // List returns all notifications for the current user in an account. // GET /api/v1/accounts/:account_id/notifications // Reference: Chatwoot index — NotificationFinder with pagination func (h *NotificationHandler) List(c *gin.Context) { if h.notificationService == nil { response.AbortWithStatusError(c, http.StatusServiceUnavailable, response.ErrInternal, "Notification service not available") return } accountID := getAccountID(c) userID := getUserID(c) p := pagination.Parse(c) if p.PerPage == pagination.DefaultPerPage && c.Query("per_page") == "" { p.PerPage = 15 } result, err := h.notificationService.ListNotificationsByAccountWithOptions(c.Request.Context(), userID, accountID, p.Page, p.PerPage, service.NotificationListOptions{ IncludeRead: notificationIncludes(c, "read"), IncludeSnoozed: notificationIncludes(c, "snoozed"), SortOrder: c.DefaultQuery("sort_order", "desc"), }) if err != nil { applogger.L().Errorf("List notifications: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "Failed to fetch notifications") return } payload := make([]gin.H, 0, len(result.Notifications)) for i := range result.Notifications { payload = append(payload, h.serializeNotification(c.Request.Context(), &result.Notifications[i])) } c.JSON(http.StatusOK, gin.H{ "data": gin.H{ "meta": gin.H{ "unread_count": result.UnreadCount, "count": result.Total, "current_page": p.Page, }, "payload": payload, }, }) } // Get returns a single notification by ID. // GET /api/v1/accounts/:account_id/notifications/:id func (h *NotificationHandler) Get(c *gin.Context) { if h.notificationService == nil { response.AbortWithStatusError(c, http.StatusServiceUnavailable, response.ErrInternal, "Notification service not available") return } notificationID, err := parseUintAnyParam(c, "notification_id", "id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid notification id") return } accountID := getAccountID(c) userID := getUserID(c) notification, svcErr := h.notificationService.GetNotificationByAccount(c.Request.Context(), notificationID, userID, accountID) if svcErr != nil { handleServiceError(c, svcErr) return } c.JSON(http.StatusOK, h.serializeNotification(c.Request.Context(), notification)) } // Update marks a notification as read. // PUT /api/v1/accounts/:account_id/notifications/:id // Reference: Chatwoot update — @notification.update(read_at: DateTime.now.utc); render json: @notification func (h *NotificationHandler) Update(c *gin.Context) { notificationID, err := parseUintAnyParam(c, "notification_id", "id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid notification id") return } accountID := getAccountID(c) userID := getUserID(c) notification, svcErr := h.notificationService.MarkReadByAccount(c.Request.Context(), notificationID, userID, accountID) if svcErr != nil { handleServiceError(c, svcErr) return } h.publishNotificationEvent(c.Request.Context(), accountID, userID, ws.EventNotificationUpdated, notification) c.JSON(http.StatusOK, h.serializeNotification(c.Request.Context(), notification)) } // MarkAllRead marks all unread notifications as read for the current user in account. // POST /api/v1/accounts/:account_id/notifications/read_all // Reference: Chatwoot read_all — update_all(read_at: ...); head :ok func (h *NotificationHandler) MarkAllRead(c *gin.Context) { accountID := getAccountID(c) userID := getUserID(c) var req struct { PrimaryActorType string `json:"primary_actor_type"` PrimaryActorID uint `json:"primary_actor_id"` } _ = bindOptionalNotificationJSON(c, &req) var err error if req.PrimaryActorType != "" && req.PrimaryActorID > 0 { err = h.notificationService.MarkPrimaryActorReadByAccount(c.Request.Context(), userID, accountID, req.PrimaryActorType, req.PrimaryActorID) } else { err = h.notificationService.MarkAllReadByAccount(c.Request.Context(), userID, accountID) } if err != nil { applogger.L().Errorf("MarkAllRead notifications: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "Failed to mark notifications as read") return } c.Status(http.StatusOK) } // UnreadCount returns the unread notification count. // GET /api/v1/accounts/:account_id/notifications/unread_count // Reference: Chatwoot unread_count — render json: @unread_count func (h *NotificationHandler) UnreadCount(c *gin.Context) { accountID := getAccountID(c) userID := getUserID(c) count, err := h.notificationService.GetUnreadCountByAccount(c.Request.Context(), userID, accountID) if err != nil { applogger.L().Errorf("UnreadCount notifications: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "Failed to count unread notifications") return } c.JSON(http.StatusOK, count) } // Snooze snoozes a notification until a specified time. // POST /api/v1/accounts/:account_id/notifications/:id/snooze // Reference: Chatwoot snooze — update(snoozed_until: ..., meta: ...) func (h *NotificationHandler) Snooze(c *gin.Context) { notificationID, err := parseUintAnyParam(c, "notification_id", "id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid notification id") return } accountID := getAccountID(c) userID := getUserID(c) var req struct { SnoozedUntil interface{} `json:"snoozed_until"` } if err := bindOptionalNotificationJSON(c, &req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid request body") return } if req.SnoozedUntil == nil || fmt.Sprint(req.SnoozedUntil) == "" { notification, svcErr := h.notificationService.GetNotificationByAccount(c.Request.Context(), notificationID, userID, accountID) if svcErr != nil { handleServiceError(c, svcErr) return } c.JSON(http.StatusOK, h.serializeNotification(c.Request.Context(), notification)) return } snoozedUntil, parseErr := parseNotificationUnixTime(req.SnoozedUntil) if parseErr != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid snoozed_until format") return } notification, svcErr := h.notificationService.SnoozeNotification(c.Request.Context(), notificationID, userID, accountID, snoozedUntil) if svcErr != nil { handleServiceError(c, svcErr) return } h.publishNotificationEvent(c.Request.Context(), accountID, userID, ws.EventNotificationUpdated, notification) c.JSON(http.StatusOK, h.serializeNotification(c.Request.Context(), notification)) } // Unread marks a notification as unread. // POST /api/v1/accounts/:account_id/notifications/:id/unread // Reference: Chatwoot unread — @notification.update(read_at: nil); render json: @notification func (h *NotificationHandler) Unread(c *gin.Context) { notificationID, err := parseUintAnyParam(c, "notification_id", "id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid notification id") return } accountID := getAccountID(c) userID := getUserID(c) notification, svcErr := h.notificationService.MarkNotificationUnread(c.Request.Context(), notificationID, userID, accountID) if svcErr != nil { handleServiceError(c, svcErr) return } h.publishNotificationEvent(c.Request.Context(), accountID, userID, ws.EventNotificationUpdated, notification) c.JSON(http.StatusOK, h.serializeNotification(c.Request.Context(), notification)) } // Destroy deletes a single notification. // DELETE /api/v1/accounts/:account_id/notifications/:id // Reference: Chatwoot destroy — @notification.destroy; head :ok func (h *NotificationHandler) Destroy(c *gin.Context) { notificationID, err := parseUintAnyParam(c, "notification_id", "id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid notification id") return } accountID := getAccountID(c) userID := getUserID(c) notification, svcErr := h.notificationService.GetNotificationByAccount(c.Request.Context(), notificationID, userID, accountID) if svcErr != nil { handleServiceError(c, svcErr) return } if svcErr := h.notificationService.DeleteNotificationByAccount(c.Request.Context(), notificationID, userID, accountID); svcErr != nil { handleServiceError(c, svcErr) return } h.publishNotificationEvent(c.Request.Context(), accountID, userID, ws.EventNotificationDeleted, notification) c.Status(http.StatusOK) } // DestroyAll deletes all (or all read) notifications. // DELETE /api/v1/accounts/:account_id/notifications/destroy_all // Reference: Chatwoot destroy_all — DeleteNotificationJob; head :ok // Query params: type=read → only read; otherwise all // Note: Chatwoot uses async DeleteNotificationJob; GoChat currently does synchronous deletion func (h *NotificationHandler) DestroyAll(c *gin.Context) { accountID := getAccountID(c) userID := getUserID(c) deleteType := c.Query("type") var req struct { Type string `json:"type"` } if err := bindOptionalNotificationJSON(c, &req); err == nil && req.Type != "" { deleteType = req.Type } var err error if deleteType == "read" { err = h.notificationService.DeleteReadNotifications(c.Request.Context(), userID, accountID) } else { err = h.notificationService.DeleteAllNotifications(c.Request.Context(), userID, accountID) } if err != nil { applogger.L().Errorf("DestroyAll notifications: %v", err) response.AbortWithStatusError(c, http.StatusInternalServerError, response.ErrInternal, "Failed to delete notifications") return } c.Status(http.StatusOK) } func notificationIncludes(c *gin.Context, value string) bool { for _, raw := range append(c.QueryArray("includes[]"), c.QueryArray("includes")...) { for _, part := range strings.Split(raw, ",") { if strings.TrimSpace(part) == value { return true } } } return false } func (h *NotificationHandler) publishNotificationEvent(ctx context.Context, accountID, userID uint, eventType string, notification *model.Notification) { if h.eventPublisher == nil || notification == nil || accountID == 0 || userID == 0 { return } unreadCount, err := h.notificationService.GetUnreadCountByAccount(ctx, userID, accountID) if err != nil { applogger.L().Warnf("notification event %s unread count: %v", eventType, err) return } total, err := h.notificationService.CountNotificationsByAccount(ctx, userID, accountID) if err != nil { applogger.L().Warnf("notification event %s total count: %v", eventType, err) return } h.eventPublisher.PublishEvent(accountID, eventType, gin.H{ "notification": h.serializeNotification(ctx, notification), "unread_count": unreadCount, "count": total, }) } func (h *NotificationHandler) serializeNotification(ctx context.Context, notification *model.Notification) gin.H { var db *gorm.DB if h != nil && h.notificationService != nil { db = h.notificationService.DB() } return serializeNotificationWithDB(ctx, db, notification) } func serializeNotification(notification *model.Notification) gin.H { return serializeNotificationWithDB(context.Background(), nil, notification) } func serializeNotificationWithDB(ctx context.Context, db *gorm.DB, notification *model.Notification) gin.H { meta := gin.H{} if len(notification.AdditionalAttributes) > 0 { _ = json.Unmarshal(notification.AdditionalAttributes, &meta) } return gin.H{ "id": notification.ID, "notification_type": notification.NotificationType, "push_message_title": notificationTitle(notification), "push_message_body": notificationBody(notification), "primary_actor_type": notification.PrimaryActorType, "primary_actor_id": notification.PrimaryActorID, "primary_actor": notificationActor(ctx, db, notification.PrimaryActorType, notification.PrimaryActorID, notification.AccountID), "read_at": notification.ReadAt, "secondary_actor": notificationActor(ctx, db, notification.SecondaryActorType, notification.SecondaryActorID, notification.AccountID), "user": notificationUser(ctx, db, notification.UserID, notification.AccountID), "created_at": notification.CreatedAt.Unix(), "last_activity_at": notification.UpdatedAt.Unix(), "snoozed_until": notification.SnoozedUntil, "meta": meta, "additional_attributes": meta, } } func notificationActor(ctx context.Context, db *gorm.DB, actorType string, actorID uint, accountID *uint) interface{} { if actorType == "" || actorID == 0 { return nil } if db == nil { return gin.H{"id": actorID, "type": actorType, "meta": gin.H{}} } switch strings.ToLower(actorType) { case "conversation": var conversation model.Conversation query := db.WithContext(ctx).Preload("Contact").Preload("Inbox").First(&conversation, actorID) if query.Error == nil && notificationAccountMatches(accountID, conversation.AccountID) { return serializeConversationForNotification(ctx, db, &conversation) } case "contact": var contact model.Contact query := db.WithContext(ctx).First(&contact, actorID) if query.Error == nil && notificationAccountMatches(accountID, contact.AccountID) { return serializeContactForNotification(&contact) } case "user": var user model.User query := db.WithContext(ctx).First(&user, actorID) if query.Error == nil && notificationAccountMatches(accountID, user.AccountID) { return serializeUserForNotification(&user) } case "agentbot", "agent_bot": var bot model.AgentBot query := db.WithContext(ctx).First(&bot, actorID) if query.Error == nil && (bot.AccountID == nil || accountID == nil || *bot.AccountID == *accountID) { return serializeAgentBotSender(&bot) } } return gin.H{"id": actorID, "type": actorType, "meta": gin.H{}} } func notificationUser(ctx context.Context, db *gorm.DB, userID uint, accountID *uint) interface{} { if db == nil || userID == 0 { return gin.H{"id": userID} } var user model.User query := db.WithContext(ctx).First(&user, userID) if query.Error == nil && notificationAccountMatches(accountID, user.AccountID) { return serializeUserForNotification(&user) } return gin.H{"id": userID} } func notificationAccountMatches(accountID *uint, recordAccountID uint) bool { return accountID == nil || *accountID == recordAccountID } func serializeConversationForNotification(ctx context.Context, db *gorm.DB, conversation *model.Conversation) map[string]any { payload := map[string]any{ "additional_attributes": jsonObject(conversation.AdditionalAttributes), "can_reply": conversationCanReply(ctx, db, conversation), "channel": conversation.ChannelType, "contact_inbox": nil, "id": conversationDisplayID(conversation), "inbox_id": conversation.InboxID, "labels": labelList(conversation.Labels), "meta": serializeConversationMeta(ctx, db, conversation), "status": conversation.Status, "custom_attributes": jsonObject(conversation.CustomAttributes), "snoozed_until": conversation.SnoozedUntil, "unread_count": unreadCount(ctx, db, conversation), "first_reply_created_at": int64Value(conversation.FirstReplyCreatedAt), "priority": conversation.Priority, "waiting_since": int64Value(conversation.WaitingSince), "agent_last_seen_at": int64Value(conversation.AgentLastSeenAt), "contact_last_seen_at": int64Value(conversation.ContactLastSeenAt), "last_activity_at": int64Value(conversation.LastActivityAt), "timestamp": int64Value(conversation.LastActivityAt), "created_at": conversation.CreatedAt.Unix(), "updated_at": float64(conversation.UpdatedAt.UnixNano()) / float64(time.Second), } payload["contact_inbox"] = serializeConversationContactInboxForNotification(ctx, db, conversation) if message := latestChatMessageForNotification(ctx, db, conversation); message != nil { payload["messages"] = []any{serializeMessage(ctx, db, message, conversation)} } else { payload["messages"] = []any{} } return payload } func serializeConversationContactInboxForNotification(ctx context.Context, db *gorm.DB, conversation *model.Conversation) map[string]any { if db == nil || conversation.ContactInboxID == nil || *conversation.ContactInboxID == 0 { return nil } var contactInbox model.ContactInbox if err := db.WithContext(ctx).First(&contactInbox, *conversation.ContactInboxID).Error; err != nil { return nil } return serializeContactInboxShell(ctx, db, &contactInbox) } func latestChatMessageForNotification(ctx context.Context, db *gorm.DB, conversation *model.Conversation) *model.Message { if db == nil || conversation == nil { return nil } var message model.Message query := db.WithContext(ctx). Where("conversation_id = ? AND account_id = ? AND message_type != ?", conversation.ID, conversation.AccountID, "activity"). Order("created_at DESC, id DESC"). First(&message) if query.Error != nil { return nil } return &message } func serializeContactForNotification(contact *model.Contact) map[string]any { payload := serializeContact(contact) payload["type"] = "contact" delete(payload, "availability_status") delete(payload, "last_activity_at") delete(payload, "created_at") return payload } func serializeUserForNotification(user *model.User) map[string]any { return map[string]any{ "id": user.ID, "name": user.Name, "available_name": nonEmpty(user.DisplayName, user.Name), "avatar_url": user.AvatarURL, "type": "user", "availability_status": availabilityStatus(user.Available), "thumbnail": user.AvatarURL, } } func notificationTitle(notification *model.Notification) string { if title := stringFromNotificationAttrs(notification.AdditionalAttributes, "push_message_title", "title"); title != "" { return title } return notification.NotificationType } func notificationBody(notification *model.Notification) string { return stringFromNotificationAttrs(notification.AdditionalAttributes, "push_message_body", "body", "message") } func stringFromNotificationAttrs(raw json.RawMessage, keys ...string) string { if len(raw) == 0 { return "" } var attrs map[string]interface{} if err := json.Unmarshal(raw, &attrs); err != nil { return "" } for _, key := range keys { if value, ok := attrs[key].(string); ok { return value } } return "" } func bindOptionalNotificationJSON(c *gin.Context, target interface{}) error { if c.Request.Body == nil || c.Request.ContentLength == 0 { return nil } return c.ShouldBindJSON(target) } func parseNotificationUnixTime(value interface{}) (time.Time, error) { switch v := value.(type) { case float64: return time.Unix(int64(v), 0).UTC(), nil case int64: return time.Unix(v, 0).UTC(), nil case int: return time.Unix(int64(v), 0).UTC(), nil case string: seconds, err := strconv.ParseInt(v, 10, 64) if err != nil { return time.Time{}, err } return time.Unix(seconds, 0).UTC(), nil default: seconds, err := strconv.ParseInt(fmt.Sprint(v), 10, 64) if err != nil { return time.Time{}, err } return time.Unix(seconds, 0).UTC(), nil } }