forked from EduCraft/curriculum-project-hub
feat: org capacity policy admin surface
ADR-0022 / Spec.System.Capacity pins layered capacity limits: a platform ceiling per dimension is unbreakable, and each organization may only set a lower `organizationLimit`. The effective limit is the minimum of the two (`LayeredLimit.effective`); dimensions with no org override fall back to the platform ceiling. No dimension may be unlimited (a ceiling must exist before an org limit can be set, `LayeredLimit.Valid`). Add the backend: the pinned 23 CapacityDimension set + labels (src/capacity/dimensions.ts), platform ceilings sourced from existing runtime env vars plus `HUB_CEILING_<DIMENSION>` (src/capacity/ceilings.ts), the OrganizationCapacityPolicy prisma model + migration, the getCapacityPolicy/setCapacityPolicy service enforcing LayeredLimit.Valid, and org-admin GET/PUT /api/org/:orgSlug/capacity-policy routes wired into the org route tree. Add the admin-web surface: CapacityDimension/CapacityPolicyView api client types, capacityPolicy/setCapacityPolicy methods, a `容量` nav entry, and a capacity page that lists every dimension with its platform ceiling (or `未配置` when unset), an org-limit input (disabled until a ceiling exists), and a live effective-value column. Saving sends the partial limits map; the service rejects values above the ceiling or for unconfigured dimensions.
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Org-admin capacity policy routes (ADR-0022).
|
||||
*/
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { getCapacityPolicy, setCapacityPolicy } from "../../org/capacityPolicy.js";
|
||||
import { isCapacityDimension } from "../../capacity/dimensions.js";
|
||||
import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
|
||||
import { handleRouteError } from "../errors.js";
|
||||
|
||||
export async function registerCapacityRoutes(
|
||||
app: FastifyInstance,
|
||||
config: { readonly prisma: PrismaClient; readonly sessionSecret: string },
|
||||
): Promise<void> {
|
||||
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
|
||||
|
||||
app.get("/api/org/:orgSlug/capacity-policy", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug } = request.params as { orgSlug: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
return await getCapacityPolicy(config.prisma, auth.organization.id);
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.put("/api/org/:orgSlug/capacity-policy", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug } = request.params as { orgSlug: string };
|
||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
||||
if (auth === null) return;
|
||||
const body = request.body as { limits?: unknown };
|
||||
if (body?.limits === null || typeof body.limits !== "object") {
|
||||
return reply.status(400).send({
|
||||
error: { code: "bad_request", message: "limits object is required" },
|
||||
});
|
||||
}
|
||||
const limits: Record<string, number | null> = {};
|
||||
for (const [key, value] of Object.entries(body.limits as Record<string, unknown>)) {
|
||||
if (!isCapacityDimension(key)) {
|
||||
return reply.status(400).send({
|
||||
error: { code: "bad_request", message: `unknown capacity dimension: ${key}` },
|
||||
});
|
||||
}
|
||||
if (value === null) {
|
||||
limits[key] = null;
|
||||
} else if (typeof value === "number" && Number.isFinite(value)) {
|
||||
limits[key] = value;
|
||||
} else {
|
||||
return reply.status(400).send({
|
||||
error: { code: "bad_request", message: `limit for ${key} must be a number or null` },
|
||||
});
|
||||
}
|
||||
}
|
||||
return await setCapacityPolicy(config.prisma, {
|
||||
organizationId: auth.organization.id,
|
||||
limits,
|
||||
});
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { registerMembersRoutes } from "./membersRoutes.js";
|
||||
import { registerSessionsAndUsageRoutes } from "./sessionsRoutes.js";
|
||||
import { registerTeamsRoutes } from "./teamsRoutes.js";
|
||||
import { registerProviderConnectionRoutes } from "./providerConnectionRoutes.js";
|
||||
import { registerCapacityRoutes } from "./capacityRoutes.js";
|
||||
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
|
||||
import type { ProviderReadinessProbe } from "../../connections/providerReadiness.js";
|
||||
import type { FeishuReadinessProbe } from "../../connections/feishuReadiness.js";
|
||||
@@ -105,6 +106,10 @@ export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteCo
|
||||
prisma: config.prisma,
|
||||
sessionSecret: config.sessionSecret,
|
||||
});
|
||||
await registerCapacityRoutes(app, {
|
||||
prisma: config.prisma,
|
||||
sessionSecret: config.sessionSecret,
|
||||
});
|
||||
await registerSessionsAndUsageRoutes(app, {
|
||||
prisma: config.prisma,
|
||||
sessionSecret: config.sessionSecret,
|
||||
|
||||
Reference in New Issue
Block a user