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.
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-12 12:16:57 +00:00
parent 166cea397b
commit 281f15c11e
3 changed files with 357 additions and 194 deletions

View file

@ -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> = {}): 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");
});
});
});

View file

@ -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<Map<string, DocEntry[]>> {
const docsByDealId = new Map<string, DocEntry[]>();
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<string, string>(
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<string, Array<{ fileType: string; measureName: string | null }>>,
approvalsByDeal: ApprovalsByDeal,
): DocStatusMap {
const measuresByDealId = new Map<string, string[]>();
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;
}

View file

@ -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<string, string>(
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<string, string[]>();
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 (
<div className="max-w-7xl mx-auto px-6 pb-10 space-y-4">