diff --git a/frontend/app/javascript/dashboard/helper/specs/actionCable.spec.js b/frontend/app/javascript/dashboard/helper/specs/actionCable.spec.js index 1ccc7a9f..54753f3f 100644 --- a/frontend/app/javascript/dashboard/helper/specs/actionCable.spec.js +++ b/frontend/app/javascript/dashboard/helper/specs/actionCable.spec.js @@ -158,6 +158,70 @@ describe('ActionCableConnector - Copilot Tests', () => { ).not.toHaveBeenCalled(); }); + it('rejects malformed timestamps without updating conversation activity', async () => { + const currentMessage = { + id: 42, + conversation_id: 7, + content: 'current', + updated_at: '2026-08-24T02:00:00Z', + }; + const state = { + allConversations: [ + { id: 7, messages: [currentMessage], last_activity_at: 200 }, + ], + selectedChatId: null, + }; + useRealConversationDispatch(state); + + await actionCable.onMessageCreated({ + ...currentMessage, + content: 'malformed', + updated_at: 'not-a-timestamp', + conversation: { last_activity_at: 300 }, + }); + + expect(state.allConversations[0].messages[0]).toBe(currentMessage); + expect(state.allConversations[0].last_activity_at).toBe(200); + expect( + DashboardAudioNotificationHelper.onNewMessage + ).not.toHaveBeenCalled(); + }); + + it('keeps accepting events with a missing timestamp', async () => { + const state = { + allConversations: [ + { + id: 7, + messages: [ + { + id: 42, + conversation_id: 7, + content: 'current', + updated_at: '2026-08-24T02:00:00Z', + }, + ], + last_activity_at: 200, + }, + ], + selectedChatId: null, + }; + useRealConversationDispatch(state); + const message = { + id: 42, + conversation_id: 7, + content: 'compatible update', + conversation: { last_activity_at: 300 }, + }; + + await actionCable.onMessageCreated(message); + + expect(state.allConversations[0].messages[0]).toEqual(message); + expect(state.allConversations[0].last_activity_at).toBe(300); + expect(DashboardAudioNotificationHelper.onNewMessage).toHaveBeenCalledWith( + message + ); + }); + it('skips dashboard side effects when the conversation mutation is a no-op', async () => { setActivePinia(createPinia()); const state = { allConversations: [], selectedChatId: null }; @@ -193,7 +257,7 @@ describe('ActionCableConnector - Copilot Tests', () => { { id: 42, conversation_id: 7, - updated_at: '2026-08-24T01:00:00Z', + updated_at: '1787533200', }, ], last_activity_at: 100, diff --git a/frontend/app/javascript/dashboard/store/modules/conversations/actions.js b/frontend/app/javascript/dashboard/store/modules/conversations/actions.js index 0b9d5f31..fff7464a 100644 --- a/frontend/app/javascript/dashboard/store/modules/conversations/actions.js +++ b/frontend/app/javascript/dashboard/store/modules/conversations/actions.js @@ -19,6 +19,7 @@ import { handleVoiceCallUpdated, syncConversationCallVisibility, } from 'dashboard/helper/voice'; +import { isNewerTimestamp } from 'shared/helpers/timeHelper'; export const hasMessageFailedWithExternalError = pendingMessage => { // This helper is used to check if the message has failed with an external error. @@ -41,9 +42,7 @@ const getStoredMessage = (conversation, message) => { }; const isNewerMessage = (currentMessage, message) => - !currentMessage?.updated_at || - !message.updated_at || - message.updated_at > currentMessage.updated_at; + isNewerTimestamp(currentMessage?.updated_at, message.updated_at); // actions const actions = { diff --git a/frontend/app/javascript/shared/helpers/timeHelper.js b/frontend/app/javascript/shared/helpers/timeHelper.js index 824a1432..3f02cb42 100644 --- a/frontend/app/javascript/shared/helpers/timeHelper.js +++ b/frontend/app/javascript/shared/helpers/timeHelper.js @@ -6,6 +6,28 @@ import { differenceInDays, } from 'date-fns'; +const normalizeTimestamp = value => { + const numericValue = + typeof value === 'string' && value.trim() ? Number(value) : value; + if (typeof numericValue === 'number' && Number.isFinite(numericValue)) { + return Math.abs(numericValue) < 1e12 ? numericValue * 1000 : numericValue; + } + if (typeof value !== 'string') return null; + + const timestamp = Date.parse(value); + return Number.isNaN(timestamp) ? null : timestamp; +}; + +export const isNewerTimestamp = (currentTimestamp, nextTimestamp) => { + if (currentTimestamp == null || nextTimestamp == null) return true; + + const next = normalizeTimestamp(nextTimestamp); + if (next === null) return false; + + const current = normalizeTimestamp(currentTimestamp); + return current === null || next > current; +}; + /** * Formats a Unix timestamp into a human-readable time format. * @param {number} time - Unix timestamp. diff --git a/frontend/app/javascript/widget/helpers/specs/actionCable.spec.js b/frontend/app/javascript/widget/helpers/specs/actionCable.spec.js index 85f865bf..858e69a4 100644 --- a/frontend/app/javascript/widget/helpers/specs/actionCable.spec.js +++ b/frontend/app/javascript/widget/helpers/specs/actionCable.spec.js @@ -3,6 +3,8 @@ import ActionCableConnector from '../actionCable'; import { playNewMessageNotificationInWidget } from '../WidgetAudioNotificationHelper'; import { IFrameHelper } from '../utils'; import { emitter } from 'shared/helpers/mitt'; +import { actions } from 'widget/store/modules/conversation/actions'; +import { mutations } from 'widget/store/modules/conversation/mutations'; vi.mock('../WidgetAudioNotificationHelper', () => ({ playNewMessageNotificationInWidget: vi.fn(), @@ -41,6 +43,16 @@ describe('Widget ActionCableConnector', () => { ); }); + const useRealConversationDispatch = state => { + const commit = (type, payload) => mutations[type](state, payload); + dispatch.mockImplementation((action, payload) => { + if (action === 'conversation/addOrUpdateMessage') { + return actions.addOrUpdateMessage({ commit, state }, payload); + } + return undefined; + }); + }; + it('emits message side effects only once when realtime replays a message', async () => { const message = { id: 42, @@ -73,4 +85,102 @@ describe('Widget ActionCableConnector', () => { expect(IFrameHelper.sendMessage).not.toHaveBeenCalled(); }); + + it('rejects malformed timestamps through the real widget mutation path', async () => { + const currentMessage = { + id: 42, + conversation_id: 7, + content: 'current', + updated_at: '2026-08-24T02:00:00Z', + }; + const state = { conversations: { 42: currentMessage } }; + useRealConversationDispatch(state); + + await actionCable.onMessageUpdated({ + ...currentMessage, + content: 'malformed', + updated_at: 'not-a-timestamp', + previous_changes: { + content_attributes: [{}, { submitted_values: ['yes'] }], + }, + }); + + expect(state.conversations[42]).toBe(currentMessage); + expect(IFrameHelper.sendMessage).not.toHaveBeenCalled(); + }); + + it('rejects an older numeric-string timestamp against an ISO timestamp', async () => { + const currentMessage = { + id: 42, + conversation_id: 7, + content: 'current', + updated_at: '2026-08-24T02:00:00Z', + }; + const state = { conversations: { 42: currentMessage } }; + useRealConversationDispatch(state); + + await actionCable.onMessageUpdated({ + ...currentMessage, + content: 'older', + updated_at: '1787533200', + previous_changes: { + content_attributes: [{}, { submitted_values: ['yes'] }], + }, + }); + + expect(state.conversations[42]).toBe(currentMessage); + expect(IFrameHelper.sendMessage).not.toHaveBeenCalled(); + }); + + it('replaces a malformed stored timestamp with a valid update', async () => { + const state = { + conversations: { + 42: { + id: 42, + conversation_id: 7, + content: 'malformed stored message', + updated_at: 'not-a-timestamp', + }, + }, + }; + useRealConversationDispatch(state); + const message = { + id: 42, + conversation_id: 7, + content: 'valid update', + updated_at: '2026-08-24T02:00:00Z', + previous_changes: { + content_attributes: [{}, { submitted_values: ['yes'] }], + }, + }; + + await actionCable.onMessageUpdated(message); + + expect(state.conversations[42]).toEqual(message); + expect(IFrameHelper.sendMessage).toHaveBeenCalledTimes(1); + }); + + it('keeps accepting widget updates with a missing timestamp', async () => { + const currentMessage = { + id: 42, + conversation_id: 7, + content: 'current', + updated_at: '2026-08-24T02:00:00Z', + }; + const state = { conversations: { 42: currentMessage } }; + useRealConversationDispatch(state); + const message = { + id: 42, + conversation_id: 7, + content: 'compatible update', + previous_changes: { + content_attributes: [{}, { submitted_values: ['yes'] }], + }, + }; + + await actionCable.onMessageUpdated(message); + + expect(state.conversations[42]).toEqual(message); + expect(IFrameHelper.sendMessage).toHaveBeenCalledTimes(1); + }); }); diff --git a/frontend/app/javascript/widget/store/modules/conversation/actions.js b/frontend/app/javascript/widget/store/modules/conversation/actions.js index 30e0cf93..55a1b565 100644 --- a/frontend/app/javascript/widget/store/modules/conversation/actions.js +++ b/frontend/app/javascript/widget/store/modules/conversation/actions.js @@ -13,6 +13,7 @@ import { import { ON_CONVERSATION_CREATED } from 'widget/constants/widgetBusEvents'; import { createTemporaryMessage, getNonDeletedMessages } from './helpers'; import { emitter } from 'shared/helpers/mitt'; +import { isNewerTimestamp } from 'shared/helpers/timeHelper'; export const actions = { createConversation: async ({ commit, dispatch }, params) => { commit('setConversationUIFlag', { isCreating: true }); @@ -178,9 +179,8 @@ export const actions = { const currentMessage = state?.conversations?.[id]; const isNew = !currentMessage; if ( - currentMessage?.updated_at && - data.updated_at && - data.updated_at <= currentMessage.updated_at + currentMessage && + !isNewerTimestamp(currentMessage.updated_at, data.updated_at) ) { return { applied: false, isNew }; }