mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
add deal-properties PATCH endpoint and update service
This commit is contained in:
parent
f6aaa89c5b
commit
b8befe56fe
3 changed files with 572 additions and 0 deletions
115
src/app/api/portfolio/[portfolioId]/deal-properties/route.ts
Normal file
115
src/app/api/portfolio/[portfolioId]/deal-properties/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
161
src/app/lib/dealPropertyUpdate.test.ts
Normal file
161
src/app/lib/dealPropertyUpdate.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
296
src/app/lib/dealPropertyUpdate.ts
Normal file
296
src/app/lib/dealPropertyUpdate.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue