add pibi-selection service

Implements selectPibiMeasures (issue #254): replaces all pibi_ordered
rows for a deal, then pushes the new list to HubSpot under
measures_for_pibi_ordered via syncMeasuresFieldToHubSpot. Mirrors the
instruct-measure service; no approval rows touched.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-05-05 19:14:12 +00:00
parent f3ce506653
commit 091388692e

View file

@ -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<void>;
// ---------------------------------------------------------------------------
// 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<SelectPibiMeasuresResult> {
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,
};
}