forked from EduCraft/curriculum-project-hub
ab9dfad53a
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.
65 lines
2.5 KiB
TypeScript
65 lines
2.5 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|
|
});
|
|
}
|