Files
gochat/internal/handler/api/v1/notification_handler.go
T

344 lines
12 KiB
Go

package v1
import (
"encoding/json"
"net/http"
"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
}
notification, svcErr := h.notificationService.GetNotification(c.Request.Context(), notificationID)
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
}
// MarkRead only returns error; need to fetch updated notification for response
if svcErr := h.notificationService.MarkRead(c.Request.Context(), notificationID); svcErr != nil {
handleServiceError(c, svcErr)
return
}
// Return the updated notification (Chatwoot renders json: @notification)
notification, svcErr := h.notificationService.GetNotification(c.Request.Context(), notificationID)
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) {
userID := getUserID(c)
count, err := h.notificationService.GetUnreadCount(c.Request.Context(), userID)
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 string `json:"snoozed_until"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid request body")
return
}
snoozedUntil, parseErr := time.Parse(time.RFC3339, 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
}
if svcErr := h.notificationService.DeleteNotification(c.Request.Context(), notificationID); 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)
}