feat(support): open PostHog tickets from the existing support dialog (#1239)
Enables PostHog Support through the Direct API (posthog.conversations), restoring the second channel Recapt used to provide, but as a real ticket linked to the person and their session replay instead of a black hole. The in-app WIDGET stays off on purpose. It is a third-party floating chat bubble, which is exactly what Recapt was: it would sit next to the Assistenten FAB (which already has a hide_assistant_fab preference because users wanted it gone), cannot follow the locked design system, and its copy is not ours to keep Swedish. The conversations API gives the same tickets from components/ui/support-link.tsx, which is already on-design, Swedish and reachable from 8 surfaces. A ticket is explicitly NOT treated as delivery. submitFeedback returns ok only when the Resend email actually went out, even if the ticket opened. Recapt's precise failure mode was reporting success on its own channel while /api/support/contact was dead, and nobody is watching PostHog at 02:00. Tests pin that: ticket-only is ok:false. Identity verification uses posthog.setIdentity(distinctId, hash) at runtime rather than the identity_distinct_id/identity_hash init options PostHog's settings page documents. init runs from instrumentation-client.ts app-wide, before the user is known and including logged-out pages, and PostHog fixes init values for the session. setIdentity is a real method on the SDK (verified typed in posthog-js 1.407.3), so the hash applies from AnalyticsIdentify once the dashboard layout knows who the user is. Without the key it is skipped and tickets fall back to browser-scoped with email recovery, which is the normal state off hosted. POSTHOG_SECRET_API_KEY is server-only, no NEXT_PUBLIC_ prefix: it signs identity hashes AND authenticates external API requests, so unlike the phc_ project token it is a real credential. Only the derived per-user HMAC crosses to the browser. Compliance: support free text is declared as its own data category (user.content.support) in .compliance/ropa.yaml and named on the privacy page. Analytics events still carry no message body (the breadcrumb sends only the subject); a ticket carries what the user wrote, because that is the point. Keeping the purposes separate is what stops the privacy page drifting the way the Recapt row did. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1dce9227a4
commit
52ec3ce497
@@ -4,7 +4,17 @@ import { submitFeedback } from '@/lib/support/submit-feedback'
|
||||
// posthog-js is browser-only and irrelevant to delivery: stub it so the
|
||||
// analytics breadcrumb can be asserted without initialising the real SDK.
|
||||
const captureMock = vi.fn()
|
||||
vi.mock('posthog-js', () => ({ default: { capture: (...a: unknown[]) => captureMock(...a) } }))
|
||||
const sendMessageMock = vi.fn()
|
||||
const isAvailableMock = vi.fn(() => true)
|
||||
vi.mock('posthog-js', () => ({
|
||||
default: {
|
||||
capture: (...a: unknown[]) => captureMock(...a),
|
||||
conversations: {
|
||||
isAvailable: () => isAvailableMock(),
|
||||
sendMessage: (...a: unknown[]) => sendMessageMock(...a),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
describe('submitFeedback', () => {
|
||||
beforeEach(() => {
|
||||
@@ -12,6 +22,8 @@ describe('submitFeedback', () => {
|
||||
vi.unstubAllEnvs()
|
||||
vi.restoreAllMocks()
|
||||
captureMock.mockClear()
|
||||
sendMessageMock.mockClear()
|
||||
isAvailableMock.mockReturnValue(true)
|
||||
// Analytics on by default so the breadcrumb path is exercised.
|
||||
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'false')
|
||||
vi.stubEnv('NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN', 'phc_test')
|
||||
@@ -34,7 +46,7 @@ describe('submitFeedback', () => {
|
||||
const result = await submitFeedback({ subject: 'Hjälpsida', message: 'Hjälp tack' })
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.channels).toEqual(['email'])
|
||||
expect(result.channels).toEqual(['email', 'ticket'])
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'/api/support/contact',
|
||||
expect.objectContaining({
|
||||
@@ -58,8 +70,9 @@ describe('submitFeedback', () => {
|
||||
|
||||
const result = await submitFeedback({ message: 'msg' })
|
||||
|
||||
// A ticket may still open, but delivery failed, so ok stays false.
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.channels).toEqual([])
|
||||
expect(result.channels).not.toContain('email')
|
||||
expect(result.error).toBe('Mailtjänsten är inte konfigurerad')
|
||||
})
|
||||
|
||||
@@ -69,7 +82,7 @@ describe('submitFeedback', () => {
|
||||
const result = await submitFeedback({ message: 'msg' })
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.channels).toEqual([])
|
||||
expect(result.channels).not.toContain('email')
|
||||
expect(result.error).toBe('Network down')
|
||||
})
|
||||
|
||||
@@ -116,6 +129,55 @@ describe('submitFeedback', () => {
|
||||
const result = await submitFeedback({ message: 'msg' })
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.channels).toEqual(['email'])
|
||||
expect(result.channels).toContain('email')
|
||||
})
|
||||
|
||||
describe('PostHog Support ticket channel', () => {
|
||||
it('opens a ticket carrying the message body, unlike the analytics breadcrumb', async () => {
|
||||
stubFetchOk()
|
||||
await submitFeedback({ subject: 'Moms', message: 'Jag fastnar på ruta 05' })
|
||||
expect(sendMessageMock).toHaveBeenCalledWith('[Moms]\n\nJag fastnar på ruta 05')
|
||||
})
|
||||
|
||||
it('omits the subject prefix when there is no subject', async () => {
|
||||
stubFetchOk()
|
||||
await submitFeedback({ message: 'bara text' })
|
||||
expect(sendMessageMock).toHaveBeenCalledWith('bara text')
|
||||
})
|
||||
|
||||
// A ticket alone is NOT delivery: nobody is watching PostHog at 02:00, and
|
||||
// Recapt masking a dead email endpoint is the exact bug we removed.
|
||||
it('does not report success when only the ticket worked', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, json: async () => ({ error: 'down' }) }))
|
||||
const result = await submitFeedback({ message: 'msg' })
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.channels).toEqual(['ticket'])
|
||||
expect(result.error).toBe('down')
|
||||
})
|
||||
|
||||
it('still delivers by email when conversations are unavailable', async () => {
|
||||
isAvailableMock.mockReturnValue(false)
|
||||
stubFetchOk()
|
||||
const result = await submitFeedback({ message: 'msg' })
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.channels).toEqual(['email'])
|
||||
expect(sendMessageMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still delivers by email when sendMessage throws', async () => {
|
||||
sendMessageMock.mockRejectedValueOnce(new Error('conversations boom'))
|
||||
stubFetchOk()
|
||||
const result = await submitFeedback({ message: 'msg' })
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.channels).toEqual(['email'])
|
||||
})
|
||||
|
||||
it('opens no ticket when analytics is off (self-hosted)', async () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_SELF_HOSTED', 'true')
|
||||
stubFetchOk()
|
||||
const result = await submitFeedback({ message: 'msg' })
|
||||
expect(result.channels).toEqual(['email'])
|
||||
expect(sendMessageMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,13 +7,19 @@ export interface SubmitFeedbackInput {
|
||||
}
|
||||
|
||||
/**
|
||||
* Delivery channels. Recapt used to be a second one: it accepted the message
|
||||
* through its feedback SDK, so a failing /api/support/contact still reported
|
||||
* success. With Recapt gone, email is the only delivery channel and its
|
||||
* failure is now a real, visible failure. That is correct: silently
|
||||
* "succeeding" while the message reached nobody was the worse behaviour.
|
||||
* Delivery channels.
|
||||
*
|
||||
* 'email' - Resend to the support inbox. The guarantee: it works with no
|
||||
* third party beyond the mail provider and needs no analytics.
|
||||
* 'ticket' - PostHog Support conversation, linked to the person and their
|
||||
* session replay so we can see what they were doing.
|
||||
*
|
||||
* Recapt used to be the second channel and would report success on its own,
|
||||
* masking a failing /api/support/contact. This does NOT repeat that: the
|
||||
* result is `ok` only when email actually delivered. A ticket alone is not
|
||||
* treated as delivery, because nobody is watching PostHog at 02:00.
|
||||
*/
|
||||
export type SupportChannel = 'email'
|
||||
export type SupportChannel = 'email' | 'ticket'
|
||||
|
||||
export interface SubmitFeedbackResult {
|
||||
ok: boolean
|
||||
@@ -59,18 +65,48 @@ function noteInAnalytics({ subject }: SubmitFeedbackInput, delivered: boolean):
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a PostHog Support ticket alongside the email.
|
||||
*
|
||||
* Unlike the analytics breadcrumb this DOES carry the message body: a support
|
||||
* ticket the user deliberately wrote is the one place their words are the
|
||||
* point. That makes tickets a distinct processing purpose from analytics, so
|
||||
* it is declared separately in .compliance/ropa.yaml and on the privacy page.
|
||||
*
|
||||
* Never throws and never blocks: if conversations are unavailable (support
|
||||
* disabled, no analytics, older SDK) the user still gets the email path.
|
||||
*/
|
||||
async function submitViaTicket({ message, subject }: SubmitFeedbackInput): Promise<boolean> {
|
||||
if (!isAnalyticsEnabled()) return false
|
||||
try {
|
||||
const conversations = posthog.conversations
|
||||
if (!conversations?.isAvailable?.()) return false
|
||||
await conversations.sendMessage(composeTicketBody(message, subject))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function composeTicketBody(message: string, subject?: string): string {
|
||||
return subject ? `[${subject}]\n\n${message}` : message
|
||||
}
|
||||
|
||||
export async function submitFeedback(input: SubmitFeedbackInput): Promise<SubmitFeedbackResult> {
|
||||
// Email first and awaited on its own: it is the delivery guarantee, and a
|
||||
// slow or failing ticket call must never delay or affect it.
|
||||
const emailResult = await submitViaEmail(input)
|
||||
const ticketOk = await submitViaTicket(input)
|
||||
|
||||
noteInAnalytics(input, emailResult.ok)
|
||||
|
||||
if (emailResult.ok) {
|
||||
return { ok: true, channels: ['email'] }
|
||||
return { ok: true, channels: ticketOk ? ['email', 'ticket'] : ['email'] }
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
channels: [],
|
||||
channels: ticketOk ? ['ticket'] : [],
|
||||
error: emailResult.error,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user