Skip to content

Commit c3c9780

Browse files
authored
feat: Ability to retrieve your previous tickets (#3121)
* feat: Ability to retrieve your previous tickets * push
1 parent 9ff5c62 commit c3c9780

20 files changed

+1719
-585
lines changed

.changeset/calm-geckos-tell.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'posthog-js': minor
3+
---
4+
5+
Ability to retrieve previous conversations

packages/browser/src/__tests__/extensions/conversations/conversations-api.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import {
66
GetMessagesResponse,
77
MarkAsReadResponse,
88
GetTicketsResponse,
9+
RestoreFromTokenResponse,
10+
RequestRestoreLinkResponse,
911
UserProvidedTraits,
1012
} from '../../../posthog-conversations-types'
1113
import { PostHog } from '../../../posthog-core'
@@ -41,6 +43,9 @@ describe('Conversations API Methods', () => {
4143
getMessages: jest.fn(),
4244
markAsRead: jest.fn(),
4345
getTickets: jest.fn(),
46+
requestRestoreLink: jest.fn(),
47+
restoreFromToken: jest.fn(),
48+
restoreFromUrlToken: jest.fn(),
4449
getCurrentTicketId: jest.fn(),
4550
getWidgetSessionId: jest.fn(),
4651
} as unknown as ConversationsManager
@@ -139,6 +144,36 @@ describe('Conversations API Methods', () => {
139144
expect(result).toBeNull()
140145
expect(consoleWarnSpy).not.toHaveBeenCalled() // Safe method, no warning
141146
})
147+
148+
it('should return null from requestRestoreLink when conversations not available', async () => {
149+
const result = await conversations.requestRestoreLink('test@example.com')
150+
151+
expect(result).toBeNull()
152+
expect(consoleWarnSpy).toHaveBeenCalledWith(
153+
'[PostHog.js] [Conversations]',
154+
expect.stringContaining('Conversations not available yet')
155+
)
156+
})
157+
158+
it('should return null from restoreFromToken when conversations not available', async () => {
159+
const result = await conversations.restoreFromToken('restore-token')
160+
161+
expect(result).toBeNull()
162+
expect(consoleWarnSpy).toHaveBeenCalledWith(
163+
'[PostHog.js] [Conversations]',
164+
expect.stringContaining('Conversations not available yet')
165+
)
166+
})
167+
168+
it('should return null from restoreFromUrlToken when conversations not available', async () => {
169+
const result = await conversations.restoreFromUrlToken()
170+
171+
expect(result).toBeNull()
172+
expect(consoleWarnSpy).toHaveBeenCalledWith(
173+
'[PostHog.js] [Conversations]',
174+
expect.stringContaining('Conversations not available yet')
175+
)
176+
})
142177
})
143178

144179
describe('API Methods After Loading', () => {
@@ -452,6 +487,44 @@ describe('Conversations API Methods', () => {
452487
})
453488
})
454489

490+
describe('requestRestoreLink', () => {
491+
it('should request a restore link through the manager', async () => {
492+
const mockResponse: RequestRestoreLinkResponse = { ok: true }
493+
;(mockManager.requestRestoreLink as jest.Mock).mockResolvedValue(mockResponse)
494+
495+
const result = await conversations.requestRestoreLink('user@example.com')
496+
497+
expect(result).toEqual({ ok: true })
498+
expect(mockManager.requestRestoreLink).toHaveBeenCalledWith('user@example.com')
499+
})
500+
})
501+
502+
describe('restore methods', () => {
503+
it('should redeem restore token through the manager', async () => {
504+
const mockResponse: RestoreFromTokenResponse = {
505+
status: 'success',
506+
widget_session_id: 'restored-session-id',
507+
migrated_ticket_ids: ['ticket-1'],
508+
}
509+
;(mockManager.restoreFromToken as jest.Mock).mockResolvedValue(mockResponse)
510+
511+
const result = await conversations.restoreFromToken('restore-token')
512+
513+
expect(result).toEqual(mockResponse)
514+
expect(mockManager.restoreFromToken).toHaveBeenCalledWith('restore-token')
515+
})
516+
517+
it('should redeem restore token from URL through the manager', async () => {
518+
const mockResponse: RestoreFromTokenResponse = { status: 'invalid', code: 'token_invalid' }
519+
;(mockManager.restoreFromUrlToken as jest.Mock).mockResolvedValue(mockResponse)
520+
521+
const result = await conversations.restoreFromUrlToken()
522+
523+
expect(result).toEqual(mockResponse)
524+
expect(mockManager.restoreFromUrlToken).toHaveBeenCalled()
525+
})
526+
})
527+
455528
describe('getCurrentTicketId', () => {
456529
it('should return current ticket ID when available', () => {
457530
;(mockManager.getCurrentTicketId as jest.Mock).mockReturnValue('ticket-abc')

packages/browser/src/__tests__/extensions/conversations/conversations-manager.test.tsx

Lines changed: 171 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,23 +14,36 @@ import { act } from '@testing-library/preact'
1414
// Mock the persistence layer
1515
jest.mock('../../../extensions/conversations/external/persistence', () => {
1616
return {
17-
ConversationsPersistence: jest.fn().mockImplementation(() => ({
18-
getOrCreateWidgetSessionId: jest.fn().mockReturnValue('test-widget-session-id'),
19-
loadTicketId: jest.fn().mockReturnValue(null),
20-
saveTicketId: jest.fn(),
21-
loadWidgetState: jest.fn().mockReturnValue('closed'),
22-
saveWidgetState: jest.fn(),
23-
loadUserTraits: jest.fn().mockReturnValue(null),
24-
saveUserTraits: jest.fn(),
25-
clearWidgetSessionId: jest.fn(),
26-
})),
17+
ConversationsPersistence: jest.fn().mockImplementation(() => {
18+
let storedTicketId: string | null = null
19+
return {
20+
getOrCreateWidgetSessionId: jest.fn().mockReturnValue('test-widget-session-id'),
21+
setWidgetSessionId: jest.fn(),
22+
loadTicketId: jest.fn(() => storedTicketId),
23+
saveTicketId: jest.fn((ticketId: string) => {
24+
storedTicketId = ticketId
25+
}),
26+
clearTicketId: jest.fn(() => {
27+
storedTicketId = null
28+
}),
29+
loadWidgetState: jest.fn().mockReturnValue('closed'),
30+
saveWidgetState: jest.fn(),
31+
loadUserTraits: jest.fn().mockReturnValue(null),
32+
saveUserTraits: jest.fn(),
33+
clearWidgetSessionId: jest.fn(),
34+
clearAll: jest.fn(() => {
35+
storedTicketId = null
36+
}),
37+
}
38+
}),
2739
}
2840
})
2941

3042
describe('ConversationsManager', () => {
3143
let manager: ConversationsManager
3244
let mockPosthog: PostHog
3345
let mockConfig: ConversationsRemoteConfig
46+
let mockRestoreResponse: { statusCode: number; json?: Record<string, any> }
3447

3548
const mockMessages: Message[] = [
3649
{
@@ -83,6 +96,15 @@ describe('ConversationsManager', () => {
8396
localStorage.clear()
8497
jest.clearAllMocks()
8598
jest.useFakeTimers()
99+
window.history.replaceState({}, '', '/')
100+
mockRestoreResponse = {
101+
statusCode: 200,
102+
json: {
103+
status: 'success',
104+
widget_session_id: 'restored-widget-session-id',
105+
migrated_ticket_ids: ['ticket-restored-1'],
106+
},
107+
}
86108

87109
// Mock scrollIntoView which is not implemented in JSDOM
88110
Element.prototype.scrollIntoView = jest.fn()
@@ -109,6 +131,13 @@ describe('ConversationsManager', () => {
109131
statusCode: 200,
110132
json: createMockSendMessageResponse(),
111133
})
134+
} else if (method === 'POST' && url.endsWith('/widget/restore')) {
135+
options.callback(mockRestoreResponse)
136+
} else if (method === 'POST' && url.includes('/widget/restore/request')) {
137+
options.callback({
138+
statusCode: 200,
139+
json: { ok: true },
140+
})
112141
} else if (url.includes('/read') && method === 'POST') {
113142
options.callback({
114143
statusCode: 200,
@@ -234,6 +263,95 @@ describe('ConversationsManager', () => {
234263
})
235264
})
236265

266+
describe('restore token flow', () => {
267+
it('should call restore endpoint when restore token exists in URL', async () => {
268+
window.history.replaceState({}, '', '/?ph_conv_restore=restore-token-1')
269+
270+
manager = new ConversationsManager(mockConfig, mockPosthog)
271+
await flushPromises()
272+
273+
expect(mockPosthog._send_request).toHaveBeenCalledWith(
274+
expect.objectContaining({
275+
method: 'POST',
276+
url: expect.stringContaining('/api/conversations/v1/widget/restore'),
277+
data: expect.objectContaining({
278+
restore_token: 'restore-token-1',
279+
widget_session_id: 'test-widget-session-id',
280+
}),
281+
})
282+
)
283+
})
284+
285+
it('should apply restored ticket/session and clear restore token from URL', async () => {
286+
window.history.replaceState({}, '', '/?ph_conv_restore=restore-token-2')
287+
288+
manager = new ConversationsManager(mockConfig, mockPosthog)
289+
await flushPromises()
290+
291+
expect(manager.getWidgetSessionId()).toBe('restored-widget-session-id')
292+
expect(manager.getCurrentTicketId()).toBe('ticket-restored-1')
293+
expect(window.location.search).toBe('')
294+
})
295+
296+
it('should keep local session when backend returns invalid status and clear URL', async () => {
297+
mockRestoreResponse = {
298+
statusCode: 200,
299+
json: { status: 'invalid' },
300+
}
301+
window.history.replaceState({}, '', '/?ph_conv_restore=restore-token-3')
302+
303+
manager = new ConversationsManager(mockConfig, mockPosthog)
304+
await flushPromises()
305+
306+
expect(manager.getWidgetSessionId()).toBe('test-widget-session-id')
307+
expect(window.location.search).toBe('')
308+
})
309+
310+
it('should preserve existing local session unless backend returns replacement', async () => {
311+
mockRestoreResponse = {
312+
statusCode: 200,
313+
json: {
314+
status: 'success',
315+
migrated_ticket_ids: ['ticket-unchanged-session'],
316+
},
317+
}
318+
window.history.replaceState({}, '', '/?ph_conv_restore=restore-token-4')
319+
320+
manager = new ConversationsManager(mockConfig, mockPosthog)
321+
await flushPromises()
322+
323+
expect(manager.getWidgetSessionId()).toBe('test-widget-session-id')
324+
expect(manager.getCurrentTicketId()).toBe('ticket-unchanged-session')
325+
})
326+
327+
it('should clear restore token from URL when restore request fails', async () => {
328+
mockRestoreResponse = {
329+
statusCode: 500,
330+
json: {
331+
detail: 'Internal server error',
332+
},
333+
}
334+
window.history.replaceState({}, '', '/?ph_conv_restore=restore-token-5')
335+
336+
manager = new ConversationsManager(mockConfig, mockPosthog)
337+
await flushPromises()
338+
339+
expect(window.location.search).toBe('')
340+
})
341+
342+
it('should clear restore token from URL when restoreFromToken is called directly', async () => {
343+
manager = new ConversationsManager(mockConfig, mockPosthog)
344+
await flushPromises()
345+
window.history.replaceState({}, '', '/?ph_conv_restore=manual-token')
346+
347+
await act(async () => {
348+
await manager.restoreFromToken('manual-token')
349+
})
350+
351+
expect(window.location.search).toBe('')
352+
})
353+
})
354+
237355
describe('show and hide', () => {
238356
beforeEach(async () => {
239357
manager = new ConversationsManager(mockConfig, mockPosthog)
@@ -615,6 +733,16 @@ describe('ConversationsManager', () => {
615733
const getMessagesCall = calls.find((call) => call[0].url.includes('/widget/messages/'))
616734
expect(getMessagesCall[0].url).not.toContain('distinct_id=')
617735
})
736+
737+
it('should not poll while restore request view is active', async () => {
738+
manager['_currentView'] = 'restore_request'
739+
740+
act(() => {
741+
jest.advanceTimersByTime(5000)
742+
})
743+
744+
expect(mockPosthog._send_request).not.toHaveBeenCalled()
745+
})
618746
})
619747

620748
describe('identify handling', () => {
@@ -813,6 +941,39 @@ describe('ConversationsManager', () => {
813941
expect(manager['_currentTicketId']).toBe('marked-ticket-999')
814942
})
815943
})
944+
945+
describe('requestRestoreLink API', () => {
946+
it('should request restore link with normalized email and request_url', async () => {
947+
await act(async () => {
948+
const response = await manager.requestRestoreLink(' TEST@Example.com ')
949+
expect(response).toEqual({ ok: true })
950+
})
951+
952+
expect(mockPosthog._send_request).toHaveBeenCalledWith(
953+
expect.objectContaining({
954+
method: 'POST',
955+
url: expect.stringContaining('/api/conversations/v1/widget/restore/request'),
956+
data: expect.objectContaining({
957+
email: 'test@example.com',
958+
request_url: window.location.href,
959+
}),
960+
})
961+
)
962+
})
963+
964+
it('should not include request_url in query params for restore link request', async () => {
965+
await act(async () => {
966+
await manager.requestRestoreLink('test@example.com')
967+
})
968+
969+
expect(mockPosthog._send_request).toHaveBeenCalledWith(
970+
expect.objectContaining({
971+
method: 'POST',
972+
url: expect.not.stringContaining('request_url='),
973+
})
974+
)
975+
})
976+
})
816977
})
817978

818979
describe('persistence integration', () => {

0 commit comments

Comments
 (0)