diff --git a/src/app/api/portfolio/[portfolioId]/deal-properties/route.ts b/src/app/api/portfolio/[portfolioId]/deal-properties/route.ts new file mode 100644 index 0000000..7cfc348 --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/deal-properties/route.ts @@ -0,0 +1,115 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { and, eq } from "drizzle-orm"; +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 { user } from "@/app/db/schema/users"; +import { applyDealPropertyUpdate } from "@/app/lib/dealPropertyUpdate"; + +const patchSchema = z.object({ + dealId: z.string().min(1, "dealId is required"), + fields: z.record(z.unknown()), +}); + +/** + * PATCH /api/portfolio/[portfolioId]/deal-properties + * + * Single update path for whitelisted, role-gated fields on + * `hubspot_deal_data`. The route is responsible for AuthN + portfolio role + * lookup; per-field validation, permission check, DB write and HubSpot + * push are delegated to `applyDealPropertyUpdate`. + * + * Body shape: + * { + * "dealId": "12345", + * "fields": { + * "pibi_order_date": "2025-03-12T00:00:00.000Z" | null, + * "pibi_completed_date": "2025-04-02T00:00:00.000Z" | null, + * ... + * } + * } + * + * Response: + * 200 { results: { [field]: { ok: true } | { ok: false, error } }, + * hubspotSync: "ok" | "failed" | "skipped", + * hubspotError?: string } + */ +export async function PATCH( + req: NextRequest, + props: { params: Promise<{ portfolioId: string }> }, +) { + const { portfolioId } = await props.params; + + const session = await getServerSession(AuthOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorised" }, { status: 401 }); + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const parsed = patchSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.flatten() }, + { status: 400 }, + ); + } + + const { dealId, fields } = parsed.data; + + // Look up the calling user's role on this portfolio. The service + // enforces per-field permissions but we still need a role string to + // pass through. + const userRow = await db + .select({ id: user.id }) + .from(user) + .where(eq(user.email, session.user.email)) + .limit(1); + + if (!userRow[0]) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + const portfolioUserRow = await db + .select({ role: portfolioUsers.role }) + .from(portfolioUsers) + .where( + and( + eq(portfolioUsers.portfolioId, BigInt(portfolioId)), + eq(portfolioUsers.userId, userRow[0].id), + ), + ) + .limit(1); + + const role = portfolioUserRow[0]?.role; + if (!role) { + return NextResponse.json( + { error: "No portfolio access" }, + { status: 403 }, + ); + } + + try { + const outcome = await applyDealPropertyUpdate({ + dealId, + fields, + role, + }); + + return NextResponse.json(outcome); + } catch (err) { + console.error("[deal-properties PATCH]", err); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/src/app/lib/dealPropertyUpdate.test.ts b/src/app/lib/dealPropertyUpdate.test.ts new file mode 100644 index 0000000..b36d807 --- /dev/null +++ b/src/app/lib/dealPropertyUpdate.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it, vi } from "vitest"; +import { + applyDealPropertyUpdate, + DEAL_PROPERTY_FIELDS, + isDealPropertyField, + roleAllowedForField, +} from "./dealPropertyUpdate"; + +describe("DEAL_PROPERTY_FIELDS registry", () => { + it("exposes the two PIBI date fields with write-or-above permissions", () => { + expect(isDealPropertyField("pibi_order_date")).toBe(true); + expect(isDealPropertyField("pibi_completed_date")).toBe(true); + expect(roleAllowedForField("pibi_order_date", "read")).toBe(false); + expect(roleAllowedForField("pibi_order_date", "write")).toBe(true); + expect(roleAllowedForField("pibi_order_date", "admin")).toBe(true); + expect(roleAllowedForField("pibi_order_date", "creator")).toBe(true); + expect(roleAllowedForField("pibi_completed_date", "read")).toBe(false); + expect(roleAllowedForField("pibi_completed_date", "write")).toBe(true); + }); + + it("maps each registered field to the matching HubSpot property", () => { + expect(DEAL_PROPERTY_FIELDS.pibi_order_date.hubspotProperty).toBe( + "pibi_order_date", + ); + expect(DEAL_PROPERTY_FIELDS.pibi_completed_date.hubspotProperty).toBe( + "pibi_completed_date", + ); + }); + + it("rejects unknown fields", () => { + expect(isDealPropertyField("not_a_field")).toBe(false); + }); +}); + +describe("applyDealPropertyUpdate", () => { + it("rejects non-whitelisted fields without writing or syncing", async () => { + const updateDb = vi.fn(); + const pushHubspot = vi.fn(); + const out = await applyDealPropertyUpdate({ + dealId: "deal-1", + fields: { unknown_field: "x" }, + role: "write", + deps: { updateDb, pushHubspot }, + }); + expect(out.results.unknown_field).toEqual({ + ok: false, + error: "Field not editable", + }); + expect(out.hubspotSync).toBe("skipped"); + expect(updateDb).not.toHaveBeenCalled(); + expect(pushHubspot).not.toHaveBeenCalled(); + }); + + it("rejects PIBI fields when the user role is read", async () => { + const updateDb = vi.fn(); + const pushHubspot = vi.fn(); + const out = await applyDealPropertyUpdate({ + dealId: "deal-1", + fields: { pibi_order_date: "2025-01-15" }, + role: "read", + deps: { updateDb, pushHubspot }, + }); + expect(out.results.pibi_order_date).toEqual({ + ok: false, + error: "Insufficient permissions", + }); + expect(out.hubspotSync).toBe("skipped"); + expect(updateDb).not.toHaveBeenCalled(); + expect(pushHubspot).not.toHaveBeenCalled(); + }); + + it("rejects values that fail validation", async () => { + const updateDb = vi.fn(); + const pushHubspot = vi.fn(); + const out = await applyDealPropertyUpdate({ + dealId: "deal-1", + fields: { pibi_order_date: "not-a-real-date" }, + role: "write", + deps: { updateDb, pushHubspot }, + }); + expect(out.results.pibi_order_date.ok).toBe(false); + expect(out.hubspotSync).toBe("skipped"); + expect(updateDb).not.toHaveBeenCalled(); + expect(pushHubspot).not.toHaveBeenCalled(); + }); + + it("persists valid fields to the DB and pushes them to HubSpot", async () => { + const updateDb = vi.fn().mockResolvedValue(undefined); + const pushHubspot = vi.fn().mockResolvedValue({ ok: true }); + const orderIso = "2025-03-12T00:00:00.000Z"; + const completedIso = "2025-04-02T00:00:00.000Z"; + + const out = await applyDealPropertyUpdate({ + dealId: "deal-42", + fields: { + pibi_order_date: orderIso, + pibi_completed_date: completedIso, + }, + role: "write", + deps: { updateDb, pushHubspot }, + }); + + expect(out.results.pibi_order_date).toEqual({ ok: true }); + expect(out.results.pibi_completed_date).toEqual({ ok: true }); + expect(out.hubspotSync).toBe("ok"); + + expect(updateDb).toHaveBeenCalledTimes(1); + const [dealIdArg, valuesArg] = updateDb.mock.calls[0]; + expect(dealIdArg).toBe("deal-42"); + expect(valuesArg.pibiOrderDate).toBeInstanceOf(Date); + expect((valuesArg.pibiOrderDate as Date).toISOString()).toBe(orderIso); + expect((valuesArg.pibiCompletedDate as Date).toISOString()).toBe( + completedIso, + ); + + expect(pushHubspot).toHaveBeenCalledTimes(1); + const pushArg = pushHubspot.mock.calls[0][0]; + expect(pushArg.hubspotDealId).toBe("deal-42"); + // HubSpot expects epoch milliseconds as strings for date properties. + expect(pushArg.properties.pibi_order_date).toBe( + String(new Date(orderIso).getTime()), + ); + expect(pushArg.properties.pibi_completed_date).toBe( + String(new Date(completedIso).getTime()), + ); + }); + + it("clears a date when null is supplied (sends empty string to HubSpot)", async () => { + const updateDb = vi.fn().mockResolvedValue(undefined); + const pushHubspot = vi.fn().mockResolvedValue({ ok: true }); + const out = await applyDealPropertyUpdate({ + dealId: "deal-7", + fields: { pibi_order_date: null }, + role: "admin", + deps: { updateDb, pushHubspot }, + }); + expect(out.results.pibi_order_date).toEqual({ ok: true }); + expect(out.hubspotSync).toBe("ok"); + expect(updateDb.mock.calls[0][1].pibiOrderDate).toBeNull(); + expect(pushHubspot.mock.calls[0][0].properties.pibi_order_date).toBe(""); + }); + + it("surfaces HubSpot push failures back to the caller", async () => { + const updateDb = vi.fn().mockResolvedValue(undefined); + const pushHubspot = vi + .fn() + .mockResolvedValue({ ok: false, error: "boom" }); + const out = await applyDealPropertyUpdate({ + dealId: "deal-9", + fields: { pibi_order_date: "2025-05-01T00:00:00.000Z" }, + role: "write", + deps: { updateDb, pushHubspot }, + }); + expect(out.results.pibi_order_date).toEqual({ ok: true }); + expect(out.hubspotSync).toBe("failed"); + expect(out.hubspotError).toBe("boom"); + // DB update still happened — UI gets the optimistic value, error is + // surfaced separately. + expect(updateDb).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/lib/dealPropertyUpdate.ts b/src/app/lib/dealPropertyUpdate.ts new file mode 100644 index 0000000..0ec2aa4 --- /dev/null +++ b/src/app/lib/dealPropertyUpdate.ts @@ -0,0 +1,296 @@ +/** + * Deal-property update service (issue #252) + * + * Centralised, whitelist-driven helper for the + * `PATCH /api/portfolio/[portfolioId]/deal-properties` endpoint. Each + * editable field on `hubspot_deal_data` is registered here once with: + * + * - the role required to write it + * - a Zod parser that validates + coerces the inbound value + * - the matching HubSpot deal property name (used by the sync push) + * - the Drizzle column to update + * + * Slice 5 (this issue) ships the two PIBI date fields. Slices 6 and 7 + * (issues #255 / #256) plug the halted state and Domna survey fields into + * the same registry without changing the route, the service entry point or + * the per-field permission logic. + */ +import { z, ZodTypeAny } from "zod"; +import { eq } from "drizzle-orm"; +import { db } from "@/app/db/db"; +import { hubspotDealData } from "@/app/db/schema/crm/hubspot_deal_table"; +import { getHubSpotClient } from "@/app/lib/hubspot/client"; + +// ----------------------------------------------------------------------- +// Field registry +// ----------------------------------------------------------------------- + +/** Roles ordered from most permissive to least. */ +export type DealPropertyRole = "read" | "write" | "admin" | "creator"; + +/** Roles that satisfy a "write or above" requirement. */ +export const WRITE_OR_ABOVE_ROLES: ReadonlyArray = [ + "write", + "admin", + "creator", +]; + +const isoDateSchema = z + .union([z.string(), z.null()]) + .transform((v, ctx) => { + if (v === null || v === "") return null; + const d = new Date(v); + if (Number.isNaN(d.getTime())) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Invalid date" }); + return z.NEVER; + } + return d; + }); + +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; + +interface DealPropertyFieldDef { + /** Schema used to parse the incoming JSON value. */ + schema: ZodTypeAny; + /** Allowed roles. Empty array means "no role can write" (i.e. read-only). */ + allowedRoles: ReadonlyArray; + /** Property name on the HubSpot deal object. */ + hubspotProperty: string; + /** + * Drizzle column to update. Typed loosely because we mix date/text/bool + * columns in the same registry; the per-field schema enforces shape. + */ + dbColumn: unknown; + /** + * Optional renderer that turns the parsed value into the string HubSpot + * expects (HubSpot stores dates as epoch-millisecond strings, booleans as + * "true" / "false", text as-is). Defaults to the parsed value coerced via + * String(...). + */ + toHubspot?: (value: TParsed) => string; +} + +// -- HubSpot value formatters ------------------------------------------------ +const dateToHubspot = (d: Date | null): string => + d === null ? "" : String(d.getTime()); + +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. +export const DEAL_PROPERTY_FIELDS = { + pibi_order_date: { + schema: isoDateSchema, + allowedRoles: WRITE_OR_ABOVE_ROLES, + hubspotProperty: "pibi_order_date", + dbColumn: hubspotDealData.pibiOrderDate, + toHubspot: dateToHubspot, + } satisfies DealPropertyFieldDef, + pibi_completed_date: { + schema: isoDateSchema, + allowedRoles: WRITE_OR_ABOVE_ROLES, + hubspotProperty: "pibi_completed_date", + dbColumn: hubspotDealData.pibiCompletedDate, + toHubspot: dateToHubspot, + } satisfies DealPropertyFieldDef, + // -- Slot for issue #255 (halted state) ---------------------------------- + // property_halted_date / property_halted_reason will plug in here. + // -- 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; + +export function isDealPropertyField( + key: string, +): key is DealPropertyFieldKey { + return Object.prototype.hasOwnProperty.call(DEAL_PROPERTY_FIELDS, key); +} + +export function roleAllowedForField( + field: DealPropertyFieldKey, + role: string | null | undefined, +): boolean { + if (!role) return false; + return (DEAL_PROPERTY_FIELDS[field].allowedRoles as ReadonlyArray).includes(role); +} + +// ----------------------------------------------------------------------- +// Update orchestration +// ----------------------------------------------------------------------- + +export type DealPropertyResult = + | { ok: true } + | { ok: false; error: string }; + +export type DealPropertyUpdateOutcome = { + /** Per-field results keyed by the same field name supplied by the caller. */ + results: Record; + /** Overall HubSpot push outcome. `null` if no push attempted. */ + hubspotSync: "ok" | "failed" | "skipped"; + hubspotError?: string; +}; + +/** + * Push the validated, whitelisted property bag to HubSpot, retrying on + * `ECONNRESET`. Mirrors the retry pattern already used by + * `syncContractorDocUploadToHubSpot` / `syncMeasureApprovalsToHubSpot`. + */ +export async function pushDealPropertiesToHubSpot(params: { + hubspotDealId: string; + properties: Record; +}): Promise<{ ok: true } | { ok: false; error: string }> { + const maxAttempts = 3; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const client = getHubSpotClient(); + await client.crm.deals.basicApi.update(params.hubspotDealId, { + properties: params.properties, + }); + return { ok: true }; + } catch (err) { + const isReset = + err instanceof Error && + "code" in err && + (err as NodeJS.ErrnoException).code === "ECONNRESET"; + if (isReset && attempt < maxAttempts) { + await new Promise((resolve) => setTimeout(resolve, 200 * attempt)); + continue; + } + const message = + err instanceof Error ? err.message : "HubSpot sync failed"; + console.error("[HubSpot] pushDealPropertiesToHubSpot failed", { + dealId: params.hubspotDealId, + attempt, + error: err, + }); + return { ok: false, error: message }; + } + } + return { ok: false, error: "HubSpot sync failed" }; +} + +export interface UpdateDealPropertiesInput { + /** HubSpot deal id (the value stored on `hubspot_deal_data.deal_id`). */ + dealId: string; + /** Caller-supplied { fieldName: rawValue } map. */ + fields: Record; + /** Role of the authenticated user making the request. */ + role: string; + /** + * Hooks injected by the route so the service can stay environment-free + * for unit testing. Defaults are wired in `applyDealPropertyUpdate`. + */ + deps?: { + pushHubspot?: typeof pushDealPropertiesToHubSpot; + updateDb?: ( + dealId: string, + values: Record, + ) => Promise; + }; +} + +async function defaultUpdateDb( + dealId: string, + values: Record, +): Promise { + if (Object.keys(values).length === 0) return; + await db + .update(hubspotDealData) + .set(values) + .where(eq(hubspotDealData.dealId, dealId)); +} + +/** + * Validate caller-supplied fields, persist the accepted ones to the DB and + * push the same set to HubSpot. Returns per-field success / error so the + * route can surface partial failures back to the UI. + */ +export async function applyDealPropertyUpdate( + input: UpdateDealPropertiesInput, +): Promise { + const results: Record = {}; + const dbValues: Record = {}; + const hubspotProperties: Record = {}; + + for (const [key, rawValue] of Object.entries(input.fields)) { + if (!isDealPropertyField(key)) { + results[key] = { ok: false, error: "Field not editable" }; + continue; + } + if (!roleAllowedForField(key, input.role)) { + results[key] = { ok: false, error: "Insufficient permissions" }; + continue; + } + const def = DEAL_PROPERTY_FIELDS[key]; + const parsed = def.schema.safeParse(rawValue); + if (!parsed.success) { + results[key] = { + ok: false, + error: parsed.error.issues[0]?.message ?? "Invalid value", + }; + continue; + } + results[key] = { ok: true }; + // The Drizzle column type for date columns accepts Date | null. + const columnName = (def.dbColumn as { name?: string }).name; + if (columnName) { + // Drizzle .set() accepts the JS column key (camelCase). Look it up by + // walking the table object so we use the field name on the schema. + const tableKey = findColumnKey(def.dbColumn); + if (tableKey) dbValues[tableKey] = parsed.data; + } + const renderer = def.toHubspot as + | ((value: unknown) => string) + | undefined; + hubspotProperties[def.hubspotProperty] = renderer + ? renderer(parsed.data) + : String(parsed.data ?? ""); + } + + const updateDb = input.deps?.updateDb ?? defaultUpdateDb; + const pushHubspot = input.deps?.pushHubspot ?? pushDealPropertiesToHubSpot; + + // No accepted fields → return early, nothing to sync. + if (Object.keys(dbValues).length === 0) { + return { results, hubspotSync: "skipped" }; + } + + await updateDb(input.dealId, dbValues); + + const sync = await pushHubspot({ + hubspotDealId: input.dealId, + properties: hubspotProperties, + }); + + if (sync.ok) return { results, hubspotSync: "ok" }; + return { results, hubspotSync: "failed", hubspotError: sync.error }; +} + +/** + * Look up the JS-side property name on the Drizzle table for a given column + * object so we can index `db.update().set({ [key]: value })`. + */ +function findColumnKey(column: unknown): string | null { + for (const [key, value] of Object.entries(hubspotDealData)) { + if (value === column) return key; + } + return null; +}