package v1 import ( "encoding/json" "fmt" "net/http" "strconv" "strings" "time" "github.com/gin-gonic/gin" "github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/service" 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 } // NewNotificationHandler creates a new Notification handler with injected service. func NewNotificationHandler(notificationService *service.NotificationService) *NotificationHandler { return &NotificationHandler{notificationService: notificationService} } // 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, serializeNotification(&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, serializeNotification(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 } c.JSON(http.StatusOK, serializeNotification(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, serializeNotification(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 } c.JSON(http.StatusOK, serializeNotification(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 } c.JSON(http.StatusOK, serializeNotification(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) if svcErr := h.notificationService.DeleteNotificationByAccount(c.Request.Context(), notificationID, userID, accountID); svcErr != nil { handleServiceError(c, svcErr) return } 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 serializeNotification(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(notification.PrimaryActorType, notification.PrimaryActorID), "read_at": notification.ReadAt, "secondary_actor": notificationActor(notification.SecondaryActorType, notification.SecondaryActorID), "user": gin.H{"id": notification.UserID}, "created_at": notification.CreatedAt.Unix(), "last_activity_at": notification.UpdatedAt.Unix(), "snoozed_until": notification.SnoozedUntil, "meta": meta, "additional_attributes": meta, } } func notificationActor(actorType string, actorID uint) interface{} { if actorType == "" || actorID == 0 { return nil } return gin.H{"id": actorID, "type": actorType, "meta": gin.H{}} } 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 } }