mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 12:22:14 +03:00
feat(api): add /api/quota/plans CRUD routes (B/F8)
This commit is contained in:
126
src/app/api/quota/plans/[connectionId]/route.ts
Normal file
126
src/app/api/quota/plans/[connectionId]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
66
src/app/api/quota/plans/route.ts
Normal file
66
src/app/api/quota/plans/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user