mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
add halted state fields and approver capability gate
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
3d08a423a6
commit
e020b3fd83
3 changed files with 233 additions and 9 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue