add halted state fields and approver capability gate

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-05 18:24:43 +00:00
parent 3d08a423a6
commit e020b3fd83
3 changed files with 233 additions and 9 deletions

View file

@ -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);

View file

@ -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

View file

@ -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<DealPropertyRole> = [
@ -35,6 +46,13 @@ export const WRITE_OR_ABOVE_ROLES: ReadonlyArray<DealPropertyRole> = [
"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<DealPropertyRole> = ["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<Date | null>,
// -- 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<Date | null>,
property_halted_reason: {
schema: stringOrNullSchema,
allowedRoles: APPROVER_ROLES,
hubspotProperty: "property_halted_reason",
dbColumn: hubspotDealData.propertyHaltedReason,
toHubspot: stringToHubspot,
} satisfies DealPropertyFieldDef<string | null>,
// -- 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<string> = [],
): boolean {
if (!role) return false;
return (DEAL_PROPERTY_FIELDS[field].allowedRoles as ReadonlyArray<string>).includes(role);
const allowed = DEAL_PROPERTY_FIELDS[field]
.allowedRoles as ReadonlyArray<string>;
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<string, unknown>;
/** 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<string>;
/**
* 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;
}