add domna survey fields to deal-properties registry

Plug `domna_survey_type` and `domna_survey_date` into
DEAL_PROPERTY_FIELDS, gated on the approver capability and reusing the
shared string-or-null + ISO date schemas. Drops the now-unused boolean
column types from the field registry. Vitest cases cover validation,
capability gating, HubSpot mapping, independent setting and clear-to-null.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-05 18:37:57 +00:00
parent 73a365468a
commit 1336d90c8d
2 changed files with 186 additions and 11 deletions

View file

@ -40,6 +40,27 @@ describe("DEAL_PROPERTY_FIELDS registry", () => {
).toBe(false);
});
it("exposes the domna survey fields gated on the approver capability", () => {
expect(isDealPropertyField("domna_survey_type")).toBe(true);
expect(isDealPropertyField("domna_survey_date")).toBe(true);
// Plain roles never satisfy the approver gate on their own.
for (const role of ["read", "write", "admin", "creator"]) {
expect(roleAllowedForField("domna_survey_type", role)).toBe(false);
expect(roleAllowedForField("domna_survey_date", role)).toBe(false);
}
// Approver capability unlocks both.
expect(
roleAllowedForField("domna_survey_type", "read", ["approver"]),
).toBe(true);
expect(
roleAllowedForField("domna_survey_date", "read", ["approver"]),
).toBe(true);
// Other capabilities do not unlock the fields.
expect(
roleAllowedForField("domna_survey_type", "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",
@ -53,6 +74,12 @@ describe("DEAL_PROPERTY_FIELDS registry", () => {
expect(DEAL_PROPERTY_FIELDS.property_halted_reason.hubspotProperty).toBe(
"property_halted_reason",
);
expect(DEAL_PROPERTY_FIELDS.domna_survey_type.hubspotProperty).toBe(
"domna_survey_type",
);
expect(DEAL_PROPERTY_FIELDS.domna_survey_date.hubspotProperty).toBe(
"domna_survey_date",
);
});
it("rejects unknown fields", () => {
@ -288,6 +315,148 @@ describe("applyDealPropertyUpdate", () => {
expect("property_halted_reason" in props).toBe(false);
});
it("rejects domna fields when the caller lacks approver capability", async () => {
const updateDb = vi.fn();
const pushHubspot = vi.fn();
const out = await applyDealPropertyUpdate({
dealId: "deal-d1",
fields: {
domna_survey_type: "Standard",
domna_survey_date: "2025-07-15T00:00:00.000Z",
},
// Even creator role alone is not enough — capability is orthogonal.
role: "creator",
capabilities: [],
deps: { updateDb, pushHubspot },
});
expect(out.results.domna_survey_type).toEqual({
ok: false,
error: "Insufficient permissions",
});
expect(out.results.domna_survey_date).toEqual({
ok: false,
error: "Insufficient permissions",
});
expect(out.hubspotSync).toBe("skipped");
expect(updateDb).not.toHaveBeenCalled();
expect(pushHubspot).not.toHaveBeenCalled();
});
it("persists domna survey type + date for an approver and pushes them to HubSpot", async () => {
const updateDb = vi.fn().mockResolvedValue(undefined);
const pushHubspot = vi.fn().mockResolvedValue({ ok: true });
const surveyType = "Detailed";
const surveyIso = "2025-07-15T00:00:00.000Z";
const out = await applyDealPropertyUpdate({
dealId: "deal-d2",
fields: {
domna_survey_type: surveyType,
domna_survey_date: surveyIso,
},
role: "read",
capabilities: ["approver"],
deps: { updateDb, pushHubspot },
});
expect(out.results.domna_survey_type).toEqual({ ok: true });
expect(out.results.domna_survey_date).toEqual({ ok: true });
expect(out.hubspotSync).toBe("ok");
const dbValues = updateDb.mock.calls[0][1];
expect(dbValues.domnaSurveyType).toBe(surveyType);
expect(dbValues.domnaSurveyDate).toBeInstanceOf(Date);
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(
String(new Date(surveyIso).getTime()),
);
});
it("validates the domna survey date format", async () => {
const updateDb = vi.fn();
const pushHubspot = vi.fn();
const out = await applyDealPropertyUpdate({
dealId: "deal-d3",
fields: { domna_survey_date: "definitely-not-a-date" },
role: "read",
capabilities: ["approver"],
deps: { updateDb, pushHubspot },
});
expect(out.results.domna_survey_date.ok).toBe(false);
expect(updateDb).not.toHaveBeenCalled();
expect(pushHubspot).not.toHaveBeenCalled();
});
it("lets domna survey type and date be settable independently", async () => {
// Setting only the type — date column is untouched.
const updateDbType = vi.fn().mockResolvedValue(undefined);
const pushHubspotType = vi.fn().mockResolvedValue({ ok: true });
const outType = await applyDealPropertyUpdate({
dealId: "deal-d4",
fields: { domna_survey_type: "Standard" },
role: "read",
capabilities: ["approver"],
deps: { updateDb: updateDbType, pushHubspot: pushHubspotType },
});
expect(outType.results.domna_survey_type).toEqual({ ok: true });
expect(outType.results.domna_survey_date).toBeUndefined();
const typeOnlyDb = updateDbType.mock.calls[0][1];
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);
// Setting only the date — type column is untouched.
const updateDbDate = vi.fn().mockResolvedValue(undefined);
const pushHubspotDate = vi.fn().mockResolvedValue({ ok: true });
const surveyIso = "2025-08-20T00:00:00.000Z";
const outDate = await applyDealPropertyUpdate({
dealId: "deal-d4b",
fields: { domna_survey_date: surveyIso },
role: "read",
capabilities: ["approver"],
deps: { updateDb: updateDbDate, pushHubspot: pushHubspotDate },
});
expect(outDate.results.domna_survey_date).toEqual({ ok: true });
expect(outDate.results.domna_survey_type).toBeUndefined();
const dateOnlyDb = updateDbDate.mock.calls[0][1];
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(
String(new Date(surveyIso).getTime()),
);
expect("domna_survey_type" in dateOnlyProps).toBe(false);
});
it("clears both domna fields to null when explicitly cleared", async () => {
const updateDb = vi.fn().mockResolvedValue(undefined);
const pushHubspot = vi.fn().mockResolvedValue({ ok: true });
const out = await applyDealPropertyUpdate({
dealId: "deal-d5",
fields: {
// empty string collapses to null (mirrors the date schema)
domna_survey_type: "",
domna_survey_date: null,
},
role: "read",
capabilities: ["approver"],
deps: { updateDb, pushHubspot },
});
expect(out.results.domna_survey_type).toEqual({ ok: true });
expect(out.results.domna_survey_date).toEqual({ ok: true });
const dbValues = updateDb.mock.calls[0][1];
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("");
});
it("surfaces HubSpot push failures back to the caller", async () => {
const updateDb = vi.fn().mockResolvedValue(undefined);
const pushHubspot = vi

View file

@ -76,15 +76,12 @@ const stringOrNullSchema = z
type DateColumn = typeof hubspotDealData.pibiOrderDate;
type TextColumn = typeof hubspotDealData.propertyHaltedReason;
type BoolColumn = typeof hubspotDealData.domnaSurveyRequired;
type ColumnFor<T> = T extends Date | null
? DateColumn
: T extends string | null
? TextColumn
: T extends boolean | null
? BoolColumn
: never;
: never;
interface DealPropertyFieldDef<TParsed = unknown> {
/** Schema used to parse the incoming JSON value. */
@ -113,9 +110,6 @@ const dateToHubspot = (d: Date | null): string =>
const stringToHubspot = (s: string | null): string => (s === null ? "" : s);
const booleanToHubspot = (b: boolean | null): string =>
b === null ? "" : b ? "true" : "false";
// -- Registry ----------------------------------------------------------------
// Slice 5 ships PIBI dates only. Halted (#255) and Domna (#256) reuse the
// same registry by adding entries here — no other code path needs to change.
@ -150,12 +144,24 @@ export const DEAL_PROPERTY_FIELDS = {
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.
// -- Domna survey (issue #256) -------------------------------------------
// Approver capability gates these — write role alone is not sufficient.
domna_survey_type: {
schema: stringOrNullSchema,
allowedRoles: APPROVER_ROLES,
hubspotProperty: "domna_survey_type",
dbColumn: hubspotDealData.domnaSurveyType,
toHubspot: stringToHubspot,
} satisfies DealPropertyFieldDef<string | null>,
domna_survey_date: {
schema: isoDateSchema,
allowedRoles: APPROVER_ROLES,
hubspotProperty: "domna_survey_date",
dbColumn: hubspotDealData.domnaSurveyDate,
toHubspot: dateToHubspot,
} satisfies DealPropertyFieldDef<Date | null>,
} as const;
void booleanToHubspot;
export type DealPropertyFieldKey = keyof typeof DEAL_PROPERTY_FIELDS;
export function isDealPropertyField(