mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-06-08 11:37:25 +00:00
add pibi-measures route
Adds POST /api/portfolio/[portfolioId]/pibi-measures (approver-gated)
that accepts { dealId, measureNames[] } and delegates to
selectPibiMeasures. Also adds a GET handler that returns the current
pibi_ordered selection, approved measures, and instructed measures for
a deal — used by the drawer's PIBI selector to pre-populate.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
091388692e
commit
1e5eb09c3e
1 changed files with 231 additions and 0 deletions
231
src/app/api/portfolio/[portfolioId]/pibi-measures/route.ts
Normal file
231
src/app/api/portfolio/[portfolioId]/pibi-measures/route.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
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 {
|
||||
portfolioCapabilities,
|
||||
portfolioUsers,
|
||||
} from "@/app/db/schema/portfolio";
|
||||
import { user } from "@/app/db/schema/users";
|
||||
import { userDefinedDealMeasures } from "@/app/db/schema/user_defined_deal_measures";
|
||||
import { dealMeasureApprovals } from "@/app/db/schema/approvals";
|
||||
import { selectPibiMeasures } from "@/app/lib/selectPibiMeasures";
|
||||
|
||||
const postSchema = z.object({
|
||||
dealId: z.string().min(1, "dealId is required"),
|
||||
measureNames: z.array(z.string()).min(0),
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/portfolio/[portfolioId]/pibi-measures?dealId=...
|
||||
*
|
||||
* Returns the current PIBI selection and approved measure names for a deal.
|
||||
* Used by the drawer's PIBI selector to pre-populate the multi-select.
|
||||
*
|
||||
* Response:
|
||||
* 200 { pibiMeasures: string[], approvedMeasures: string[], instructedMeasures: string[] }
|
||||
*/
|
||||
export async function GET(
|
||||
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 });
|
||||
}
|
||||
|
||||
const dealId = req.nextUrl.searchParams.get("dealId");
|
||||
if (!dealId) {
|
||||
return NextResponse.json(
|
||||
{ error: "dealId query param is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (!portfolioUserRow[0]?.role) {
|
||||
return NextResponse.json(
|
||||
{ error: "No portfolio access" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const [pibiRows, approvalRows, instructedRows] = await Promise.all([
|
||||
db
|
||||
.select({ measureName: userDefinedDealMeasures.measureName })
|
||||
.from(userDefinedDealMeasures)
|
||||
.where(
|
||||
and(
|
||||
eq(userDefinedDealMeasures.hubspotDealId, dealId),
|
||||
eq(userDefinedDealMeasures.source, "pibi_ordered"),
|
||||
),
|
||||
),
|
||||
db
|
||||
.select({ measureName: dealMeasureApprovals.measureName })
|
||||
.from(dealMeasureApprovals)
|
||||
.where(
|
||||
and(
|
||||
eq(dealMeasureApprovals.hubspotDealId, dealId),
|
||||
eq(dealMeasureApprovals.isApproved, true),
|
||||
),
|
||||
),
|
||||
db
|
||||
.select({ measureName: userDefinedDealMeasures.measureName })
|
||||
.from(userDefinedDealMeasures)
|
||||
.where(
|
||||
and(
|
||||
eq(userDefinedDealMeasures.hubspotDealId, dealId),
|
||||
eq(userDefinedDealMeasures.source, "instructed"),
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
pibiMeasures: pibiRows.map((r) => r.measureName),
|
||||
approvedMeasures: approvalRows.map((r) => r.measureName),
|
||||
instructedMeasures: instructedRows.map((r) => r.measureName),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/portfolio/[portfolioId]/pibi-measures
|
||||
*
|
||||
* Approver-only endpoint that records which measures on a deal are going for
|
||||
* PIBI. The incoming `measureNames[]` is the FULL desired set — it replaces
|
||||
* any prior selection. Persists to `user_defined_deal_measures` with
|
||||
* `source = "pibi_ordered"` and pushes back to HubSpot under
|
||||
* `measures_for_pibi_ordered`. See `selectPibiMeasures` for the full
|
||||
* contract.
|
||||
*
|
||||
* Body:
|
||||
* { dealId: string, measureNames: string[] }
|
||||
*
|
||||
* Response:
|
||||
* 200 { ok: true, hubspotSync: "ok" | "failed", hubspotError? }
|
||||
* 400 { ok: false, error }
|
||||
* 401 / 403 / 404 on auth/role/user errors.
|
||||
*/
|
||||
export async function POST(
|
||||
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 = postSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.flatten() },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const { dealId, measureNames } = parsed.data;
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
// Caller must be a portfolio member AND have the approver capability.
|
||||
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);
|
||||
|
||||
if (!portfolioUserRow[0]?.role) {
|
||||
return NextResponse.json(
|
||||
{ error: "No portfolio access" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const capabilityRows = await db
|
||||
.select({ capability: portfolioCapabilities.capability })
|
||||
.from(portfolioCapabilities)
|
||||
.where(
|
||||
and(
|
||||
eq(portfolioCapabilities.portfolioId, BigInt(portfolioId)),
|
||||
eq(portfolioCapabilities.userId, userRow[0].id),
|
||||
),
|
||||
);
|
||||
const capabilities = capabilityRows.map((r) => r.capability);
|
||||
if (!capabilities.includes("approver")) {
|
||||
return NextResponse.json(
|
||||
{ error: "Approver capability required" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await selectPibiMeasures({
|
||||
dealId,
|
||||
measureNames,
|
||||
userId: userRow[0].id,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
return NextResponse.json({ ok: false, error: result.error }, {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
hubspotSync: result.hubspotSync,
|
||||
hubspotError: result.hubspotError,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[pibi-measures POST]", err);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue