Skip to main content

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.vuegetPersons. 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) |

ColumnSource field
Nameperson.name
Emailperson.email
Phoneperson.phone
Match keyscount of person.match_keys[]
Profilesperson.profile_count
Sessionsperson.session_count
Last activeperson.last_active_at
ActionsView → 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.vuegetPerson (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:

TabContents
Company Profilesone 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:)
Sessionslast 50 sessions (Company, Identified as, Trust, Current URL, Created); trust derives from identity_trust / verified_person_id / claimed_person_id
Bookingsbookings flattened across all sessions + per-company profiles
Match KeysType, 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.vuegetPersonProfile. The full per-company facet. Tabs:

TabContents
Form Submissionsevery captured form answer for this person+company
Extracted Factsfacts the AI extracted from conversations
Bookingsthis profile's bookings
CRM EnrichmentHubSpot and Go High Level enrichment blocks
Identity Auditthe audit log: Event, Source, Score, Session, When; flags Conflict and Needs review rows
IP Addressesreverse-IP signals: IP, Subnet, Ver (verified), Hits, First seen, Last seen

Profiles list — /admin/identity/profiles

Source: identity/PersonProfiles.vuegetPersonProfiles. A flat cross-tenant list of PersonProfiles (filterable by company, sort/order via buildQuery).

Form submissions — /admin/identity/form-submissions

Source: identity/FormSubmissions.vuegetFormSubmissions. Every form answer captured across the graph, rendered as cards with Person/Company/profile crumbs.

FilterValues
Searchfield label or value (debounced 300ms)
Form typeAll · contact_info · pre_call · booking · website_form · custom
SourceAll · 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/keyvalue).

CRM Reconcile — /admin/identity/reconcile-conflicts

Source: identity/ReconcileConflicts.vuegetReconcileConflicts / 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:

ActionEffectAPI payload
Overwrite anywayPush 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'}
DismissKeep 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.

JobEndpointWrites?Key fieldsWhat it does
GHL Reconcile BackfillPOST …/maintenance (runReconcileBackfill/admin/identity/reconcile-backfill)yesCompany 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 IdentitiesPOST /admin/identity/maintenance/cleanupyesCompany 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:trueyesCompany 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:trueyesCompany 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 SignalsPOST /admin/identity/maintenance/backfill-ip-signalsyesCompany 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 AnalysisPOST /admin/identity/maintenance/ip-uniquenessread-onlyCompany ID, Days (default 90)Measures how many people share an IP / subnet — informs how aggressively IP can be trusted.
IP Shadow StatsPOST /admin/identity/maintenance/ip-shadow-statsread-onlyCompany 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 NamePOST /admin/identity/maintenance/backfill-session-nameyesCompany 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 EventsPOST /admin/identity/maintenance/trim-session-eventsyesCompany ID, Keep last N (default 500), Page size (default 100), Max pages/call (default 500), Resume after _idCaps 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.vuegetIdentityGraphDefaults / 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):

FieldTypeDefaultMeaning
Enable Identity GraphtoggleonWhen off, AI agents receive no cross-session identity context.
Capture Website FormstoggleonInjects the form-intercept script alongside the widget so customer-site form submissions flow into the graph.
Verification PolicyradiostrictHow aggressively a session that claims an identity is promoted to "verified" — see below.
Trust Inbound Caller-IDtoggleonPromote an inbound voice call's session to verified when caller-ID matches a known phone.
AI Agent Context SectionscheckboxesallWhich 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 IntrotextareaParagraph prepended to every AI prompt's visitor-context block.
Max Recent Pagesnumber 1–205Limits on context size.
Max Past Conversationsnumber 1–205
Max Extracted Factsnumber 1–5015

Verification Policy options:

PolicyBehavior
PermissiveTrust every claim — any session typing a known email/phone gets full cross-session access. Highest impersonation risk.
ModerateScore-based — auto-verifies when corroborating signals (FingerprintJS hash, browser signature, IP/subnet, known identifier) reach a moderate threshold.
StrictSame scoring engine, higher threshold — requires multiple corroborating signals or a direct trigger.
RegulatedScore-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; the company_id-less variant of the has_profile lookup 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 source matches ^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 diagnostic GET /admin/identity/sessions/:user_session_id/markdown-preview returning 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 under frontend/src/views/admin/identity/.
  • Services that back these screens: IdentityGraphConfigService, IdentityGhlReconcileService, IdentityCleanupService, IdentityNormalize, IpSignalSeedService (wired as global.identityCleanup, global.ipSignalSeed), plus lib/userSessionNameBackfill.js, lib/sessionEventsTrim.js, lib/progressStream.js.
  • Config defaults model: backend/app/models/IdentityGraphDefaultsModel.js.