Implement create custom recommendations
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
import { prisma } from "./db";
|
||||
import { isDatabaseConfigured } from "./env";
|
||||
import {
|
||||
type RequestedFacets,
|
||||
type SectionId,
|
||||
flattenRequestedFacets,
|
||||
} from "./validation/methodFacetsSchemas";
|
||||
|
||||
/**
|
||||
* Per-method ranking output (CR-88, §9.2).
|
||||
*
|
||||
* `score` = number of requested `(group, value)` pairs that this method's
|
||||
* `MethodFacet { matches: true }` rows cover. `matchedFacets` is the
|
||||
* deduped list of `"<group>:<value>"` keys that contributed — useful for
|
||||
* an eventual "Why this method?" UI tooltip.
|
||||
*/
|
||||
export type MethodRanking = {
|
||||
slug: string;
|
||||
matches: { score: number; matchedFacets: string[] };
|
||||
};
|
||||
|
||||
export type ListMethodRecommendationsResult = {
|
||||
/** Ordered slug list, ranked highest-`score`-first; absent slugs scored `0`. */
|
||||
rankedSlugs: string[];
|
||||
/** Per-slug match data; missing entries should be treated as `score = 0`. */
|
||||
matchesBySlug: Record<string, MethodRanking["matches"]>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the per-method match scores for `section`, given `facets`.
|
||||
* Returns `null` so callers can fall back to messages-file order when DB
|
||||
* is unavailable or the query fails.
|
||||
*
|
||||
* Notes:
|
||||
* - Empty facets ⇒ `rankedSlugs: []`, `matchesBySlug: {}` (caller falls back
|
||||
* to authoring order).
|
||||
* - Sort is `score` desc only — re-stabilising into authoring order is the
|
||||
* caller's job (the wizard already iterates the on-disk `methods[]` array).
|
||||
*/
|
||||
export async function listMethodRecommendations(args: {
|
||||
section: SectionId;
|
||||
facets: RequestedFacets;
|
||||
}): Promise<ListMethodRecommendationsResult | null> {
|
||||
if (!isDatabaseConfigured()) return null;
|
||||
|
||||
const requested = flattenRequestedFacets(args.facets);
|
||||
if (requested.length === 0) {
|
||||
return { rankedSlugs: [], matchesBySlug: {} };
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await prisma.methodFacet.findMany({
|
||||
where: {
|
||||
section: args.section,
|
||||
matches: true,
|
||||
OR: requested.map(({ group, value }) => ({ group, value })),
|
||||
},
|
||||
select: { slug: true, group: true, value: true },
|
||||
});
|
||||
|
||||
const matchesBySlug: Record<string, MethodRanking["matches"]> = {};
|
||||
for (const row of rows) {
|
||||
const key = `${row.group}:${row.value}`;
|
||||
const entry =
|
||||
matchesBySlug[row.slug] ??
|
||||
(matchesBySlug[row.slug] = { score: 0, matchedFacets: [] });
|
||||
if (!entry.matchedFacets.includes(key)) {
|
||||
entry.matchedFacets.push(key);
|
||||
entry.score += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const rankedSlugs = Object.entries(matchesBySlug)
|
||||
.sort(([, a], [, b]) => b.score - a.score)
|
||||
.map(([slug]) => slug);
|
||||
|
||||
return { rankedSlugs, matchesBySlug };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Score every template by joining its composed `(section, slug)` pairs
|
||||
* against `MethodFacet`. Returns a per-slug score map keyed by template slug
|
||||
* and a per-template breakdown of which method-level matches contributed.
|
||||
*
|
||||
* `templateMethods` enumerates the method slugs each template composes;
|
||||
* derived from `RuleTemplate.body` by the caller.
|
||||
*/
|
||||
export type TemplateRanking = {
|
||||
templateSlug: string;
|
||||
score: number;
|
||||
matchedFacets: string[];
|
||||
};
|
||||
|
||||
export async function scoreTemplatesByFacets(args: {
|
||||
templateMethods: ReadonlyArray<{
|
||||
templateSlug: string;
|
||||
methods: ReadonlyArray<{ section: SectionId; slug: string }>;
|
||||
}>;
|
||||
facets: RequestedFacets;
|
||||
}): Promise<TemplateRanking[] | null> {
|
||||
if (!isDatabaseConfigured()) return null;
|
||||
const requested = flattenRequestedFacets(args.facets);
|
||||
if (requested.length === 0) {
|
||||
return args.templateMethods.map((t) => ({
|
||||
templateSlug: t.templateSlug,
|
||||
score: 0,
|
||||
matchedFacets: [],
|
||||
}));
|
||||
}
|
||||
|
||||
// Collect distinct (section, slug) pairs across all templates so we make
|
||||
// exactly one query.
|
||||
const sectionSlugSet = new Set<string>();
|
||||
for (const t of args.templateMethods) {
|
||||
for (const m of t.methods) {
|
||||
sectionSlugSet.add(`${m.section}:${m.slug}`);
|
||||
}
|
||||
}
|
||||
const sectionSlugPairs = Array.from(sectionSlugSet).map((key) => {
|
||||
const [section, slug] = key.split(":");
|
||||
return { section, slug };
|
||||
});
|
||||
if (sectionSlugPairs.length === 0) {
|
||||
return args.templateMethods.map((t) => ({
|
||||
templateSlug: t.templateSlug,
|
||||
score: 0,
|
||||
matchedFacets: [],
|
||||
}));
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await prisma.methodFacet.findMany({
|
||||
where: {
|
||||
matches: true,
|
||||
AND: [
|
||||
{ OR: sectionSlugPairs },
|
||||
{ OR: requested.map(({ group, value }) => ({ group, value })) },
|
||||
],
|
||||
},
|
||||
select: { section: true, slug: true, group: true, value: true },
|
||||
});
|
||||
|
||||
// Build a lookup: (section,slug) -> Set of "<group>:<value>" matches.
|
||||
const matchesByMethod = new Map<string, Set<string>>();
|
||||
for (const row of rows) {
|
||||
const k = `${row.section}:${row.slug}`;
|
||||
const set = matchesByMethod.get(k) ?? new Set<string>();
|
||||
set.add(`${row.group}:${row.value}`);
|
||||
matchesByMethod.set(k, set);
|
||||
}
|
||||
|
||||
return args.templateMethods.map((t) => {
|
||||
let score = 0;
|
||||
const matched: string[] = [];
|
||||
for (const m of t.methods) {
|
||||
const set = matchesByMethod.get(`${m.section}:${m.slug}`);
|
||||
if (!set) continue;
|
||||
for (const key of set) {
|
||||
score += 1;
|
||||
matched.push(`${m.section}:${m.slug}:${key}`);
|
||||
}
|
||||
}
|
||||
return { templateSlug: t.templateSlug, score, matchedFacets: matched };
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+95
-11
@@ -1,6 +1,27 @@
|
||||
import type { RuleTemplateDto } from "../create/fetchTemplates";
|
||||
import { prisma } from "./db";
|
||||
import { isDatabaseConfigured } from "./env";
|
||||
import { scoreTemplatesByFacets } from "./methodRecommendations";
|
||||
import { templateMethodsFromBody } from "./templateMethods";
|
||||
import type { RequestedFacets } from "./validation/methodFacetsSchemas";
|
||||
import { flattenRequestedFacets } from "./validation/methodFacetsSchemas";
|
||||
|
||||
const TEMPLATE_SELECT = {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
category: true,
|
||||
description: true,
|
||||
body: true,
|
||||
sortOrder: true,
|
||||
featured: true,
|
||||
} as const;
|
||||
|
||||
const CURATED_ORDER_BY = [
|
||||
{ featured: "desc" as const },
|
||||
{ sortOrder: "asc" as const },
|
||||
{ title: "asc" as const },
|
||||
];
|
||||
|
||||
/**
|
||||
* Curated templates for public list UIs (same query as GET /api/templates).
|
||||
@@ -12,19 +33,82 @@ export async function listRuleTemplatesFromDb(): Promise<RuleTemplateDto[]> {
|
||||
}
|
||||
try {
|
||||
return await prisma.ruleTemplate.findMany({
|
||||
orderBy: [{ featured: "desc" }, { sortOrder: "asc" }, { title: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
category: true,
|
||||
description: true,
|
||||
body: true,
|
||||
sortOrder: true,
|
||||
featured: true,
|
||||
},
|
||||
orderBy: CURATED_ORDER_BY,
|
||||
select: TEMPLATE_SELECT,
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export type TemplateScore = {
|
||||
score: number;
|
||||
matchedFacets: string[];
|
||||
};
|
||||
|
||||
export type RankedTemplatesResult = {
|
||||
templates: RuleTemplateDto[];
|
||||
/** Per-template-slug score map; absent slugs scored `0`. */
|
||||
scores: Record<string, TemplateScore>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Curated templates ranked by how many of `facets` each composed method
|
||||
* matches (§9.1). When `facets` is empty, returns the curated ordering with
|
||||
* an empty `scores` map (caller can omit it from the API response).
|
||||
*
|
||||
* Ties (and zero-score templates) fall back to the curated
|
||||
* `(featured, sortOrder, title)` order so no-facets and zero-match cases
|
||||
* produce identical output to `listRuleTemplatesFromDb`.
|
||||
*/
|
||||
export async function listRankedRuleTemplatesFromDb(
|
||||
facets: RequestedFacets,
|
||||
): Promise<RankedTemplatesResult> {
|
||||
if (!isDatabaseConfigured()) {
|
||||
return { templates: [], scores: {} };
|
||||
}
|
||||
const requested = flattenRequestedFacets(facets);
|
||||
if (requested.length === 0) {
|
||||
const templates = await listRuleTemplatesFromDb();
|
||||
return { templates, scores: {} };
|
||||
}
|
||||
|
||||
let templates: RuleTemplateDto[];
|
||||
try {
|
||||
templates = await prisma.ruleTemplate.findMany({
|
||||
orderBy: CURATED_ORDER_BY,
|
||||
select: TEMPLATE_SELECT,
|
||||
});
|
||||
} catch {
|
||||
return { templates: [], scores: {} };
|
||||
}
|
||||
|
||||
const templateMethods = templates.map((t) => ({
|
||||
templateSlug: t.slug,
|
||||
methods: templateMethodsFromBody(t.body),
|
||||
}));
|
||||
|
||||
const ranked = await scoreTemplatesByFacets({ templateMethods, facets });
|
||||
if (!ranked) {
|
||||
return { templates, scores: {} };
|
||||
}
|
||||
|
||||
const scores: Record<string, TemplateScore> = {};
|
||||
for (const r of ranked) {
|
||||
scores[r.templateSlug] = {
|
||||
score: r.score,
|
||||
matchedFacets: r.matchedFacets,
|
||||
};
|
||||
}
|
||||
|
||||
// Stable sort: scoreDesc, then preserve curated index order.
|
||||
const indexBySlug = new Map(templates.map((t, i) => [t.slug, i]));
|
||||
const sorted = [...templates].sort((a, b) => {
|
||||
const sa = scores[a.slug]?.score ?? 0;
|
||||
const sb = scores[b.slug]?.score ?? 0;
|
||||
if (sa !== sb) return sb - sa;
|
||||
return (indexBySlug.get(a.slug) ?? 0) - (indexBySlug.get(b.slug) ?? 0);
|
||||
});
|
||||
|
||||
return { templates: sorted, scores };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { SectionId } from "./validation/methodFacetsSchemas";
|
||||
|
||||
/**
|
||||
* Extracts the `(section, slug)` pairs that a curated `RuleTemplate.body`
|
||||
* composes. Used by `/api/templates` to score templates by facet match
|
||||
* (CR-88, §9.1).
|
||||
*
|
||||
* `body.sections[].categoryName` is mapped to the canonical recommendation
|
||||
* `section` id; `entries[].title` is slugified the same way the messages
|
||||
* ingest produced `methods[].id` (kebab-case, ASCII-folded, lowercase) so
|
||||
* the slugs line up with `MethodFacet.slug`.
|
||||
*
|
||||
* "Values" entries are intentionally skipped — values are out of scope for
|
||||
* the facet matrix (§11).
|
||||
*/
|
||||
|
||||
const CATEGORY_NAME_TO_SECTION: Record<string, SectionId> = {
|
||||
Communication: "communication",
|
||||
Membership: "membership",
|
||||
"Decision-making": "decisionApproaches",
|
||||
"Conflict management": "conflictManagement",
|
||||
};
|
||||
|
||||
export function methodSlugFromTitle(title: string): string {
|
||||
// Match the slugify rules of the one-time messages ingest: NFKD-normalize,
|
||||
// strip diacritics, drop apostrophes/brackets, collapse non-alphanumerics
|
||||
// to single hyphens, trim leading/trailing hyphens.
|
||||
const folded = title.normalize("NFKD").replace(/[\u0300-\u036f]/g, "");
|
||||
const stripped = folded
|
||||
.toLowerCase()
|
||||
.replace(/['’`()\[\]]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return stripped;
|
||||
}
|
||||
|
||||
type RuleTemplateBodySection = {
|
||||
categoryName?: unknown;
|
||||
entries?: unknown;
|
||||
};
|
||||
type RuleTemplateBody = { sections?: unknown };
|
||||
|
||||
export type TemplateMethodRef = { section: SectionId; slug: string };
|
||||
|
||||
export function templateMethodsFromBody(
|
||||
body: unknown,
|
||||
): TemplateMethodRef[] {
|
||||
if (!body || typeof body !== "object") return [];
|
||||
const sections = (body as RuleTemplateBody).sections;
|
||||
if (!Array.isArray(sections)) return [];
|
||||
|
||||
const out: TemplateMethodRef[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const raw of sections) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
const sec = raw as RuleTemplateBodySection;
|
||||
const categoryName =
|
||||
typeof sec.categoryName === "string" ? sec.categoryName : null;
|
||||
if (!categoryName) continue;
|
||||
const section = CATEGORY_NAME_TO_SECTION[categoryName];
|
||||
if (!section) continue; // Values, or any future category we don't score.
|
||||
if (!Array.isArray(sec.entries)) continue;
|
||||
for (const entry of sec.entries) {
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
const title = (entry as { title?: unknown }).title;
|
||||
if (typeof title !== "string" || title.trim() === "") continue;
|
||||
const slug = methodSlugFromTitle(title);
|
||||
if (!slug) continue;
|
||||
const key = `${section}:${slug}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push({ section, slug });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
|
||||
export const SECTION_IDS = [
|
||||
"communication",
|
||||
"membership",
|
||||
"decisionApproaches",
|
||||
"conflictManagement",
|
||||
] as const;
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user