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:
Khalim Conn-Kowlessar 2026-05-05 19:14:20 +00:00
parent 091388692e
commit 1e5eb09c3e

View 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 },
);
}
}