From fccf4130c8e0f290f6e5f46098e34c837f0354ee Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 6 May 2026 18:00:06 +0000 Subject: [PATCH 01/36] added instruct measures approval --- .../instructed-measures/route.ts | 31 +-- src/app/lib/instructMeasure.test.ts | 180 +++++++++++++++ src/app/lib/instructMeasure.ts | 218 ++++++++++++++++++ .../live/PropertyDetailDrawer.tsx | 195 +++++++++++----- .../your-projects/live/[dealId]/DealPage.tsx | 1 + 5 files changed, 555 insertions(+), 70 deletions(-) diff --git a/src/app/api/portfolio/[portfolioId]/instructed-measures/route.ts b/src/app/api/portfolio/[portfolioId]/instructed-measures/route.ts index ff7a74d..533f228 100644 --- a/src/app/api/portfolio/[portfolioId]/instructed-measures/route.ts +++ b/src/app/api/portfolio/[portfolioId]/instructed-measures/route.ts @@ -10,12 +10,14 @@ import { portfolioUsers, } from "@/app/db/schema/portfolio"; import { user } from "@/app/db/schema/users"; -import { instructMeasure } from "@/app/lib/instructMeasure"; +import { instructMeasures } from "@/app/lib/instructMeasure"; import { MEASURE_NAMES } from "@/app/lib/measureDocumentRequirements"; const postSchema = z.object({ dealId: z.string().min(1, "dealId is required"), - measureName: z.string().min(1, "measureName is required"), + measureNames: z + .array(z.string().min(1)) + .min(1, "measureNames must not be empty"), }); /** @@ -27,10 +29,10 @@ const postSchema = z.object({ * pushes back to HubSpot. See `instructMeasure` for the full contract. * * Body: - * { dealId: string, measureName: string } + * { dealId: string, measureNames: string[] } * * Response: - * 200 { ok: true, hubspotSync: "ok" | "failed", autoPopulatedProposed: boolean, hubspotError? } + * 200 { ok: true, hubspotSync: "ok" | "failed", hubspotError? } * 400 { ok: false, error } * 401 / 403 / 404 on auth/role/user errors. */ @@ -60,15 +62,16 @@ export async function POST( ); } - const { dealId, measureName } = parsed.data; + const { dealId, measureNames } = parsed.data; - // Validate against the canonical catalogue up-front so the route returns - // a clean 400 rather than relying on the service-level check. - if (!(MEASURE_NAMES as ReadonlyArray).includes(measureName)) { - return NextResponse.json( - { error: `Unknown measure: ${measureName}` }, - { status: 400 }, - ); + // Validate all names against the canonical catalogue up-front. + for (const name of measureNames) { + if (!(MEASURE_NAMES as ReadonlyArray).includes(name)) { + return NextResponse.json( + { error: `Unknown measure: ${name}` }, + { status: 400 }, + ); + } } const userRow = await db @@ -119,9 +122,9 @@ export async function POST( } try { - const result = await instructMeasure({ + const result = await instructMeasures({ dealId, - measureName, + measureNames, userId: userRow[0].id, }); diff --git a/src/app/lib/instructMeasure.test.ts b/src/app/lib/instructMeasure.test.ts index 7f8ae71..a9bf993 100644 --- a/src/app/lib/instructMeasure.test.ts +++ b/src/app/lib/instructMeasure.test.ts @@ -11,10 +11,13 @@ import { PROPOSED_MEASURES_PROP, APPROVED_MEASURES_PROP, instructMeasure, + instructMeasures, } from "./instructMeasure"; import type { InstructTxOutcome, + InstructMeasuresTxOutcome, RunInstructTx, + RunInstructMeasuresTx, ReadInstructedMeasureNames, StampPushedAt, SyncMeasuresField, @@ -307,3 +310,180 @@ describe("instructMeasure — HubSpot push failure leaves DB committed", () => { expect(deps.stampPushedAt).not.toHaveBeenCalled(); }); }); + +// --------------------------------------------------------------------------- +// instructMeasures (plural) — batch variant +// --------------------------------------------------------------------------- + +function makeBatchDeps(overrides?: { + txOutcome?: Partial; + txError?: Error; + instructedAfter?: string[]; + syncResults?: Array<{ ok: true } | { ok: false; error: string }>; + stampError?: Error; +}) { + const txOutcome: InstructMeasuresTxOutcome = { + instructedRowIds: [1n, 2n], + existingProposedMeasures: [], + allApprovedMeasureNames: [], + ...overrides?.txOutcome, + }; + const runInstructMeasuresTx: RunInstructMeasuresTx = vi.fn(async () => { + if (overrides?.txError) throw overrides.txError; + return txOutcome; + }); + const readInstructedMeasureNames: ReadInstructedMeasureNames = vi.fn( + async () => overrides?.instructedAfter ?? ["ASHP", "Solar PV"], + ); + const syncQueue: Array<{ ok: true } | { ok: false; error: string }> = + overrides?.syncResults ?? [{ ok: true }, { ok: true }, { ok: true }]; + const syncMeasuresField: SyncMeasuresField = vi.fn(async () => { + return syncQueue.shift() ?? ({ ok: true } as const); + }); + const stampPushedAt: StampPushedAt = vi.fn(async () => { + if (overrides?.stampError) throw overrides.stampError; + }); + return { + runInstructMeasuresTx, + readInstructedMeasureNames, + syncMeasuresField, + stampPushedAt, + }; +} + +describe("instructMeasures — input validation", () => { + it("rejects when measureNames is empty", async () => { + const deps = makeBatchDeps(); + const result = await instructMeasures({ + dealId: "deal-1", + measureNames: [], + userId: 1n, + deps, + }); + expect(result).toEqual({ ok: false, error: "measureNames must not be empty" }); + expect(deps.runInstructMeasuresTx).not.toHaveBeenCalled(); + expect(deps.syncMeasuresField).not.toHaveBeenCalled(); + }); + + it("rejects when any measureName is unknown", async () => { + const deps = makeBatchDeps(); + const result = await instructMeasures({ + dealId: "deal-1", + measureNames: ["ASHP", "Not a real measure"], + userId: 1n, + deps, + }); + expect(result).toEqual({ ok: false, error: "Unknown measure: Not a real measure" }); + expect(deps.runInstructMeasuresTx).not.toHaveBeenCalled(); + expect(deps.syncMeasuresField).not.toHaveBeenCalled(); + }); +}); + +describe("instructMeasures — happy path", () => { + it("commits single tx, pushes instructed + proposed + approved, stamps all rowIds", async () => { + const deps = makeBatchDeps({ + instructedAfter: ["ASHP", "Solar PV"], + txOutcome: { + instructedRowIds: [10n, 11n], + existingProposedMeasures: [], + allApprovedMeasureNames: ["ASHP", "Solar PV"], + }, + }); + const result = await instructMeasures({ + dealId: "deal-42", + measureNames: ["ASHP", "Solar PV"], + userId: 7n, + deps, + }); + expect(result).toMatchObject({ ok: true, instructedRowIds: [10n, 11n], hubspotSync: "ok" }); + expect(deps.runInstructMeasuresTx).toHaveBeenCalledOnce(); + expect(deps.runInstructMeasuresTx).toHaveBeenCalledWith({ + dealId: "deal-42", + measureNames: ["ASHP", "Solar PV"], + userId: 7n, + notes: null, + }); + expect(deps.syncMeasuresField).toHaveBeenCalledTimes(3); + expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(1, { + hubspotDealId: "deal-42", + propName: INSTRUCTED_MEASURES_PROP, + measureNames: ["ASHP", "Solar PV"], + }); + expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(2, { + hubspotDealId: "deal-42", + propName: PROPOSED_MEASURES_PROP, + measureNames: ["ASHP", "Solar PV"], + }); + expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(3, { + hubspotDealId: "deal-42", + propName: APPROVED_MEASURES_PROP, + measureNames: ["ASHP", "Solar PV"], + }); + expect(deps.stampPushedAt).toHaveBeenCalledTimes(2); + expect(deps.stampPushedAt).toHaveBeenCalledWith(10n); + expect(deps.stampPushedAt).toHaveBeenCalledWith(11n); + }); + + it("merges all new measures into existing proposed (deduped)", async () => { + const deps = makeBatchDeps({ + instructedAfter: ["ASHP", "EWI", "Solar PV"], + txOutcome: { + instructedRowIds: [20n, 21n], + existingProposedMeasures: ["ASHP", "Loft insulation"], + allApprovedMeasureNames: ["ASHP", "EWI", "Solar PV"], + }, + }); + await instructMeasures({ + dealId: "deal-merge", + measureNames: ["EWI", "Solar PV"], + userId: 3n, + deps, + }); + expect(deps.syncMeasuresField).toHaveBeenNthCalledWith(2, { + hubspotDealId: "deal-merge", + propName: PROPOSED_MEASURES_PROP, + measureNames: ["ASHP", "Loft insulation", "EWI", "Solar PV"], + }); + }); +}); + +describe("instructMeasures — DB transaction failure", () => { + it("returns error and skips HubSpot when tx throws", async () => { + const deps = makeBatchDeps({ txError: new Error("batch insert failed") }); + const result = await instructMeasures({ + dealId: "deal-x", + measureNames: ["ASHP", "EWI"], + userId: 1n, + deps, + }); + expect(result).toEqual({ ok: false, error: "batch insert failed" }); + expect(deps.syncMeasuresField).not.toHaveBeenCalled(); + expect(deps.stampPushedAt).not.toHaveBeenCalled(); + }); +}); + +describe("instructMeasures — HubSpot push failure leaves DB committed", () => { + it("returns ok=true with hubspotSync=failed when sync fails, does NOT stamp", async () => { + const deps = makeBatchDeps({ + instructedAfter: ["ASHP", "EWI"], + txOutcome: { + instructedRowIds: [30n, 31n], + existingProposedMeasures: [], + allApprovedMeasureNames: ["ASHP", "EWI"], + }, + syncResults: [{ ok: false, error: "hubspot 500" }, { ok: true }, { ok: true }], + }); + const result = await instructMeasures({ + dealId: "deal-h", + measureNames: ["ASHP", "EWI"], + userId: 1n, + deps, + }); + expect(result).toMatchObject({ + ok: true, + hubspotSync: "failed", + hubspotError: "hubspot 500", + }); + expect(deps.stampPushedAt).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/lib/instructMeasure.ts b/src/app/lib/instructMeasure.ts index a7421ca..3e66e5e 100644 --- a/src/app/lib/instructMeasure.ts +++ b/src/app/lib/instructMeasure.ts @@ -196,6 +196,224 @@ const defaultStampPushedAt: StampPushedAt = async (rowId) => { .where(eq(userDefinedDealMeasures.id, rowId)); }; +// --------------------------------------------------------------------------- +// Batch (plural) types +// --------------------------------------------------------------------------- + +export interface InstructMeasuresTxOutcome { + instructedRowIds: bigint[]; + existingProposedMeasures: string[]; + allApprovedMeasureNames: string[]; +} + +export type RunInstructMeasuresTx = (params: { + dealId: string; + measureNames: MeasureName[]; + userId: bigint; + notes: string | null; +}) => Promise; + +export type InstructMeasuresResult = + | { + ok: true; + instructedRowIds: bigint[]; + hubspotSync: "ok" | "failed"; + hubspotError?: string; + } + | { ok: false; error: string }; + +export interface InstructMeasuresInput { + dealId: string; + measureNames: string[]; + userId: bigint; + notes?: string; + deps?: { + runInstructMeasuresTx?: RunInstructMeasuresTx; + readInstructedMeasureNames?: ReadInstructedMeasureNames; + syncMeasuresField?: SyncMeasuresField; + stampPushedAt?: StampPushedAt; + }; +} + +const defaultRunInstructMeasuresTx: RunInstructMeasuresTx = async ({ + dealId, + measureNames, + userId, + notes, +}) => { + return await db.transaction(async (tx) => { + const instructedRowIds: bigint[] = []; + + for (const measureName of measureNames) { + const inserted = await tx + .insert(userDefinedDealMeasures) + .values({ + hubspotDealId: dealId, + measureName, + source: "instructed", + createdByUserId: userId, + notes, + }) + .returning({ id: userDefinedDealMeasures.id }); + const rowId = inserted[0]?.id; + if (rowId === undefined || rowId === null) { + throw new Error("Failed to insert user_defined_deal_measures row"); + } + instructedRowIds.push(rowId); + + await tx + .insert(dealMeasureApprovals) + .values({ + hubspotDealId: dealId, + measureName, + isApproved: true, + approvedBy: userId, + }) + .onConflictDoUpdate({ + target: [ + dealMeasureApprovals.hubspotDealId, + dealMeasureApprovals.measureName, + ], + set: { + isApproved: true, + approvedBy: userId, + approvedAt: new Date(), + }, + }); + + await tx.insert(dealMeasureApprovalEvents).values({ + hubspotDealId: dealId, + measureName, + action: "approved", + actedBy: userId, + }); + } + + const dealRows = await tx + .select({ proposedMeasures: hubspotDealData.proposedMeasures }) + .from(hubspotDealData) + .where(eq(hubspotDealData.dealId, dealId)) + .limit(1); + const existingProposedMeasures = parseMeasures(dealRows[0]?.proposedMeasures ?? null); + + const approvedRows = await tx + .select({ measureName: dealMeasureApprovals.measureName }) + .from(dealMeasureApprovals) + .where( + and( + eq(dealMeasureApprovals.hubspotDealId, dealId), + eq(dealMeasureApprovals.isApproved, true), + ), + ); + const allApprovedMeasureNames = approvedRows.map((r) => r.measureName); + + return { instructedRowIds, existingProposedMeasures, allApprovedMeasureNames }; + }); +}; + +export async function instructMeasures( + input: InstructMeasuresInput, +): Promise { + if (input.measureNames.length === 0) { + return { ok: false, error: "measureNames must not be empty" }; + } + + const validatedNames: MeasureName[] = []; + for (const name of input.measureNames) { + const trimmed = name.trim(); + if (!isMeasureName(trimmed)) { + return { ok: false, error: `Unknown measure: ${trimmed}` }; + } + validatedNames.push(trimmed); + } + + const runInstructMeasuresTx = + input.deps?.runInstructMeasuresTx ?? defaultRunInstructMeasuresTx; + const readInstructed = + input.deps?.readInstructedMeasureNames ?? defaultReadInstructedMeasureNames; + const syncMeasuresField = + input.deps?.syncMeasuresField ?? defaultSyncMeasuresField; + const stampPushedAt = input.deps?.stampPushedAt ?? defaultStampPushedAt; + + let txResult: InstructMeasuresTxOutcome; + try { + txResult = await runInstructMeasuresTx({ + dealId: input.dealId, + measureNames: validatedNames, + userId: input.userId, + notes: input.notes ?? null, + }); + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to instruct measures"; + console.error("[instructMeasures] transaction failed", { + dealId: input.dealId, + measureNames: validatedNames, + error: err, + }); + return { ok: false, error: message }; + } + + const allInstructed = await readInstructed(input.dealId); + + const mergedProposed = Array.from( + new Set([...txResult.existingProposedMeasures, ...validatedNames]), + ); + + const instructedSync = await syncMeasuresField({ + hubspotDealId: input.dealId, + propName: INSTRUCTED_MEASURES_PROP, + measureNames: allInstructed, + }); + + const proposedSync = await syncMeasuresField({ + hubspotDealId: input.dealId, + propName: PROPOSED_MEASURES_PROP, + measureNames: mergedProposed, + }); + + const approvedSync = await syncMeasuresField({ + hubspotDealId: input.dealId, + propName: APPROVED_MEASURES_PROP, + measureNames: txResult.allApprovedMeasureNames, + }); + + const overallOk = instructedSync.ok && proposedSync.ok && approvedSync.ok; + + if (overallOk) { + for (const rowId of txResult.instructedRowIds) { + try { + await stampPushedAt(rowId); + } catch (err) { + console.error("[instructMeasures] failed to stamp pushed_at", { + rowId: String(rowId), + error: err, + }); + } + } + return { + ok: true, + instructedRowIds: txResult.instructedRowIds, + hubspotSync: "ok", + }; + } + + const hubspotError = !instructedSync.ok + ? instructedSync.error + : !proposedSync.ok + ? proposedSync.error + : !approvedSync.ok + ? approvedSync.error + : "HubSpot sync failed"; + + return { + ok: true, + instructedRowIds: txResult.instructedRowIds, + hubspotSync: "failed", + hubspotError, + }; +} + export async function instructMeasure( input: InstructMeasureInput, ): Promise { diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx index fd9fe62..a3ab982 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx @@ -30,6 +30,7 @@ import { ApprovalConfirmDialog } from "./ApprovalConfirmDialog"; import type { PendingDiff } from "./ApprovalConfirmDialog"; import { MEASURE_NAMES } from "@/app/lib/measureDocumentRequirements"; import { outOfOrderInstructionWarning } from "@/app/lib/softWarnings"; +import { useToast } from "@/app/hooks/use-toast"; // Sections the caller can request focus on. Used by entry-points like the // Measures table row click that should land the user on a specific tab. @@ -1505,12 +1506,13 @@ export function PibiMeasureSelector({ } // ----------------------------------------------------------------------- -// Instruct measure editor — approver-only form to instruct an out-of-band -// measure that the coordinator did not propose (issue #253). +// Instruct measure editor — approver-only form to instruct out-of-band +// measures that the coordinator did not propose (issue #253). // ----------------------------------------------------------------------- interface InstructMeasureEditorProps { dealId: string; portfolioId: string; + proposedMeasures: string[]; /** True when the user has the approver capability on this portfolio. */ canEdit: boolean; /** Soft-warning string from `outOfOrderInstructionWarning`, or null. */ @@ -1520,47 +1522,85 @@ interface InstructMeasureEditorProps { export function InstructMeasureEditor({ dealId, portfolioId, + proposedMeasures, canEdit, outOfOrderWarning, }: InstructMeasureEditorProps) { - const [selected, setSelected] = useState(""); - const [optimisticList, setOptimisticList] = useState([]); + const queryClient = useQueryClient(); + const { toast } = useToast(); + + const { data: pibiData } = useQuery<{ + pibiMeasures: string[]; + approvedMeasures: string[]; + instructedMeasures: string[]; + }>({ + queryKey: ["pibiMeasures", portfolioId, dealId], + queryFn: async () => { + const res = await fetch( + `/api/portfolio/${portfolioId}/pibi-measures?dealId=${encodeURIComponent(dealId)}`, + ); + if (!res.ok) throw new Error("Failed to fetch measures"); + return res.json(); + }, + staleTime: 30_000, + enabled: canEdit, + }); + + const excluded = useMemo(() => { + const set = new Set(proposedMeasures); + for (const m of pibiData?.approvedMeasures ?? []) set.add(m); + for (const m of pibiData?.instructedMeasures ?? []) set.add(m); + return set; + }, [proposedMeasures, pibiData]); + + const eligible = useMemo( + () => MEASURE_NAMES.filter((m) => !excluded.has(m)), + [excluded], + ); + + const [checked, setChecked] = useState>(new Set()); + const [confirmOpen, setConfirmOpen] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); - // Reset optimistic state when the drawer switches deals. useEffect(() => { - setOptimisticList([]); - setSelected(""); + setChecked(new Set()); setError(null); }, [dealId]); if (!canEdit) return null; - async function handleSubmit() { - if (!selected) return; - const measure = selected; + function toggleMeasure(m: string) { + setChecked((prev) => { + const next = new Set(prev); + if (next.has(m)) next.delete(m); + else next.add(m); + return next; + }); + } + + async function handleConfirm() { + const measureNames = Array.from(checked); + if (measureNames.length === 0) return; setSubmitting(true); setError(null); - // Optimistic append. Reverted on failure. - setOptimisticList((prev) => [...prev, measure]); try { const res = await fetch( `/api/portfolio/${portfolioId}/instructed-measures`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ dealId, measureName: measure }), + body: JSON.stringify({ dealId, measureNames }), }, ); if (!res.ok) { - setOptimisticList((prev) => prev.filter((m) => m !== measure)); const json = await res.json().catch(() => ({})); setError( typeof json.error === "string" ? json.error - : "Failed to instruct measure", + : "Failed to instruct measures", ); + setConfirmOpen(false); return; } const json = (await res.json()) as { @@ -1568,19 +1608,28 @@ export function InstructMeasureEditor({ hubspotSync: "ok" | "failed"; hubspotError?: string; }; + setConfirmOpen(false); + setChecked(new Set()); + void queryClient.invalidateQueries({ queryKey: ["pibiMeasures", portfolioId, dealId] }); if (json.hubspotSync === "failed") { - setError( - json.hubspotError + toast({ + title: "Measures instructed", + description: json.hubspotError ? `Saved locally — HubSpot sync failed: ${json.hubspotError}` : "Saved locally — HubSpot sync failed", - ); + variant: "destructive", + }); + } else { + toast({ + title: "Measures instructed", + description: `${measureNames.join(", ")} ${measureNames.length === 1 ? "has" : "have"} been instructed.`, + }); } - setSelected(""); } catch (err) { - setOptimisticList((prev) => prev.filter((m) => m !== measure)); setError( - err instanceof Error ? err.message : "Failed to instruct measure", + err instanceof Error ? err.message : "Failed to instruct measures", ); + setConfirmOpen(false); } finally { setSubmitting(false); } @@ -1597,49 +1646,44 @@ export function InstructMeasureEditor({ {outOfOrderWarning} )} - {optimisticList.length > 0 && ( -
- {optimisticList.map((m) => ( - - {m} - - ))} -
- )} -
- ))} - - +
+ )} + + {eligible.length > 0 && ( - + )} {error && (

)} + +

+ + + Instruct measures + +

+ The following measures will be instructed and approved: +

+
    + {Array.from(checked).map((m) => ( +
  • + + {m} +
  • + ))} +
+ + + + +
+
); } @@ -2136,6 +2218,7 @@ export default function PropertyDetailDrawer({ diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/DealPage.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/DealPage.tsx index cbd680f..534d81c 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/DealPage.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/DealPage.tsx @@ -551,6 +551,7 @@ export default function DealPage({ From e6e94176cd9d1fef5a9ea781c75db1684e879751 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 6 May 2026 18:16:55 +0000 Subject: [PATCH 02/36] sdding modal close --- .../your-projects/live/PropertyDetailDrawer.tsx | 8 +++++--- .../(portfolio)/your-projects/live/[dealId]/DealPage.tsx | 1 + 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx index a3ab982..500fb0e 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx @@ -1517,6 +1517,8 @@ interface InstructMeasureEditorProps { canEdit: boolean; /** Soft-warning string from `outOfOrderInstructionWarning`, or null. */ outOfOrderWarning: string | null; + /** Called after a successful instruction (e.g. to close an outer modal). */ + onSuccess?: () => void; } export function InstructMeasureEditor({ @@ -1525,6 +1527,7 @@ export function InstructMeasureEditor({ proposedMeasures, canEdit, outOfOrderWarning, + onSuccess, }: InstructMeasureEditorProps) { const queryClient = useQueryClient(); const { toast } = useToast(); @@ -1582,6 +1585,7 @@ export function InstructMeasureEditor({ async function handleConfirm() { const measureNames = Array.from(checked); if (measureNames.length === 0) return; + setConfirmOpen(false); setSubmitting(true); setError(null); try { @@ -1600,7 +1604,6 @@ export function InstructMeasureEditor({ ? json.error : "Failed to instruct measures", ); - setConfirmOpen(false); return; } const json = (await res.json()) as { @@ -1608,9 +1611,9 @@ export function InstructMeasureEditor({ hubspotSync: "ok" | "failed"; hubspotError?: string; }; - setConfirmOpen(false); setChecked(new Set()); void queryClient.invalidateQueries({ queryKey: ["pibiMeasures", portfolioId, dealId] }); + onSuccess?.(); if (json.hubspotSync === "failed") { toast({ title: "Measures instructed", @@ -1629,7 +1632,6 @@ export function InstructMeasureEditor({ setError( err instanceof Error ? err.message : "Failed to instruct measures", ); - setConfirmOpen(false); } finally { setSubmitting(false); } diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/DealPage.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/DealPage.tsx index 534d81c..ee0a52c 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/DealPage.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/DealPage.tsx @@ -554,6 +554,7 @@ export default function DealPage({ proposedMeasures={parseMeasures(deal.proposedMeasures ?? null)} canEdit={isApprover} outOfOrderWarning={outOfOrderInstructionWarning(deal)} + onSuccess={() => setInstructModalOpen(false)} /> From ffb2f8c7c3c76dd244944b5a3895e8fd66246a2d Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 6 May 2026 18:27:28 +0000 Subject: [PATCH 03/36] simplify second modal --- .../live/PropertyDetailDrawer.tsx | 101 +++++++++--------- 1 file changed, 48 insertions(+), 53 deletions(-) diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx index 500fb0e..721ece3 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { X, CheckCircle2, Circle, AlertTriangle, ChevronRight, ChevronDown, Trash2, RotateCcw } from "lucide-react"; +import { X, CheckCircle2, Circle, AlertTriangle, ChevronRight, ChevronDown, Trash2, RotateCcw, Loader2 } from "lucide-react"; import { Drawer, DrawerClose, @@ -1562,12 +1562,13 @@ export function InstructMeasureEditor({ ); const [checked, setChecked] = useState>(new Set()); - const [confirmOpen, setConfirmOpen] = useState(false); + const [confirmText, setConfirmText] = useState(""); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); useEffect(() => { setChecked(new Set()); + setConfirmText(""); setError(null); }, [dealId]); @@ -1582,10 +1583,9 @@ export function InstructMeasureEditor({ }); } - async function handleConfirm() { + async function handleSubmit() { const measureNames = Array.from(checked); - if (measureNames.length === 0) return; - setConfirmOpen(false); + if (measureNames.length === 0 || confirmText !== "confirm") return; setSubmitting(true); setError(null); try { @@ -1612,6 +1612,7 @@ export function InstructMeasureEditor({ hubspotError?: string; }; setChecked(new Set()); + setConfirmText(""); void queryClient.invalidateQueries({ queryKey: ["pibiMeasures", portfolioId, dealId] }); onSuccess?.(); if (json.hubspotSync === "failed") { @@ -1637,6 +1638,9 @@ export function InstructMeasureEditor({ } } + const hasSelection = checked.size > 0; + const canSubmit = hasSelection && confirmText === "confirm" && !submitting; + return (
{outOfOrderWarning && ( @@ -1675,17 +1679,46 @@ export function InstructMeasureEditor({
)} - {eligible.length > 0 && ( - + + {hasSelection && ( +
+
+ {Array.from(checked).map((m) => ( + + {m} + + ))} +
+ + +
)} + {error && (

)} - -

- - - Instruct measures - -

- The following measures will be instructed and approved: -

-
    - {Array.from(checked).map((m) => ( -
  • - - {m} -
  • - ))} -
- - - - -
-
); } From 2b05abb1854fa7fe44d01a5cc49b73afc50e69c1 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 6 May 2026 19:10:47 +0000 Subject: [PATCH 04/36] Adding tests for modified approvals route --- .../[portfolioId]/approvals/route.test.ts | 289 ++++++++++++++++++ .../[portfolioId]/approvals/route.ts | 19 +- 2 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 src/app/api/portfolio/[portfolioId]/approvals/route.test.ts diff --git a/src/app/api/portfolio/[portfolioId]/approvals/route.test.ts b/src/app/api/portfolio/[portfolioId]/approvals/route.test.ts new file mode 100644 index 0000000..ba668d4 --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/approvals/route.test.ts @@ -0,0 +1,289 @@ +/** + * Unit tests for the approvals POST handler. + * + * Focuses on HubSpot sync behaviour: after approve/unapprove changes are + * persisted to the DB, the handler must push both the audit log + * (client_measures_approval_log) and the structured field (approved_measures) + * to HubSpot. Prior to this fix only the audit log was synced. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +// ── Hoisted mocks (declared before vi.mock factories run) ───────────────────── +const { + mockGetServerSession, + syncMeasureApprovalsToHubSpotMock, + syncMeasuresFieldToHubSpotMock, + mockDbSelect, + mockDbInsert, +} = vi.hoisted(() => ({ + mockGetServerSession: vi.fn(), + syncMeasureApprovalsToHubSpotMock: vi.fn(), + syncMeasuresFieldToHubSpotMock: vi.fn(), + mockDbSelect: vi.fn(), + mockDbInsert: vi.fn(), +})); + +// ── Auth ────────────────────────────────────────────────────────────────────── +vi.mock("next-auth", () => ({ getServerSession: mockGetServerSession })); +vi.mock("@/app/api/auth/[...nextauth]/authOptions", () => ({ + AuthOptions: {}, +})); + +// ── HubSpot syncs ───────────────────────────────────────────────────────────── +vi.mock("@/app/lib/hubspot/dealSync", () => ({ + syncMeasureApprovalsToHubSpot: syncMeasureApprovalsToHubSpotMock, + syncMeasuresFieldToHubSpot: syncMeasuresFieldToHubSpotMock, +})); +vi.mock("@/app/lib/instructMeasure", () => ({ + APPROVED_MEASURES_PROP: "approved_measures", +})); + +// ── Drizzle ORM ─────────────────────────────────────────────────────────────── +vi.mock("drizzle-orm", () => ({ + and: vi.fn((...args: unknown[]) => ({ $and: args })), + eq: vi.fn((a: unknown, b: unknown) => ({ $eq: [a, b] })), + inArray: vi.fn((col: unknown, vals: unknown) => ({ $inArray: [col, vals] })), + sql: vi.fn(), +})); + +// ── DB schema stubs ─────────────────────────────────────────────────────────── +vi.mock("@/app/db/schema/approvals", () => ({ + dealMeasureApprovals: { hubspotDealId: {}, measureName: {}, isApproved: {}, approvedBy: {}, approvedAt: {} }, + dealMeasureApprovalEvents: { hubspotDealId: {}, measureName: {}, action: {}, actedBy: {}, actedAt: {} }, +})); +vi.mock("@/app/db/schema/portfolio", () => ({ + portfolioCapabilities: { portfolioId: {}, userId: {}, capability: {}, id: {} }, +})); +vi.mock("@/app/db/schema/users", () => ({ + user: { id: {}, email: {}, firstName: {} }, +})); + +// ── DB mock ─────────────────────────────────────────────────────────────────── +vi.mock("@/app/db/db", () => ({ + db: { + get select() { return mockDbSelect; }, + get insert() { return mockDbInsert; }, + }, +})); + +// ── DB mock helpers ──────────────────────────────────────────────────────────── +// Builds a thenable select chain where .limit() resolves to `limitResult` +// and awaiting the chain without .limit() resolves to `directResult`. +function makeSelectChain( + limitResult: unknown[], + directResult: unknown[] = [], +) { + const self: Record = {}; + // thenable so `await chain.where(...)` resolves to directResult + self["then"] = ( + resolve: (v: unknown) => unknown, + reject: (e: unknown) => unknown, + ) => Promise.resolve(directResult).then(resolve, reject); + self["from"] = vi.fn(() => self); + self["leftJoin"] = vi.fn(() => self); + self["where"] = vi.fn(() => self); + self["limit"] = vi.fn(() => Promise.resolve(limitResult)); + return self; +} + +// Builds an insert chain. .values() is thenable (plain insert) and also +// exposes .onConflictDoUpdate() (upsert). +function makeInsertChain() { + const values: Record = {}; + values["then"] = ( + resolve: (v: unknown) => unknown, + reject: (e: unknown) => unknown, + ) => Promise.resolve(undefined).then(resolve, reject); + values["onConflictDoUpdate"] = vi.fn(() => Promise.resolve(undefined)); + const insert = { values: vi.fn(() => values) }; + return insert; +} + + +// ── Subject under test ──────────────────────────────────────────────────────── +import { POST } from "./route"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── +function makeRequest(body: unknown, portfolioId = "10") { + const req = new NextRequest( + `http://localhost/api/portfolio/${portfolioId}/approvals`, + { + method: "POST", + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + }, + ); + return { req, params: Promise.resolve({ portfolioId }) }; +} + +function setupHappyPath(approvalRowsAfterChange: Array<{ measureName: string; approvedByEmail: string }>) { + // 1. getServerSession + mockGetServerSession.mockResolvedValue({ user: { email: "approver@test.com" } }); + + // 2. getUserId select + mockDbSelect.mockImplementationOnce(() => + makeSelectChain([{ id: 1n }]), + ); + + // 3. hasApproverCapability select + mockDbSelect.mockImplementationOnce(() => + makeSelectChain([{ id: 1n }]), + ); + + // 4. upsert dealMeasureApprovals (one per change) + mockDbInsert.mockImplementationOnce(() => makeInsertChain()); + + // 5. insert dealMeasureApprovalEvents (one per change) + mockDbInsert.mockImplementationOnce(() => makeInsertChain()); + + // 6. post-change approvalRows select (no .limit — awaited at .where()) + mockDbSelect.mockImplementationOnce(() => + makeSelectChain([], approvalRowsAfterChange), + ); + + // HubSpot syncs + syncMeasureApprovalsToHubSpotMock.mockResolvedValue(undefined); + syncMeasuresFieldToHubSpotMock.mockResolvedValue({ ok: true }); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── +describe("POST /approvals — approved_measures HubSpot sync", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("syncs approved_measures field to HubSpot after an unapprove action", async () => { + setupHappyPath([ + // Only one measure remains approved after the unapprove + { measureName: "ASHP", approvedByEmail: "approver@test.com" }, + ]); + + const { req, params } = makeRequest({ + changes: [ + { hubspotDealId: "deal-1", measureName: "Solar PV", approved: false }, + ], + }); + + const res = await POST(req, { params }); + expect(res.status).toBe(200); + + // Allow fire-and-forget promises to settle + await vi.waitFor(() => + expect(syncMeasuresFieldToHubSpotMock).toHaveBeenCalled(), + ); + + expect(syncMeasuresFieldToHubSpotMock).toHaveBeenCalledWith({ + hubspotDealId: "deal-1", + propName: "approved_measures", + measureNames: ["ASHP"], + }); + }); + + it("syncs approved_measures with empty list when all measures removed", async () => { + setupHappyPath([]); // nothing approved after removal + + const { req, params } = makeRequest({ + changes: [ + { hubspotDealId: "deal-2", measureName: "ASHP", approved: false }, + ], + }); + + const res = await POST(req, { params }); + expect(res.status).toBe(200); + + await vi.waitFor(() => + expect(syncMeasuresFieldToHubSpotMock).toHaveBeenCalled(), + ); + + expect(syncMeasuresFieldToHubSpotMock).toHaveBeenCalledWith({ + hubspotDealId: "deal-2", + propName: "approved_measures", + measureNames: [], + }); + }); + + it("syncs approved_measures when a new measure is approved", async () => { + setupHappyPath([ + { measureName: "ASHP", approvedByEmail: "approver@test.com" }, + { measureName: "Solar PV", approvedByEmail: "approver@test.com" }, + ]); + + const { req, params } = makeRequest({ + changes: [ + { hubspotDealId: "deal-3", measureName: "Solar PV", approved: true }, + ], + }); + + const res = await POST(req, { params }); + expect(res.status).toBe(200); + + await vi.waitFor(() => + expect(syncMeasuresFieldToHubSpotMock).toHaveBeenCalled(), + ); + + expect(syncMeasuresFieldToHubSpotMock).toHaveBeenCalledWith({ + hubspotDealId: "deal-3", + propName: "approved_measures", + measureNames: ["ASHP", "Solar PV"], + }); + }); + + it("also calls the audit-log sync (existing behaviour preserved)", async () => { + setupHappyPath([ + { measureName: "EWI", approvedByEmail: "approver@test.com" }, + ]); + + const { req, params } = makeRequest({ + changes: [ + { hubspotDealId: "deal-4", measureName: "EWI", approved: true }, + ], + }); + + await POST(req, { params }); + + await vi.waitFor(() => + expect(syncMeasureApprovalsToHubSpotMock).toHaveBeenCalled(), + ); + + expect(syncMeasureApprovalsToHubSpotMock).toHaveBeenCalledWith( + expect.objectContaining({ + hubspotDealId: "deal-4", + approvedMeasures: [{ measureName: "EWI", approvedByEmail: "approver@test.com" }], + }), + ); + }); + + it("does not call HubSpot syncs when session is missing", async () => { + mockGetServerSession.mockResolvedValue(null); + + const { req, params } = makeRequest({ + changes: [ + { hubspotDealId: "deal-5", measureName: "ASHP", approved: false }, + ], + }); + + const res = await POST(req, { params }); + expect(res.status).toBe(401); + expect(syncMeasuresFieldToHubSpotMock).not.toHaveBeenCalled(); + }); + + it("does not call HubSpot syncs when user lacks approver capability", async () => { + mockGetServerSession.mockResolvedValue({ user: { email: "writer@test.com" } }); + + // getUserId + mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: 99n }])); + // hasApproverCapability → empty → no capability + mockDbSelect.mockImplementationOnce(() => makeSelectChain([])); + + const { req, params } = makeRequest({ + changes: [ + { hubspotDealId: "deal-6", measureName: "ASHP", approved: false }, + ], + }); + + const res = await POST(req, { params }); + expect(res.status).toBe(403); + expect(syncMeasuresFieldToHubSpotMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/portfolio/[portfolioId]/approvals/route.ts b/src/app/api/portfolio/[portfolioId]/approvals/route.ts index 85f2962..90bc7b4 100644 --- a/src/app/api/portfolio/[portfolioId]/approvals/route.ts +++ b/src/app/api/portfolio/[portfolioId]/approvals/route.ts @@ -10,7 +10,11 @@ import { and, eq, inArray, sql } from "drizzle-orm"; import { z } from "zod"; import { getServerSession } from "next-auth"; import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; -import { syncMeasureApprovalsToHubSpot } from "@/app/lib/hubspot/dealSync"; +import { + syncMeasureApprovalsToHubSpot, + syncMeasuresFieldToHubSpot, +} from "@/app/lib/hubspot/dealSync"; +import { APPROVED_MEASURES_PROP } from "@/app/lib/instructMeasure"; async function getRequestingUserId(email: string): Promise { const rows = await db @@ -230,6 +234,19 @@ export async function POST( actedByEmail: session.user.email, actedAt: now, }); + + void syncMeasuresFieldToHubSpot({ + hubspotDealId: dealId, + propName: APPROVED_MEASURES_PROP, + measureNames: approvalRows.map((r) => r.measureName), + }).then((result) => { + if (!result.ok) { + console.error("[HubSpot] approved_measures sync failed", { + dealId, + error: result.error, + }); + } + }); } return NextResponse.json({ success: true }); From 93cdc7a3d96ab10c2058d2aff78cc9104d4ec23b Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 6 May 2026 19:29:46 +0000 Subject: [PATCH 05/36] showing approved measures --- .../your-projects/live/MeasuresTable.tsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/MeasuresTable.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/MeasuresTable.tsx index 9259fc4..b25dd5d 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/MeasuresTable.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/MeasuresTable.tsx @@ -218,6 +218,9 @@ export default function MeasuresTable({ Proposed Measures + + Technical Approved + Status @@ -311,6 +314,20 @@ export default function MeasuresTable({ + {/* Technical Approved */} + +
+ {parseMeasures(deal.technicalApprovedMeasuresForInstall).map((measure) => ( + + {measure} + + ))} +
+
+ {/* Status */} @@ -322,7 +339,7 @@ export default function MeasuresTable({ {isExpanded && (
From 70374d56f0fd0b210443e8473ca4a2e8d85ce97c Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 6 May 2026 19:38:09 +0000 Subject: [PATCH 06/36] moved position of lodgmenet status --- .../(portfolio)/your-projects/live/[dealId]/DealPage.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/DealPage.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/DealPage.tsx index ee0a52c..afb38ee 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/DealPage.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/[dealId]/DealPage.tsx @@ -163,6 +163,9 @@ export default function DealPage({ + + +
-
From 19139b62533b365cd383cdcc11405f300e9010fe Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Wed, 6 May 2026 20:37:30 +0000 Subject: [PATCH 07/36] Updated survey request UI for Devon County Council --- skills-lock.json | 89 ++++++ .../survey-requests/route.test.ts | 176 ++++++++++++ .../[portfolioId]/survey-requests/route.ts | 79 ++++-- src/app/db/schema/survey_requests.ts | 2 +- src/app/lib/dealPropertyUpdate.test.ts | 20 +- src/app/lib/dealPropertyUpdate.ts | 4 +- src/app/lib/hubspot/dealSync.test.ts | 35 ++- src/app/lib/hubspot/dealSync.ts | 10 +- .../live/PropertyDetailDrawer.tsx | 267 +++--------------- .../your-projects/live/[dealId]/DealPage.tsx | 13 +- 10 files changed, 424 insertions(+), 271 deletions(-) create mode 100644 skills-lock.json create mode 100644 src/app/api/portfolio/[portfolioId]/survey-requests/route.test.ts diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..e56778a --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,89 @@ +{ + "version": 1, + "skills": { + "caveman": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/productivity/caveman/SKILL.md", + "computedHash": "934433479903febc585bf6deb5f0cebc63137e3f86b7babe0aab1ecb94d6d7a4" + }, + "diagnose": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/diagnose/SKILL.md", + "computedHash": "15939a26f86edec2d4862042b8564e5a062cb81d04e047a0cea6305c8830b5f5" + }, + "grill-me": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/productivity/grill-me/SKILL.md", + "computedHash": "784f0dbb7403b0f00324bce9a112f715342777a0daee7bbb7385f9c6f0a170ea" + }, + "grill-with-docs": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/grill-with-docs/SKILL.md", + "computedHash": "31a5b1ae116558bf7d3f633f442835f54bd7645923d4f45c7823e52a97317666" + }, + "improve-codebase-architecture": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/improve-codebase-architecture/SKILL.md", + "computedHash": "c77b86b4332919499608f9af1880074e1fec65a59b95c70c27a9f39cd137865e" + }, + "ralph-loop": { + "source": "Hestia-Homes/agentic-toolkit", + "sourceType": "github", + "skillPath": "skills/engineering/ralph-loop/SKILL.md", + "computedHash": "6d45d44d84abf566d0f298af6b7d710e5f6ebaecb5a06c31fedacd20085ae88d" + }, + "setup-matt-pocock-skills": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/setup-matt-pocock-skills/SKILL.md", + "computedHash": "3a32f8f1ed8160c9d286a2aabe88ee9b884c6f3f88a7a6c47b7d5d552c959587" + }, + "tdd": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/tdd/SKILL.md", + "computedHash": "15a7b5e36383ebadb2dec5e586679e55e9663d292da418926b8da6fc0ef27d84" + }, + "to-issues": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/to-issues/SKILL.md", + "computedHash": "73a91f30784523aa59ec9b02769576ebfc738e2cd5ad8f6441076031f0a5d5ac" + }, + "to-prd": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/to-prd/SKILL.md", + "computedHash": "fd8c259f9c44eff08e29a1a2fc71a806a3568d279a55387a361f78620b10f2aa" + }, + "to-project": { + "source": "Hestia-Homes/agentic-toolkit", + "sourceType": "github", + "skillPath": "skills/engineering/to-project/SKILL.md", + "computedHash": "59daf039ac699a44a9416f8ec403b83d4166e05489959e127746231ff8be4e12" + }, + "triage": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/triage/SKILL.md", + "computedHash": "2b6efb6da12d92551772fcc04acf331f4e0e6f7bd9d4cb23ce0b301e0b128feb" + }, + "write-a-skill": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/productivity/write-a-skill/SKILL.md", + "computedHash": "b44d8aab2ead83c716e01af4c9a24ccc4575ce70ad58ec4f1749fb88c9cc82ba" + }, + "zoom-out": { + "source": "mattpocock/skills", + "sourceType": "github", + "skillPath": "skills/engineering/zoom-out/SKILL.md", + "computedHash": "8357aeaece3b709c442eab67e64b86844e05e2f1ea95b109565eba50b6def36e" + } + } +} diff --git a/src/app/api/portfolio/[portfolioId]/survey-requests/route.test.ts b/src/app/api/portfolio/[portfolioId]/survey-requests/route.test.ts new file mode 100644 index 0000000..8db83b0 --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/survey-requests/route.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +// ── Hoisted mocks ───────────────────────────────────────────────────────────── +const { + mockGetServerSession, + mockSyncSurveyRequestToHubSpot, + mockDbSelect, + mockDbInsert, +} = vi.hoisted(() => ({ + mockGetServerSession: vi.fn(), + mockSyncSurveyRequestToHubSpot: vi.fn(), + mockDbSelect: vi.fn(), + mockDbInsert: vi.fn(), +})); + +vi.mock("next-auth", () => ({ getServerSession: mockGetServerSession })); +vi.mock("@/app/api/auth/[...nextauth]/authOptions", () => ({ + AuthOptions: {}, +})); +vi.mock("@/app/lib/hubspot/dealSync", () => ({ + syncSurveyRequestToHubSpot: mockSyncSurveyRequestToHubSpot, +})); +vi.mock("drizzle-orm", () => ({ + and: vi.fn((...args: unknown[]) => ({ $and: args })), + eq: vi.fn((a: unknown, b: unknown) => ({ $eq: [a, b] })), + desc: vi.fn((col: unknown) => ({ $desc: col })), +})); +vi.mock("@/app/db/schema/survey_requests", () => ({ + surveyRequests: { + id: {}, hubspotDealId: {}, portfolioId: {}, notes: {}, + surveyType: {}, status: {}, requestedBy: {}, requestedAt: {}, fulfilledAt: {}, + }, +})); +vi.mock("@/app/db/schema/portfolio", () => ({ + portfolioUsers: { portfolioId: {}, userId: {}, role: {} }, + portfolioCapabilities: { portfolioId: {}, userId: {}, capability: {} }, +})); +vi.mock("@/app/db/schema/users", () => ({ + user: { id: {}, email: {} }, +})); +vi.mock("@/app/db/db", () => ({ + db: { + get select() { return mockDbSelect; }, + get insert() { return mockDbInsert; }, + }, +})); + +// ── Helpers ─────────────────────────────────────────────────────────────────── +function makeSelectChain(limitResult: unknown[], directResult: unknown[] = []) { + const self: Record = {}; + self["then"] = (resolve: (v: unknown) => unknown, reject: (e: unknown) => unknown) => + Promise.resolve(directResult).then(resolve, reject); + self["from"] = vi.fn(() => self); + self["innerJoin"] = vi.fn(() => self); + self["where"] = vi.fn(() => self); + self["orderBy"] = vi.fn(() => self); + self["limit"] = vi.fn(() => Promise.resolve(limitResult)); + return self; +} + +function makeInsertChain(returningResult: unknown[] = []) { + const returning = vi.fn(() => Promise.resolve(returningResult)); + const values = vi.fn(() => ({ returning })); + return { values }; +} + +function makeRequest(body: unknown, portfolioId = "5") { + const req = new NextRequest( + `http://localhost/api/portfolio/${portfolioId}/survey-requests`, + { + method: "POST", + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + }, + ); + return { req, params: Promise.resolve({ portfolioId }) }; +} + +// ── Subject under test ──────────────────────────────────────────────────────── +import { POST } from "./route"; + +describe("POST /survey-requests", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockSyncSurveyRequestToHubSpot.mockResolvedValue({ ok: true }); + }); + + it("returns 401 when unauthenticated", async () => { + mockGetServerSession.mockResolvedValue(null); + const { req, params } = makeRequest({ hubspotDealId: "deal-1", surveyType: "technical_building_survey" }); + const res = await POST(req, { params }); + expect(res.status).toBe(401); + }); + + it("returns 403 when user lacks approver capability", async () => { + mockGetServerSession.mockResolvedValue({ user: { email: "write@test.com" } }); + // user lookup + mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: 1n, email: "write@test.com" }])); + // portfolio role check + mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ role: "write" }])); + // capability check — no rows (directResult), so not an approver + mockDbSelect.mockImplementationOnce(() => makeSelectChain([], [])); + + const { req, params } = makeRequest({ hubspotDealId: "deal-1", surveyType: "technical_building_survey" }); + const res = await POST(req, { params }); + expect(res.status).toBe(403); + const json = await res.json(); + expect(json.error).toMatch(/approver/i); + }); + + it("returns 409 when a pending request already exists for the deal", async () => { + mockGetServerSession.mockResolvedValue({ user: { email: "approver@test.com" } }); + mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: 2n, email: "approver@test.com" }])); + mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ role: "admin" }])); + // capability rows come back via directResult (no .limit() on that query) + mockDbSelect.mockImplementationOnce(() => makeSelectChain([], [{ capability: "approver" }])); + // pending check — returns a pending row + mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: 99n, status: "pending" }])); + + const { req, params } = makeRequest({ hubspotDealId: "deal-1", surveyType: "technical_building_survey" }); + const res = await POST(req, { params }); + expect(res.status).toBe(409); + const json = await res.json(); + expect(json.error).toMatch(/pending/i); + }); + + it("creates the request with surveyType and syncs to HubSpot", async () => { + const insertedAt = new Date("2026-05-06T10:00:00.000Z"); + mockGetServerSession.mockResolvedValue({ user: { email: "approver@test.com" } }); + mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: 2n, email: "approver@test.com" }])); + mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ role: "admin" }])); + mockDbSelect.mockImplementationOnce(() => makeSelectChain([], [{ capability: "approver" }])); + // no pending request + mockDbSelect.mockImplementationOnce(() => makeSelectChain([])); + // insert returning + mockDbInsert.mockImplementationOnce(() => + makeInsertChain([{ id: 42n, requestedAt: insertedAt }]) + ); + + const { req, params } = makeRequest({ hubspotDealId: "deal-abc", surveyType: "technical_building_survey" }); + const res = await POST(req, { params }); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.ok).toBe(true); + expect(json.id).toBe("42"); + expect(json.hubspotSync).toBe("ok"); + + expect(mockSyncSurveyRequestToHubSpot).toHaveBeenCalledWith({ + hubspotDealId: "deal-abc", + surveyType: "technical_building_survey", + requestedAt: insertedAt, + }); + }); + + it("returns hubspotSync: failed but still 200 when HubSpot fails", async () => { + const insertedAt = new Date(); + mockGetServerSession.mockResolvedValue({ user: { email: "approver@test.com" } }); + mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ id: 2n, email: "approver@test.com" }])); + mockDbSelect.mockImplementationOnce(() => makeSelectChain([{ role: "admin" }])); + mockDbSelect.mockImplementationOnce(() => makeSelectChain([], [{ capability: "approver" }])); + mockDbSelect.mockImplementationOnce(() => makeSelectChain([])); + mockDbInsert.mockImplementationOnce(() => + makeInsertChain([{ id: 43n, requestedAt: insertedAt }]) + ); + mockSyncSurveyRequestToHubSpot.mockResolvedValue({ ok: false, error: "HubSpot sync failed" }); + + const { req, params } = makeRequest({ hubspotDealId: "deal-abc", surveyType: "technical_building_survey" }); + const res = await POST(req, { params }); + expect(res.status).toBe(200); + const json = await res.json(); + expect(json.ok).toBe(true); + expect(json.hubspotSync).toBe("failed"); + expect(json.hubspotError).toBe("HubSpot sync failed"); + }); +}); diff --git a/src/app/api/portfolio/[portfolioId]/survey-requests/route.ts b/src/app/api/portfolio/[portfolioId]/survey-requests/route.ts index 8190fa9..7b2c02b 100644 --- a/src/app/api/portfolio/[portfolioId]/survey-requests/route.ts +++ b/src/app/api/portfolio/[portfolioId]/survey-requests/route.ts @@ -1,7 +1,7 @@ import { db } from "@/app/db/db"; import { NextRequest, NextResponse } from "next/server"; import { surveyRequests } from "@/app/db/schema/survey_requests"; -import { portfolioUsers } from "@/app/db/schema/portfolio"; +import { portfolioUsers, portfolioCapabilities } from "@/app/db/schema/portfolio"; import { user } from "@/app/db/schema/users"; import { and, eq, desc } from "drizzle-orm"; import { z } from "zod"; @@ -9,8 +9,6 @@ import { getServerSession } from "next-auth"; import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; import { syncSurveyRequestToHubSpot } from "@/app/lib/hubspot/dealSync"; -const WRITE_ROLES = ["creator", "admin", "write"] as const; - async function getRequestingUser(email: string) { const rows = await db .select({ id: user.id, email: user.email }) @@ -20,7 +18,7 @@ async function getRequestingUser(email: string) { return rows[0] ?? null; } -async function getUserRole(portfolioId: bigint, userId: bigint) { +async function hasPortfolioRole(portfolioId: bigint, userId: bigint) { const rows = await db .select({ role: portfolioUsers.role }) .from(portfolioUsers) @@ -31,11 +29,38 @@ async function getUserRole(portfolioId: bigint, userId: bigint) { ), ) .limit(1); - return rows[0]?.role ?? null; + return !!rows[0]?.role; +} + +async function hasApproverCapability(portfolioId: bigint, userId: bigint) { + const rows = await db + .select({ capability: portfolioCapabilities.capability }) + .from(portfolioCapabilities) + .where( + and( + eq(portfolioCapabilities.portfolioId, portfolioId), + eq(portfolioCapabilities.userId, userId), + ), + ); + return rows.map((r) => r.capability).includes("approver"); +} + +async function getPendingRequest(hubspotDealId: string, portfolioId: bigint) { + const rows = await db + .select({ id: surveyRequests.id, status: surveyRequests.status }) + .from(surveyRequests) + .where( + and( + eq(surveyRequests.hubspotDealId, hubspotDealId), + eq(surveyRequests.portfolioId, portfolioId), + eq(surveyRequests.status, "pending"), + ), + ) + .limit(1); + return rows[0] ?? null; } // GET /api/portfolio/[portfolioId]/survey-requests?dealId=xxx -// Returns all survey requests for a deal, most recent first. export async function GET( req: NextRequest, props: { params: Promise<{ portfolioId: string }> }, @@ -57,7 +82,7 @@ export async function GET( .select({ id: surveyRequests.id, hubspotDealId: surveyRequests.hubspotDealId, - notes: surveyRequests.notes, + surveyType: surveyRequests.surveyType, status: surveyRequests.status, requestedAt: surveyRequests.requestedAt, fulfilledAt: surveyRequests.fulfilledAt, @@ -77,7 +102,7 @@ export async function GET( const requests = rows.map((r) => ({ id: String(r.id), hubspotDealId: r.hubspotDealId, - notes: r.notes, + surveyType: r.surveyType ?? null, status: r.status, requestedByEmail: r.requestedByEmail, requestedAt: r.requestedAt?.toISOString() ?? null, @@ -93,11 +118,11 @@ export async function GET( const postSchema = z.object({ hubspotDealId: z.string().min(1), - notes: z.string().min(1, "Notes are required"), + surveyType: z.string().min(1), }); // POST /api/portfolio/[portfolioId]/survey-requests -// Submit a new survey request — requires write+ role. +// Submit a new survey request — requires approver capability. export async function POST( req: NextRequest, props: { params: Promise<{ portfolioId: string }> }, @@ -114,10 +139,17 @@ export async function POST( return NextResponse.json({ error: "User not found" }, { status: 401 }); } - const role = await getUserRole(BigInt(portfolioId), requestingUser.id); - if (!role || !WRITE_ROLES.includes(role as (typeof WRITE_ROLES)[number])) { + const pid = BigInt(portfolioId); + + const isMember = await hasPortfolioRole(pid, requestingUser.id); + if (!isMember) { + return NextResponse.json({ error: "No portfolio access" }, { status: 403 }); + } + + const isApprover = await hasApproverCapability(pid, requestingUser.id); + if (!isApprover) { return NextResponse.json( - { error: "Write access required to submit a survey request" }, + { error: "Approver capability required to submit a survey request" }, { status: 403 }, ); } @@ -134,24 +166,33 @@ export async function POST( return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); } - const { hubspotDealId, notes } = parsed.data; + const { hubspotDealId, surveyType } = parsed.data; + + const existing = await getPendingRequest(hubspotDealId, pid); + if (existing) { + return NextResponse.json( + { error: "A pending survey request already exists for this deal" }, + { status: 409 }, + ); + } try { const [inserted] = await db .insert(surveyRequests) .values({ hubspotDealId, - portfolioId: BigInt(portfolioId), - notes, + portfolioId: pid, + notes: "", + surveyType, status: "pending", requestedBy: requestingUser.id, }) - .returning({ id: surveyRequests.id }); + .returning({ id: surveyRequests.id, requestedAt: surveyRequests.requestedAt }); const hubspotResult = await syncSurveyRequestToHubSpot({ hubspotDealId, - notes, - requestedByEmail: requestingUser.email, + surveyType, + requestedAt: inserted.requestedAt, }); return NextResponse.json({ diff --git a/src/app/db/schema/survey_requests.ts b/src/app/db/schema/survey_requests.ts index 815d18f..55001d0 100644 --- a/src/app/db/schema/survey_requests.ts +++ b/src/app/db/schema/survey_requests.ts @@ -20,8 +20,8 @@ export const surveyRequests = pgTable( portfolioId: bigint("portfolio_id", { mode: "bigint" }) .notNull() .references(() => portfolio.id), - // Free-text notes from the requester describing what survey is needed. notes: text("notes").notNull(), + surveyType: text("survey_type"), // 'pending' | 'fulfilled' status: text("status").notNull().default("pending"), requestedBy: bigint("requested_by", { mode: "bigint" }) diff --git a/src/app/lib/dealPropertyUpdate.test.ts b/src/app/lib/dealPropertyUpdate.test.ts index 2f092b5..4c2b34c 100644 --- a/src/app/lib/dealPropertyUpdate.test.ts +++ b/src/app/lib/dealPropertyUpdate.test.ts @@ -75,10 +75,10 @@ describe("DEAL_PROPERTY_FIELDS registry", () => { "property_halted_reason", ); expect(DEAL_PROPERTY_FIELDS.domna_survey_type.hubspotProperty).toBe( - "domna_survey_type", + "osmosis_survey_required", ); expect(DEAL_PROPERTY_FIELDS.domna_survey_date.hubspotProperty).toBe( - "domna_survey_date", + "osmosis_survey_date", ); }); @@ -369,8 +369,8 @@ describe("applyDealPropertyUpdate", () => { expect((dbValues.domnaSurveyDate as Date).toISOString()).toBe(surveyIso); const props = pushHubspot.mock.calls[0][0].properties; - expect(props.domna_survey_type).toBe(surveyType); - expect(props.domna_survey_date).toBe( + expect(props.osmosis_survey_required).toBe(surveyType); + expect(props.osmosis_survey_date).toBe( String(new Date(surveyIso).getTime()), ); }); @@ -407,8 +407,8 @@ describe("applyDealPropertyUpdate", () => { expect(typeOnlyDb.domnaSurveyType).toBe("Standard"); expect("domnaSurveyDate" in typeOnlyDb).toBe(false); const typeOnlyProps = pushHubspotType.mock.calls[0][0].properties; - expect(typeOnlyProps.domna_survey_type).toBe("Standard"); - expect("domna_survey_date" in typeOnlyProps).toBe(false); + expect(typeOnlyProps.osmosis_survey_required).toBe("Standard"); + expect("osmosis_survey_date" in typeOnlyProps).toBe(false); // Setting only the date — type column is untouched. const updateDbDate = vi.fn().mockResolvedValue(undefined); @@ -427,10 +427,10 @@ describe("applyDealPropertyUpdate", () => { expect(dateOnlyDb.domnaSurveyDate).toBeInstanceOf(Date); expect("domnaSurveyType" in dateOnlyDb).toBe(false); const dateOnlyProps = pushHubspotDate.mock.calls[0][0].properties; - expect(dateOnlyProps.domna_survey_date).toBe( + expect(dateOnlyProps.osmosis_survey_date).toBe( String(new Date(surveyIso).getTime()), ); - expect("domna_survey_type" in dateOnlyProps).toBe(false); + expect("osmosis_survey_required" in dateOnlyProps).toBe(false); }); it("clears both domna fields to null when explicitly cleared", async () => { @@ -453,8 +453,8 @@ describe("applyDealPropertyUpdate", () => { expect(dbValues.domnaSurveyType).toBeNull(); expect(dbValues.domnaSurveyDate).toBeNull(); const props = pushHubspot.mock.calls[0][0].properties; - expect(props.domna_survey_type).toBe(""); - expect(props.domna_survey_date).toBe(""); + expect(props.osmosis_survey_required).toBe(""); + expect(props.osmosis_survey_date).toBe(""); }); it("surfaces HubSpot push failures back to the caller", async () => { diff --git a/src/app/lib/dealPropertyUpdate.ts b/src/app/lib/dealPropertyUpdate.ts index 5aaf122..2336006 100644 --- a/src/app/lib/dealPropertyUpdate.ts +++ b/src/app/lib/dealPropertyUpdate.ts @@ -149,14 +149,14 @@ export const DEAL_PROPERTY_FIELDS = { domna_survey_type: { schema: stringOrNullSchema, allowedRoles: APPROVER_ROLES, - hubspotProperty: "domna_survey_type", + hubspotProperty: "osmosis_survey_required", dbColumn: hubspotDealData.domnaSurveyType, toHubspot: stringToHubspot, } satisfies DealPropertyFieldDef, domna_survey_date: { schema: isoDateSchema, allowedRoles: APPROVER_ROLES, - hubspotProperty: "domna_survey_date", + hubspotProperty: "osmosis_survey_date", dbColumn: hubspotDealData.domnaSurveyDate, toHubspot: dateToHubspot, } satisfies DealPropertyFieldDef, diff --git a/src/app/lib/hubspot/dealSync.test.ts b/src/app/lib/hubspot/dealSync.test.ts index 2ca92a0..4d5aa47 100644 --- a/src/app/lib/hubspot/dealSync.test.ts +++ b/src/app/lib/hubspot/dealSync.test.ts @@ -10,7 +10,7 @@ vi.mock("./client", () => ({ }), })); -import { syncMeasuresFieldToHubSpot } from "./dealSync"; +import { syncMeasuresFieldToHubSpot, syncSurveyRequestToHubSpot } from "./dealSync"; describe("syncMeasuresFieldToHubSpot", () => { beforeEach(() => { @@ -122,3 +122,36 @@ describe("syncMeasuresFieldToHubSpot", () => { }); }); }); + +describe("syncSurveyRequestToHubSpot", () => { + beforeEach(() => { + updateMock.mockReset(); + }); + + it("writes survey type to osmosis_survey_required and date to osmosis_survey_date", async () => { + updateMock.mockResolvedValueOnce(undefined); + const requestedAt = new Date("2026-05-06T10:00:00.000Z"); + const result = await syncSurveyRequestToHubSpot({ + hubspotDealId: "deal-99", + surveyType: "technical_building_survey", + requestedAt, + }); + expect(result).toEqual({ ok: true }); + expect(updateMock).toHaveBeenCalledWith("deal-99", { + properties: { + osmosis_survey_required: "technical_building_survey", + osmosis_survey_date: "2026-05-06T10:00:00.000Z", + }, + }); + }); + + it("returns ok: false with error message on HubSpot failure", async () => { + updateMock.mockRejectedValueOnce(new Error("HubSpot 400")); + const result = await syncSurveyRequestToHubSpot({ + hubspotDealId: "deal-99", + surveyType: "technical_building_survey", + requestedAt: new Date(), + }); + expect(result).toEqual({ ok: false, error: "HubSpot sync failed" }); + }); +}); diff --git a/src/app/lib/hubspot/dealSync.ts b/src/app/lib/hubspot/dealSync.ts index 8b08c89..26a0497 100644 --- a/src/app/lib/hubspot/dealSync.ts +++ b/src/app/lib/hubspot/dealSync.ts @@ -187,14 +187,16 @@ export async function syncMeasuresFieldToHubSpot(params: { export async function syncSurveyRequestToHubSpot(params: { hubspotDealId: string; - notes: string; - requestedByEmail: string; + surveyType: string; + requestedAt: Date; }): Promise<{ ok: boolean; error?: string }> { try { const client = getHubSpotClient(); - const log = `Survey requested by: ${params.requestedByEmail}\nNotes: ${params.notes}`; await client.crm.deals.basicApi.update(params.hubspotDealId, { - properties: { survey_request_log: log }, + properties: { + osmosis_survey_required: params.surveyType, + osmosis_survey_date: params.requestedAt.toISOString(), + }, }); return { ok: true }; } catch (err) { diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx index 721ece3..a76adf8 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/PropertyDetailDrawer.tsx @@ -359,12 +359,16 @@ export function RemovalRequestSection({ } // ----------------------------------------------------------------------- -// Survey request section — write-role users can request a survey from Domna +// Survey request section — approver-only type-select flow // ----------------------------------------------------------------------- +const SURVEY_TYPES = [ + { value: "technical_building_survey", label: "Technical Building Survey" }, +] as const; + type SurveyRequestRecord = { id: string; hubspotDealId: string; - notes: string; + surveyType: string | null; status: string; requestedByEmail: string; requestedAt: string | null; @@ -374,19 +378,17 @@ type SurveyRequestRecord = { export function SurveyRequestSection({ dealId, portfolioId, - userRole, + canEdit, }: { dealId: string; portfolioId: string; - userRole: string; + canEdit: boolean; }) { const queryClient = useQueryClient(); - const [notes, setNotes] = useState(""); + const [selectedType, setSelectedType] = useState(SURVEY_TYPES[0].value); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); - const canRequest = WRITE_ROLES.includes(userRole as (typeof WRITE_ROLES)[number]); - const { data, isLoading } = useQuery<{ requests: SurveyRequestRecord[] }>({ queryKey: ["surveyRequests", portfolioId, dealId], queryFn: async () => { @@ -400,16 +402,17 @@ export function SurveyRequestSection({ }); const pending = data?.requests?.find((r) => r.status === "pending") ?? null; + const fulfilled = (data?.requests ?? []).filter((r) => r.status === "fulfilled"); async function handleSubmit() { - if (!notes.trim() || submitting) return; + if (submitting) return; setSubmitting(true); setError(null); try { const res = await fetch(`/api/portfolio/${portfolioId}/survey-requests`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ hubspotDealId: dealId, notes: notes.trim() }), + body: JSON.stringify({ hubspotDealId: dealId, surveyType: selectedType }), }); if (!res.ok) { const json = await res.json().catch(() => ({})); @@ -418,15 +421,19 @@ export function SurveyRequestSection({ } const json = (await res.json()) as { ok: boolean; hubspotSync?: string; hubspotError?: string }; if (json.hubspotSync === "failed") { - setError(json.hubspotError ?? "Saved locally — HubSpot sync failed"); + setError(json.hubspotError ? `Saved locally — HubSpot sync failed: ${json.hubspotError}` : "Saved locally — HubSpot sync failed"); } - setNotes(""); queryClient.invalidateQueries({ queryKey: ["surveyRequests", portfolioId, dealId] }); } finally { setSubmitting(false); } } + function surveyTypeLabel(value: string | null) { + if (!value) return value; + return SURVEY_TYPES.find((t) => t.value === value)?.label ?? value; + } + if (isLoading) { return

Loading…

; } @@ -437,6 +444,11 @@ export function SurveyRequestSection({

{error}

)} + {/* Empty state */} + {!pending && fulfilled.length === 0 && ( +

No surveys requested

+ )} + {/* Pending badge */} {pending && (
Survey Requested -

{pending.notes}

+

{surveyTypeLabel(pending.surveyType)}

Requested by{" "} {pending.requestedByEmail} @@ -455,27 +467,29 @@ export function SurveyRequestSection({

)} - {/* Request form — only shown when no pending request */} - {canRequest && !pending && ( + {/* Request form — shown to approvers when no pending request */} + {canEdit && !pending && (
-

- Request a survey from Domna. Notes will be sent to the coordination team. -

-