// visitor-event-ingest — admin-side companion to POST /v/event for test // harness work and manual replay. Takes a single event payload identical // in shape to what the pixel sends, writes it into the graph against the // caller's accountId. Not exposed on the public agent surface. import { getSession } from "../lib/neo4j.js"; export interface IngestParams { accountId: string; type: "pageview" | "click" | "scroll" | "dwell"; sessionId: string; visitorId: string; /** optional Person elementId — overrides identity resolution */ personId?: string | null; url: string; path?: string; title?: string; pageViewId?: string; site?: string; referrer?: string; label?: string; href?: string; depth?: number; dwellMs?: number; occurredAt?: string; } export interface IngestResult { ok: boolean; type: string; sessionId: string; } export async function visitorEventIngest(p: IngestParams): Promise { const session = getSession(); const occurredAt = p.occurredAt ?? new Date().toISOString(); try { if (p.type === "pageview") { await session.run( `MERGE (s:Session {accountId: $accountId, sessionId: $sid}) ON CREATE SET s.startedAt = datetime($occurredAt), s.lastSeenAt = datetime($occurredAt) ON MATCH SET s.lastSeenAt = datetime($occurredAt) MERGE (pg:Page {accountId: $accountId, url: $url}) ON CREATE SET pg.firstSeenAt = datetime($occurredAt), pg.title = $title CREATE (pv:PageView { accountId: $accountId, pageViewId: $pvId, occurredAt: datetime($occurredAt), referrer: $referrer, path: $path }) MERGE (s)-[:HAS_EVENT]->(pv) MERGE (pv)-[:OF_PAGE]->(pg)`, { accountId: p.accountId, sid: p.sessionId, url: p.url, title: p.title ?? "", pvId: p.pageViewId ?? `${p.sessionId}_${Date.parse(occurredAt)}`, path: p.path ?? "", referrer: p.referrer ?? "", occurredAt, }, ); } else if (p.type === "click") { await session.run( `MERGE (s:Session {accountId: $accountId, sessionId: $sid}) ON CREATE SET s.startedAt = datetime($occurredAt), s.lastSeenAt = datetime($occurredAt) ON MATCH SET s.lastSeenAt = datetime($occurredAt) CREATE (cl:Click {accountId: $accountId, label: $label, href: $href, pageViewId: $pvId, occurredAt: datetime($occurredAt)}) MERGE (s)-[:HAS_EVENT]->(cl)`, { accountId: p.accountId, sid: p.sessionId, label: (p.label ?? "unknown").slice(0, 64), href: (p.href ?? "").slice(0, 2048), pvId: p.pageViewId ?? "", occurredAt, }, ); } else if (p.type === "scroll") { await session.run( `MERGE (s:Session {accountId: $accountId, sessionId: $sid}) MERGE (sm:ScrollMilestone {accountId: $accountId, sessionId: $sid, pageViewId: $pvId}) ON CREATE SET sm.maxDepth = $depth, sm.firstAt = datetime($occurredAt), sm.lastAt = datetime($occurredAt) ON MATCH SET sm.maxDepth = CASE WHEN sm.maxDepth >= $depth THEN sm.maxDepth ELSE $depth END, sm.lastAt = datetime($occurredAt) MERGE (s)-[:HAS_EVENT]->(sm)`, { accountId: p.accountId, sid: p.sessionId, pvId: p.pageViewId ?? "", depth: p.depth ?? 0, occurredAt }, ); } else if (p.type === "dwell") { await session.run( `MATCH (pv:PageView {accountId: $accountId, pageViewId: $pvId}) SET pv.dwellMs = coalesce(pv.dwellMs, 0) + $ms, pv.dwellLastAt = datetime($occurredAt)`, { accountId: p.accountId, pvId: p.pageViewId ?? "", ms: p.dwellMs ?? 0, occurredAt }, ); } if (p.personId) { await session.run( `MATCH (per:Person {accountId: $accountId}) WHERE elementId(per) = $personId MATCH (s:Session {accountId: $accountId, sessionId: $sid}) MERGE (per)-[:VISITED {accountId: $accountId}]->(s)`, { accountId: p.accountId, personId: p.personId, sid: p.sessionId }, ); } else { await session.run( `MERGE (av:AnonVisitor {accountId: $accountId, visitorId: $vid}) ON CREATE SET av.firstSeenAt = datetime($occurredAt), av.lastSeenAt = datetime($occurredAt) ON MATCH SET av.lastSeenAt = datetime($occurredAt) WITH av MATCH (s:Session {accountId: $accountId, sessionId: $sid}) MERGE (av)-[:VISITED {accountId: $accountId}]->(s)`, { accountId: p.accountId, vid: p.visitorId, sid: p.sessionId, occurredAt }, ); } return { ok: true, type: p.type, sessionId: p.sessionId }; } finally { await session.close(); } }