diff --git a/src/app/api/portfolio/[portfolioId]/pibi-measures/route.ts b/src/app/api/portfolio/[portfolioId]/pibi-measures/route.ts new file mode 100644 index 0000000..afe84c0 --- /dev/null +++ b/src/app/api/portfolio/[portfolioId]/pibi-measures/route.ts @@ -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 }, + ); + } +}