// visitor-session-detail — full event timeline for one :Session. // Operator-side query: "show me everything Sarah did in session abc12". import { getSession } from "../lib/neo4j.js"; export interface SessionDetailParams { accountId: string; sessionId: string; } export interface SessionEvent { kind: "pageview" | "click" | "scroll"; occurredAt: string; /** PageView only */ url?: string; path?: string; pageViewId?: string; referrer?: string; dwellMs?: number; /** Click only */ label?: string; href?: string; /** Scroll only */ maxDepth?: number; /** Optional listing link */ listingSlug?: string; } export interface SessionDetail { sessionId: string; startedAt: string | null; lastSeenAt: string | null; personId: string | null; personName: string | null; anonVisitorId: string | null; events: SessionEvent[]; } export async function visitorSessionDetail(p: SessionDetailParams): Promise { const session = getSession(); try { const sessionRes = await session.run( `MATCH (s:Session {accountId: $accountId, sessionId: $sessionId}) OPTIONAL MATCH (per:Person)-[:VISITED]->(s) OPTIONAL MATCH (av:AnonVisitor)-[:VISITED]->(s) RETURN toString(s.startedAt) AS startedAt, toString(s.lastSeenAt) AS lastSeenAt, elementId(per) AS personId, per.name AS personName, av.visitorId AS anonVisitorId LIMIT 1`, { accountId: p.accountId, sessionId: p.sessionId }, ); if (sessionRes.records.length === 0) { return { sessionId: p.sessionId, startedAt: null, lastSeenAt: null, personId: null, personName: null, anonVisitorId: null, events: [], }; } const head = sessionRes.records[0]; const eventsRes = await session.run( `MATCH (s:Session {accountId: $accountId, sessionId: $sessionId})-[:HAS_EVENT]->(e) OPTIONAL MATCH (e)-[:OF_PAGE]->(pg:Page) OPTIONAL MATCH (e)-[:OF_LISTING]->(l:Listing) RETURN labels(e) AS labels, toString(e.occurredAt) AS occurredAt, toString(coalesce(e.firstAt, e.occurredAt)) AS firstAt, e.url AS url, pg.url AS pageUrl, e.path AS path, e.pageViewId AS pageViewId, e.referrer AS referrer, e.dwellMs AS dwellMs, e.label AS label, e.href AS href, e.maxDepth AS maxDepth, l.slug AS listingSlug ORDER BY firstAt ASC`, { accountId: p.accountId, sessionId: p.sessionId }, ); const events: SessionEvent[] = eventsRes.records.map((r) => { const labels = r.get("labels") as string[]; const kind: SessionEvent["kind"] = labels.includes("PageView") ? "pageview" : labels.includes("Click") ? "click" : "scroll"; const base: SessionEvent = { kind, occurredAt: r.get("occurredAt") ?? r.get("firstAt") ?? "", }; if (kind === "pageview") { base.url = r.get("pageUrl") ?? r.get("url") ?? ""; base.path = r.get("path") ?? undefined; base.pageViewId = r.get("pageViewId") ?? undefined; base.referrer = r.get("referrer") ?? undefined; base.dwellMs = r.get("dwellMs") != null ? Number(r.get("dwellMs")) : undefined; base.listingSlug = r.get("listingSlug") ?? undefined; } else if (kind === "click") { base.label = r.get("label") ?? "unknown"; base.href = r.get("href") ?? undefined; } else { base.maxDepth = r.get("maxDepth") != null ? Number(r.get("maxDepth")) : undefined; } return base; }); return { sessionId: p.sessionId, startedAt: head.get("startedAt"), lastSeenAt: head.get("lastSeenAt"), personId: head.get("personId"), personName: head.get("personName"), anonVisitorId: head.get("anonVisitorId"), events, }; } finally { await session.close(); } }