diff --git a/src/app/lib/dealPropertyUpdate.test.ts b/src/app/lib/dealPropertyUpdate.test.ts index 0eb72bc3..2f092b58 100644 --- a/src/app/lib/dealPropertyUpdate.test.ts +++ b/src/app/lib/dealPropertyUpdate.test.ts @@ -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 diff --git a/src/app/lib/dealPropertyUpdate.ts b/src/app/lib/dealPropertyUpdate.ts index f674bda7..5aaf1226 100644 --- a/src/app/lib/dealPropertyUpdate.ts +++ b/src/app/lib/dealPropertyUpdate.ts @@ -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 extends Date | null ? DateColumn : T extends string | null ? TextColumn - : T extends boolean | null - ? BoolColumn - : never; + : never; interface DealPropertyFieldDef { /** 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, - // -- 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, + domna_survey_date: { + schema: isoDateSchema, + allowedRoles: APPROVER_ROLES, + hubspotProperty: "domna_survey_date", + dbColumn: hubspotDealData.domnaSurveyDate, + toHubspot: dateToHubspot, + } satisfies DealPropertyFieldDef, } as const; -void booleanToHubspot; - export type DealPropertyFieldKey = keyof typeof DEAL_PROPERTY_FIELDS; export function isDealPropertyField(