feat(api): add /api/quota/pools CRUD + usage routes (B/F8)

This commit is contained in:
diegosouzapw
2026-05-28 07:49:30 -03:00
parent f211fcf509
commit 9dfea1e7ad
3 changed files with 242 additions and 0 deletions

View File

@@ -0,0 +1,99 @@
/**
* GET /api/quota/pools/[id] — get a single quota pool
* PATCH /api/quota/pools/[id] — update pool name/allocations
* DELETE /api/quota/pools/[id] — delete pool
*
* Auth: requireManagementAuth
* Zod: PoolUpdateSchema from @/shared/schemas/quota (PATCH only)
* Audit: quota.pool.updated / quota.pool.deleted (B26)
* 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 { PoolUpdateSchema } from "@/shared/schemas/quota";
import { getPool, updatePool, deletePool } from "@/lib/localDb";
import { logAuditEvent, getAuditRequestContext } from "@/lib/compliance/index";
export const dynamic = "force-dynamic";
type RouteParams = { params: Promise<{ id: string }> };
export async function GET(request: Request, { params }: RouteParams): Promise<Response> {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const { id } = await params;
const pool = getPool(id);
if (!pool) {
return NextResponse.json(buildErrorBody(404, "Pool not found"), { status: 404 });
}
return NextResponse.json({ pool });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to get pool";
return NextResponse.json(buildErrorBody(500, message), { status: 500 });
}
}
export async function PATCH(request: Request, { params }: RouteParams): Promise<Response> {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const { id } = await params;
const body = await request.json().catch(() => null);
const parsed = PoolUpdateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(buildErrorBody(400, parsed.error.message), { status: 400 });
}
const pool = updatePool(id, parsed.data);
if (!pool) {
return NextResponse.json(buildErrorBody(404, "Pool not found"), { status: 404 });
}
const ctx = getAuditRequestContext(request);
logAuditEvent({
action: "quota.pool.updated",
target: id,
metadata: parsed.data,
ipAddress: ctx.ipAddress ?? undefined,
requestId: ctx.requestId,
});
return NextResponse.json({ pool });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to update pool";
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 { id } = await params;
const existed = deletePool(id);
if (!existed) {
return NextResponse.json(buildErrorBody(404, "Pool not found"), { status: 404 });
}
const ctx = getAuditRequestContext(request);
logAuditEvent({
action: "quota.pool.deleted",
target: id,
ipAddress: ctx.ipAddress ?? undefined,
requestId: ctx.requestId,
});
return new Response(null, { status: 204 });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to delete pool";
return NextResponse.json(buildErrorBody(500, message), { status: 500 });
}
}

View File

@@ -0,0 +1,80 @@
/**
* GET /api/quota/pools/[id]/usage — pool consumption snapshot with dimensions
*
* Resolves the pool's provider plan to get dimensions, then calls
* poolUsageWithDimensions on the concrete store implementation.
*
* Note on poolUsageWithDimensions availability:
* This method is defined on SqliteQuotaStore (and RedisQuotaStore) but is NOT
* part of the QuotaStore interface (keeping the interface minimal). F8 accesses
* it via dynamic type-narrowing:
*
* const storeExt = store as { poolUsageWithDimensions?: (...) => Promise<...> };
* if (typeof storeExt.poolUsageWithDimensions === "function") { ... }
* else { fallback to store.poolUsage(id) }
*
* This avoids modifying the QuotaStore interface (F6 responsibility) while
* still using the richer method when available.
*
* 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 { getPool } from "@/lib/localDb";
import { getQuotaStore } from "@/lib/quota/QuotaStore";
import { resolvePlan } from "@/lib/quota/planResolver";
import type { PoolUsageSnapshot } from "@/lib/quota/types";
export const dynamic = "force-dynamic";
type RouteParams = { params: Promise<{ id: string }> };
export async function GET(request: Request, { params }: RouteParams): Promise<Response> {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const { id } = await params;
// 1. Get pool — 404 if not found
const pool = getPool(id);
if (!pool) {
return NextResponse.json(buildErrorBody(404, "Pool not found"), { status: 404 });
}
// 2. Resolve the provider plan for this pool's connection
// Provider name is not stored on pool — use empty string to trigger catalog/empty fallback
const plan = resolvePlan(pool.connectionId, "");
// 3. Get the quota store and call poolUsageWithDimensions when available
const store = await getQuotaStore();
let snapshot: PoolUsageSnapshot;
const storeExt = store as unknown as {
poolUsageWithDimensions?: (
poolId: string,
dimensions: Array<{ unit: string; window: string; limit: number }>
) => Promise<PoolUsageSnapshot>;
};
if (
typeof storeExt.poolUsageWithDimensions === "function" &&
plan.dimensions.length > 0
) {
snapshot = await storeExt.poolUsageWithDimensions(id, plan.dimensions);
} else {
// Fallback: use the interface-standard poolUsage (dimensions come from stored data only)
snapshot = await store.poolUsage(id);
}
return NextResponse.json({ usage: snapshot });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to get pool usage";
return NextResponse.json(buildErrorBody(500, message), { status: 500 });
}
}

View File

@@ -0,0 +1,63 @@
/**
* GET /api/quota/pools — list all quota pools with allocations
* POST /api/quota/pools — create a new quota pool
*
* Auth: requireManagementAuth (same pattern as /api/compliance/audit-log)
* Zod: PoolCreateSchema from @/shared/schemas/quota
* Audit: quota.pool.created logged on POST (B26)
* Sanitization: all error responses via buildErrorBody (Hard Rule #12, B25)
*
* NOT LOCAL_ONLY — does not spawn processes (B18, Hard Rules #15/#17 do not apply).
*
* 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 { PoolCreateSchema } from "@/shared/schemas/quota";
import { listPools, createPool } from "@/lib/localDb";
import { logAuditEvent, getAuditRequestContext } from "@/lib/compliance/index";
export const dynamic = "force-dynamic";
export async function GET(request: Request): Promise<Response> {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const pools = listPools();
return NextResponse.json({ pools });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to list pools";
return NextResponse.json(buildErrorBody(500, message), { status: 500 });
}
}
export async function POST(request: Request): Promise<Response> {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const body = await request.json().catch(() => null);
const parsed = PoolCreateSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(buildErrorBody(400, parsed.error.message), { status: 400 });
}
const pool = createPool(parsed.data);
const ctx = getAuditRequestContext(request);
logAuditEvent({
action: "quota.pool.created",
target: pool.id,
metadata: { connectionId: pool.connectionId, name: pool.name },
ipAddress: ctx.ipAddress ?? undefined,
requestId: ctx.requestId,
});
return NextResponse.json({ pool }, { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to create pool";
return NextResponse.json(buildErrorBody(500, message), { status: 500 });
}
}