// visitor-recent-by-page — who viewed a given page/listing recently. // Operator-side query: "who has shown interest in 12 Oak Street this week?" import { int } from "neo4j-driver"; import { getSession } from "../lib/neo4j.js"; export interface RecentByPageParams { accountId: string; /** Either listingSlug or url; one is required. */ listingSlug?: string; url?: string; sinceDays?: number; limit?: number; } export interface VisitorSummary { personId: string | null; personName: string | null; anonVisitorId: string | null; pageviewCount: number; lastSeenAt: string; } export async function visitorRecentByPage(p: RecentByPageParams): Promise<{ visitors: VisitorSummary[] }> { if (!p.listingSlug && !p.url) { throw new Error("visitor-recent-by-page requires listingSlug or url"); } const since = p.sinceDays ?? 7; const limit = Math.max(1, Math.min(p.limit ?? 50, 200)); const session = getSession(); try { const result = await session.run( `MATCH (pv:PageView {accountId: $accountId}) WHERE pv.occurredAt >= datetime() - duration({days: $since}) AND ($url IS NULL OR EXISTS { MATCH (pv)-[:OF_PAGE]->(:Page {accountId: $accountId, url: $url}) }) AND ($slug IS NULL OR EXISTS { MATCH (pv)-[:OF_LISTING]->(:Listing {accountId: $accountId, slug: $slug, scope:'public'}) }) MATCH (s:Session)-[:HAS_EVENT]->(pv) OPTIONAL MATCH (per:Person)-[:VISITED]->(s) OPTIONAL MATCH (av:AnonVisitor)-[:VISITED]->(s) WITH per, av, s, count(pv) AS pageviews RETURN coalesce(elementId(per), null) AS personId, coalesce(per.name, null) AS personName, coalesce(av.visitorId, null) AS anonVisitorId, sum(pageviews) AS totalPageviews, toString(max(s.lastSeenAt)) AS lastSeenAt ORDER BY lastSeenAt DESC LIMIT $limit`, { accountId: p.accountId, url: p.url ?? null, slug: p.listingSlug ?? null, since, limit: int(limit) }, ); return { visitors: result.records.map((r) => ({ personId: r.get("personId"), personName: r.get("personName"), anonVisitorId: r.get("anonVisitorId"), pageviewCount: Number(r.get("totalPageviews") ?? 0), lastSeenAt: r.get("lastSeenAt"), })), }; } finally { await session.close(); } }