Identity Graph (Admin)
Audience: Ops · Super-admins · Engineering · Support escalations · Where in app: /admin/identity/* · Access: Super-admin only — every endpoint enforces request.user.user_type === 'admin' (AdminIdentityController._requireAdmin, returns 403 Unauthorized otherwise).
The Identity Graph is the platform's record of who a visitor is across every session, device, and company. A Person is the global identity; a PersonProfile is that person's per-company facet (their CRM links, extracted facts, bookings, form answers for one tenant). These admin screens let a super-admin browse those records cross-tenant, tune how aggressively identity is trusted, resolve CRM write conflicts, and run one-shot data-maintenance sweeps. For the conceptual model and runtime behavior see the subsystem and module docs linked under Related.
What it does
- Browse the graph — list/search Persons, per-company Profiles, and every captured form submission across all tenants.
- Inspect one Person — see their profiles, recent sessions (with trust state), bookings, match keys, identity audit trail, CRM enrichment, and IP signals.
- Configure verification posture — set global Identity Graph defaults and per-company overrides (the super-admin replacement for the old customer-side settings UI).
- Reconcile CRM conflicts — review GoHighLevel contacts where first-party identity should supersede Happier-Leads enrichment but looked like a real pre-existing contact, so the system flagged instead of overwrote.
- Run maintenance jobs — dry-runnable backfills/cleanups (cleanup, normalize, moderate names, trim bloated session-event arrays, IP-signal backfill and analyses, session-name backfill, GHL reconcile backfill).
How it works
The frontend repository frontend/src/repositories/admin/identity.js wraps the backend routes declared in backend/app/routes/adminIdentityRoutes.js (all under /admin/identity/*, all passport-authed). The controller is backend/app/controllers/AdminIdentityController.js.
Reads (persons / profiles / form-submissions / reconcile-conflicts) are paginated via a shared buildQuery that converts {page, pageSize} into skip/limit. List queries cap MongoDB execution at 15s (maxTimeMS) and return 504 on timeout rather than hanging until the load balancer cuts the connection.
Maintenance writes are super-admin-gated wrappers around the api-key-gated /migrations/* sweeps, so they can be triggered from the admin UI. Every write action defaults to a dry run (apply:true must be sent to actually mutate). Most return a {mode, company_id, elapsed_ms, stats} envelope; the Trim Session Events job streams NDJSON progress instead (see below).
Screens & fields
Cross-entity navigation
Every Identity screen shows a row of nav pills (Persons, Profiles, Form Submissions, CRM Reconcile, Maintenance, Config) so an admin can move between facets of the same data.
Persons list — /admin/identity/persons
Source: identity/Persons.vue → getPersons. The global directory of unique people.
| Filter | Values | Notes |
| ----------- | -------------------------------------- | ----------------------------------------------------------------------------- | ------ |
| Search | free text | matches name, email, or phone (case-insensitive regex), debounced 300ms |
| Has Profile | All / With profiles / Without profiles | sends has_profile=true | false |
| Sort | Last active / First seen / Name | sort field; order is -1 (desc) |
| Column | Source field |
|---|---|
| Name | person.name |
person.email | |
| Phone | person.phone |
| Match keys | count of person.match_keys[] |
| Profiles | person.profile_count |
| Sessions | person.session_count |
| Last active | person.last_active_at |
| Actions | View → person detail |
Rows are click-through to the detail page. Pagination total comes from pagination.total.
Person detail — /admin/identity/persons/:id
Source: identity/PersonDetail.vue → getPerson (controller aggregates counts server-side to avoid shipping the heavy form_submissions / extracted_facts / crm_enrichment arrays over the wire). Header shows name, first-seen date, and match-key count. Tabs:
| Tab | Contents |
|---|---|
| Company Profiles | one card per PersonProfile: company, first-seen/last-active, form-submission count, extracted-fact count, CRM-presence flag, recent identity events, a "conflicting claims" flag (any event whose source starts with conflict_with_verified:) |
| Sessions | last 50 sessions (Company, Identified as, Trust, Current URL, Created); trust derives from identity_trust / verified_person_id / claimed_person_id |
| Bookings | bookings flattened across all sessions + per-company profiles |
| Match Keys | Type, Normalized value, Hash — the deterministic keys (email/phone/etc.) used to link records |
Profile detail — /admin/identity/persons/:id/profiles/:company_id
Source: identity/PersonProfileDetail.vue → getPersonProfile. The full per-company facet. Tabs:
| Tab | Contents |
|---|---|
| Form Submissions | every captured form answer for this person+company |
| Extracted Facts | facts the AI extracted from conversations |
| Bookings | this profile's bookings |
| CRM Enrichment | HubSpot and Go High Level enrichment blocks |
| Identity Audit | the audit log: Event, Source, Score, Session, When; flags Conflict and Needs review rows |
| IP Addresses | reverse-IP signals: IP, Subnet, Ver (verified), Hits, First seen, Last seen |
Profiles list — /admin/identity/profiles
Source: identity/PersonProfiles.vue → getPersonProfiles. A flat cross-tenant list of PersonProfiles (filterable by company, sort/order via buildQuery).
Form submissions — /admin/identity/form-submissions
Source: identity/FormSubmissions.vue → getFormSubmissions. Every form answer captured across the graph, rendered as cards with Person/Company/profile crumbs.
| Filter | Values |
|---|---|
| Search | field label or value (debounced 300ms) |
| Form type | All · contact_info · pre_call · booking · website_form · custom |
| Source | All · chat · call · booking_flow · auto_trigger · form_intercept |
Each card shows form_type, source, the relationship crumbs, submitted_at, and a key/value grid of fields[] (label/key → value).
CRM Reconcile — /admin/identity/reconcile-conflicts
Source: identity/ReconcileConflicts.vue → getReconcileConflicts / resolveReconcileConflict. The review queue of GoHighLevel contacts where a visitor declared real first-party details (form/booking/call) but the linked contact looked like a real, pre-existing person, so the system flagged instead of overwrote.
Each conflict card shows: Person crumb, Company crumb, First-party email, First-party phone, GHL contact id, Flagged via (latest_conflict_source, with a ×N repeat count), and latest_conflict_at. Actions per card:
| Action | Effect | API payload |
|---|---|---|
| Overwrite anyway | Push first-party details to the GHL contact (re-links to another contact if that email/phone already belongs to one). Use only when the contact IS this person. Opens a confirm dialog. | {person_id, company_id, action:'overwrite'} |
| Dismiss | Keep the existing GHL contact, drop from queue. Use when Happier Leads matched a different person. Optional Also unlink clears the wrong person↔contact association. | {person_id, company_id, action:'dismiss', unlink?} |
The reconcile backfill itself is run from the Maintenance screen (see "GHL Reconcile Backfill" below), not here.
Maintenance — /admin/identity/maintenance
Source: identity/IdentityMaintenance.vue, each job rendered via components/admin/MigrationPanel.vue. Every job defaults to a dry run (reads/counts only); review the stats, then Apply. Most accept an optional Company ID to scope to one tenant.
| Job | Endpoint | Writes? | Key fields | What it does |
|---|---|---|---|---|
| GHL Reconcile Backfill | POST …/maintenance (runReconcileBackfill → /admin/identity/reconcile-backfill) | yes | Company ID, Limit (1–5000, default 200) | Sweeps already-verified sessions where first-party identity should supersede Happier-Leads enrichment and corrects their GHL contacts; real pre-existing contacts are flagged into CRM Reconcile, not overwritten. |
| Cleanup Identities | POST /admin/identity/maintenance/cleanup | yes | Company ID, Clean sessions, Drop foreign submissions, Prune empty Persons (destructive — requires Apply) | Removes invalid identity values (bad emails/phones/names) from Person, profiles, sessions, form submissions; optionally prunes empty Persons and drops foreign submissions. |
| Normalize Identity Values | …/cleanup with normalize:true | yes | Company ID (sessions only) | Lowercases emails, promotes phones to E.164 where a country code is known (explicit + or session region — never a default), collapses name whitespace, rebuilds Person match keys so historical rows link consistently. Rewrites values; deletes nothing. |
| Moderate Names | …/cleanup with moderateNames:true | yes | Company ID (sessions only) | Blanks stored Person + session names the NameModeration gate now rejects (junk / non-name phrases / profanity); display falls back to "Unknown Visitor". |
| Backfill IP Signals | POST /admin/identity/maintenance/backfill-ip-signals | yes | Company ID, Days (1–365, default 90) | Seeds the reverse-IP index (person_ip_signals) from recent sessions so IP-assisted resolution and the per-person IP tab have data. |
| IP Uniqueness Analysis | POST /admin/identity/maintenance/ip-uniqueness | read-only | Company ID, Days (default 90) | Measures how many people share an IP / subnet — informs how aggressively IP can be trusted. |
| IP Shadow Stats | POST /admin/identity/maintenance/ip-shadow-stats | read-only | Company ID, Days (default 30) | IP-PR2 go/no-go: how often a would-be IP claim matched the person the visitor actually resolved to (confirmed) vs a different one (contradicted). The contradiction rate gates enabling IP-assisted resolution. |
| Backfill Session Display Name | POST /admin/identity/maintenance/backfill-session-name | yes | Company ID, Sample rows (0–100, default 20) | Populates user_session.name from legacy first_name+last_name (treats Guest/User/empty as no real data). |
| Trim Session Events | POST /admin/identity/maintenance/trim-session-events | yes | Company ID, Keep last N (default 500), Page size (default 100), Max pages/call (default 500), Resume after _id | Caps tab_focus_events / video_unmutes arrays bloated by the (now $slice-capped) uncapped $push. Pages by _id so it never loads the bloated docs; resumable. |
Trim Session Events — NDJSON streaming
This job can run long, so it streams NDJSON (backend/app/lib/progressStream.js) instead of buffering one JSON body — a heartbeat keeps the ingress idle-timeout from killing the request. The repository (runTrimSessionEvents) reads responseType:'text', splits on newlines, and parses each line:
{"type":"progress", "...":"..."}
{"type":"ping"}
{"type":"result","mode":"APPLY","stats":{"cap":500,"scanned":1200,"trimmed":340,"done":false,"next_after":"<objectId>"}}
{"type":"error","message":"..."}
Errors arrive in-band with HTTP 200, so the body must be inspected. Because the job is batched and resumable, when done:false paste the returned next_after into Resume after _id and Apply again until done:true.
Config — /admin/identity/config
Source: identity/Config.vue + identity/_ConfigForm.vue → getIdentityGraphDefaults / updateIdentityGraphDefaults / updateCompanyIdentityGraphOverride. Sets the global defaults that apply to every company, plus optional per-company overrides.
The config form fields (shared between global and override):
| Field | Type | Default | Meaning |
|---|---|---|---|
| Enable Identity Graph | toggle | on | When off, AI agents receive no cross-session identity context. |
| Capture Website Forms | toggle | on | Injects the form-intercept script alongside the widget so customer-site form submissions flow into the graph. |
| Verification Policy | radio | strict | How aggressively a session that claims an identity is promoted to "verified" — see below. |
| Trust Inbound Caller-ID | toggle | on | Promote an inbound voice call's session to verified when caller-ID matches a known phone. |
| AI Agent Context Sections | checkboxes | all | Which sections to include in visitor context (Identity, Company Insights, Engagement, Current Session, Recent Pages, Past Conversations, Behavioral Signals, CRM State, Bookings, Form Responses, Extracted Facts). Leave all unchecked = include everything. |
| Custom Intro | textarea | — | Paragraph prepended to every AI prompt's visitor-context block. |
| Max Recent Pages | number 1–20 | 5 | Limits on context size. |
| Max Past Conversations | number 1–20 | 5 | |
| Max Extracted Facts | number 1–50 | 15 |
Verification Policy options:
| Policy | Behavior |
|---|---|
| Permissive | Trust every claim — any session typing a known email/phone gets full cross-session access. Highest impersonation risk. |
| Moderate | Score-based — auto-verifies when corroborating signals (FingerprintJS hash, browser signature, IP/subnet, known identifier) reach a moderate threshold. |
| Strict | Same scoring engine, higher threshold — requires multiple corroborating signals or a direct trigger. |
| Regulated | Score-based path disabled — only form / CRM / inbound-call / first-profile / returning-device triggers verify. |
Per-company override flow: search a company (debounced lookup against /admin/companies), tick Use a custom Identity Graph config for this company, then save (PUT /admin/identity/config/company/:id with {use_custom_config:true, config:{...}}). Override fields left blank inherit from the global defaults. Unticking and saving sends {use_custom_config:false} to revert the company to global defaults.
Behaviors & edge cases
- All endpoints are super-admin only. Non-admin requests get
403 Unauthorized. - Dry-run by default. Maintenance writes require
apply:true. "Prune empty Persons" additionally rejects a non-apply request (400— run a dry-run count first). - Query timeouts. Person/profile reads cap at 15s and return
504 — Query timed out, try again or narrow the request; thecompany_id-less variant of thehas_profilelookup is rejected as unbounded. - Trim Session Events errors are in-band (HTTP 200,
{type:'error'}line) — the body must be checked; a thrown UI error means the stream reported a failure. - Conflict provenance. A profile's "conflicting claims" flag is true when any identity event's
sourcematches^conflict_with_verified:. - Reconcile "overwrite anyway" may re-link the GHL contact to a different contact if the email/phone already belongs to one — confirm the contact really is this person first.
Technical implementation
- Routes:
backend/app/routes/adminIdentityRoutes.js(all/admin/identity/*, passport-authed). - Controller:
backend/app/controllers/AdminIdentityController.js— also exposes a diagnosticGET /admin/identity/sessions/:user_session_id/markdown-previewreturning the exact markdown an AI agent would receive plus the gating flags (handy when context is unexpectedly empty). - Frontend repo:
frontend/src/repositories/admin/identity.js; views underfrontend/src/views/admin/identity/. - Services that back these screens:
IdentityGraphConfigService,IdentityGhlReconcileService,IdentityCleanupService,IdentityNormalize,IpSignalSeedService(wired asglobal.identityCleanup,global.ipSignalSeed), pluslib/userSessionNameBackfill.js,lib/sessionEventsTrim.js,lib/progressStream.js. - Config defaults model:
backend/app/models/IdentityGraphDefaultsModel.js.