// Chain graph I/O — the single responsibility of this file is reading and // writing the sales-progression ontology (:Sale / :Chain / :ChainLink + party // :Person) via raw parameterised cypher. Every write is account-scoped and // attaches its parent edge in the same statement, so the account/adjacency // write-gate holds by construction (a cross-account or adjacency-less write is // structurally impossible). This is deliberately NOT writeNodeWithEdges: that // primitive is CREATE-only and cannot express MERGE-on-natural-key upsert or // the single-field milestone SET this domain needs. import neo4j, { Session, Integer } from "neo4j-driver"; /** The ten milestone stages → their :ChainLink date property names. */ export const MILESTONE_STAGES = { "mortgage-valuation": "mortgageValuationDate", survey: "surveyDate", "draft-contract-issued": "draftContractIssuedDate", "searches-requested": "searchesRequestedDate", "searches-received": "searchesReceivedDate", "mortgage-offer-received": "mortgageOfferReceivedDate", "seller-signed": "sellerSignedDate", "buyer-signed": "buyerSignedDate", exchanged: "exchangedDate", completed: "completedDate", } as const; export type MilestoneStage = keyof typeof MILESTONE_STAGES; export const MILESTONE_STAGE_NAMES = Object.keys(MILESTONE_STAGES) as MilestoneStage[]; export const MILESTONE_DATE_PROPS = Object.values(MILESTONE_STAGES); /** Party role → the :ChainLink→:Person edge type carrying that role. */ export const PARTY_ROLES = { seller: "HAS_SELLER", "seller-solicitor": "HAS_SELLER_SOLICITOR", buyer: "HAS_BUYER", "buyer-solicitor": "HAS_BUYER_SOLICITOR", agent: "HAS_AGENT", } as const; export type PartyRole = keyof typeof PARTY_ROLES; export const PARTY_ROLE_NAMES = Object.keys(PARTY_ROLES) as PartyRole[]; export interface PartyInput { name?: string | null; email?: string | null; phone?: string | null; } export interface LinkInput { priority: number; linkType: "loop-property" | "external"; address?: string | null; salePrice?: number | null; generalNotes?: string | null; proceedabilityNotes?: string | null; /** Slug of the :Property this link is, when linkType = loop-property. */ propertySlug?: string | null; /** Milestone dates keyed by stage short-name (subset allowed). */ milestones?: Partial>; /** Parties keyed by role (subset allowed). */ parties?: Partial>; } export interface ChainUpsertInput { chainId: string; sale: { sourceSystem: string; sourceId: string; salePrice?: number | null; status?: string | null; sstcDate?: string | null; isCompleteChain?: boolean | null; }; chain?: { anticipatedExchangeDate?: string | null; desiredCompletionDate?: string | null; createdDate?: string | null; owningTeam?: string | null; owningAgent?: string | null; isComplete?: boolean | null; }; links: LinkInput[]; } export interface UpsertResult { nodesCreated: number; propertiesSet: number; relationshipsCreated: number; linkCount: number; partyCount: number; listingLinked: boolean; offerLinked: boolean; } export interface PartyState { role: string; name: string | null; email: string | null; phone: string | null; } export interface LinkState { priority: number; linkType: string | null; address: string | null; salePrice: number | null; generalNotes: string | null; proceedabilityNotes: string | null; milestones: Record; parties: PartyState[]; } export interface ChainState { chainId: string; chain: Record | null; sale: Record | null; links: LinkState[]; stalled: boolean; cause: string; structuralFlags: string[]; } export interface PipelineRow { chainId: string; sourceId: string | null; address: string | null; salePrice: number | null; lastMovement: string | null; daysSinceMovement: number | null; stalled: boolean; } export interface PartyMatch { chainId: string; priority: number; partyRole: string; name: string | null; address: string | null; } export const DEFAULT_STALLED_THRESHOLD_DAYS = 7; // ---- helpers ---------------------------------------------------------------- /** Neo4j returns integers as `Integer`; normalise to a JS number for output. */ function toNum(v: unknown): number | null { if (v === null || v === undefined) return null; if (v instanceof Integer) return v.toNumber(); if (typeof v === "number") return v; if (neo4j.isInt(v)) return (v as Integer).toNumber(); return Number(v); } /** Drop undefined keys so `SET n += $map` only touches supplied fields. */ function definedOnly(obj: Record): Record { const out: Record = {}; for (const [k, v] of Object.entries(obj)) if (v !== undefined) out[k] = v; return out; } /** Normalise a node's properties for JSON output: neo4j Integers → numbers. * Without this, a stored int (salePrice, priority) serialises as `{low, high}`. */ function normalizeProps(props: Record): Record { const out: Record = {}; for (const [k, v] of Object.entries(props)) { out[k] = v instanceof Integer || neo4j.isInt(v) ? (v as Integer).toNumber() : v; } return out; } /** Most recent date in a list, ignoring null/empty/unparseable. Compares by * parsed timestamp (not lexically) so a mix of date-only and datetime strings * orders correctly, and returns the original string of the newest. */ function newestDate(dates: (string | null | undefined)[]): string | null { let best: string | null = null; let bestMs = -Infinity; for (const d of dates) { if (!d) continue; const ms = Date.parse(d); if (Number.isNaN(ms)) continue; if (ms > bestMs) { bestMs = ms; best = d; } } return best; } function daysBetween(iso: string, nowMs: number): number { const then = Date.parse(iso); if (Number.isNaN(then)) return 0; return Math.floor((nowMs - then) / 86_400_000); } // ---- upsert ----------------------------------------------------------------- export async function upsertChain( session: Session, accountId: string, input: ChainUpsertInput, ): Promise { const counters = { nodesCreated: 0, propertiesSet: 0, relationshipsCreated: 0 }; const add = (summary: { counters: { updates(): { nodesCreated: number; propertiesSet: number; relationshipsCreated: number } } }) => { const u = summary.counters.updates(); counters.nodesCreated += u.nodesCreated; counters.propertiesSet += u.propertiesSet; counters.relationshipsCreated += u.relationshipsCreated; }; const result = await session.executeWrite(async (tx) => { // 1. Chain + OWNED_BY (mandatory adjacency). const chainProps = definedOnly({ anticipatedExchangeDate: input.chain?.anticipatedExchangeDate, desiredCompletionDate: input.chain?.desiredCompletionDate, createdDate: input.chain?.createdDate, owningTeam: input.chain?.owningTeam, owningAgent: input.chain?.owningAgent, isComplete: input.chain?.isComplete, }); const chainRes = await tx.run( `MERGE (c:Chain {accountId: $accountId, chainId: $chainId}) SET c += $chainProps WITH c OPTIONAL MATCH (b:LocalBusiness {accountId: $accountId}) FOREACH (_ IN CASE WHEN b IS NULL THEN [] ELSE [1] END | MERGE (c)-[:OWNED_BY]->(b)) RETURN b IS NOT NULL AS hasBusiness`, { accountId, chainId: input.chainId, chainProps }, ); add(chainRes.summary); // The Chain's only mandatory adjacency is OWNED_BY -> the account's own // LocalBusiness singleton. If it is absent the MERGE above creates no edge // and the Chain would land orphaned; throw to roll the whole upsert back // rather than write an adjacency-less node the reconcile cannot see. if (!chainRes.records[0]?.get("hasBusiness")) { throw new Error( `no LocalBusiness for account ${accountId.slice(0, 8)} — cannot anchor chain ${input.chainId}. Seed the account before writing progression state.`, ); } // 2. Sale + IN_CHAIN (mandatory) + FOR_LISTING/FROM_OFFER (best-effort). const saleProps = definedOnly({ status: input.sale.status, sstcDate: input.sale.sstcDate, isCompleteChain: input.sale.isCompleteChain, salePrice: input.sale.salePrice === undefined || input.sale.salePrice === null ? undefined : neo4j.int(input.sale.salePrice), }); const saleRes = await tx.run( `MATCH (c:Chain {accountId: $accountId, chainId: $chainId}) MERGE (s:Sale {accountId: $accountId, sourceSystem: $sourceSystem, sourceId: $sourceId}) SET s += $saleProps, s.chainId = $chainId MERGE (s)-[:IN_CHAIN]->(c) WITH s OPTIONAL MATCH (l:Listing {accountId: $accountId, sourceSystem: $sourceSystem, sourceId: $sourceId}) FOREACH (_ IN CASE WHEN l IS NULL THEN [] ELSE [1] END | MERGE (s)-[:FOR_LISTING]->(l)) WITH s, l OPTIONAL MATCH (o:Offer {accountId: $accountId, sourceSystem: $sourceSystem, sourceId: $sourceId}) FOREACH (_ IN CASE WHEN o IS NULL THEN [] ELSE [1] END | MERGE (s)-[:FROM_OFFER]->(o)) RETURN l IS NOT NULL AS listingLinked, o IS NOT NULL AS offerLinked`, { accountId, chainId: input.chainId, sourceSystem: input.sale.sourceSystem, sourceId: input.sale.sourceId, saleProps, }, ); add(saleRes.summary); const listingLinked = Boolean(saleRes.records[0]?.get("listingLinked")); const offerLinked = Boolean(saleRes.records[0]?.get("offerLinked")); // 3. Links + IS_PROPERTY + parties. let partyCount = 0; for (const link of input.links) { const linkProps = definedOnly({ linkType: link.linkType, address: link.address, generalNotes: link.generalNotes, proceedabilityNotes: link.proceedabilityNotes, salePrice: link.salePrice === undefined || link.salePrice === null ? undefined : neo4j.int(link.salePrice), }); const milestoneProps: Record = {}; for (const [stage, value] of Object.entries(link.milestones ?? {})) { if (value === undefined) continue; milestoneProps[MILESTONE_STAGES[stage as MilestoneStage]] = value; } const isLoopProperty = link.linkType === "loop-property"; const linkRes = await tx.run( `MATCH (c:Chain {accountId: $accountId, chainId: $chainId}) MERGE (cl:ChainLink {accountId: $accountId, chainId: $chainId, priority: $priority}) SET cl += $linkProps, cl += $milestoneProps MERGE (c)-[:HAS_LINK]->(cl) WITH cl OPTIONAL MATCH (p:Property {accountId: $accountId, slug: $propertySlug}) FOREACH (_ IN CASE WHEN $isLoopProperty AND $propertySlug IS NOT NULL AND p IS NOT NULL THEN [1] ELSE [] END | MERGE (cl)-[:IS_PROPERTY]->(p))`, { accountId, chainId: input.chainId, priority: neo4j.int(link.priority), linkProps, milestoneProps, isLoopProperty, propertySlug: link.propertySlug ?? null, }, ); add(linkRes.summary); for (const role of PARTY_ROLE_NAMES) { const party = link.parties?.[role]; if (!party) continue; const partyProps = definedOnly({ name: party.name, partyEmail: party.email, partyPhone: party.phone, }); const edge = PARTY_ROLES[role]; const partyRes = await tx.run( `MATCH (cl:ChainLink {accountId: $accountId, chainId: $chainId, priority: $priority}) MERGE (per:Person {accountId: $accountId, chainId: $chainId, priority: $priority, partyRole: $partyRole}) SET per += $partyProps MERGE (cl)-[:\`${edge}\`]->(per)`, { accountId, chainId: input.chainId, priority: neo4j.int(link.priority), partyRole: role, partyProps, }, ); add(partyRes.summary); partyCount += 1; } } return { listingLinked, offerLinked, partyCount }; }); return { ...counters, linkCount: input.links.length, partyCount: result.partyCount, listingLinked: result.listingLinked, offerLinked: result.offerLinked, }; } // ---- milestone set ---------------------------------------------------------- export async function setMilestone( session: Session, accountId: string, chainId: string, priority: number, stage: MilestoneStage, date: string, source?: string | null, ): Promise<{ updated: number }> { const prop = MILESTONE_STAGES[stage]; const props: Record = { [prop]: date }; if (source) props[`${prop}Source`] = source; const res = await session.executeWrite((tx) => tx.run( `MATCH (cl:ChainLink {accountId: $accountId, chainId: $chainId, priority: $priority}) SET cl += $props RETURN count(cl) AS updated`, { accountId, chainId, priority: neo4j.int(priority), props }, ), ); return { updated: toNum(res.records[0]?.get("updated")) ?? 0 }; } // ---- read ------------------------------------------------------------------- export async function readChain( session: Session, accountId: string, sel: { chainId?: string; address?: string }, nowMs: number, thresholdDays: number = DEFAULT_STALLED_THRESHOLD_DAYS, ): Promise { let chainId = sel.chainId ?? null; if (!chainId && sel.address) { const r = await session.run( `MATCH (cl:ChainLink {accountId: $accountId}) WHERE cl.address = $address RETURN cl.chainId AS chainId LIMIT 1`, { accountId, address: sel.address }, ); chainId = r.records[0]?.get("chainId") ?? null; } if (!chainId) return null; const head = await session.run( `MATCH (c:Chain {accountId: $accountId, chainId: $chainId}) OPTIONAL MATCH (s:Sale {accountId: $accountId, chainId: $chainId})-[:IN_CHAIN]->(c) RETURN c AS chain, s AS sale`, { accountId, chainId }, ); if (head.records.length === 0) return null; const chainNodeRaw = head.records[0].get("chain"); const saleNodeRaw = head.records[0].get("sale"); const chainProps = chainNodeRaw ? normalizeProps(chainNodeRaw.properties as Record) : null; const salePropsNorm = saleNodeRaw ? normalizeProps(saleNodeRaw.properties as Record) : null; const linkRes = await session.run( `MATCH (c:Chain {accountId: $accountId, chainId: $chainId})-[:HAS_LINK]->(cl:ChainLink) OPTIONAL MATCH (cl)-[rel]->(per:Person) WHERE type(rel) STARTS WITH 'HAS_' WITH cl, collect({role: per.partyRole, name: per.name, email: per.partyEmail, phone: per.partyPhone}) AS parties RETURN cl AS link, parties ORDER BY cl.priority`, { accountId, chainId }, ); const links: LinkState[] = []; const priorities: number[] = []; for (const rec of linkRes.records) { const cl = rec.get("link").properties as Record; const priority = toNum(cl.priority) ?? 0; priorities.push(priority); const milestones: Record = {}; for (const [stage, propName] of Object.entries(MILESTONE_STAGES)) { milestones[stage] = (cl[propName] as string | undefined) ?? null; } const parties: PartyState[] = (rec.get("parties") as Record[]) .filter((p) => p.role !== null && p.role !== undefined) .map((p) => ({ role: p.role as string, name: (p.name as string | null) ?? null, email: (p.email as string | null) ?? null, phone: (p.phone as string | null) ?? null, })); links.push({ priority, linkType: (cl.linkType as string | null) ?? null, address: (cl.address as string | null) ?? null, salePrice: toNum(cl.salePrice), generalNotes: (cl.generalNotes as string | null) ?? null, proceedabilityNotes: (cl.proceedabilityNotes as string | null) ?? null, milestones, parties, }); } // Stall: newest movement across all links' milestone dates and the sale's // sstcDate is older than the threshold. const allDates: (string | null)[] = []; for (const l of links) allDates.push(...Object.values(l.milestones)); if (salePropsNorm?.sstcDate) allDates.push(salePropsNorm.sstcDate as string); const lastMovement = newestDate(allDates); const days = lastMovement ? daysBetween(lastMovement, nowMs) : null; const stalled = days !== null && days > thresholdDays; const cause = stalled ? `no milestone movement in ${days} days` : lastMovement ? "moving" : "no dates recorded yet"; // Structural flags: gaps or duplicates in the priority sequence. const structuralFlags: string[] = []; const sorted = [...priorities].sort((a, b) => a - b); const seen = new Set(); for (const p of sorted) { if (seen.has(p)) structuralFlags.push(`duplicate priority ${p}`); seen.add(p); } for (let i = 1; i < sorted.length; i++) { if (sorted[i] - sorted[i - 1] > 1) { structuralFlags.push(`priority gap between ${sorted[i - 1]} and ${sorted[i]}`); } } return { chainId, chain: chainProps, sale: salePropsNorm, links, stalled, cause, structuralFlags, }; } // ---- pipeline --------------------------------------------------------------- export async function listPipeline( session: Session, accountId: string, nowMs: number, thresholdDays: number = DEFAULT_STALLED_THRESHOLD_DAYS, ): Promise { const res = await session.run( `MATCH (s:Sale {accountId: $accountId, status: 'sale-agreed'})-[:IN_CHAIN]->(c:Chain) OPTIONAL MATCH (c)-[:HAS_LINK]->(cl:ChainLink) WITH s, c, collect(cl) AS links RETURN s.chainId AS chainId, s.sourceId AS sourceId, s.salePrice AS salePrice, s.sstcDate AS sstcDate, links, head([l IN links WHERE l.priority = 1 | l.address]) AS firstAddress`, { accountId }, ); const rows: PipelineRow[] = []; for (const rec of res.records) { const links = rec.get("links") as { properties: Record }[]; const dates: (string | null)[] = []; for (const l of links) { if (!l) continue; for (const propName of MILESTONE_DATE_PROPS) { dates.push((l.properties[propName] as string | undefined) ?? null); } } const sstc = rec.get("sstcDate") as string | null; if (sstc) dates.push(sstc); const lastMovement = newestDate(dates); const days = lastMovement ? daysBetween(lastMovement, nowMs) : null; rows.push({ chainId: rec.get("chainId") as string, sourceId: (rec.get("sourceId") as string | null) ?? null, address: (rec.get("firstAddress") as string | null) ?? null, salePrice: toNum(rec.get("salePrice")), lastMovement, daysSinceMovement: days, stalled: days !== null && days > thresholdDays, }); } return rows; } // ---- party lookup ----------------------------------------------------------- export async function lookupParty( session: Session, accountId: string, sel: { email?: string; phone?: string }, ): Promise { const res = await session.run( `MATCH (per:Person {accountId: $accountId})<-[rel]-(cl:ChainLink {accountId: $accountId}) WHERE type(rel) STARTS WITH 'HAS_' AND (($email IS NOT NULL AND per.partyEmail = $email) OR ($phone IS NOT NULL AND per.partyPhone = $phone)) RETURN cl.chainId AS chainId, cl.priority AS priority, per.partyRole AS partyRole, per.name AS name, cl.address AS address`, { accountId, email: sel.email ?? null, phone: sel.phone ?? null }, ); return res.records.map((rec) => ({ chainId: rec.get("chainId") as string, priority: toNum(rec.get("priority")) ?? 0, partyRole: rec.get("partyRole") as string, name: (rec.get("name") as string | null) ?? null, address: (rec.get("address") as string | null) ?? null, })); }