271 lines
8.4 KiB
TypeScript
271 lines
8.4 KiB
TypeScript
import { z } from "zod";
|
|
import { METHOD_FACET_API_SECTION_IDS } from "../../create/customRuleFacets";
|
|
|
|
/**
|
|
* Zod schemas for the recommendation matrix (CR-88).
|
|
*
|
|
* Source of truth at runtime is `data/create/customRule/<section>.json` plus
|
|
* `data/create/customRule/_facetGroups.json`. These schemas validate those
|
|
* files at seed time and in the parity test (`tests/unit/methodFacets.test.ts`).
|
|
* They are also reused by the API request shapes for `/api/templates` and
|
|
* `/api/create-flow/methods` so a single set of canonical ids drives both
|
|
* the on-disk JSON and the query-string contract.
|
|
*
|
|
* See `docs/guides/template-recommendation-matrix.md` §2 (canonical 19
|
|
* facet values), §6 (JSON shape), §7 (`MethodFacet` schema), §9 (API).
|
|
*/
|
|
|
|
/** Canonical ids — source: {@link METHOD_FACET_API_SECTION_IDS} in `lib/create/customRuleFacets.ts`. */
|
|
export const SECTION_IDS = METHOD_FACET_API_SECTION_IDS;
|
|
export type SectionId = (typeof SECTION_IDS)[number];
|
|
export const sectionIdSchema = z.enum(SECTION_IDS);
|
|
|
|
export const FACET_GROUP_IDS = [
|
|
"size",
|
|
"orgType",
|
|
"scale",
|
|
"maturity",
|
|
] as const;
|
|
export type FacetGroupId = (typeof FACET_GROUP_IDS)[number];
|
|
export const facetGroupIdSchema = z.enum(FACET_GROUP_IDS);
|
|
|
|
export const SIZE_VALUE_IDS = [
|
|
"oneMember",
|
|
"twoToFive",
|
|
"sixToTwelve",
|
|
"thirteenToOneHundred",
|
|
"oneHundredToOneHundredK",
|
|
] as const;
|
|
export const ORG_TYPE_VALUE_IDS = [
|
|
"dao",
|
|
"forProfit",
|
|
"nonprofit",
|
|
"openSource",
|
|
"mutualAid",
|
|
"workersCoop",
|
|
] as const;
|
|
export const SCALE_VALUE_IDS = [
|
|
"global",
|
|
"national",
|
|
"regional",
|
|
"local",
|
|
] as const;
|
|
export const MATURITY_VALUE_IDS = [
|
|
"earlyStage",
|
|
"growthStage",
|
|
"established",
|
|
"enterprise",
|
|
] as const;
|
|
|
|
export type SizeValueId = (typeof SIZE_VALUE_IDS)[number];
|
|
export type OrgTypeValueId = (typeof ORG_TYPE_VALUE_IDS)[number];
|
|
export type ScaleValueId = (typeof SCALE_VALUE_IDS)[number];
|
|
export type MaturityValueId = (typeof MATURITY_VALUE_IDS)[number];
|
|
|
|
export const FACET_VALUE_IDS_BY_GROUP: Record<
|
|
FacetGroupId,
|
|
readonly string[]
|
|
> = {
|
|
size: SIZE_VALUE_IDS,
|
|
orgType: ORG_TYPE_VALUE_IDS,
|
|
scale: SCALE_VALUE_IDS,
|
|
maturity: MATURITY_VALUE_IDS,
|
|
};
|
|
|
|
const sizeValueIdSchema = z.enum(SIZE_VALUE_IDS);
|
|
const orgTypeValueIdSchema = z.enum(ORG_TYPE_VALUE_IDS);
|
|
const scaleValueIdSchema = z.enum(SCALE_VALUE_IDS);
|
|
const maturityValueIdSchema = z.enum(MATURITY_VALUE_IDS);
|
|
|
|
/**
|
|
* Per-cell shape: bare boolean, or an object with optional `weight`.
|
|
* The object form is reserved for a future weighted-rank pass (v1 ignores
|
|
* `weight`; see §9.1 "Notes").
|
|
*/
|
|
const facetMatchSchema = z.union([
|
|
z.boolean(),
|
|
z
|
|
.object({
|
|
match: z.boolean(),
|
|
weight: z.number().finite().optional(),
|
|
})
|
|
.strict(),
|
|
]);
|
|
export type FacetMatch = z.infer<typeof facetMatchSchema>;
|
|
|
|
/**
|
|
* Builds a Zod object schema for a facet group where every canonical value id
|
|
* is optional. Omitted keys default to `false` (see §6 "Bulk shorthand").
|
|
*/
|
|
function partialGroupSchema<Values extends readonly [string, ...string[]]>(
|
|
values: Values,
|
|
) {
|
|
const enumSchema = z.enum(values);
|
|
return z.record(enumSchema, facetMatchSchema);
|
|
}
|
|
|
|
const sizeFacetsSchema = partialGroupSchema(SIZE_VALUE_IDS);
|
|
const orgTypeFacetsSchema = partialGroupSchema(ORG_TYPE_VALUE_IDS);
|
|
const scaleFacetsSchema = partialGroupSchema(SCALE_VALUE_IDS);
|
|
const maturityFacetsSchema = partialGroupSchema(MATURITY_VALUE_IDS);
|
|
|
|
/**
|
|
* Per-method facet entry. All four groups are optional; an omitted group
|
|
* defaults to "all false" at seed time (see `flattenSectionFacets` in
|
|
* `prisma/seed/methodFacets.ts`).
|
|
*/
|
|
export const methodFacetsSchema = z
|
|
.object({
|
|
size: sizeFacetsSchema.optional(),
|
|
orgType: orgTypeFacetsSchema.optional(),
|
|
scale: scaleFacetsSchema.optional(),
|
|
maturity: maturityFacetsSchema.optional(),
|
|
})
|
|
.strict();
|
|
export type MethodFacets = z.infer<typeof methodFacetsSchema>;
|
|
|
|
/**
|
|
* Whole-section file shape: object keyed by method slug
|
|
* (`messages/en/create/customRule/<section>.json#/methods[].id`).
|
|
*/
|
|
export const sectionFacetsSchema = z.record(z.string(), methodFacetsSchema);
|
|
export type SectionFacetsFile = z.infer<typeof sectionFacetsSchema>;
|
|
|
|
/**
|
|
* `_facetGroups.json` shape: positional chip id ↔ canonical facet value id
|
|
* mapping (see §2). Validated alongside the section files so chip drift in a
|
|
* messages file fails CI loudly.
|
|
*/
|
|
const facetGroupValueEntrySchema = z
|
|
.object({
|
|
chipId: z.string().min(1),
|
|
})
|
|
.strict();
|
|
|
|
const facetGroupBlockSchema = z
|
|
.object({
|
|
source: z.string().min(1),
|
|
values: z.record(z.string(), facetGroupValueEntrySchema),
|
|
})
|
|
.strict();
|
|
|
|
export const facetGroupsFileSchema = z
|
|
.object({
|
|
size: facetGroupBlockSchema,
|
|
orgType: facetGroupBlockSchema,
|
|
scale: facetGroupBlockSchema,
|
|
maturity: facetGroupBlockSchema,
|
|
})
|
|
.strict()
|
|
.superRefine((data, ctx) => {
|
|
for (const group of FACET_GROUP_IDS) {
|
|
const expected = new Set(FACET_VALUE_IDS_BY_GROUP[group]);
|
|
const actual = new Set(Object.keys(data[group].values));
|
|
for (const v of expected) {
|
|
if (!actual.has(v)) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: [group, "values"],
|
|
message: `Missing canonical value ${v}`,
|
|
});
|
|
}
|
|
}
|
|
for (const v of actual) {
|
|
if (!expected.has(v)) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: [group, "values", v],
|
|
message: `Unknown facet value ${v} for group ${group}`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
});
|
|
export type FacetGroupsFile = z.infer<typeof facetGroupsFileSchema>;
|
|
|
|
/**
|
|
* Resolve a `FacetMatch` value to its boolean (the shape can be either a bare
|
|
* boolean or `{ match, weight? }`). Used by both the seed flattener and the
|
|
* scoring helpers.
|
|
*/
|
|
export function resolveFacetMatch(
|
|
v: FacetMatch | undefined,
|
|
): { match: boolean; weight: number | null } {
|
|
if (v === undefined) return { match: false, weight: null };
|
|
if (typeof v === "boolean") return { match: v, weight: null };
|
|
return { match: v.match, weight: v.weight ?? null };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// API request shapes (used by /api/templates and /api/create-flow/methods)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Generic facet-id-array shape, scoped per group. URLSearchParams produces
|
|
* either a single string or repeated values; both flatten to `string[]`.
|
|
*/
|
|
export const requestedFacetsSchema = z
|
|
.object({
|
|
size: z.array(sizeValueIdSchema).max(SIZE_VALUE_IDS.length).optional(),
|
|
orgType: z
|
|
.array(orgTypeValueIdSchema)
|
|
.max(ORG_TYPE_VALUE_IDS.length)
|
|
.optional(),
|
|
scale: z.array(scaleValueIdSchema).max(SCALE_VALUE_IDS.length).optional(),
|
|
maturity: z
|
|
.array(maturityValueIdSchema)
|
|
.max(MATURITY_VALUE_IDS.length)
|
|
.optional(),
|
|
})
|
|
.strict();
|
|
export type RequestedFacets = z.infer<typeof requestedFacetsSchema>;
|
|
|
|
/** Flattened `(group, value)` tuple for scoring. */
|
|
export type RequestedFacetPair = { group: FacetGroupId; value: string };
|
|
|
|
export function flattenRequestedFacets(
|
|
facets: RequestedFacets,
|
|
): RequestedFacetPair[] {
|
|
const out: RequestedFacetPair[] = [];
|
|
for (const group of FACET_GROUP_IDS) {
|
|
const values = facets[group];
|
|
if (!values) continue;
|
|
for (const value of values) {
|
|
out.push({ group, value });
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Parse `?facet.size=oneMember&facet.orgType=dao&facet.orgType=nonprofit` into
|
|
* a typed `RequestedFacets`. Unknown groups and unknown values are dropped
|
|
* silently — the API "never errors on partial facets" (§9.3).
|
|
*/
|
|
export function parseRequestedFacetsFromSearchParams(
|
|
search: URLSearchParams,
|
|
): RequestedFacets {
|
|
const collected: Record<FacetGroupId, Set<string>> = {
|
|
size: new Set(),
|
|
orgType: new Set(),
|
|
scale: new Set(),
|
|
maturity: new Set(),
|
|
};
|
|
for (const [rawKey, rawVal] of search.entries()) {
|
|
if (!rawKey.startsWith("facet.")) continue;
|
|
const group = rawKey.slice("facet.".length) as FacetGroupId;
|
|
if (!FACET_GROUP_IDS.includes(group)) continue;
|
|
const allowed = new Set(FACET_VALUE_IDS_BY_GROUP[group]);
|
|
if (allowed.has(rawVal)) {
|
|
collected[group].add(rawVal);
|
|
}
|
|
}
|
|
const out: RequestedFacets = {};
|
|
for (const group of FACET_GROUP_IDS) {
|
|
if (collected[group].size > 0) {
|
|
out[group] = Array.from(collected[group]) as never;
|
|
}
|
|
}
|
|
return out;
|
|
}
|