From e020b3fd835f10e670268f0a0aee5adaaf8b3704 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 5 May 2026 18:24:43 +0000 Subject: [PATCH] add halted state fields and approver capability gate Co-Authored-By: Claude Opus 4.7 --- .../[portfolioId]/deal-properties/route.ts | 19 ++- src/app/lib/dealPropertyUpdate.test.ts | 148 ++++++++++++++++++ src/app/lib/dealPropertyUpdate.ts | 75 ++++++++- 3 files changed, 233 insertions(+), 9 deletions(-) diff --git a/src/app/api/portfolio/[portfolioId]/deal-properties/route.ts b/src/app/api/portfolio/[portfolioId]/deal-properties/route.ts index 7cfc3485..f85b7729 100644 --- a/src/app/api/portfolio/[portfolioId]/deal-properties/route.ts +++ b/src/app/api/portfolio/[portfolioId]/deal-properties/route.ts @@ -5,7 +5,10 @@ import { z } from "zod"; import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions"; import { db } from "@/app/db/db"; -import { portfolioUsers } from "@/app/db/schema/portfolio"; +import { + portfolioCapabilities, + portfolioUsers, +} from "@/app/db/schema/portfolio"; import { user } from "@/app/db/schema/users"; import { applyDealPropertyUpdate } from "@/app/lib/dealPropertyUpdate"; @@ -97,11 +100,25 @@ export async function PATCH( ); } + // Capabilities are orthogonal to role — used by approver-gated fields + // (e.g. property_halted_date / _reason in issue #255). + const capabilityRows = await db + .select({ capability: portfolioCapabilities.capability }) + .from(portfolioCapabilities) + .where( + and( + eq(portfolioCapabilities.portfolioId, BigInt(portfolioId)), + eq(portfolioCapabilities.userId, userRow[0].id), + ), + ); + const capabilities = capabilityRows.map((r) => r.capability); + try { const outcome = await applyDealPropertyUpdate({ dealId, fields, role, + capabilities, }); return NextResponse.json(outcome); diff --git a/src/app/lib/dealPropertyUpdate.test.ts b/src/app/lib/dealPropertyUpdate.test.ts index b36d8072..0eb72bc3 100644 --- a/src/app/lib/dealPropertyUpdate.test.ts +++ b/src/app/lib/dealPropertyUpdate.test.ts @@ -18,6 +18,28 @@ describe("DEAL_PROPERTY_FIELDS registry", () => { expect(roleAllowedForField("pibi_completed_date", "write")).toBe(true); }); + it("exposes the halted fields gated on the approver capability", () => { + expect(isDealPropertyField("property_halted_date")).toBe(true); + expect(isDealPropertyField("property_halted_reason")).toBe(true); + // Plain roles — even the most permissive — never satisfy the approver + // gate on their own. + for (const role of ["read", "write", "admin", "creator"]) { + expect(roleAllowedForField("property_halted_date", role)).toBe(false); + expect(roleAllowedForField("property_halted_reason", role)).toBe(false); + } + // Capability list grants access regardless of role tier. + expect( + roleAllowedForField("property_halted_date", "read", ["approver"]), + ).toBe(true); + expect( + roleAllowedForField("property_halted_reason", "write", ["approver"]), + ).toBe(true); + // Other capabilities should not unlock the field. + expect( + roleAllowedForField("property_halted_date", "write", ["contractor"]), + ).toBe(false); + }); + it("maps each registered field to the matching HubSpot property", () => { expect(DEAL_PROPERTY_FIELDS.pibi_order_date.hubspotProperty).toBe( "pibi_order_date", @@ -25,6 +47,12 @@ describe("DEAL_PROPERTY_FIELDS registry", () => { expect(DEAL_PROPERTY_FIELDS.pibi_completed_date.hubspotProperty).toBe( "pibi_completed_date", ); + expect(DEAL_PROPERTY_FIELDS.property_halted_date.hubspotProperty).toBe( + "property_halted_date", + ); + expect(DEAL_PROPERTY_FIELDS.property_halted_reason.hubspotProperty).toBe( + "property_halted_reason", + ); }); it("rejects unknown fields", () => { @@ -140,6 +168,126 @@ describe("applyDealPropertyUpdate", () => { expect(pushHubspot.mock.calls[0][0].properties.pibi_order_date).toBe(""); }); + it("rejects halted fields when the caller lacks approver capability", async () => { + const updateDb = vi.fn(); + const pushHubspot = vi.fn(); + const out = await applyDealPropertyUpdate({ + dealId: "deal-h1", + fields: { + property_halted_date: "2025-06-01T00:00:00.000Z", + property_halted_reason: "Awaiting access", + }, + // Even creator role alone is not enough — capability is orthogonal. + role: "creator", + capabilities: [], + deps: { updateDb, pushHubspot }, + }); + expect(out.results.property_halted_date).toEqual({ + ok: false, + error: "Insufficient permissions", + }); + expect(out.results.property_halted_reason).toEqual({ + ok: false, + error: "Insufficient permissions", + }); + expect(out.hubspotSync).toBe("skipped"); + expect(updateDb).not.toHaveBeenCalled(); + expect(pushHubspot).not.toHaveBeenCalled(); + }); + + it("persists halted date + reason for an approver and pushes them to HubSpot", async () => { + const updateDb = vi.fn().mockResolvedValue(undefined); + const pushHubspot = vi.fn().mockResolvedValue({ ok: true }); + const haltedIso = "2025-06-01T00:00:00.000Z"; + const reason = "Awaiting roof access"; + + const out = await applyDealPropertyUpdate({ + dealId: "deal-h2", + fields: { + property_halted_date: haltedIso, + property_halted_reason: reason, + }, + role: "read", + capabilities: ["approver"], + deps: { updateDb, pushHubspot }, + }); + + expect(out.results.property_halted_date).toEqual({ ok: true }); + expect(out.results.property_halted_reason).toEqual({ ok: true }); + expect(out.hubspotSync).toBe("ok"); + + const dbValues = updateDb.mock.calls[0][1]; + expect(dbValues.propertyHaltedDate).toBeInstanceOf(Date); + expect((dbValues.propertyHaltedDate as Date).toISOString()).toBe(haltedIso); + expect(dbValues.propertyHaltedReason).toBe(reason); + + const props = pushHubspot.mock.calls[0][0].properties; + expect(props.property_halted_date).toBe( + String(new Date(haltedIso).getTime()), + ); + expect(props.property_halted_reason).toBe(reason); + }); + + it("validates the halted date format", async () => { + const updateDb = vi.fn(); + const pushHubspot = vi.fn(); + const out = await applyDealPropertyUpdate({ + dealId: "deal-h3", + fields: { property_halted_date: "definitely-not-a-date" }, + role: "read", + capabilities: ["approver"], + deps: { updateDb, pushHubspot }, + }); + expect(out.results.property_halted_date.ok).toBe(false); + expect(updateDb).not.toHaveBeenCalled(); + expect(pushHubspot).not.toHaveBeenCalled(); + }); + + it("collapses an empty halted reason to null on save", async () => { + const updateDb = vi.fn().mockResolvedValue(undefined); + const pushHubspot = vi.fn().mockResolvedValue({ ok: true }); + const out = await applyDealPropertyUpdate({ + dealId: "deal-h4", + fields: { property_halted_reason: "" }, + role: "read", + capabilities: ["approver"], + deps: { updateDb, pushHubspot }, + }); + expect(out.results.property_halted_reason).toEqual({ ok: true }); + expect(updateDb.mock.calls[0][1].propertyHaltedReason).toBeNull(); + expect(pushHubspot.mock.calls[0][0].properties.property_halted_reason).toBe( + "", + ); + }); + + it("resume clears the halted date and leaves the reason untouched in DB + HubSpot payload", async () => { + const updateDb = vi.fn().mockResolvedValue(undefined); + const pushHubspot = vi.fn().mockResolvedValue({ ok: true }); + // The drawer's "Resume" action only sends `property_halted_date: null`. + // The reason field is omitted entirely so the existing value is + // preserved. + const out = await applyDealPropertyUpdate({ + dealId: "deal-h5", + fields: { property_halted_date: null }, + role: "read", + capabilities: ["approver"], + deps: { updateDb, pushHubspot }, + }); + expect(out.results.property_halted_date).toEqual({ ok: true }); + expect(out.results.property_halted_reason).toBeUndefined(); + expect(out.hubspotSync).toBe("ok"); + + const dbValues = updateDb.mock.calls[0][1]; + expect(dbValues.propertyHaltedDate).toBeNull(); + // No reason key at all → reason column not touched. + expect("propertyHaltedReason" in dbValues).toBe(false); + + const props = pushHubspot.mock.calls[0][0].properties; + expect(props.property_halted_date).toBe(""); + // No reason key in the HubSpot payload → reason not pushed. + expect("property_halted_reason" in props).toBe(false); + }); + it("surfaces HubSpot push failures back to the caller", async () => { const updateDb = vi.fn().mockResolvedValue(undefined); const pushHubspot = vi diff --git a/src/app/lib/dealPropertyUpdate.ts b/src/app/lib/dealPropertyUpdate.ts index 0ec2aa4d..f674bda7 100644 --- a/src/app/lib/dealPropertyUpdate.ts +++ b/src/app/lib/dealPropertyUpdate.ts @@ -25,8 +25,19 @@ import { getHubSpotClient } from "@/app/lib/hubspot/client"; // Field registry // ----------------------------------------------------------------------- -/** Roles ordered from most permissive to least. */ -export type DealPropertyRole = "read" | "write" | "admin" | "creator"; +/** + * Access tokens that gate a field. These are a flat union of the portfolio + * role hierarchy ("read" | "write" | "admin" | "creator") plus any + * orthogonal capability tokens (currently just "approver"). The service + * checks the caller against this set, so a field can require either a + * write-or-above role *or* a specific capability. + */ +export type DealPropertyRole = + | "read" + | "write" + | "admin" + | "creator" + | "approver"; /** Roles that satisfy a "write or above" requirement. */ export const WRITE_OR_ABOVE_ROLES: ReadonlyArray = [ @@ -35,6 +46,13 @@ export const WRITE_OR_ABOVE_ROLES: ReadonlyArray = [ "creator", ]; +/** + * Roles allowed to edit fields gated on "approver capability". An approver + * may not have a write role on the portfolio, but the capability is granted + * orthogonally — see `portfolio_capabilities` table. + */ +export const APPROVER_ROLES: ReadonlyArray = ["approver"]; + const isoDateSchema = z .union([z.string(), z.null()]) .transform((v, ctx) => { @@ -47,6 +65,15 @@ const isoDateSchema = z return d; }); +/** + * String-or-null schema — empty strings collapse to null so the UI can + * "clear" a free-text field by sending an empty value, mirroring how the + * date schema treats "" as null. + */ +const stringOrNullSchema = z + .union([z.string(), z.null()]) + .transform((v) => (v === null || v === "" ? null : v)); + type DateColumn = typeof hubspotDealData.pibiOrderDate; type TextColumn = typeof hubspotDealData.propertyHaltedReason; type BoolColumn = typeof hubspotDealData.domnaSurveyRequired; @@ -107,13 +134,26 @@ export const DEAL_PROPERTY_FIELDS = { dbColumn: hubspotDealData.pibiCompletedDate, toHubspot: dateToHubspot, } satisfies DealPropertyFieldDef, - // -- Slot for issue #255 (halted state) ---------------------------------- - // property_halted_date / property_halted_reason will plug in here. + // -- Halted state (issue #255) ------------------------------------------- + // Approver capability gates these — write role alone is not sufficient. + property_halted_date: { + schema: isoDateSchema, + allowedRoles: APPROVER_ROLES, + hubspotProperty: "property_halted_date", + dbColumn: hubspotDealData.propertyHaltedDate, + toHubspot: dateToHubspot, + } satisfies DealPropertyFieldDef, + property_halted_reason: { + schema: stringOrNullSchema, + allowedRoles: APPROVER_ROLES, + hubspotProperty: "property_halted_reason", + dbColumn: hubspotDealData.propertyHaltedReason, + toHubspot: stringToHubspot, + } satisfies DealPropertyFieldDef, // -- Slot for issue #256 (Domna survey type / date) ---------------------- // domna_survey_type / domna_survey_date will plug in here. } as const; -void stringToHubspot; void booleanToHubspot; export type DealPropertyFieldKey = keyof typeof DEAL_PROPERTY_FIELDS; @@ -124,12 +164,24 @@ export function isDealPropertyField( return Object.prototype.hasOwnProperty.call(DEAL_PROPERTY_FIELDS, key); } +/** + * Check whether the caller is allowed to write `field`, given their + * portfolio role and any orthogonal capabilities (e.g. "approver"). The + * field passes if any one of the caller's tokens is on the field's + * allow-list. + */ export function roleAllowedForField( field: DealPropertyFieldKey, role: string | null | undefined, + capabilities: ReadonlyArray = [], ): boolean { - if (!role) return false; - return (DEAL_PROPERTY_FIELDS[field].allowedRoles as ReadonlyArray).includes(role); + const allowed = DEAL_PROPERTY_FIELDS[field] + .allowedRoles as ReadonlyArray; + if (role && allowed.includes(role)) return true; + for (const cap of capabilities) { + if (allowed.includes(cap)) return true; + } + return false; } // ----------------------------------------------------------------------- @@ -194,6 +246,13 @@ export interface UpdateDealPropertiesInput { fields: Record; /** Role of the authenticated user making the request. */ role: string; + /** + * Orthogonal capability tokens (e.g. `"approver"`). Used by fields whose + * `allowedRoles` list a capability rather than a role tier. Optional so + * existing call sites that only need role-based gating do not have to + * supply it. + */ + capabilities?: ReadonlyArray; /** * Hooks injected by the route so the service can stay environment-free * for unit testing. Defaults are wired in `applyDealPropertyUpdate`. @@ -235,7 +294,7 @@ export async function applyDealPropertyUpdate( results[key] = { ok: false, error: "Field not editable" }; continue; } - if (!roleAllowedForField(key, input.role)) { + if (!roleAllowedForField(key, input.role, input.capabilities)) { results[key] = { ok: false, error: "Insufficient permissions" }; continue; }