diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 922a33e6..522ec797 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -49,11 +49,11 @@ Hermes task landing checklist: ## Current Baseline -- Current tracking checkpoint: 2026-06-07 P3.95 conversation mute contact-block parity, prepared as `feat(conversations): align mute semantics`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(conversations): align mute semantics`. -- Latest documentation/tooling checkpoint: this tracker update records Chatwoot conversation mute/unmute and automation `mute_conversation` contact-block parity. No route artifacts change. +- Current tracking checkpoint: 2026-06-07 P3.96 conversation last-seen read parity, prepared as `feat(conversations): align last seen reads`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(conversations): align last seen reads`. +- Latest documentation/tooling checkpoint: this tracker update records Chatwoot conversation `update_last_seen`/`unread` timestamp and notification-read parity. No route artifacts change. - Plan landing status: complete for the current known Hermes plans and user-confirmed scope. Future work should update this file directly instead of opening a parallel tracker. -- Worktree status at this implementation checkpoint: account conversation `mute`, `unmute`, serialized `muted`, and automation `mute_conversation` now follow Chatwoot's `ConversationMuteHelpers`: mute resolves the conversation, blocks the conversation contact, and exposes `muted` from the contact blocked state; unmute unblocks the contact without reopening the resolved conversation. This retains P3.94 conversation toggle-priority empty-body parity, P3.93 conversation toggle-status fallback parity, P3.92 automation-rule attachment parity, P3.91 upload/macro attachment parity, P3.90 SearchAPI filter-surface parity, P3.89 fixed page size, P3.88 global result type scoping, P3.87 article payloads, P3.86 message payloads, P3.85 conversation payloads, P3.84 agent-sender filter parity, and prior checkpoints. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. +- Worktree status at this implementation checkpoint: account conversation `update_last_seen` now marks current-user conversation notifications read, updates assignee and agent last-seen columns according to Chatwoot unread/throttle rules, and skips redundant writes when there are no unread messages and recent timestamps; `unread` now backdates both agent and assignee last-seen columns to the last incoming message minus one second. This retains P3.95 conversation mute contact-block parity, P3.94 conversation toggle-priority empty-body parity, P3.93 conversation toggle-status fallback parity, P3.92 automation-rule attachment parity, P3.91 upload/macro attachment parity, P3.90 SearchAPI filter-surface parity, P3.89 fixed page size, P3.88 global result type scoping, P3.87 article payloads, P3.86 message payloads, P3.85 conversation payloads, P3.84 agent-sender filter parity, and prior checkpoints. Live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. - Next executable implementation checkpoint: continue Phase 2/3 drift audit for the next reused-frontend mismatch, or run B12 live smoke when the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack is available. Re-run Phase 6 placeholder audit after future route/smoke changes. - `go test ./...` passes when run outside the restricted socket sandbox for the latest implementation baseline; the latest docs/tooling checkpoint verified `scripts/parity_frontend_smoke.sh --check` with workspace-local temp/cache dirs after `/tmp` was full. - Route dump succeeds with `967` registered routes after profile MFA route tracking. @@ -156,6 +156,7 @@ This table is the shortest authoritative handoff view. If an older lower section | Priority | Workstream | Current state | Next checkpoint | Commit close rule | | --- | --- | --- | --- | --- | +| 0 | P3.96 conversation last-seen read parity | Implemented for reused conversation read/unread actions: `POST /conversations/:conversation_id/update_last_seen` marks current-user unread notifications for the conversation as read, updates `agent_last_seen_at` plus `assignee_last_seen_at` when the viewer is the assignee and there are unread messages, updates only agent last-seen for non-assignees, and applies Chatwoot's one-hour no-unread write throttle; `unread` backdates both last-seen columns to the last incoming message minus one second. | Keep in Review; reopen from B12 conversation read/unread smoke or fresh reference evidence for unread-count notifier/event side effects not covered by the inspected controller/model contract. | Focused ConversationService and ConversationHandler last-seen/unread tests passed; full `go test ./...` must pass outside the restricted socket sandbox; `git diff --check` must pass. No route artifacts change. | | 0 | P3.95 conversation mute contact-block parity | Implemented for reused conversation mute/unmute actions and automation `mute_conversation`: `POST /conversations/:conversation_id/mute` now resolves the conversation, blocks the contact, and serializes `muted` from the contact blocked state; `unmute` unblocks the contact without reopening the conversation, matching Chatwoot `ConversationMuteHelpers`. | Keep in Review; reopen from B12 conversation action smoke, automation mute smoke, or fresh reference evidence for activity-message wording/Current.user side effects beyond the inspected mute helper. | Focused ConversationService, ConversationHandler CRUD, and ActionService mute tests passed; full `go test ./...` must pass outside the restricted socket sandbox; `git diff --check` must pass. No route artifacts change. | | 0 | P3.94 conversation toggle-priority empty-body parity | Implemented for reused conversation priority action and direct API compatibility: `POST /conversations/:conversation_id/toggle_priority` now accepts empty bodies without JSON-binding failure and returns empty `200 OK`, matching Chatwoot `head :ok` behavior while using GoChat's existing no-priority sentinel. | Keep in Review; reopen from B12 conversation action smoke or fresh reference evidence for exact nil-vs-sentinel priority serialization drift. | Focused ConversationHandler toggle-priority tests passed; full `go test ./...` must pass outside the restricted socket sandbox; `git diff --check` must pass. No route artifacts change. | | 0 | P3.93 conversation toggle-status optional status parity | Implemented for reused conversation action paths and direct API compatibility: `POST /conversations/:conversation_id/toggle_status` now accepts empty bodies or `{}` without requiring `status`, then applies Chatwoot `Conversation#toggle_status` fallback semantics. | Keep in Review; reopen from B12 conversation action smoke or fresh reference evidence for bot handoff, assignment side effects, or exact `@status` success value drift beyond the inspected controller/model/Jbuilder contract. | Focused ConversationHandler toggle-status tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | @@ -259,6 +260,7 @@ These rows are the executable development plan from this point forward. A checkp | ID | Owner files | Reference files | Work to land | Exit gate | | --- | --- | --- | --- | --- | +| P3.96 conversation last-seen read parity | `internal/service/conversation_service.go`, `internal/handler/api/v1/conversation_handler.go`, conversation handler/service tests | `reference/chatwoot/app/controllers/api/v1/accounts/conversations_controller.rb#update_last_seen`, `#unread`, `reference/chatwoot/app/models/conversation.rb#unread_messages`, `#assignee_unread_messages`, `reference/chatwoot/app/services/notification/mark_conversation_read_service.rb`, reused dashboard `ConversationApi.markMessageRead/markMessagesUnread` and store `messageReadActions.js` | Account conversation `update_last_seen` now mirrors Chatwoot's read path: it marks unread notifications for the conversation/current user as read, updates assignee last-seen only when the current viewer is the assignee, always updates agent last-seen when unread messages exist, and throttles no-unread writes to one hour. The `unread` endpoint now uses the Chatwoot helper shape by updating both `agent_last_seen_at` and `assignee_last_seen_at` to the last incoming message timestamp minus one second, or clearing both when no incoming message exists. | Review by `feat(conversations): align last seen reads`; focused tests prove notification read side effects, assignee/non-assignee column behavior, throttle behavior, and unread dual-column backdating; full `go test ./...` must pass outside the restricted socket sandbox; `git diff --check` must pass. No route artifacts change. | | P3.95 conversation mute contact-block parity | `internal/service/conversation_service.go`, `internal/handler/api/v1/conversation_handler.go`, `internal/handler/api/v1/conversation_serializer.go`, `internal/automation/action_service.go`, conversation handler/service/action tests | `reference/chatwoot/app/controllers/api/v1/accounts/conversations_controller.rb#mute`, `#unmute`, `reference/chatwoot/app/models/concerns/conversation_mute_helpers.rb`, `reference/chatwoot/app/services/action_service.rb#mute_conversation`, `reference/chatwoot/app/views/api/v1/conversations/partials/_conversation.json.jbuilder`, reused conversation mute/unmute store/actions | Account conversation mute/unmute and automation `mute_conversation` now use Chatwoot's contact-block semantics instead of the old local label/legacy mute-row behavior: mute resolves the conversation, blocks the contact, sets the local conversation muted cache, and serialization derives `muted` from `contact.blocked`; unmute unblocks and clears muted without reopening. | Review by `feat(conversations): align mute semantics`; focused tests prove service persistence, handler empty `200 OK` plus serialized payload state, and automation action side effects; full `go test ./...` must pass outside the restricted socket sandbox; `git diff --check` must pass. No route artifacts change. | | P3.94 conversation toggle-priority empty-body parity | `internal/handler/api/v1/conversation_handler.go`, `internal/service/conversation_service.go`, conversation handler tests | `reference/chatwoot/app/controllers/api/v1/accounts/conversations_controller.rb#toggle_priority`, `reference/chatwoot/app/models/conversation.rb#toggle_priority`, `reference/chatwoot/app/javascript/dashboard/api/inbox/conversation.js`, reused conversation priority action store/components | Account conversation `toggle_priority` now accepts omitted priority/empty request bodies like Chatwoot's `params[:priority]` member action and returns empty `200 OK` instead of a local validation envelope. Explicit priority updates remain handled by the existing priority service. | Review by `feat(conversations): align toggle priority fallback`; focused tests prove empty-body `toggle_priority` succeeds and invalid JSON still fails; full `go test ./...` must pass outside the restricted socket sandbox; `git diff --check` must pass. No route artifacts change. | | P3.93 conversation toggle-status optional status parity | `internal/handler/api/v1/conversation_handler.go`, `internal/service/conversation_service.go`, conversation handler/service tests | `reference/chatwoot/app/controllers/api/v1/accounts/conversations_controller.rb#toggle_status`, `reference/chatwoot/app/models/conversation.rb#toggle_status`, `reference/chatwoot/app/views/api/v1/accounts/conversations/toggle_status.json.jbuilder`, `reference/chatwoot/app/javascript/dashboard/api/inbox/conversation.js`, reused conversation status action components/store | Account conversation `toggle_status` now accepts omitted status/empty request bodies like Chatwoot and falls back to model toggling: open becomes resolved, while pending/snoozed/resolved becomes open. Explicit `status` behavior and the existing Chatwoot response envelope remain intact. | Review by `feat(conversations): align toggle status fallback`; focused tests prove omitted-status empty-body toggle and `{}` pending-to-open fallback; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | @@ -378,6 +380,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `feat(conversations): align last seen reads` | Advances P3.96 with Chatwoot conversation `update_last_seen` and `unread` parity. GoChat now marks current-user conversation notifications read, applies assignee-aware last-seen updates and the no-unread one-hour throttle, and marks conversations unread by backdating both agent and assignee last-seen columns. | Focused ConversationService and ConversationHandler last-seen/unread tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | Move P3.96 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | | `feat(conversations): align mute semantics` | Advances P3.95 with Chatwoot conversation mute/unmute and automation mute parity. GoChat now follows `ConversationMuteHelpers`: mute resolves the conversation, blocks the conversation contact, keeps serialized `muted` tied to contact blocked state, and automation `mute_conversation` applies the same side effects instead of writing only a legacy mute row. | Focused ConversationService mute/unmute tests, ConversationHandler CRUD mute/unmute tests, and ActionService `mute_conversation` tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | Move P3.95 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | | `feat(conversations): align toggle priority fallback` | Advances P3.94 with Chatwoot conversation `toggle_priority` empty-body parity. The account conversation member action now accepts omitted priority/empty bodies, keeps empty `200 OK`, and avoids the local JSON-binding failure that drifted from Chatwoot `params[:priority]`. | Focused ConversationHandler toggle-priority tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | Move P3.94 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | | `feat(conversations): align toggle status fallback` | Advances P3.93 with Chatwoot conversation `toggle_status` optional-status parity. The account conversation endpoint now accepts omitted status/empty bodies and uses Chatwoot model fallback toggling while preserving the existing response envelope consumed by the reused dashboard. | Focused ConversationHandler toggle-status tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. | Move P3.93 to Review; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | @@ -2762,3 +2765,4 @@ Verification milestone gates: - 2026-06-07: P3.93 conversation toggle-status checkpoint prepared as `feat(conversations): align toggle status fallback`; audited Chatwoot `ConversationsController#toggle_status`, `Conversation#toggle_status`, `toggle_status.json.jbuilder`, and reused dashboard conversation status API/store/actions. GoChat account conversation `toggle_status` now accepts omitted `status` and empty request bodies, toggles open conversations to resolved and pending/snoozed/resolved conversations to open, and keeps the Chatwoot response envelope with `success`, display `conversation_id`, `current_status`, and `snoozed_until`. Focused ConversationHandler toggle-status tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. - 2026-06-07: P3.94 conversation toggle-priority checkpoint prepared as `feat(conversations): align toggle priority fallback`; audited Chatwoot `ConversationsController#toggle_priority`, `Conversation#toggle_priority`, and reused dashboard conversation priority API/store actions. GoChat account conversation `toggle_priority` now accepts omitted `priority` and empty request bodies, returns empty `200 OK` instead of a local JSON-binding error, and continues to persist the existing no-priority sentinel through the priority service. Focused ConversationHandler toggle-priority tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. - 2026-06-07: P3.95 conversation mute checkpoint prepared as `feat(conversations): align mute semantics`; audited Chatwoot `ConversationsController#mute/#unmute`, `ConversationMuteHelpers`, `ActionService#mute_conversation`, `_conversation.json.jbuilder`, and reused dashboard mute/unmute store actions. GoChat account conversation mute now resolves the conversation, blocks the contact, and serializes `muted` from contact blocked state; unmute unblocks without reopening; automation `mute_conversation` applies the same Chatwoot side effects instead of writing only a legacy mute row. Focused ConversationService, ConversationHandler CRUD, and ActionService mute tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. +- 2026-06-07: P3.96 conversation last-seen checkpoint prepared as `feat(conversations): align last seen reads`; audited Chatwoot `ConversationsController#update_last_seen/#unread`, `Conversation#unread_messages/#assignee_unread_messages`, `Notification::MarkConversationReadService`, and reused dashboard message read/unread API/store actions. GoChat account conversation `update_last_seen` now marks current-user unread notifications for the conversation as read, updates agent and assignee last-seen columns according to assignee/unread-message state, and throttles no-unread writes to once per hour; `unread` now backdates or clears both agent and assignee last-seen columns. Focused ConversationService and ConversationHandler tests passed; full `go test ./...` passed outside the restricted socket sandbox; `git diff --check` passed. No route artifacts change. diff --git a/internal/handler/api/v1/conversation_handler.go b/internal/handler/api/v1/conversation_handler.go index 338a9c4d..d0a76464 100644 --- a/internal/handler/api/v1/conversation_handler.go +++ b/internal/handler/api/v1/conversation_handler.go @@ -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 diff --git a/internal/handler/api/v1/conversation_handler_test.go b/internal/handler/api/v1/conversation_handler_test.go index 057602d6..fa4d63f4 100644 --- a/internal/handler/api/v1/conversation_handler_test.go +++ b/internal/handler/api/v1/conversation_handler_test.go @@ -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) diff --git a/internal/service/conversation_service.go b/internal/service/conversation_service.go index 08f9a352..5fe62514 100644 --- a/internal/service/conversation_service.go +++ b/internal/service/conversation_service.go @@ -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. diff --git a/internal/service/conversation_service_test.go b/internal/service/conversation_service_test.go index a7afc1fd..2f922c89 100644 --- a/internal/service/conversation_service_test.go +++ b/internal/service/conversation_service_test.go @@ -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) {