feat(conversations): align last seen reads

This commit is contained in:
2026-06-07 11:33:08 +08:00
parent 6b6289f896
commit 5ae2d7fec9
5 changed files with 192 additions and 17 deletions
@@ -930,7 +930,7 @@ func (h *ConversationHandler) UpdateLastSeen(c *gin.Context) {
if !ok {
return
}
svcErr := h.conversationSvc.UpdateLastSeen(c.Request.Context(), accountID, conversation.ID)
svcErr := h.conversationSvc.UpdateLastSeen(c.Request.Context(), accountID, conversation.ID, currentUserID(c))
if svcErr != nil {
handleServiceError(c, svcErr)
return
@@ -50,6 +50,7 @@ type ConversationHandlerTestSuite struct {
testAccount *model.Account
testConv *model.Conversation
testUser *model.User
testInbox *model.Inbox
}
func (s *ConversationHandlerTestSuite) SetupSuite() {
@@ -71,6 +72,7 @@ func (s *ConversationHandlerTestSuite) SetupSuite() {
&model.ConversationParticipant{},
&model.ReportingEvent{},
&model.Message{},
&model.Notification{},
&model.Attachment{},
&model.InboxMember{},
&model.AccountUser{},
@@ -94,6 +96,7 @@ func (s *ConversationHandlerTestSuite) SetupSuite() {
// Create inbox and contact
inbox := &model.Inbox{AccountID: account.ID, Name: "ConvHandlerTestInbox", ChannelType: "web_widget", ChannelID: 1}
s.Require().NoError(db.Create(inbox).Error)
s.testInbox = inbox
s.Require().NoError(db.Create(&model.InboxMember{InboxID: inbox.ID, UserID: user.ID}).Error)
contact := &model.Contact{AccountID: account.ID, Name: "ConvHandlerTestContact"}
@@ -733,6 +736,30 @@ func (s *ConversationHandlerTestSuite) TestToggleTyping_MissingFields() {
assert.Equal(s.T(), http.StatusBadRequest, w.Code)
}
func (s *ConversationHandlerTestSuite) TestUpdateLastSeen_SuccessMarksNotificationRead() {
oldSeen := time.Now().Add(-2 * time.Hour).Unix()
s.Require().NoError(s.db.Model(s.testConv).Update("agent_last_seen_at", oldSeen).Error)
s.Require().NoError(s.db.Create(&model.Message{AccountID: s.testAccount.ID, InboxID: s.testInbox.ID, ConversationID: s.testConv.ID, MessageType: string(model.MessageTypeIncoming), Content: "new message"}).Error)
accountID := s.testAccount.ID
notification := &model.Notification{UserID: s.testUser.ID, AccountID: &accountID, NotificationType: "assigned_conversation_new_message", PrimaryActorType: "Conversation", PrimaryActorID: s.testConv.ID}
s.Require().NoError(s.db.Create(notification).Error)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/conversations/%d/update_last_seen", s.testAccount.ID, s.testConv.ID), nil)
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusOK, w.Code)
assert.Empty(s.T(), w.Body.String())
var conversation model.Conversation
s.Require().NoError(s.db.First(&conversation, s.testConv.ID).Error)
s.Require().NotNil(conversation.AgentLastSeenAt)
assert.Greater(s.T(), *conversation.AgentLastSeenAt, oldSeen)
var updatedNotification model.Notification
s.Require().NoError(s.db.First(&updatedNotification, notification.ID).Error)
assert.NotNil(s.T(), updatedNotification.ReadAt)
}
func (s *ConversationHandlerTestSuite) TestUpdateLastSeen_InvalidAccountID() {
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/accounts/abc/conversations/1/update_last_seen", nil)
+73 -10
View File
@@ -1429,14 +1429,14 @@ func (s *ConversationService) MarkUnread(ctx context.Context, accountID, id uint
// Find the last incoming message for this conversation
lastIncomingMsg, err := s.msgRepo.FindLastIncomingByConversation(ctx, id)
if err != nil {
// No incoming message found — set agent_last_seen_at to nil
if err := s.repo.MarkUnread(ctx, id); err != nil {
// No incoming message found; clear both last-seen columns like Chatwoot's update_last_seen_on_conversation(nil, true).
if err := s.updateLastSeenColumns(ctx, accountID, id, nil, true); err != nil {
return nil, err
}
} else {
// Set agent_last_seen_at to last_incoming_message.CreatedAt - 1 second
lastSeenAt := lastIncomingMsg.CreatedAt.Add(-1 * time.Second)
if err := s.repo.UpdateAgentLastSeenAt(ctx, id, lastSeenAt); err != nil {
// Set both last-seen columns to last_incoming_message.CreatedAt - 1 second.
lastSeenAt := lastIncomingMsg.CreatedAt.Add(-1 * time.Second).Unix()
if err := s.updateLastSeenColumns(ctx, accountID, id, &lastSeenAt, true); err != nil {
return nil, err
}
}
@@ -1675,16 +1675,79 @@ func (s *ConversationService) ToggleTyping(ctx context.Context, accountID, conve
return nil
}
// UpdateLastSeen sets the agent_last_seen_at timestamp on a conversation.
// UpdateLastSeen marks the current user as having viewed a conversation.
// Reference: Chatwoot conversations_controller.rb #update_last_seen
func (s *ConversationService) UpdateLastSeen(ctx context.Context, accountID, conversationID uint) error {
func (s *ConversationService) UpdateLastSeen(ctx context.Context, accountID, conversationID, userID uint) error {
conversation, err := s.GetByAccountAndID(ctx, accountID, conversationID)
if err != nil {
return err
}
now := time.Now().Unix()
conversation.AgentLastSeenAt = &now
return s.repo.Update(ctx, conversation)
if userID != 0 {
now := time.Now()
if err := s.repo.DB().WithContext(ctx).Model(&model.Notification{}).
Where("user_id = ? AND account_id = ? AND primary_actor_type = ? AND primary_actor_id = ? AND read_at IS NULL", userID, accountID, "Conversation", conversation.ID).
Update("read_at", now).Error; err != nil {
return err
}
}
isAssignee := userID != 0 && conversation.AssigneeID != nil && *conversation.AssigneeID == userID
nowTS := time.Now().Unix()
if isAssignee {
hasUnread, err := s.hasMessagesSince(ctx, conversation, conversation.AssigneeLastSeenAt)
if err != nil {
return err
}
if hasUnread {
return s.updateLastSeenColumns(ctx, accountID, conversation.ID, &nowTS, true)
}
} else {
hasUnread, err := s.hasMessagesSince(ctx, conversation, conversation.AgentLastSeenAt)
if err != nil {
return err
}
if hasUnread {
return s.updateLastSeenColumns(ctx, accountID, conversation.ID, &nowTS, false)
}
}
if !shouldUpdateConversationLastSeen(conversation, isAssignee, nowTS) {
return nil
}
return s.updateLastSeenColumns(ctx, accountID, conversation.ID, &nowTS, isAssignee)
}
func (s *ConversationService) hasMessagesSince(ctx context.Context, conversation *model.Conversation, seenAt *int64) (bool, error) {
query := s.repo.DB().WithContext(ctx).Model(&model.Message{}).
Where("account_id = ? AND conversation_id = ?", conversation.AccountID, conversation.ID)
if seenAt != nil {
query = query.Where("created_at > ?", time.Unix(*seenAt, 0))
}
var count int64
if err := query.Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
func shouldUpdateConversationLastSeen(conversation *model.Conversation, isAssignee bool, nowTS int64) bool {
agentNeedsUpdate := conversation.AgentLastSeenAt == nil || *conversation.AgentLastSeenAt < nowTS-int64(time.Hour/time.Second)
if !isAssignee {
return agentNeedsUpdate
}
assigneeNeedsUpdate := conversation.AssigneeLastSeenAt == nil || *conversation.AssigneeLastSeenAt < nowTS-int64(time.Hour/time.Second)
return agentNeedsUpdate || assigneeNeedsUpdate
}
func (s *ConversationService) updateLastSeenColumns(ctx context.Context, accountID, conversationID uint, lastSeenAt *int64, updateAssignee bool) error {
updates := map[string]any{"agent_last_seen_at": lastSeenAt}
if updateAssignee {
updates["assignee_last_seen_at"] = lastSeenAt
}
return s.repo.DB().WithContext(ctx).Model(&model.Conversation{}).
Where("id = ? AND account_id = ?", conversationID, accountID).
UpdateColumns(updates).Error
}
// AssignTeam assigns a team (and optionally a specific agent) to a conversation.
+83 -2
View File
@@ -35,6 +35,7 @@ func setupConversationServiceTestDB(t *testing.T) *gorm.DB {
&model.ContactInbox{},
&model.Conversation{},
&model.Message{},
&model.Notification{},
&model.ConversationParticipant{},
&model.InboxMember{},
&model.ConversationLabel{},
@@ -467,7 +468,7 @@ func TestConversationService_MarkUnread(t *testing.T) {
// Set agent_last_seen_at to a non-nil value
seenAt := int64(1700000000)
require.NoError(t, db.Model(conv).Update("agent_last_seen_at", seenAt).Error)
require.NoError(t, db.Model(conv).Updates(map[string]any{"agent_last_seen_at": seenAt, "assignee_last_seen_at": seenAt}).Error)
// Create an incoming message so MarkUnread sets agent_last_seen_at to last_incoming.CreatedAt - 1s
incomingMsg := &model.Message{
@@ -488,6 +489,8 @@ func TestConversationService_MarkUnread(t *testing.T) {
expectedSeenAt := incomingMsg.CreatedAt.Unix() - 1
assert.NotNil(t, conversation.AgentLastSeenAt)
assert.Equal(t, expectedSeenAt, *conversation.AgentLastSeenAt)
assert.NotNil(t, conversation.AssigneeLastSeenAt)
assert.Equal(t, expectedSeenAt, *conversation.AssigneeLastSeenAt)
}
func TestConversationService_MarkUnread_NoIncomingMessages(t *testing.T) {
@@ -501,13 +504,14 @@ func TestConversationService_MarkUnread_NoIncomingMessages(t *testing.T) {
// Set agent_last_seen_at to a non-nil value
seenAt := int64(1700000000)
require.NoError(t, db.Model(conv).Update("agent_last_seen_at", seenAt).Error)
require.NoError(t, db.Model(conv).Updates(map[string]any{"agent_last_seen_at": seenAt, "assignee_last_seen_at": seenAt}).Error)
// No incoming messages — MarkUnread should clear agent_last_seen_at to nil
conversation, err := svc.MarkUnread(context.Background(), account.ID, conv.ID)
assert.NoError(t, err)
assert.NotNil(t, conversation)
assert.Nil(t, conversation.AgentLastSeenAt)
assert.Nil(t, conversation.AssigneeLastSeenAt)
}
func TestConversationService_MarkUnread_WrongAccount(t *testing.T) {
@@ -534,6 +538,83 @@ func TestConversationService_MarkUnread_NotFound(t *testing.T) {
assert.Nil(t, conversation)
}
func TestConversationService_UpdateLastSeen_AssigneeUnreadUpdatesBothColumnsAndReadsNotifications(t *testing.T) {
svc, db := setupConversationService(t)
account := createConversationServiceTestAccount(t, db)
inbox := createConversationServiceTestInbox(t, db, account.ID)
contact := createConversationServiceTestContact(t, db, account.ID)
user := &model.User{Name: "Last Seen Agent", Email: "last-seen-agent@example.com"}
require.NoError(t, db.Create(user).Error)
conv := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open")
oldSeen := time.Now().Add(-2 * time.Hour).Unix()
require.NoError(t, db.Model(conv).Updates(map[string]any{"assignee_id": user.ID, "agent_last_seen_at": oldSeen, "assignee_last_seen_at": oldSeen}).Error)
require.NoError(t, db.Create(&model.Message{AccountID: account.ID, InboxID: inbox.ID, ConversationID: conv.ID, MessageType: string(model.MessageTypeIncoming), Content: "new"}).Error)
accountID := account.ID
notification := &model.Notification{UserID: user.ID, AccountID: &accountID, NotificationType: "assigned_conversation_new_message", PrimaryActorType: "Conversation", PrimaryActorID: conv.ID}
require.NoError(t, db.Create(notification).Error)
require.NoError(t, svc.UpdateLastSeen(context.Background(), account.ID, conv.ID, user.ID))
var updated model.Conversation
require.NoError(t, db.First(&updated, conv.ID).Error)
require.NotNil(t, updated.AgentLastSeenAt)
require.NotNil(t, updated.AssigneeLastSeenAt)
assert.Greater(t, *updated.AgentLastSeenAt, oldSeen)
assert.Greater(t, *updated.AssigneeLastSeenAt, oldSeen)
var updatedNotification model.Notification
require.NoError(t, db.First(&updatedNotification, notification.ID).Error)
assert.NotNil(t, updatedNotification.ReadAt)
}
func TestConversationService_UpdateLastSeen_NonAssigneeUnreadUpdatesOnlyAgentColumn(t *testing.T) {
svc, db := setupConversationService(t)
account := createConversationServiceTestAccount(t, db)
inbox := createConversationServiceTestInbox(t, db, account.ID)
contact := createConversationServiceTestContact(t, db, account.ID)
assignee := &model.User{Name: "Assigned Agent", Email: "assigned-agent@example.com"}
viewer := &model.User{Name: "Viewing Agent", Email: "viewing-agent@example.com"}
require.NoError(t, db.Create(assignee).Error)
require.NoError(t, db.Create(viewer).Error)
conv := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open")
oldSeen := time.Now().Add(-2 * time.Hour).Unix()
require.NoError(t, db.Model(conv).Updates(map[string]any{"assignee_id": assignee.ID, "agent_last_seen_at": oldSeen, "assignee_last_seen_at": oldSeen}).Error)
require.NoError(t, db.Create(&model.Message{AccountID: account.ID, InboxID: inbox.ID, ConversationID: conv.ID, MessageType: string(model.MessageTypeIncoming), Content: "new"}).Error)
require.NoError(t, svc.UpdateLastSeen(context.Background(), account.ID, conv.ID, viewer.ID))
var updated model.Conversation
require.NoError(t, db.First(&updated, conv.ID).Error)
require.NotNil(t, updated.AgentLastSeenAt)
require.NotNil(t, updated.AssigneeLastSeenAt)
assert.Greater(t, *updated.AgentLastSeenAt, oldSeen)
assert.Equal(t, oldSeen, *updated.AssigneeLastSeenAt)
}
func TestConversationService_UpdateLastSeen_ThrottlesRecentNoUnreadTimestamps(t *testing.T) {
svc, db := setupConversationService(t)
account := createConversationServiceTestAccount(t, db)
inbox := createConversationServiceTestInbox(t, db, account.ID)
contact := createConversationServiceTestContact(t, db, account.ID)
user := &model.User{Name: "Recent Agent", Email: "recent-agent@example.com"}
require.NoError(t, db.Create(user).Error)
conv := createConversationServiceTestConversation(t, db, account.ID, inbox.ID, contact.ID, "open")
recentSeen := time.Now().Add(-time.Minute).Unix()
require.NoError(t, db.Model(conv).Updates(map[string]any{"agent_last_seen_at": recentSeen, "assignee_last_seen_at": recentSeen}).Error)
require.NoError(t, svc.UpdateLastSeen(context.Background(), account.ID, conv.ID, user.ID))
var updated model.Conversation
require.NoError(t, db.First(&updated, conv.ID).Error)
require.NotNil(t, updated.AgentLastSeenAt)
require.NotNil(t, updated.AssigneeLastSeenAt)
assert.Equal(t, recentSeen, *updated.AgentLastSeenAt)
assert.Equal(t, recentSeen, *updated.AssigneeLastSeenAt)
}
// ========== SendTranscript Tests ==========
func TestConversationService_SendTranscript(t *testing.T) {