From 043f3c412bc5fa12bd9fa5ece6dbb0e39786a175 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Thu, 28 May 2026 07:49:38 -0300 Subject: [PATCH] feat(api): add /api/quota/plans CRUD routes (B/F8) --- .../api/quota/plans/[connectionId]/route.ts | 126 ++++++++++++++++++ src/app/api/quota/plans/route.ts | 66 +++++++++ 2 files changed, 192 insertions(+) create mode 100644 src/app/api/quota/plans/[connectionId]/route.ts create mode 100644 src/app/api/quota/plans/route.ts diff --git a/src/app/api/quota/plans/[connectionId]/route.ts b/src/app/api/quota/plans/[connectionId]/route.ts new file mode 100644 index 0000000000..517db505dc --- /dev/null +++ b/src/app/api/quota/plans/[connectionId]/route.ts @@ -0,0 +1,126 @@ +/** + * GET /api/quota/plans/[connectionId] — get resolved plan for a connection + * PUT /api/quota/plans/[connectionId] — upsert manual plan override + * DELETE /api/quota/plans/[connectionId] — clear manual override (revert to auto/catalog) + * + * Auth: requireManagementAuth + * Zod: PlanUpsertSchema from @/shared/schemas/quota (PUT only) + * Audit: quota.plan.updated on PUT (B26) + * Sanitization: all error responses via buildErrorBody (Hard Rule #12, B25) + * + * For PUT: provider is derived from the connectionId via getProviderConnectionById. + * If the connection lookup fails (e.g. provider not found), provider defaults to + * "unknown" — the override is still stored so operator can correct later. + * + * Part of: Group B — REST routes for Quota Sharing (plan 22, frente F8). + */ + +import { NextResponse } from "next/server"; +import { buildErrorBody } from "@omniroute/open-sse/utils/error"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { PlanUpsertSchema } from "@/shared/schemas/quota"; +import { + getProviderPlan, + upsertProviderPlan, + deleteProviderPlan, +} from "@/lib/localDb"; +import { resolvePlan } from "@/lib/quota/planResolver"; +import { logAuditEvent, getAuditRequestContext } from "@/lib/compliance/index"; + +export const dynamic = "force-dynamic"; + +type RouteParams = { params: Promise<{ connectionId: string }> }; + +/** + * Attempt to look up the provider name for a connection. + * Falls back to "unknown" if the DB lookup fails or returns nothing. + */ +async function resolveProvider(connectionId: string): Promise { + try { + // Lazy import — avoids circular deps and keeps module loadable without full DB + const { getProviderConnectionById } = await import("@/lib/localDb"); + if (typeof getProviderConnectionById === "function") { + const conn = getProviderConnectionById(connectionId); + if (conn && typeof (conn as { provider?: string }).provider === "string") { + return (conn as { provider: string }).provider; + } + } + } catch { + // DB not available or export not present — fall through + } + return "unknown"; +} + +export async function GET(request: Request, { params }: RouteParams): Promise { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + try { + const { connectionId } = await params; + + // Try DB override first, then fall back to resolved plan (catalog/empty) + const dbPlan = getProviderPlan(connectionId); + if (dbPlan) { + return NextResponse.json({ plan: dbPlan }); + } + + // Resolve via catalog (may return empty plan) + const provider = await resolveProvider(connectionId); + const plan = resolvePlan(connectionId, provider); + return NextResponse.json({ plan }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to get plan"; + return NextResponse.json(buildErrorBody(500, message), { status: 500 }); + } +} + +export async function PUT(request: Request, { params }: RouteParams): Promise { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + try { + const { connectionId } = await params; + const body = await request.json().catch(() => null); + const parsed = PlanUpsertSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json(buildErrorBody(400, parsed.error.message), { status: 400 }); + } + + // Derive provider for the connection + const provider = await resolveProvider(connectionId); + + upsertProviderPlan(connectionId, provider, parsed.data.dimensions, "manual"); + + const ctx = getAuditRequestContext(request); + logAuditEvent({ + action: "quota.plan.updated", + target: connectionId, + metadata: { provider, dimensions: parsed.data.dimensions, source: "manual" }, + ipAddress: ctx.ipAddress ?? undefined, + requestId: ctx.requestId, + }); + + // Return the stored plan + const plan = getProviderPlan(connectionId); + return NextResponse.json({ plan }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to upsert plan"; + return NextResponse.json(buildErrorBody(500, message), { status: 500 }); + } +} + +export async function DELETE(request: Request, { params }: RouteParams): Promise { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + try { + const { connectionId } = await params; + // deleteProviderPlan returns true if a row was deleted, false if not found + // We return 204 either way (idempotent delete) + deleteProviderPlan(connectionId); + return new Response(null, { status: 204 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to delete plan"; + return NextResponse.json(buildErrorBody(500, message), { status: 500 }); + } +} diff --git a/src/app/api/quota/plans/route.ts b/src/app/api/quota/plans/route.ts new file mode 100644 index 0000000000..92fba4fadc --- /dev/null +++ b/src/app/api/quota/plans/route.ts @@ -0,0 +1,66 @@ +/** + * GET /api/quota/plans — list all resolved provider plans + * + * Returns plans from two sources merged into one list: + * 1. Known catalog providers (planRegistry.knownProviders) with resolved plan + * 2. DB-overridden plans (listProviderPlans from providerPlans table) + * + * Each entry includes the `source` field ("auto" | "manual") so callers can + * distinguish catalog defaults from manual overrides. + * + * Auth: requireManagementAuth + * Sanitization: all error responses via buildErrorBody (Hard Rule #12, B25) + * + * Part of: Group B — REST routes for Quota Sharing (plan 22, frente F8). + */ + +import { NextResponse } from "next/server"; +import { buildErrorBody } from "@omniroute/open-sse/utils/error"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { listProviderPlans } from "@/lib/localDb"; +import { knownProviders, getKnownPlan } from "@/lib/quota/planRegistry"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: Request): Promise { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + try { + // 1. Catalog plans (auto-detected defaults) + const catalogPlans = knownProviders().map((provider) => { + const known = getKnownPlan(provider); + return { + connectionId: null, + provider, + dimensions: known?.dimensions ?? [], + source: "auto" as const, + }; + }); + + // 2. Manual DB overrides — may overlap with catalog providers (override wins) + const dbPlans = listProviderPlans(); + + // 3. Merge: DB plans by provider key override catalog entries + const dbByProvider = new Map(dbPlans.map((p) => [p.provider, p])); + const merged = catalogPlans.map((catalog) => { + const override = dbByProvider.get(catalog.provider); + if (override) { + // Remove from dbByProvider so we track non-catalog db plans separately + dbByProvider.delete(catalog.provider); + return override; + } + return catalog; + }); + + // 4. Any remaining DB plans not in catalog (connectionId-scoped overrides) + for (const dbPlan of dbByProvider.values()) { + merged.push(dbPlan); + } + + return NextResponse.json({ plans: merged }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to list plans"; + return NextResponse.json(buildErrorBody(500, message), { status: 500 }); + } +}