feat(notifications): align chatwoot notification payloads

This commit is contained in:
2026-06-05 22:01:51 +08:00
parent ddf2694ed3
commit 3fc275b639
5 changed files with 369 additions and 42 deletions
+131 -16
View File
@@ -1,11 +1,14 @@
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"
@@ -34,19 +37,34 @@ func (h *NotificationHandler) List(c *gin.Context) {
accountID := getAccountID(c)
userID := getUserID(c)
p := pagination.Parse(c)
if p.PerPage == pagination.DefaultPerPage && c.Query("per_page") == "" {
p.PerPage = 15
}
notifications, total, err := h.notificationService.ListNotificationsByAccount(c.Request.Context(), userID, accountID, p.Page, p.PerPage)
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
}
response.OK(c, gin.H{
"notifications": notifications,
"page": p.Page,
"per_page": p.PerPage,
"total": total,
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,
},
})
}
@@ -69,7 +87,7 @@ func (h *NotificationHandler) Get(c *gin.Context) {
return
}
response.OK(c, notification)
c.JSON(http.StatusOK, serializeNotification(notification))
}
// Update marks a notification as read.
@@ -95,7 +113,7 @@ func (h *NotificationHandler) Update(c *gin.Context) {
return
}
response.OK(c, notification)
c.JSON(http.StatusOK, serializeNotification(notification))
}
// MarkAllRead marks all unread notifications as read for the current user in account.
@@ -104,8 +122,19 @@ func (h *NotificationHandler) Update(c *gin.Context) {
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)
if err := h.notificationService.MarkAllReadByAccount(c.Request.Context(), userID, accountID); err != nil {
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
@@ -127,7 +156,7 @@ func (h *NotificationHandler) UnreadCount(c *gin.Context) {
return
}
response.OK(c, gin.H{"unread_count": count})
c.JSON(http.StatusOK, count)
}
// Snooze snoozes a notification until a specified time.
@@ -163,7 +192,7 @@ func (h *NotificationHandler) Snooze(c *gin.Context) {
return
}
response.OK(c, notification)
c.JSON(http.StatusOK, serializeNotification(notification))
}
// Unread marks a notification as unread.
@@ -185,7 +214,7 @@ func (h *NotificationHandler) Unread(c *gin.Context) {
return
}
response.OK(c, notification)
c.JSON(http.StatusOK, serializeNotification(notification))
}
// Destroy deletes a single notification.
@@ -214,11 +243,21 @@ func (h *NotificationHandler) Destroy(c *gin.Context) {
func (h *NotificationHandler) DestroyAll(c *gin.Context) {
accountID := getAccountID(c)
userID := getUserID(c)
// Chatwoot supports type=read filter; GoChat currently deletes all regardless
// TODO: implement type=read filter when async job system is available
_ = c.Query("type")
deleteType := c.Query("type")
var req struct {
Type string `json:"type"`
}
if err := bindOptionalNotificationJSON(c, &req); err == nil && req.Type != "" {
deleteType = req.Type
}
if err := h.notificationService.DeleteAllNotifications(c.Request.Context(), userID, accountID); err != nil {
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
@@ -226,3 +265,79 @@ func (h *NotificationHandler) DestroyAll(c *gin.Context) {
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)
}
@@ -1,6 +1,7 @@
package v1
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
@@ -59,6 +60,7 @@ func setupNotificationRouter(handler *NotificationHandler) *gin.Engine {
// G8 extension routes
router.POST("/api/v1/accounts/:account_id/notifications/:notification_id/snooze", handler.Snooze)
router.POST("/api/v1/accounts/:account_id/notifications/:notification_id/unread", handler.Unread)
router.POST("/api/v1/accounts/:account_id/notifications/destroy_all", handler.DestroyAll)
router.DELETE("/api/v1/accounts/:account_id/notifications/destroy_all", handler.DestroyAll)
return router
}
@@ -91,6 +93,59 @@ func TestNotificationListEmpty(t *testing.T) {
sqlDB.Close()
}
func TestNotificationHandler_List_ChatwootEnvelopeAndIncludes(t *testing.T) {
db := setupNotificationDB(t)
handler := setupNotificationHandler(t, db)
router := setupNotificationRouter(handler)
user := &model.User{Name: "Envelope User", Email: "envelope@example.com", Password: "pass", AccountID: 1}
require.NoError(t, db.Create(user).Error)
accountID := uint(1)
readAt := time.Now()
snoozedUntil := time.Now().Add(time.Hour)
unread := &model.Notification{UserID: user.ID, AccountID: &accountID, NotificationType: "message_created", PrimaryActorType: "Conversation", PrimaryActorID: 101, AdditionalAttributes: []byte(`{"push_message_title":"New message","push_message_body":"Hello"}`)}
read := &model.Notification{UserID: user.ID, AccountID: &accountID, NotificationType: "conversation_assignment", PrimaryActorType: "Conversation", PrimaryActorID: 102, ReadAt: &readAt}
snoozed := &model.Notification{UserID: user.ID, AccountID: &accountID, NotificationType: "conversation_mention", PrimaryActorType: "Conversation", PrimaryActorID: 103, SnoozedUntil: &snoozedUntil}
require.NoError(t, db.Create(unread).Error)
require.NoError(t, db.Create(read).Error)
require.NoError(t, db.Create(snoozed).Error)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/accounts/1/notifications", nil)
req.Header.Set("X-User-ID", strconv.FormatUint(uint64(user.ID), 10))
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var body map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
data := body["data"].(map[string]any)
meta := data["meta"].(map[string]any)
payload := data["payload"].([]any)
assert.Equal(t, float64(1), meta["count"])
assert.Equal(t, float64(1), meta["unread_count"])
require.Len(t, payload, 1)
item := payload[0].(map[string]any)
assert.Equal(t, "New message", item["push_message_title"])
assert.Equal(t, "Hello", item["push_message_body"])
assert.Equal(t, float64(101), item["primary_actor_id"])
assert.NotContains(t, body, "success")
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/api/v1/accounts/1/notifications?includes[]=read&includes[]=snoozed", nil)
req.Header.Set("X-User-ID", strconv.FormatUint(uint64(user.ID), 10))
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
data = body["data"].(map[string]any)
meta = data["meta"].(map[string]any)
payload = data["payload"].([]any)
assert.Equal(t, float64(3), meta["count"])
assert.Equal(t, float64(2), meta["unread_count"])
assert.Len(t, payload, 3)
sqlDB, _ := db.DB()
sqlDB.Close()
}
func TestNotificationGet(t *testing.T) {
db := setupNotificationDB(t)
handler := setupNotificationHandler(t, db)
@@ -155,11 +210,11 @@ func TestNotificationCRUDWithDB(t *testing.T) {
// Create notification
notif := &model.Notification{
UserID: user.ID,
AccountID: uintPtr(1),
NotificationType: "conversation_assignment",
PrimaryActorType: "conversation",
PrimaryActorID: 5,
UserID: user.ID,
AccountID: uintPtr(1),
NotificationType: "conversation_assignment",
PrimaryActorType: "conversation",
PrimaryActorID: 5,
SecondaryActorType: "user",
SecondaryActorID: 3,
}
@@ -201,11 +256,11 @@ func TestNotificationReadAllWithDB(t *testing.T) {
// Create multiple unread notifications
for i := 0; i < 5; i++ {
notif := &model.Notification{
UserID: user.ID,
AccountID: uintPtr(1),
NotificationType: "message_created",
PrimaryActorType: "message",
PrimaryActorID: uint(i + 1),
UserID: user.ID,
AccountID: uintPtr(1),
NotificationType: "message_created",
PrimaryActorType: "message",
PrimaryActorID: uint(i + 1),
}
err := db.Create(notif).Error
assert.NoError(t, err)
@@ -244,11 +299,11 @@ func TestNotificationTypes(t *testing.T) {
func TestNotificationModelFields(t *testing.T) {
notif := model.Notification{
UserID: 1,
AccountID: uintPtr(2),
NotificationType: "conversation_assignment",
PrimaryActorType: "conversation",
PrimaryActorID: 5,
UserID: 1,
AccountID: uintPtr(2),
NotificationType: "conversation_assignment",
PrimaryActorType: "conversation",
PrimaryActorID: 5,
SecondaryActorType: "user",
SecondaryActorID: 3,
}
@@ -547,4 +602,66 @@ func TestNotificationHandler_DestroyAllWithDB(t *testing.T) {
sqlDB, _ := db.DB()
sqlDB.Close()
}
}
func TestNotificationHandler_DestroyAll_ReadOnly(t *testing.T) {
db := setupNotificationDB(t)
handler := setupNotificationHandler(t, db)
router := setupNotificationRouter(handler)
user := &model.User{Name: "DestroyRead User", Email: "destroy-read@example.com", Password: "pass", AccountID: 1}
require.NoError(t, db.Create(user).Error)
accountID := uint(1)
readAt := time.Now()
readNotification := &model.Notification{UserID: user.ID, AccountID: &accountID, NotificationType: "read", PrimaryActorType: "conversation", PrimaryActorID: 1, ReadAt: &readAt}
unreadNotification := &model.Notification{UserID: user.ID, AccountID: &accountID, NotificationType: "unread", PrimaryActorType: "conversation", PrimaryActorID: 2}
require.NoError(t, db.Create(readNotification).Error)
require.NoError(t, db.Create(unreadNotification).Error)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/accounts/1/notifications/destroy_all", strings.NewReader(`{"type":"read"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-ID", strconv.FormatUint(uint64(user.ID), 10))
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var count int64
require.NoError(t, db.Model(&model.Notification{}).Where("id = ?", readNotification.ID).Count(&count).Error)
assert.Equal(t, int64(0), count)
require.NoError(t, db.Model(&model.Notification{}).Where("id = ?", unreadNotification.ID).Count(&count).Error)
assert.Equal(t, int64(1), count)
sqlDB, _ := db.DB()
sqlDB.Close()
}
func TestNotificationHandler_MarkAllRead_PrimaryActorOnly(t *testing.T) {
db := setupNotificationDB(t)
handler := setupNotificationHandler(t, db)
router := setupNotificationRouter(handler)
user := &model.User{Name: "Actor Read User", Email: "actor-read@example.com", Password: "pass", AccountID: 1}
require.NoError(t, db.Create(user).Error)
accountID := uint(1)
matching := &model.Notification{UserID: user.ID, AccountID: &accountID, NotificationType: "message_created", PrimaryActorType: "Conversation", PrimaryActorID: 1}
other := &model.Notification{UserID: user.ID, AccountID: &accountID, NotificationType: "message_created", PrimaryActorType: "Conversation", PrimaryActorID: 2}
require.NoError(t, db.Create(matching).Error)
require.NoError(t, db.Create(other).Error)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/accounts/1/notifications/read_all", strings.NewReader(`{"primary_actor_type":"Conversation","primary_actor_id":1}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-ID", strconv.FormatUint(uint64(user.ID), 10))
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var matchingReloaded model.Notification
require.NoError(t, db.First(&matchingReloaded, matching.ID).Error)
assert.NotNil(t, matchingReloaded.ReadAt)
var otherReloaded model.Notification
require.NoError(t, db.First(&otherReloaded, other.ID).Error)
assert.Nil(t, otherReloaded.ReadAt)
sqlDB, _ := db.DB()
sqlDB.Close()
}