52ec3ce497
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>
97 lines
2.9 KiB
TypeScript
97 lines
2.9 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect } from 'react'
|
|
import posthog from 'posthog-js'
|
|
import { isAnalyticsEnabled } from '@/lib/analytics/enabled'
|
|
import {
|
|
buildPersonProperties,
|
|
buildGroupProperties,
|
|
type AnalyticsCompanyInput,
|
|
type AnalyticsUserInput,
|
|
} from '@/lib/analytics/properties'
|
|
|
|
/**
|
|
* Attaches the logged-in user and their active company to PostHog.
|
|
*
|
|
* Mounted from app/(dashboard)/layout.tsx, which is the only place that has
|
|
* both the auth user and the resolved company in one render, and gated there
|
|
* on `!isSandbox` so demo companies never pollute funnels or get surveyed.
|
|
*
|
|
* identify() runs on every dashboard load rather than only at login, per
|
|
* PostHog's guidance: with `persistence: 'memory'` (see
|
|
* instrumentation-client.ts) nothing survives a hard reload, so re-identifying
|
|
* on each load is what keeps person-level analytics correct without storing
|
|
* anything on the device.
|
|
*
|
|
* A useEffect is correct here despite the general rule against it: this
|
|
* synchronises with an external, non-React system (the PostHog SDK).
|
|
*/
|
|
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 {
|
|
id: companyId,
|
|
name: companyName,
|
|
entityType,
|
|
accountingFramework,
|
|
paysSalaries,
|
|
trialEndsAt,
|
|
capabilities,
|
|
} = company
|
|
|
|
// The dashboard layout rebuilds the props objects (and the capabilities
|
|
// array) on every render, so depending on them directly would re-fire
|
|
// identify on every navigation. Depend on primitives, and collapse the
|
|
// array to a stable string key.
|
|
const capabilityKey = capabilities ? [...capabilities].sort().join(',') : ''
|
|
|
|
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',
|
|
companyId,
|
|
buildGroupProperties({
|
|
id: companyId,
|
|
name: companyName,
|
|
entityType,
|
|
accountingFramework,
|
|
paysSalaries,
|
|
trialEndsAt,
|
|
capabilities: capabilityKey ? capabilityKey.split(',') : undefined,
|
|
})
|
|
)
|
|
}, [
|
|
userId,
|
|
email,
|
|
fullName,
|
|
role,
|
|
companyId,
|
|
companyName,
|
|
entityType,
|
|
accountingFramework,
|
|
paysSalaries,
|
|
trialEndsAt,
|
|
capabilityKey,
|
|
identityHash,
|
|
])
|
|
|
|
return null
|
|
}
|