add deal-properties PATCH endpoint and update service

This commit is contained in:
Khalim Conn-Kowlessar 2026-05-05 14:33:59 +00:00
parent f6aaa89c5b
commit b8befe56fe
3 changed files with 572 additions and 0 deletions

View file

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

View file

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

View file

@ -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<DealPropertyRole> = [
"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> = T extends Date | null
? DateColumn
: T extends string | null
? TextColumn
: T extends boolean | null
? BoolColumn
: never;
interface DealPropertyFieldDef<TParsed = unknown> {
/** 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<DealPropertyRole>;
/** 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<Date | null>,
pibi_completed_date: {
schema: isoDateSchema,
allowedRoles: WRITE_OR_ABOVE_ROLES,
hubspotProperty: "pibi_completed_date",
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.
// -- 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<string>).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<string, DealPropertyResult>;
/** 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<string, string>;
}): 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<string, unknown>;
/** 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<string, unknown>,
) => Promise<void>;
};
}
async function defaultUpdateDb(
dealId: string,
values: Record<string, unknown>,
): Promise<void> {
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<DealPropertyUpdateOutcome> {
const results: Record<string, DealPropertyResult> = {};
const dbValues: Record<string, unknown> = {};
const hubspotProperties: Record<string, string> = {};
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;
}