diff --git a/src/app/lib/selectPibiMeasures.ts b/src/app/lib/selectPibiMeasures.ts new file mode 100644 index 00000000..aa2b7341 --- /dev/null +++ b/src/app/lib/selectPibiMeasures.ts @@ -0,0 +1,187 @@ +/** + * PIBI-selection service (issue #254) + * + * Lets an approver mark which measures on a deal are going for PIBI. The + * selection is recorded locally as `user_defined_deal_measures` rows with + * `source = "pibi_ordered"` and pushed back to HubSpot under the + * `measures_for_pibi_ordered` deal property via `syncMeasuresFieldToHubSpot`. + * + * Semantics deliberately mirror instruct-measure (issue #253): + * - The incoming `measureNames[]` is the NEW desired set, not a delta. The + * service REPLACES all existing `pibi_ordered` rows for the deal, then + * re-inserts one row per selected measure. + * - No `deal_measure_approvals` rows are created or modified. + * - `pushed_at` is stamped on every new row when HubSpot sync succeeds; + * left null on failure so a reconcile job can retry. + * - HubSpot push failures do NOT roll back the DB. + * + * The service accepts injectable deps so it stays environment-free in tests. + */ +import { and, eq } from "drizzle-orm"; +import { db } from "@/app/db/db"; +import { userDefinedDealMeasures } from "@/app/db/schema/user_defined_deal_measures"; +import { syncMeasuresFieldToHubSpot as defaultSyncMeasuresField } from "@/app/lib/hubspot/dealSync"; + +export const PIBI_MEASURES_PROP = "measures_for_pibi_ordered"; + +// --------------------------------------------------------------------------- +// Injected dependency types — mirrors the pattern in instructMeasure.ts so +// the service is fully testable without touching the real DB or HubSpot. +// --------------------------------------------------------------------------- + +export type SyncMeasuresField = typeof defaultSyncMeasuresField; + +/** + * Replace the pibi_ordered rows for the deal inside a transaction. + * Returns the IDs of the newly inserted rows (one per selected measure). + */ +export type RunPibiTx = (params: { + dealId: string; + measureNames: string[]; + userId: bigint; +}) => Promise<{ insertedRowIds: bigint[] }>; + +export type StampPushedAt = (rowIds: bigint[]) => Promise; + +// --------------------------------------------------------------------------- +// Public result type +// --------------------------------------------------------------------------- + +export type SelectPibiMeasuresResult = + | { + ok: true; + insertedRowIds: bigint[]; + hubspotSync: "ok" | "failed"; + hubspotError?: string; + } + | { ok: false; error: string }; + +// --------------------------------------------------------------------------- +// Input +// --------------------------------------------------------------------------- + +export interface SelectPibiMeasuresInput { + dealId: string; + /** The FULL desired set of PIBI measures (replaces any prior selection). */ + measureNames: string[]; + userId: bigint; + deps?: { + runPibiTx?: RunPibiTx; + syncMeasuresField?: SyncMeasuresField; + stampPushedAt?: StampPushedAt; + }; +} + +// --------------------------------------------------------------------------- +// Default DB-backed implementations +// --------------------------------------------------------------------------- + +const defaultRunPibiTx: RunPibiTx = async ({ dealId, measureNames, userId }) => { + return await db.transaction(async (tx) => { + // Delete ALL existing pibi_ordered rows for this deal so the new + // selection fully replaces the previous one. + await tx + .delete(userDefinedDealMeasures) + .where( + and( + eq(userDefinedDealMeasures.hubspotDealId, dealId), + eq(userDefinedDealMeasures.source, "pibi_ordered"), + ), + ); + + if (measureNames.length === 0) { + return { insertedRowIds: [] }; + } + + const inserted = await tx + .insert(userDefinedDealMeasures) + .values( + measureNames.map((measureName) => ({ + hubspotDealId: dealId, + measureName, + source: "pibi_ordered" as const, + createdByUserId: userId, + })), + ) + .returning({ id: userDefinedDealMeasures.id }); + + return { insertedRowIds: inserted.map((r) => r.id) }; + }); +}; + +const defaultStampPushedAt: StampPushedAt = async (rowIds) => { + if (rowIds.length === 0) return; + for (const rowId of rowIds) { + await db + .update(userDefinedDealMeasures) + .set({ pushedAt: new Date() }) + .where(eq(userDefinedDealMeasures.id, rowId)); + } +}; + +// --------------------------------------------------------------------------- +// Service entry-point +// --------------------------------------------------------------------------- + +export async function selectPibiMeasures( + input: SelectPibiMeasuresInput, +): Promise { + const runPibiTx = input.deps?.runPibiTx ?? defaultRunPibiTx; + const syncMeasuresField = + input.deps?.syncMeasuresField ?? defaultSyncMeasuresField; + const stampPushedAt = input.deps?.stampPushedAt ?? defaultStampPushedAt; + + // ------------------------------------------------------------------------- + // DB transaction. Any throw here rolls everything back — no row is + // touched, no HubSpot push happens. + // ------------------------------------------------------------------------- + let txResult: { insertedRowIds: bigint[] }; + try { + txResult = await runPibiTx({ + dealId: input.dealId, + measureNames: input.measureNames, + userId: input.userId, + }); + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to save PIBI measures"; + console.error("[selectPibiMeasures] transaction failed", { + dealId: input.dealId, + error: err, + }); + return { ok: false, error: message }; + } + + // ------------------------------------------------------------------------- + // Post-commit: push the new selection list to HubSpot. Failures here do + // NOT roll back the DB; `pushed_at` simply stays null. + // ------------------------------------------------------------------------- + const syncResult = await syncMeasuresField({ + hubspotDealId: input.dealId, + propName: PIBI_MEASURES_PROP, + measureNames: input.measureNames, + }); + + if (syncResult.ok) { + try { + await stampPushedAt(txResult.insertedRowIds); + } catch (err) { + console.error("[selectPibiMeasures] failed to stamp pushed_at", { + rowIds: txResult.insertedRowIds.map(String), + error: err, + }); + } + return { + ok: true, + insertedRowIds: txResult.insertedRowIds, + hubspotSync: "ok", + }; + } + + return { + ok: true, + insertedRowIds: txResult.insertedRowIds, + hubspotSync: "failed", + hubspotError: syncResult.error, + }; +}