mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-30 12:55:02 +00:00
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:
parent
f3ce506653
commit
091388692e
1 changed files with 187 additions and 0 deletions
187
src/app/lib/selectPibiMeasures.ts
Normal file
187
src/app/lib/selectPibiMeasures.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue