From 281f15c11eb2152ec89497ed2de17e09cbd3e45e Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 12 May 2026 12:16:57 +0000 Subject: [PATCH] Move the DocStatusMap computation and document fetch logic out of the server component into a dedicated docStatus.ts module. computeDocStatusMap is now a pure function with full test coverage (8 tests). Also consolidates the double user table lookup in page.tsx into a single query. --- .../your-projects/live/docStatus.test.ts | 168 +++++++++++++ .../your-projects/live/docStatus.ts | 152 ++++++++++++ .../(portfolio)/your-projects/live/page.tsx | 231 +++--------------- 3 files changed, 357 insertions(+), 194 deletions(-) create mode 100644 src/app/portfolio/[slug]/(portfolio)/your-projects/live/docStatus.test.ts create mode 100644 src/app/portfolio/[slug]/(portfolio)/your-projects/live/docStatus.ts diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/docStatus.test.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/docStatus.test.ts new file mode 100644 index 0000000..e04ccb6 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/docStatus.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect } from "vitest"; +import { computeDocStatusMap } from "./docStatus"; +import { EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES } from "./types"; +import type { HubspotDeal, ApprovalsByDeal } from "./types"; + +function makeDeal(overrides: Partial = {}): HubspotDeal { + return { + id: "1", + dealId: "deal-1", + dealname: "Test Property", + dealstage: null, + companyId: null, + projectCode: null, + landlordPropertyId: null, + uprn: null, + outcome: null, + outcomeNotes: null, + majorConditionIssueDescription: null, + majorConditionIssuePhotos: null, + majorConditionIssuePhotosS3: null, + coordinationStatus: null, + designStatus: null, + pashubLink: null, + sharepointLink: null, + dampMouldFlag: null, + dampMouldAndRepairComments: null, + preSapScore: null, + coordinator: null, + ioeV1Date: null, + ioeV2Date: null, + ioeV3Date: null, + proposedMeasures: null, + approvedPackage: null, + designer: null, + designDate: null, + actualMeasuresInstalled: null, + installer: null, + installerHandover: null, + lodgementStatus: null, + measuresLodgementDate: null, + fullLodgementDate: null, + confirmedSurveyDate: null, + confirmedSurveyTime: null, + surveyedDate: null, + designType: null, + eiScore: null, + eiScorePotential: null, + epcSapScore: null, + epcSapScorePotential: null, + surveyType: null, + measuresForPibiOrdered: null, + pibiOrderDate: null, + pibiCompletedDate: null, + propertyHaltedDate: null, + propertyHaltedReason: null, + technicalApprovedMeasuresForInstall: null, + domnaSurveyType: null, + domnaSurveyDate: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +const allSurveyDocs = EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES.map((t) => ({ + fileType: t, + measureName: null, +})); + +describe("computeDocStatusMap", () => { + it("marks survey as complete when all 9 mandatory doc types are present", () => { + const deal = makeDeal({ dealId: "deal-1" }); + const docs = new Map([["deal-1", allSurveyDocs]]); + + const result = computeDocStatusMap([deal], docs, {}); + + expect(result["deal-1"].isSurveyComplete).toBe(true); + expect(result["deal-1"].hasSurveyDocs).toBe(true); + }); + + it("marks survey as incomplete when only some mandatory doc types are present", () => { + const deal = makeDeal({ dealId: "deal-1" }); + const partialSurveyDocs = [{ fileType: "photo_pack", measureName: null }]; + const docs = new Map([["deal-1", partialSurveyDocs]]); + + const result = computeDocStatusMap([deal], docs, {}); + + expect(result["deal-1"].isSurveyComplete).toBe(false); + expect(result["deal-1"].hasSurveyDocs).toBe(true); + }); + + it("omits a deal from the map when it has no uploaded documents", () => { + const deal = makeDeal({ dealId: "deal-1" }); + + const result = computeDocStatusMap([deal], new Map(), {}); + + expect(result["deal-1"]).toBeUndefined(); + }); + + it("uses approved measures in preference to proposed measures", () => { + const deal = makeDeal({ dealId: "deal-1", proposedMeasures: "CWI" }); + const approvalsByDeal: ApprovalsByDeal = { "deal-1": ["ASHP"] }; + const docs = new Map([["deal-1", allSurveyDocs]]); + + const result = computeDocStatusMap([deal], docs, approvalsByDeal); + + const measureNames = result["deal-1"].measureProgress.map((m) => m.measureName); + expect(measureNames).toEqual(["ASHP"]); + expect(measureNames).not.toContain("CWI"); + }); + + describe("installStatus", () => { + // CWI requires only BASE_DOCS — simple to satisfy in tests + const cwi = "CWI"; + const cwiRequiredDocs = [ + "pre_photo", + "mid_photo", + "post_photo", + "pre_installation_building_inspection", + "claim_of_compliance", + "insurance_guarantee", + "workmanship_warranty", + ]; + + it('is "all" when every measure has all required install docs uploaded', () => { + const deal = makeDeal({ dealId: "deal-1", proposedMeasures: cwi }); + const installDocs = cwiRequiredDocs.map((ft) => ({ + fileType: ft, + measureName: cwi, + })); + const docs = new Map([["deal-1", installDocs]]); + + const result = computeDocStatusMap([deal], docs, {}); + + expect(result["deal-1"].installStatus).toBe("all"); + }); + + it('is "partial" when some (but not all) measures have docs uploaded', () => { + const deal = makeDeal({ dealId: "deal-1", proposedMeasures: cwi }); + const partialInstallDocs = [{ fileType: "pre_photo", measureName: cwi }]; + const docs = new Map([["deal-1", partialInstallDocs]]); + + const result = computeDocStatusMap([deal], docs, {}); + + expect(result["deal-1"].installStatus).toBe("partial"); + }); + + it('is "hasDocs" when install docs exist but no measures are defined on the deal', () => { + const deal = makeDeal({ dealId: "deal-1", proposedMeasures: null }); + const installDocs = [{ fileType: "pre_photo", measureName: null }]; + const docs = new Map([["deal-1", installDocs]]); + + const result = computeDocStatusMap([deal], docs, {}); + + expect(result["deal-1"].installStatus).toBe("hasDocs"); + }); + + it('is "none" when measures are defined but no install docs have been uploaded', () => { + const deal = makeDeal({ dealId: "deal-1", proposedMeasures: cwi }); + // Only survey docs — no install docs + const docs = new Map([["deal-1", allSurveyDocs]]); + + const result = computeDocStatusMap([deal], docs, {}); + + expect(result["deal-1"].installStatus).toBe("none"); + }); + }); +}); diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/docStatus.ts b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/docStatus.ts new file mode 100644 index 0000000..1bb7960 --- /dev/null +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/docStatus.ts @@ -0,0 +1,152 @@ +import { inArray } from "drizzle-orm"; +import { db } from "@/app/db/db"; +import { uploadedFiles } from "@/app/db/schema/uploaded_files"; +import { getRequiredDocs } from "@/app/lib/measureDocumentRequirements"; +import { + EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES, + SURVEY_ALL_DOC_TYPES, +} from "./types"; +import type { + HubspotDeal, + DocStatus, + DocStatusMap, + MeasureDocProgress, + ApprovalsByDeal, +} from "./types"; + +type DocEntry = { fileType: string; measureName: string | null }; + +export async function fetchDocsByDealId( + deals: HubspotDeal[], + dealIds: string[], +): Promise> { + const docsByDealId = new Map(); + + if (dealIds.length === 0) return docsByDealId; + + const phase1Rows = await db + .select({ + hubsotDealId: uploadedFiles.hubsotDealId, + fileType: uploadedFiles.fileType, + measureName: uploadedFiles.measureName, + }) + .from(uploadedFiles) + .where(inArray(uploadedFiles.hubsotDealId, dealIds)); + + for (const row of phase1Rows) { + if (!row.hubsotDealId || row.fileType === null) continue; + if (!docsByDealId.has(row.hubsotDealId)) docsByDealId.set(row.hubsotDealId, []); + docsByDealId.get(row.hubsotDealId)!.push({ fileType: row.fileType, measureName: row.measureName }); + } + + // Phase 2: UPRN fallback for deals that returned no results in phase 1 + const dealsWithoutDocs = deals.filter((d) => !docsByDealId.has(d.dealId)); + const fallbackUprns = dealsWithoutDocs + .map((d) => d.uprn) + .filter((u): u is string => !!u) + .map((u) => { + try { return BigInt(u); } catch { return null; } + }) + .filter((u): u is bigint => u !== null); + + if (fallbackUprns.length > 0) { + const phase2Rows = await db + .select({ + uprn: uploadedFiles.uprn, + fileType: uploadedFiles.fileType, + measureName: uploadedFiles.measureName, + }) + .from(uploadedFiles) + .where(inArray(uploadedFiles.uprn, fallbackUprns)); + + const uprnToDealId = new Map( + dealsWithoutDocs + .filter((d) => d.uprn) + .map((d) => { + try { return [String(BigInt(d.uprn!)), d.dealId] as [string, string]; } + catch { return null; } + }) + .filter((e): e is [string, string] => e !== null), + ); + + for (const row of phase2Rows) { + if (row.uprn === null || row.fileType === null) continue; + const dealId = uprnToDealId.get(String(row.uprn)); + if (!dealId) continue; + if (!docsByDealId.has(dealId)) docsByDealId.set(dealId, []); + docsByDealId.get(dealId)!.push({ fileType: row.fileType, measureName: row.measureName }); + } + } + + return docsByDealId; +} + +export function computeDocStatusMap( + deals: HubspotDeal[], + docsByDealId: Map>, + approvalsByDeal: ApprovalsByDeal, +): DocStatusMap { + const measuresByDealId = new Map(); + for (const deal of deals) { + const approved = approvalsByDeal[deal.dealId] ?? []; + const measures = + approved.length > 0 + ? approved + : (deal.proposedMeasures ?? "") + .split(",") + .map((m) => m.trim()) + .filter(Boolean); + measuresByDealId.set(deal.dealId, measures); + } + + const docStatusMap: DocStatusMap = {}; + + for (const [dealId, docs] of docsByDealId) { + const surveyDocs = docs.filter((d) => SURVEY_ALL_DOC_TYPES.has(d.fileType)); + const installDocs = docs.filter((d) => !SURVEY_ALL_DOC_TYPES.has(d.fileType)); + const surveyTypeSet = new Set(surveyDocs.map((d) => d.fileType)); + + const measures = measuresByDealId.get(dealId) ?? []; + + const measureProgress: MeasureDocProgress[] = measures.map((measureName) => { + const required = getRequiredDocs(measureName); + const docsForMeasure = installDocs.filter((d) => d.measureName === measureName); + const uploadedTypeSet = new Set(docsForMeasure.map((d) => d.fileType)); + const uploaded = required.filter((r) => uploadedTypeSet.has(r)); + return { + measureName, + required, + uploaded, + isComplete: uploaded.length === required.length, + uploadedCount: uploaded.length, + requiredCount: required.length, + }; + }); + + let installStatus: DocStatus["installStatus"] = "none"; + if (installDocs.length > 0) { + if (measures.length === 0) { + installStatus = "hasDocs"; + } else { + installStatus = measureProgress.every((m) => m.isComplete) + ? "all" + : measureProgress.some((m) => m.uploadedCount > 0) + ? "partial" + : "none"; + } + } + + docStatusMap[dealId] = { + presentSurveyTypes: Array.from(surveyTypeSet), + hasSurveyDocs: surveyDocs.length > 0, + isSurveyComplete: EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES.every((t) => + surveyTypeSet.has(t), + ), + hasInstallDocs: installDocs.length > 0, + installStatus, + measureProgress, + }; + } + + return docStatusMap; +} diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx index 06cdd8a..840a2d3 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/page.tsx @@ -4,11 +4,11 @@ import { redirect } from "next/navigation"; import { eq, inArray, and, desc, sql } from "drizzle-orm"; import LiveTracker from "./LiveTracker"; import { computeLiveTrackerData } from "./transforms"; +import { fetchDocsByDealId, computeDocStatusMap } from "./docStatus"; import { db } from "@/app/db/db"; import { hubspotDealData } from "@/app/db/schema/crm/hubspot_deal_table"; import { alias } from "drizzle-orm/pg-core"; import { hubspotUsers } from "@/app/db/schema/crm/hubspot_user_table"; -import { uploadedFiles } from "@/app/db/schema/uploaded_files"; import { portfolioOrganisation } from "@/app/db/schema/portfolio_organisation"; import { organisation } from "@/app/db/schema/organisation"; import { @@ -21,20 +21,12 @@ import { propertyRemovalRequests } from "@/app/db/schema/removal_requests"; import { user as userTable } from "@/app/db/schema/users"; import type { HubspotDeal, - DocStatusMap, - DocStatus, - MeasureDocProgress, PortfolioCapabilityType, ApprovalsByDeal, InstructedMeasuresByDeal, RemovalStatusByDeal, EffectiveRemovalState, } from "./types"; -import { - EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES, - SURVEY_ALL_DOC_TYPES, -} from "./types"; -import { getRequiredDocs } from "@/app/lib/measureDocumentRequirements"; import type { InferSelectModel } from "drizzle-orm"; import { Card, CardContent } from "@/app/shadcn_components/ui/card"; import { Building2 } from "lucide-react"; @@ -193,57 +185,53 @@ export default async function LiveReportingPage(props: { const deals = rawDeals.map(mapDbRowToHubspotDeal); const trackerData = computeLiveTrackerData(deals); - // Fetch current user's portfolio capabilities (approver / contractor — can have both) - let userCapability: PortfolioCapabilityType = []; const userEmail = user?.user?.email; + + // Single user lookup shared by capability and role queries + let userId: bigint | null = null; if (userEmail) { const userRow = await db .select({ id: userTable.id }) .from(userTable) .where(eq(userTable.email, userEmail)) .limit(1); + userId = userRow[0]?.id ?? null; + } - if (userRow[0]) { - const capRows = await db - .select({ capability: portfolioCapabilities.capability }) - .from(portfolioCapabilities) - .where( - and( - eq(portfolioCapabilities.portfolioId, BigInt(portfolioId)), - eq(portfolioCapabilities.userId, userRow[0].id), - ), - ); - userCapability = capRows - .map((r) => r.capability) - .filter( - (c): c is "approver" | "contractor" => - c === "approver" || c === "contractor", - ); - } + // Fetch current user's portfolio capabilities (approver / contractor — can have both) + let userCapability: PortfolioCapabilityType = []; + if (userId) { + const capRows = await db + .select({ capability: portfolioCapabilities.capability }) + .from(portfolioCapabilities) + .where( + and( + eq(portfolioCapabilities.portfolioId, BigInt(portfolioId)), + eq(portfolioCapabilities.userId, userId), + ), + ); + userCapability = capRows + .map((r) => r.capability) + .filter( + (c): c is "approver" | "contractor" => + c === "approver" || c === "contractor", + ); } // Fetch current user's portfolio role (creator / admin / write / read) let userRole = "read"; - if (userEmail) { - const userRow = await db - .select({ id: userTable.id }) - .from(userTable) - .where(eq(userTable.email, userEmail)) + if (userId) { + const roleRow = await db + .select({ role: portfolioUsers.role }) + .from(portfolioUsers) + .where( + and( + eq(portfolioUsers.portfolioId, BigInt(portfolioId)), + eq(portfolioUsers.userId, userId), + ), + ) .limit(1); - - if (userRow[0]) { - const roleRow = await db - .select({ role: portfolioUsers.role }) - .from(portfolioUsers) - .where( - and( - eq(portfolioUsers.portfolioId, BigInt(portfolioId)), - eq(portfolioUsers.userId, userRow[0].id), - ), - ) - .limit(1); - userRole = roleRow[0]?.role ?? "read"; - } + userRole = roleRow[0]?.role ?? "read"; } // Fetch currently approved measures for all deals in scope @@ -318,153 +306,8 @@ export default async function LiveReportingPage(props: { if (state !== "none") removalStatusByDeal[row.hubspotDealId] = state; } - // Fetch document status for all deals — two-phase strategy: - // Phase 1: query by dealId (reliable even when UPRN is missing from hubspot_deal_data) - // Phase 2: UPRN fallback only for deals that returned no results in phase 1 - const docsByDealId = new Map< - string, - Array<{ fileType: string; measureName: string | null }> - >(); - - if (dealIds.length > 0) { - const phase1Rows = await db - .select({ - hubsotDealId: uploadedFiles.hubsotDealId, - fileType: uploadedFiles.fileType, - measureName: uploadedFiles.measureName, - }) - .from(uploadedFiles) - .where(inArray(uploadedFiles.hubsotDealId, dealIds)); - - for (const row of phase1Rows) { - if (!row.hubsotDealId || row.fileType === null) continue; - if (!docsByDealId.has(row.hubsotDealId)) - docsByDealId.set(row.hubsotDealId, []); - docsByDealId - .get(row.hubsotDealId)! - .push({ fileType: row.fileType, measureName: row.measureName }); - } - } - - // Phase 2: for deals with no docs from phase 1 that have a UPRN, try UPRN lookup - const dealsWithoutDocs = deals.filter((d) => !docsByDealId.has(d.dealId)); - const fallbackUprns = dealsWithoutDocs - .map((d) => d.uprn) - .filter((u): u is string => !!u) - .map((u) => { - try { - return BigInt(u); - } catch { - return null; - } - }) - .filter((u): u is bigint => u !== null); - - if (fallbackUprns.length > 0) { - const phase2Rows = await db - .select({ - uprn: uploadedFiles.uprn, - fileType: uploadedFiles.fileType, - measureName: uploadedFiles.measureName, - }) - .from(uploadedFiles) - .where(inArray(uploadedFiles.uprn, fallbackUprns)); - - // Map phase 2 UPRN results back to dealId - const uprnToDealId = new Map( - dealsWithoutDocs - .filter((d) => d.uprn) - .map((d) => { - try { - return [String(BigInt(d.uprn!)), d.dealId] as [string, string]; - } catch { - return null; - } - }) - .filter((e): e is [string, string] => e !== null), - ); - - for (const row of phase2Rows) { - if (row.uprn === null || row.fileType === null) continue; - const dealId = uprnToDealId.get(String(row.uprn)); - if (!dealId) continue; - if (!docsByDealId.has(dealId)) docsByDealId.set(dealId, []); - docsByDealId - .get(dealId)! - .push({ fileType: row.fileType, measureName: row.measureName }); - } - } - - // Build measures lookup by dealId (approved measures, falling back to proposed) - const measuresByDealId = new Map(); - for (const deal of deals) { - const approved = approvalsByDeal[deal.dealId] ?? []; - const measures = - approved.length > 0 - ? approved - : (deal.proposedMeasures ?? "") - .split(",") - .map((m: string) => m.trim()) - .filter(Boolean); - measuresByDealId.set(deal.dealId, measures); - } - - // Build docStatusMap keyed by dealId - const docStatusMap: DocStatusMap = {}; - - for (const [dealId, docs] of docsByDealId) { - const surveyDocs = docs.filter((d) => SURVEY_ALL_DOC_TYPES.has(d.fileType)); - const installDocs = docs.filter( - (d) => !SURVEY_ALL_DOC_TYPES.has(d.fileType), - ); - const surveyTypeSet = new Set(surveyDocs.map((d) => d.fileType)); - - const measures = measuresByDealId.get(dealId) ?? []; - - // Compute per-measure document progress against the requirements matrix - const measureProgress: MeasureDocProgress[] = measures.map( - (measureName) => { - const required = getRequiredDocs(measureName); - const docsForMeasure = installDocs.filter( - (d) => d.measureName === measureName, - ); - const uploadedTypeSet = new Set(docsForMeasure.map((d) => d.fileType)); - const uploaded = required.filter((r) => uploadedTypeSet.has(r)); - return { - measureName, - required, - uploaded, - isComplete: uploaded.length === required.length, - uploadedCount: uploaded.length, - requiredCount: required.length, - }; - }, - ); - - let installStatus: DocStatus["installStatus"] = "none"; - if (installDocs.length > 0) { - if (measures.length === 0) { - installStatus = "hasDocs"; - } else { - installStatus = measureProgress.every((m) => m.isComplete) - ? "all" - : measureProgress.some((m) => m.uploadedCount > 0) - ? "partial" - : "none"; - } - } - - docStatusMap[dealId] = { - presentSurveyTypes: Array.from(surveyTypeSet), - hasSurveyDocs: surveyDocs.length > 0, - isSurveyComplete: EXPECTED_RETROFIT_ASSESSMENT_DOC_TYPES.every((t) => - surveyTypeSet.has(t), - ), - hasInstallDocs: installDocs.length > 0, - installStatus, - measureProgress, - }; - } + const docsByDealId = await fetchDocsByDealId(deals, dealIds); + const docStatusMap = computeDocStatusMap(deals, docsByDealId, approvalsByDeal); return (