feat(api): add /api/quota/plans CRUD routes (B/F8)

This commit is contained in:
diegosouzapw
2026-05-28 07:49:38 -03:00
parent 9dfea1e7ad
commit 043f3c412b
2 changed files with 192 additions and 0 deletions

View File

@@ -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<string> {
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<Response> {
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<Response> {
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<Response> {
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 });
}
}

View File

@@ -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<Response> {
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 });
}
}