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:
Jakob Wennberg
2026-07-27 16:23:32 +02:00
committed by GitHub
parent 1dce9227a4
commit 52ec3ce497
10 changed files with 229 additions and 17 deletions
+12 -1
View File
@@ -537,7 +537,7 @@ processing_activities:
- immutable_status_machine_trigger
- id: product.analytics
name: Produktanalys, sessionsinspelning, felrapportering och enkäter (PostHog)
name: Produktanalys, sessionsinspelning, felrapportering, enkäter och supportärenden (PostHog)
purpose: >-
Förstå hur tjänsten faktiskt används (var användare fastnar i
onboarding, vilka flöden som avbryts), fånga klientfel och serverfel så
@@ -557,6 +557,13 @@ processing_activities:
- user.name # profiles.full_name
- user.behavior # sidvisningar, klick, händelser, sessionsinspelning
- user.device # user agent, skärmstorlek, IP (trunkeras av PostHog)
# Supportärenden: fritext som användaren SJÄLV skriver till supporten via
# in-app-dialogen (components/ui/support-link.tsx). Till skillnad från
# analysdata bär ärendet meddelandetexten, eftersom det är hela poängen
# med att skriva till supporten. Kan i praktiken innehålla bokförings-
# detaljer som användaren väljer att beskriva. Widgeten är avstängd:
# endast vår egen dialog kan öppna ärenden.
- user.content.support # fritext i supportmeddelanden, användarinitierat
# EJ organisationsnummer: för enskild firma ÄR orgnr innehavarens
# personnummer. buildGroupProperties() vägrar skicka det och ett
# enhetstest låser fast beteendet.
@@ -586,3 +593,7 @@ processing_activities:
- sandbox_companies_never_identified
- disabled_entirely_when_self_hosted
- server_side_payloads_redacted_before_send # lib/observability/redact.ts
- support_widget_disabled # endast egen UI via conversations-API
- support_ticket_identity_hmac_signed # POSTHOG_SECRET_API_KEY, serverside
- support_message_body_never_in_event_properties # breadcrumb bär endast subject
- email_remains_the_delivery_guarantee # ärendet är ett komplement, ej leveransväg
+8
View File
@@ -65,6 +65,14 @@ CRON_SECRET=generate-a-random-secret
# NEXT_PUBLIC_POSTHOG_HOST is only used by the server-side SDK.
# NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN=
# NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com
#
# PostHog Support identity verification. A REAL SECRET (it also authenticates
# external API requests), so no NEXT_PUBLIC_ prefix: it must never reach the
# client bundle. Only the derived per-user HMAC crosses to the browser
# (lib/analytics/identity-hash.ts). Unset means support tickets are scoped to
# one browser session and users recover them by email link, which is the
# normal state for local dev, CI and self-hosted.
# POSTHOG_SECRET_API_KEY=
# ── Optional: error tracking / observability ──────────────
# The app routes every error-level log line, and anything flagged
+5
View File
@@ -603,3 +603,8 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-27] Chat thumbs emit the existing agent.feedback event with actorType 'user' rather than a new table or event type: the product team already queries event_log for that type from the MCP tool, so a second store would be a second place someone has to remember to read, and event_log's jsonb payload needs no migration.
[2026-07-27] A cast vote does not toggle off: it emits append-only telemetry and there is no un-emitting one, so an undo control would be a button that lies. Changing your mind sends the opposite sentiment, which the backlog can actually see.
[2026-07-27] Dropped the free-text `comment` field from /api/agent/feedback instead of documenting a retention policy for it (compliance-swarm V14.1): no caller ever sent it, and an unused free-text parameter in an accounting product is a PII sink into event_log's telemetry retention (client names, personnummer). A comment box needs its own classification and redaction decision made with the UI in front of it, not as dead API surface.
[2026-07-27] PostHog Support enabled with the Direct API (posthog.conversations) and the in-app WIDGET deliberately off. The widget is a third-party floating chat bubble: that is exactly what Recapt was, it would sit next to the existing Assistenten FAB (which already has a hide_assistant_fab preference because users wanted it gone), it cannot follow the locked editorial-monochrome design system, and its copy is not ours to keep Swedish. The conversations API gives the same tickets from our own on-design dialog (components/ui/support-link.tsx, already reachable from 8 surfaces). Email channel also on, so the support mailbox becomes tickets with two-way replies.
[2026-07-27] A PostHog support ticket is NOT treated as delivery: submitFeedback returns ok only when the Resend email actually went out, even though the ticket may have opened. Recapt's exact failure mode was reporting success on its own channel while /api/support/contact was dead, and nobody is watching PostHog at 02:00. The ticket is a complement that links the message to the session replay; email remains the guarantee.
[2026-07-27] Identity verification via 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 method on the SDK (verified typed in posthog-js 1.407.3) so the hash can be applied from AnalyticsIdentify once the dashboard layout knows who the user is. Without it tickets are browser-scoped and recovered by email.
[2026-07-27] POSTHOG_SECRET_API_KEY is server-only (no NEXT_PUBLIC_ prefix) and the HMAC is computed in the dashboard layout; only the derived per-user hash reaches the browser. The key signs identity hashes AND authenticates external API requests, so it is a real credential unlike the phc_ project token, which is public by design and embedded in the client bundle.
[2026-07-27] Support ticket free-text is declared as its own data category (user.content.support) in .compliance/ropa.yaml and named on the privacy page, separate from analytics. Analytics events deliberately carry no message body (the breadcrumb sends only the subject), but a ticket carries what the user wrote, because that is the point of writing to support. Keeping the two purposes distinct is what stops the privacy page drifting out of date the way the Recapt row did.
+2
View File
@@ -4,6 +4,7 @@ import DashboardNav from '@/components/dashboard/DashboardNav'
import { MainContainer } from '@/components/dashboard/MainContainer'
import CompanyTabSync from '@/components/dashboard/CompanyTabSync'
import AnalyticsIdentify from '@/components/AnalyticsIdentify'
import { computeIdentityHash } from '@/lib/analytics/identity-hash'
import { AgentSheetProvider } from '@/components/agent/AgentSheetProvider'
import AgentTrigger from '@/components/agent/AgentTrigger'
import LazyCommandPalette from '@/components/common/LazyCommandPalette'
@@ -331,6 +332,7 @@ export default async function DashboardLayout({
fullName: userProfile?.full_name ?? null,
role: memberRow.role as CompanyRole,
}}
identityHash={computeIdentityHash(user.id)}
company={{
id: companyId,
name: displayName,
+5 -3
View File
@@ -153,9 +153,11 @@ export default function PrivacyPolicyPage() {
<tr className="border-b">
<td className="py-2 pr-4 font-medium">PostHog</td>
<td className="py-2 pr-4">
Produktanalys, sessionsinspelning, felrapportering och
enkäter. Överförda uppgifter: användar-ID,
e-postadress, namn och företagsnamn. All text i
Produktanalys, sessionsinspelning, felrapportering,
enkäter och supportärenden. Överförda uppgifter:
användar-ID, e-postadress, namn och företagsnamn. Om du
själv skriver till supporten i appen skickas även ditt
meddelande dit som ett ärende, att vi kan svara. All text i
sessionsinspelningar maskeras: vi spelar in var i
gränssnittet du klickar, aldrig vad som står i din
bokföring. Organisationsnummer överförs aldrig.
+11
View File
@@ -29,9 +29,13 @@ import {
export default function AnalyticsIdentify({
user,
company,
identityHash,
}: {
user: AnalyticsUserInput
company: AnalyticsCompanyInput
/** Server-computed HMAC of userId (lib/analytics/identity-hash.ts). Null
* when POSTHOG_SECRET_API_KEY is unset, which is normal off hosted. */
identityHash?: string | null
}) {
const { userId, email, fullName, role } = user
const {
@@ -53,6 +57,12 @@ export default function AnalyticsIdentify({
useEffect(() => {
if (!isAnalyticsEnabled()) return
// Verified identity first: it tells PostHog Support this browser really
// is `userId`, so a support ticket follows the person across devices
// instead of being scoped to one browser session. Without the secret key
// this is skipped and tickets fall back to email recovery.
if (identityHash) posthog.setIdentity(userId, identityHash)
posthog.identify(userId, buildPersonProperties({ userId, email, fullName, role }))
posthog.group(
'company',
@@ -79,6 +89,7 @@ export default function AnalyticsIdentify({
paysSalaries,
trialEndsAt,
capabilityKey,
identityHash,
])
return null
@@ -0,0 +1,49 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import crypto from 'crypto'
import { computeIdentityHash } from '../identity-hash'
const USER = '3f7c1d2e-0000-4000-8000-abcdefabcdef'
describe('computeIdentityHash', () => {
afterEach(() => vi.unstubAllEnvs())
it('returns null when the secret is unset (dev / CI / self-hosted)', () => {
vi.stubEnv('POSTHOG_SECRET_API_KEY', '')
expect(computeIdentityHash(USER)).toBeNull()
})
it('returns null for an empty distinct id rather than hashing nothing', () => {
vi.stubEnv('POSTHOG_SECRET_API_KEY', 'phs_secret')
expect(computeIdentityHash('')).toBeNull()
})
it('matches HMAC-SHA256(distinctId, secret) in hex, as PostHog expects', () => {
vi.stubEnv('POSTHOG_SECRET_API_KEY', 'phs_secret')
const expected = crypto.createHmac('sha256', 'phs_secret').update(USER).digest('hex')
expect(computeIdentityHash(USER)).toBe(expected)
expect(computeIdentityHash(USER)).toMatch(/^[a-f0-9]{64}$/)
})
it('is stable across calls for the same user', () => {
vi.stubEnv('POSTHOG_SECRET_API_KEY', 'phs_secret')
expect(computeIdentityHash(USER)).toBe(computeIdentityHash(USER))
})
it('differs per user', () => {
vi.stubEnv('POSTHOG_SECRET_API_KEY', 'phs_secret')
expect(computeIdentityHash(USER)).not.toBe(computeIdentityHash(USER.replace('3f7c', '4a8d')))
})
it('changes when the key is rotated, invalidating old hashes', () => {
vi.stubEnv('POSTHOG_SECRET_API_KEY', 'phs_old')
const before = computeIdentityHash(USER)
vi.stubEnv('POSTHOG_SECRET_API_KEY', 'phs_new')
expect(computeIdentityHash(USER)).not.toBe(before)
})
// The hash crosses to the browser; the key must not be derivable from it.
it('never echoes the secret into its output', () => {
vi.stubEnv('POSTHOG_SECRET_API_KEY', 'phs_supersecretvalue')
expect(computeIdentityHash(USER)).not.toContain('supersecretvalue')
})
})
+26
View File
@@ -0,0 +1,26 @@
import 'server-only'
import crypto from 'crypto'
/**
* HMAC identity verification for PostHog Support (conversations).
*
* Without it, a support ticket is scoped to one browser session: the user
* loses the thread when they switch device, and recovery is by email link.
* With it, PostHog trusts that this browser really is `distinctId`, so the
* thread follows the person.
*
* The hash MUST be computed server-side. `POSTHOG_SECRET_API_KEY` is a real
* secret (it also authenticates external API requests), so it is deliberately
* NOT `NEXT_PUBLIC_`: it must never reach the client bundle. Only the derived
* hash crosses to the browser, which is safe because it is per-user and
* useless without the key.
*
* Returns null when the key is unset, which is the normal state for local
* dev, CI and self-hosted. Callers degrade to unverified (browser-scoped)
* tickets rather than failing.
*/
export function computeIdentityHash(distinctId: string): string | null {
const secret = process.env.POSTHOG_SECRET_API_KEY
if (!secret || !distinctId) return null
return crypto.createHmac('sha256', secret).update(distinctId).digest('hex')
}
+67 -5
View File
@@ -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()
})
})
})
+44 -8
View File
@@ -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,
}
}