Template recommendation implemented
This commit is contained in:
@@ -15,7 +15,16 @@ import { useCreateFlowFinalize } from "./hooks/useCreateFlowFinalize";
|
|||||||
import { useTemplateReviewActions } from "./hooks/useTemplateReviewActions";
|
import { useTemplateReviewActions } from "./hooks/useTemplateReviewActions";
|
||||||
import CreateFlowFooter from "../../components/navigation/CreateFlowFooter";
|
import CreateFlowFooter from "../../components/navigation/CreateFlowFooter";
|
||||||
import CreateFlowTopNav from "../../components/navigation/CreateFlowTopNav";
|
import CreateFlowTopNav from "../../components/navigation/CreateFlowTopNav";
|
||||||
import { getNextStep, getStepIndex, parseReviewReturnSearchParam, CREATE_FLOW_REVIEW_RETURN_QUERY_KEY } from "./utils/flowSteps";
|
import {
|
||||||
|
getNextStep,
|
||||||
|
getStepIndex,
|
||||||
|
parseReviewReturnSearchParam,
|
||||||
|
CREATE_FLOW_REVIEW_RETURN_QUERY_KEY,
|
||||||
|
TEMPLATES_FACET_RECOMMEND_QUERY,
|
||||||
|
TEMPLATES_FACET_RECOMMEND_VALUE,
|
||||||
|
TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY,
|
||||||
|
TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE,
|
||||||
|
} from "./utils/flowSteps";
|
||||||
import { getProportionBarProgressForCreateFlowStep } from "./utils/createFlowProportionProgress";
|
import { getProportionBarProgressForCreateFlowStep } from "./utils/createFlowProportionProgress";
|
||||||
import {
|
import {
|
||||||
createFlowStepUsesCenteredTextLayout,
|
createFlowStepUsesCenteredTextLayout,
|
||||||
@@ -600,7 +609,9 @@ function CreateFlowLayoutContent({
|
|||||||
// detour. Direct entries to `/templates` (no marker) and
|
// detour. Direct entries to `/templates` (no marker) and
|
||||||
// home "Popular templates" clicks always start fresh by
|
// home "Popular templates" clicks always start fresh by
|
||||||
// wiping anonymous draft storage at click time.
|
// wiping anonymous draft storage at click time.
|
||||||
router.push("/templates?fromFlow=1");
|
router.push(
|
||||||
|
`/templates?${TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY}=${TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE}&${TEMPLATES_FACET_RECOMMEND_QUERY}=${TEMPLATES_FACET_RECOMMEND_VALUE}`,
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{footer.createFromTemplate}
|
{footer.createFromTemplate}
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import facetGroups from "../../../../data/create/customRule/_facetGroups.json";
|
import { buildFacetQueryString } from "../../../../lib/create/buildFacetQueryString";
|
||||||
import {
|
|
||||||
type CreateFlowState,
|
|
||||||
} from "../types";
|
|
||||||
import { useCreateFlow } from "../context/CreateFlowContext";
|
import { useCreateFlow } from "../context/CreateFlowContext";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,60 +13,6 @@ export type RecommendationSection =
|
|||||||
| "decisionApproaches"
|
| "decisionApproaches"
|
||||||
| "conflictManagement";
|
| "conflictManagement";
|
||||||
|
|
||||||
const FACET_GROUPS = ["size", "orgType", "scale", "maturity"] as const;
|
|
||||||
type FacetGroupId = (typeof FACET_GROUPS)[number];
|
|
||||||
|
|
||||||
/** Reverse map chipId → canonical facet value id, per group. */
|
|
||||||
const CHIP_TO_VALUE_BY_GROUP: Record<FacetGroupId, Record<string, string>> = (() => {
|
|
||||||
const out: Record<FacetGroupId, Record<string, string>> = {
|
|
||||||
size: {},
|
|
||||||
orgType: {},
|
|
||||||
scale: {},
|
|
||||||
maturity: {},
|
|
||||||
};
|
|
||||||
for (const group of FACET_GROUPS) {
|
|
||||||
const block = (facetGroups as Record<string, unknown>)[group];
|
|
||||||
if (block && typeof block === "object" && "values" in block) {
|
|
||||||
const values = (block as { values: Record<string, { chipId: string }> })
|
|
||||||
.values;
|
|
||||||
for (const [valueId, entry] of Object.entries(values)) {
|
|
||||||
out[group][entry.chipId] = valueId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
})();
|
|
||||||
|
|
||||||
/** Chip-id state accessors per group. */
|
|
||||||
const STATE_KEY_BY_GROUP: Record<FacetGroupId, keyof CreateFlowState> = {
|
|
||||||
size: "selectedCommunitySizeIds",
|
|
||||||
orgType: "selectedOrganizationTypeIds",
|
|
||||||
scale: "selectedScaleIds",
|
|
||||||
maturity: "selectedMaturityIds",
|
|
||||||
};
|
|
||||||
|
|
||||||
function readChipIds(
|
|
||||||
state: CreateFlowState,
|
|
||||||
group: FacetGroupId,
|
|
||||||
): string[] {
|
|
||||||
const value = state[STATE_KEY_BY_GROUP[group]];
|
|
||||||
return Array.isArray(value) ? (value as string[]) : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildFacetQuery(state: CreateFlowState): string {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
for (const group of FACET_GROUPS) {
|
|
||||||
const valuesById = CHIP_TO_VALUE_BY_GROUP[group];
|
|
||||||
for (const chipId of readChipIds(state, group)) {
|
|
||||||
const valueId = valuesById[chipId];
|
|
||||||
if (valueId) {
|
|
||||||
params.append(`facet.${group}`, valueId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return params.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FacetRecommendationsResult = {
|
export type FacetRecommendationsResult = {
|
||||||
/** `true` once the network call completes (or short-circuits with no facets). */
|
/** `true` once the network call completes (or short-circuits with no facets). */
|
||||||
isReady: boolean;
|
isReady: boolean;
|
||||||
@@ -99,7 +42,10 @@ export function useFacetRecommendations(
|
|||||||
section: RecommendationSection,
|
section: RecommendationSection,
|
||||||
): FacetRecommendationsResult {
|
): FacetRecommendationsResult {
|
||||||
const { state } = useCreateFlow();
|
const { state } = useCreateFlow();
|
||||||
const queryString = useMemo(() => buildFacetQuery(state), [state]);
|
const queryString = useMemo(
|
||||||
|
() => buildFacetQueryString(state),
|
||||||
|
[state],
|
||||||
|
);
|
||||||
const hasAnyFacets = queryString.length > 0;
|
const hasAnyFacets = queryString.length > 0;
|
||||||
|
|
||||||
const [result, setResult] = useState<FacetRecommendationsResult>({
|
const [result, setResult] = useState<FacetRecommendationsResult>({
|
||||||
|
|||||||
@@ -153,6 +153,14 @@ export function parseCreateFlowScreenFromPathname(
|
|||||||
export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY = "fromFlow" as const;
|
export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY = "fromFlow" as const;
|
||||||
export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE = "1" as const;
|
export const TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE = "1" as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only set from `/create/review` “Create from template” with `fromFlow=1`.
|
||||||
|
* Enables facet-ranked `GET /api/templates` + “RECOMMENDED” on the grid; omit
|
||||||
|
* on profile and marketing so stale localStorage facets never show badges.
|
||||||
|
*/
|
||||||
|
export const TEMPLATES_FACET_RECOMMEND_QUERY = "recommendTemplates" as const;
|
||||||
|
export const TEMPLATES_FACET_RECOMMEND_VALUE = "1" as const;
|
||||||
|
|
||||||
/** `/create/{step}?reviewReturn=…` — set when opening a custom-rule step from final-review or edit-rule via + */
|
/** `/create/{step}?reviewReturn=…` — set when opening a custom-rule step from final-review or edit-rule via + */
|
||||||
export const CREATE_FLOW_REVIEW_RETURN_QUERY_KEY = "reviewReturn" as const;
|
export const CREATE_FLOW_REVIEW_RETURN_QUERY_KEY = "reviewReturn" as const;
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,13 @@ import HeaderLockup from "../../components/type/HeaderLockup";
|
|||||||
import { GovernanceTemplateGrid } from "../../components/sections/GovernanceTemplateGrid";
|
import { GovernanceTemplateGrid } from "../../components/sections/GovernanceTemplateGrid";
|
||||||
import type { TemplateGridCardEntry } from "../../../lib/templates/templateGridPresentation";
|
import type { TemplateGridCardEntry } from "../../../lib/templates/templateGridPresentation";
|
||||||
import { prepareFreshCreateFlowEntry } from "../../(app)/create/utils/prepareFreshCreateFlowEntry";
|
import { prepareFreshCreateFlowEntry } from "../../(app)/create/utils/prepareFreshCreateFlowEntry";
|
||||||
import { buildTemplateReviewHref } from "../../(app)/create/utils/flowSteps";
|
import {
|
||||||
|
buildTemplateReviewHref,
|
||||||
|
TEMPLATES_FACET_RECOMMEND_QUERY,
|
||||||
|
TEMPLATES_FACET_RECOMMEND_VALUE,
|
||||||
|
} from "../../(app)/create/utils/flowSteps";
|
||||||
import { useTranslation } from "../../contexts/MessagesContext";
|
import { useTranslation } from "../../contexts/MessagesContext";
|
||||||
|
import { useTemplatesFacetGridEntries } from "./useTemplatesFacetGridEntries";
|
||||||
|
|
||||||
export interface TemplatesPageClientProps {
|
export interface TemplatesPageClientProps {
|
||||||
initialGridEntries: TemplateGridCardEntry[];
|
initialGridEntries: TemplateGridCardEntry[];
|
||||||
@@ -44,9 +49,16 @@ export default function TemplatesPageClient({
|
|||||||
{/* Suspense boundary required by `useSearchParams` below
|
{/* Suspense boundary required by `useSearchParams` below
|
||||||
(Next.js 15+ static-generation contract). */}
|
(Next.js 15+ static-generation contract). */}
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={<TemplatesGrid entries={initialGridEntries} fromFlow={false} />}
|
fallback={
|
||||||
|
<TemplatesGrid
|
||||||
|
entries={initialGridEntries}
|
||||||
|
fromFlow={false}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<TemplatesGridWithSearchParams entries={initialGridEntries} />
|
<TemplatesGridWithSearchParams
|
||||||
|
initialGridEntries={initialGridEntries}
|
||||||
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,18 +67,25 @@ export default function TemplatesPageClient({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads `fromFlow=1` off the URL so we can skip the fresh-slate clear when
|
* - `fromFlow=1` — skip `prepareFreshCreateFlowEntry` on template click
|
||||||
* the user arrived from `/create/review`'s "Create from template" button.
|
* (draft preserved). Used by review “Create from template” and profile.
|
||||||
* That button pushes `/templates?fromFlow=1` so their in-progress community
|
* - `recommendTemplates=1` (with review only) — rank templates + “RECOMMENDED”
|
||||||
* stage is preserved when they pick a template here.
|
* from `GET /api/templates?facet.*` using the persisted community draft.
|
||||||
*/
|
*/
|
||||||
function TemplatesGridWithSearchParams({
|
function TemplatesGridWithSearchParams({
|
||||||
entries,
|
initialGridEntries,
|
||||||
}: {
|
}: {
|
||||||
entries: TemplateGridCardEntry[];
|
initialGridEntries: TemplateGridCardEntry[];
|
||||||
}) {
|
}) {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const fromFlow = searchParams.get("fromFlow") === "1";
|
const fromFlow = searchParams.get("fromFlow") === "1";
|
||||||
|
const enableFacetRecommendations =
|
||||||
|
searchParams.get(TEMPLATES_FACET_RECOMMEND_QUERY) ===
|
||||||
|
TEMPLATES_FACET_RECOMMEND_VALUE;
|
||||||
|
const entries = useTemplatesFacetGridEntries({
|
||||||
|
initialGridEntries,
|
||||||
|
enableFacetRecommendations,
|
||||||
|
});
|
||||||
return <TemplatesGrid entries={entries} fromFlow={fromFlow} />;
|
return <TemplatesGrid entries={entries} fromFlow={fromFlow} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { readAnonymousCreateFlowState } from "../../(app)/create/utils/anonymousDraftStorage";
|
||||||
|
import { buildFacetQueryString } from "../../../lib/create/buildFacetQueryString";
|
||||||
|
import {
|
||||||
|
fetchRankedTemplatesByFacets,
|
||||||
|
isTemplatesFetchAborted,
|
||||||
|
} from "../../../lib/create/fetchTemplates";
|
||||||
|
import {
|
||||||
|
gridEntriesWithFacetScores,
|
||||||
|
type TemplateGridCardEntry,
|
||||||
|
} from "../../../lib/templates/templateGridPresentation";
|
||||||
|
|
||||||
|
type UseTemplatesFacetGridEntriesArgs = {
|
||||||
|
initialGridEntries: TemplateGridCardEntry[];
|
||||||
|
enableFacetRecommendations: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When `enableFacetRecommendations` (review → “Create from template” only),
|
||||||
|
* re-fetch ranked templates from `GET /api/templates?facet.*` using the
|
||||||
|
* persisted create-flow draft. Otherwise returns `initialGridEntries` from SSR.
|
||||||
|
*/
|
||||||
|
export function useTemplatesFacetGridEntries({
|
||||||
|
initialGridEntries,
|
||||||
|
enableFacetRecommendations,
|
||||||
|
}: UseTemplatesFacetGridEntriesArgs): TemplateGridCardEntry[] {
|
||||||
|
const [entries, setEntries] = useState(initialGridEntries);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enableFacetRecommendations) {
|
||||||
|
setEntries(initialGridEntries);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const state = readAnonymousCreateFlowState();
|
||||||
|
const facetQuery = buildFacetQueryString(state);
|
||||||
|
if (facetQuery.length === 0) {
|
||||||
|
setEntries(initialGridEntries);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ac = new AbortController();
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const result = await fetchRankedTemplatesByFacets({
|
||||||
|
signal: ac.signal,
|
||||||
|
facetQuery,
|
||||||
|
});
|
||||||
|
if (ac.signal.aborted) return;
|
||||||
|
if ("error" in result) {
|
||||||
|
setEntries(initialGridEntries);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEntries(
|
||||||
|
gridEntriesWithFacetScores(result.templates, result.scores),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (isTemplatesFetchAborted(e)) return;
|
||||||
|
setEntries(initialGridEntries);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ac.abort();
|
||||||
|
};
|
||||||
|
}, [enableFacetRecommendations, initialGridEntries]);
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
{
|
||||||
|
"consensus": {
|
||||||
|
"size": ["oneMember", "twoToFive", "sixToTwelve"],
|
||||||
|
"orgType": ["dao", "openSource", "mutualAid", "workersCoop"],
|
||||||
|
"scale": ["national", "regional", "local"],
|
||||||
|
"maturity": ["earlyStage", "growthStage", "established", "enterprise"]
|
||||||
|
},
|
||||||
|
"consensus-clusters": {
|
||||||
|
"size": ["sixToTwelve", "thirteenToOneHundred", "oneHundredToOneHundredK"],
|
||||||
|
"orgType": ["dao", "openSource", "mutualAid"],
|
||||||
|
"scale": ["global", "national", "regional", "local"],
|
||||||
|
"maturity": ["earlyStage", "growthStage", "established", "enterprise"]
|
||||||
|
},
|
||||||
|
"solidarity-network": {
|
||||||
|
"size": ["oneMember"],
|
||||||
|
"orgType": ["dao", "openSource", "workersCoop"],
|
||||||
|
"scale": ["national", "regional", "local"],
|
||||||
|
"maturity": ["earlyStage", "growthStage", "established"]
|
||||||
|
},
|
||||||
|
"sortition-jury": {
|
||||||
|
"size": [],
|
||||||
|
"orgType": [],
|
||||||
|
"scale": ["national", "regional", "local"],
|
||||||
|
"maturity": ["growthStage", "established", "enterprise"]
|
||||||
|
},
|
||||||
|
"liquid-democracy": {
|
||||||
|
"size": ["thirteenToOneHundred", "oneHundredToOneHundredK"],
|
||||||
|
"orgType": ["dao", "openSource"],
|
||||||
|
"scale": ["national", "regional", "local"],
|
||||||
|
"maturity": ["earlyStage", "growthStage", "established", "enterprise"]
|
||||||
|
},
|
||||||
|
"do-ocracy": {
|
||||||
|
"size": ["oneMember"],
|
||||||
|
"orgType": ["dao", "openSource", "workersCoop"],
|
||||||
|
"scale": ["national", "regional", "local"],
|
||||||
|
"maturity": ["earlyStage", "growthStage", "established"]
|
||||||
|
},
|
||||||
|
"quadratic-governance": {
|
||||||
|
"size": [
|
||||||
|
"twoToFive",
|
||||||
|
"sixToTwelve",
|
||||||
|
"thirteenToOneHundred",
|
||||||
|
"oneHundredToOneHundredK"
|
||||||
|
],
|
||||||
|
"orgType": ["dao", "openSource", "workersCoop"],
|
||||||
|
"scale": ["global", "national", "regional", "local"],
|
||||||
|
"maturity": ["earlyStage", "growthStage", "established", "enterprise"]
|
||||||
|
},
|
||||||
|
"federated-clusters": {
|
||||||
|
"size": ["sixToTwelve", "thirteenToOneHundred"],
|
||||||
|
"orgType": ["dao", "openSource", "mutualAid", "workersCoop"],
|
||||||
|
"scale": ["national", "regional", "local"],
|
||||||
|
"maturity": ["earlyStage", "growthStage", "established", "enterprise"]
|
||||||
|
},
|
||||||
|
"devolution": {
|
||||||
|
"size": ["oneMember"],
|
||||||
|
"orgType": ["forProfit", "nonprofit"],
|
||||||
|
"scale": ["global", "national", "regional", "local"],
|
||||||
|
"maturity": ["earlyStage", "growthStage", "established", "enterprise"]
|
||||||
|
},
|
||||||
|
"benevolent-dictator": {
|
||||||
|
"size": ["oneMember"],
|
||||||
|
"orgType": ["forProfit", "nonprofit", "openSource"],
|
||||||
|
"scale": ["global", "national", "regional", "local"],
|
||||||
|
"maturity": ["earlyStage", "growthStage", "established"]
|
||||||
|
},
|
||||||
|
"petition": {
|
||||||
|
"size": ["thirteenToOneHundred", "oneHundredToOneHundredK"],
|
||||||
|
"orgType": ["dao", "openSource", "workersCoop"],
|
||||||
|
"scale": ["global", "national", "regional"],
|
||||||
|
"maturity": ["growthStage", "established", "enterprise"]
|
||||||
|
},
|
||||||
|
"self-appointed-board": {
|
||||||
|
"size": [
|
||||||
|
"oneMember",
|
||||||
|
"twoToFive",
|
||||||
|
"sixToTwelve",
|
||||||
|
"thirteenToOneHundred",
|
||||||
|
"oneHundredToOneHundredK"
|
||||||
|
],
|
||||||
|
"orgType": ["dao", "forProfit", "nonprofit", "openSource"],
|
||||||
|
"scale": ["global", "national", "regional", "local"],
|
||||||
|
"maturity": ["earlyStage", "growthStage", "established", "enterprise"]
|
||||||
|
},
|
||||||
|
"elected-board": {
|
||||||
|
"size": ["oneHundredToOneHundredK"],
|
||||||
|
"orgType": ["dao", "forProfit", "nonprofit"],
|
||||||
|
"scale": ["national", "regional", "local"],
|
||||||
|
"maturity": ["growthStage", "established", "enterprise"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import facetGroups from "../../data/create/customRule/_facetGroups.json";
|
||||||
|
import type { CreateFlowState } from "../../app/(app)/create/types";
|
||||||
|
|
||||||
|
const FACET_GROUPS = ["size", "orgType", "scale", "maturity"] as const;
|
||||||
|
type FacetGroupId = (typeof FACET_GROUPS)[number];
|
||||||
|
|
||||||
|
const CHIP_TO_VALUE_BY_GROUP: Record<FacetGroupId, Record<string, string>> =
|
||||||
|
(() => {
|
||||||
|
const out: Record<FacetGroupId, Record<string, string>> = {
|
||||||
|
size: {},
|
||||||
|
orgType: {},
|
||||||
|
scale: {},
|
||||||
|
maturity: {},
|
||||||
|
};
|
||||||
|
for (const group of FACET_GROUPS) {
|
||||||
|
const block = (facetGroups as Record<string, unknown>)[group];
|
||||||
|
if (block && typeof block === "object" && "values" in block) {
|
||||||
|
const values = (block as { values: Record<string, { chipId: string }> })
|
||||||
|
.values;
|
||||||
|
for (const [valueId, entry] of Object.entries(values)) {
|
||||||
|
out[group][entry.chipId] = valueId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const STATE_KEY_BY_GROUP: Record<FacetGroupId, keyof CreateFlowState> = {
|
||||||
|
size: "selectedCommunitySizeIds",
|
||||||
|
orgType: "selectedOrganizationTypeIds",
|
||||||
|
scale: "selectedScaleIds",
|
||||||
|
maturity: "selectedMaturityIds",
|
||||||
|
};
|
||||||
|
|
||||||
|
function readChipIds(
|
||||||
|
state: CreateFlowState,
|
||||||
|
group: FacetGroupId,
|
||||||
|
): string[] {
|
||||||
|
const value = state[STATE_KEY_BY_GROUP[group]];
|
||||||
|
return Array.isArray(value) ? (value as string[]) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build `facet.size=…&facet.orgType=…` query string from Create Community
|
||||||
|
* chip selections. Shared by `/api/create-flow/methods` and
|
||||||
|
* `GET /api/templates` ranking (CR-88).
|
||||||
|
*/
|
||||||
|
export function buildFacetQueryString(state: CreateFlowState): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
for (const group of FACET_GROUPS) {
|
||||||
|
const valuesById = CHIP_TO_VALUE_BY_GROUP[group];
|
||||||
|
for (const chipId of readChipIds(state, group)) {
|
||||||
|
const valueId = valuesById[chipId];
|
||||||
|
if (valueId) {
|
||||||
|
params.append(`facet.${group}`, valueId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return params.toString();
|
||||||
|
}
|
||||||
@@ -13,7 +13,21 @@ export type RuleTemplateDto = {
|
|||||||
featured: boolean;
|
featured: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TemplatesResponse = { templates?: RuleTemplateDto[] };
|
type TemplatesResponse = {
|
||||||
|
templates?: RuleTemplateDto[];
|
||||||
|
scores?: Record<string, TemplateFacetScoreDto>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Matches `listRankedRuleTemplatesFromDb` / GET `/api/templates` with facet params. */
|
||||||
|
export type TemplateFacetScoreDto = {
|
||||||
|
score: number;
|
||||||
|
matchedFacets: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RankedTemplatesFetchResult = {
|
||||||
|
templates: RuleTemplateDto[];
|
||||||
|
scores: Record<string, TemplateFacetScoreDto>;
|
||||||
|
};
|
||||||
|
|
||||||
export type FetchTemplatesOptions = {
|
export type FetchTemplatesOptions = {
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
@@ -57,6 +71,46 @@ export async function fetchTemplates(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Facet-ranked list + per-template scores (CR-88). Query must be non-empty
|
||||||
|
* `facet.size=…&…` from {@link buildFacetQueryString}.
|
||||||
|
*/
|
||||||
|
export async function fetchRankedTemplatesByFacets(options: {
|
||||||
|
facetQuery: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}): Promise<RankedTemplatesFetchResult | { error: string }> {
|
||||||
|
if (options.facetQuery.length === 0) {
|
||||||
|
return { error: "Could not load templates" };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/templates?${options.facetQuery}`, {
|
||||||
|
credentials: "include",
|
||||||
|
signal: options.signal,
|
||||||
|
});
|
||||||
|
const data = (await res.json()) as TemplatesResponse & { error?: string };
|
||||||
|
if (!res.ok) {
|
||||||
|
return {
|
||||||
|
error:
|
||||||
|
typeof data.error === "string"
|
||||||
|
? data.error
|
||||||
|
: "Could not load templates",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const templates = Array.isArray(data.templates) ? data.templates : [];
|
||||||
|
const raw = data.scores;
|
||||||
|
const scores: Record<string, TemplateFacetScoreDto> =
|
||||||
|
raw && typeof raw === "object" && !Array.isArray(raw)
|
||||||
|
? (raw as Record<string, TemplateFacetScoreDto>)
|
||||||
|
: {};
|
||||||
|
return { templates, scores };
|
||||||
|
} catch (e) {
|
||||||
|
if (isAbortError(e)) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return { error: "Could not load templates" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchTemplateBySlug(
|
export async function fetchTemplateBySlug(
|
||||||
slug: string,
|
slug: string,
|
||||||
options?: FetchTemplatesOptions,
|
options?: FetchTemplatesOptions,
|
||||||
|
|||||||
@@ -169,3 +169,72 @@ export async function scoreTemplatesByFacets(args: {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slugs that have at least one `TemplateFacet` row (Template Composition
|
||||||
|
* matrix, cols G–Y) — use {@link scoreTemplatesByTemplateFacets} for these;
|
||||||
|
* others use {@link scoreTemplatesByFacets}.
|
||||||
|
*/
|
||||||
|
export async function getTemplateFacetSlugSet(): Promise<Set<string> | null> {
|
||||||
|
if (!isDatabaseConfigured()) return null;
|
||||||
|
try {
|
||||||
|
const rows = await prisma.templateFacet.findMany({
|
||||||
|
where: { matches: true },
|
||||||
|
distinct: ["templateSlug"],
|
||||||
|
select: { templateSlug: true },
|
||||||
|
});
|
||||||
|
return new Set(rows.map((r) => r.templateSlug));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-template score from the `TemplateFacet` table: one point per
|
||||||
|
* user-requested `(group, value)` that exists for that `templateSlug`.
|
||||||
|
* Same counting semantics as the pre-DB matrix JSON path.
|
||||||
|
*/
|
||||||
|
export async function scoreTemplatesByTemplateFacets(args: {
|
||||||
|
templateSlugs: ReadonlyArray<string>;
|
||||||
|
facets: RequestedFacets;
|
||||||
|
}): Promise<TemplateRanking[] | null> {
|
||||||
|
if (!isDatabaseConfigured()) return null;
|
||||||
|
const requested = flattenRequestedFacets(args.facets);
|
||||||
|
if (requested.length === 0) {
|
||||||
|
return args.templateSlugs.map((templateSlug) => ({
|
||||||
|
templateSlug,
|
||||||
|
score: 0,
|
||||||
|
matchedFacets: [] as string[],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (args.templateSlugs.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = await prisma.templateFacet.findMany({
|
||||||
|
where: {
|
||||||
|
matches: true,
|
||||||
|
templateSlug: { in: [...args.templateSlugs] },
|
||||||
|
OR: requested.map(({ group, value }) => ({ group, value })),
|
||||||
|
},
|
||||||
|
select: { templateSlug: true, group: true, value: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const pairSet = new Set(
|
||||||
|
rows.map((r) => `${r.templateSlug}\0${r.group}\0${r.value}` as const),
|
||||||
|
);
|
||||||
|
|
||||||
|
return args.templateSlugs.map((templateSlug) => {
|
||||||
|
const matched: string[] = [];
|
||||||
|
for (const { group, value } of requested) {
|
||||||
|
if (pairSet.has(`${templateSlug}\0${group}\0${value}`)) {
|
||||||
|
matched.push(`${group}:${value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { templateSlug, score: matched.length, matchedFacets: matched };
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+46
-10
@@ -1,7 +1,11 @@
|
|||||||
import type { RuleTemplateDto } from "../create/fetchTemplates";
|
import type { RuleTemplateDto } from "../create/fetchTemplates";
|
||||||
import { prisma } from "./db";
|
import { prisma } from "./db";
|
||||||
import { isDatabaseConfigured } from "./env";
|
import { isDatabaseConfigured } from "./env";
|
||||||
import { scoreTemplatesByFacets } from "./methodRecommendations";
|
import {
|
||||||
|
getTemplateFacetSlugSet,
|
||||||
|
scoreTemplatesByFacets,
|
||||||
|
scoreTemplatesByTemplateFacets,
|
||||||
|
} from "./methodRecommendations";
|
||||||
import { templateMethodsFromBody } from "./templateMethods";
|
import { templateMethodsFromBody } from "./templateMethods";
|
||||||
import type { RequestedFacets } from "./validation/methodFacetsSchemas";
|
import type { RequestedFacets } from "./validation/methodFacetsSchemas";
|
||||||
import { flattenRequestedFacets } from "./validation/methodFacetsSchemas";
|
import { flattenRequestedFacets } from "./validation/methodFacetsSchemas";
|
||||||
@@ -53,8 +57,11 @@ export type RankedTemplatesResult = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Curated templates ranked by how many of `facets` each composed method
|
* Curated templates ranked by facet match. Templates with a row in
|
||||||
* matches (§9.1). When `facets` is empty, returns the curated ordering with
|
* `TemplateFacet` (seeded from `data/templates/templateFacet.json`, Template
|
||||||
|
* Composition-2, cols G–Y) use that matrix; others fall back to
|
||||||
|
* composed-method × `MethodFacet`
|
||||||
|
* scoring (§9.1). When `facets` is empty, returns the curated ordering with
|
||||||
* an empty `scores` map (caller can omit it from the API response).
|
* an empty `scores` map (caller can omit it from the API response).
|
||||||
*
|
*
|
||||||
* Ties (and zero-score templates) fall back to the curated
|
* Ties (and zero-score templates) fall back to the curated
|
||||||
@@ -83,22 +90,51 @@ export async function listRankedRuleTemplatesFromDb(
|
|||||||
return { templates: [], scores: {} };
|
return { templates: [], scores: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const slugs = templates.map((t) => t.slug);
|
||||||
const templateMethods = templates.map((t) => ({
|
const templateMethods = templates.map((t) => ({
|
||||||
templateSlug: t.slug,
|
templateSlug: t.slug,
|
||||||
methods: templateMethodsFromBody(t.body),
|
methods: templateMethodsFromBody(t.body),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const ranked = await scoreTemplatesByFacets({ templateMethods, facets });
|
const [matrixRanked, facetSlugSet, methodRanked] = await Promise.all([
|
||||||
if (!ranked) {
|
scoreTemplatesByTemplateFacets({ templateSlugs: slugs, facets }),
|
||||||
|
getTemplateFacetSlugSet(),
|
||||||
|
scoreTemplatesByFacets({ templateMethods, facets }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!methodRanked) {
|
||||||
return { templates, scores: {} };
|
return { templates, scores: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const matrixBySlug =
|
||||||
|
matrixRanked == null
|
||||||
|
? new Map()
|
||||||
|
: new Map(matrixRanked.map((r) => [r.templateSlug, r] as const));
|
||||||
|
const methodBySlug = new Map(
|
||||||
|
methodRanked.map((r) => [r.templateSlug, r] as const),
|
||||||
|
);
|
||||||
|
|
||||||
const scores: Record<string, TemplateScore> = {};
|
const scores: Record<string, TemplateScore> = {};
|
||||||
for (const r of ranked) {
|
for (const t of templates) {
|
||||||
scores[r.templateSlug] = {
|
const useMatrix =
|
||||||
score: r.score,
|
matrixRanked != null && (facetSlugSet?.has(t.slug) ?? false);
|
||||||
matchedFacets: r.matchedFacets,
|
if (useMatrix) {
|
||||||
};
|
const m = matrixBySlug.get(t.slug);
|
||||||
|
if (m) {
|
||||||
|
scores[t.slug] = {
|
||||||
|
score: m.score,
|
||||||
|
matchedFacets: m.matchedFacets,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const m = methodBySlug.get(t.slug);
|
||||||
|
if (m) {
|
||||||
|
scores[t.slug] = {
|
||||||
|
score: m.score,
|
||||||
|
matchedFacets: m.matchedFacets,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stable sort: scoreDesc, then preserve curated index order.
|
// Stable sort: scoreDesc, then preserve curated index order.
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
MATURITY_VALUE_IDS,
|
||||||
|
ORG_TYPE_VALUE_IDS,
|
||||||
|
SCALE_VALUE_IDS,
|
||||||
|
SIZE_VALUE_IDS,
|
||||||
|
} from "./methodFacetsSchemas";
|
||||||
|
|
||||||
|
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-template row for Template Composition-2 (spreadsheet cols G–Y). Each
|
||||||
|
* array lists canonical facet `value` ids that are a fit (✓) for that
|
||||||
|
* community dimension.
|
||||||
|
*/
|
||||||
|
export const templateFacetRowSchema = z
|
||||||
|
.object({
|
||||||
|
size: z.array(sizeValueIdSchema),
|
||||||
|
orgType: z.array(orgTypeValueIdSchema),
|
||||||
|
scale: z.array(scaleValueIdSchema),
|
||||||
|
maturity: z.array(maturityValueIdSchema),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const templateFacetFileSchema = z.record(
|
||||||
|
z.string().min(1),
|
||||||
|
templateFacetRowSchema,
|
||||||
|
);
|
||||||
|
|
||||||
|
export type TemplateFacetFile = z.infer<typeof templateFacetFileSchema>;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { RuleTemplateDto } from "../create/fetchTemplates";
|
import type { RuleTemplateDto, TemplateFacetScoreDto } from "../create/fetchTemplates";
|
||||||
import { templateSummaryFromBody } from "../create/templateReviewMapping";
|
import { templateSummaryFromBody } from "../create/templateReviewMapping";
|
||||||
import type { GovernanceTemplateCatalogEntry } from "./governanceTemplateCatalog";
|
import type { GovernanceTemplateCatalogEntry } from "./governanceTemplateCatalog";
|
||||||
import {
|
import {
|
||||||
@@ -47,6 +47,70 @@ export function ruleTemplateToGridEntry(template: RuleTemplateDto): TemplateGrid
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Max templates that show the “RECOMMENDED” tag when facet-ranked. Within the
|
||||||
|
* **top score tier** only: we do not pad with lower-scoring templates (e.g. two
|
||||||
|
* at score 4 and three at 3 → recommend the two 4s only), but if the top tier
|
||||||
|
* exceeds this cap we still take the first `limit` in API order.
|
||||||
|
*/
|
||||||
|
export const TEMPLATE_GRID_COMPACT_RECOMMENDED_LIMIT = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Among templates in **API rank order** (score desc) with `score > 0`, mark
|
||||||
|
* only those in the **maximum-score tier** (no lower tiers), at most `limit`
|
||||||
|
* (API order is the tie-break when many tie for first place).
|
||||||
|
*/
|
||||||
|
export function deriveRecommendedTemplateSlugs(
|
||||||
|
templatesInRankOrder: ReadonlyArray<{ slug: string }>,
|
||||||
|
scores: Record<string, { score?: number } | undefined>,
|
||||||
|
limit: number,
|
||||||
|
): Set<string> {
|
||||||
|
if (limit <= 0) return new Set();
|
||||||
|
const matched = templatesInRankOrder.filter(
|
||||||
|
(t) => (scores[t.slug]?.score ?? 0) > 0,
|
||||||
|
);
|
||||||
|
if (matched.length === 0) return new Set();
|
||||||
|
let maxScore = 0;
|
||||||
|
for (const t of matched) {
|
||||||
|
const s = scores[t.slug]?.score ?? 0;
|
||||||
|
if (s > maxScore) maxScore = s;
|
||||||
|
}
|
||||||
|
const topTier = matched.filter(
|
||||||
|
(t) => (scores[t.slug]?.score ?? 0) === maxScore,
|
||||||
|
);
|
||||||
|
return new Set(topTier.slice(0, limit).map((t) => t.slug));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GridEntriesWithFacetScoresOptions = {
|
||||||
|
/** Default {@link TEMPLATE_GRID_COMPACT_RECOMMENDED_LIMIT}. */
|
||||||
|
compactRecommendedLimit?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After `GET /api/templates?facet.*` with `scores`, mark `recommended` only
|
||||||
|
* for the top facet matches (see {@link deriveRecommendedTemplateSlugs}).
|
||||||
|
*/
|
||||||
|
export function gridEntriesWithFacetScores(
|
||||||
|
templates: RuleTemplateDto[],
|
||||||
|
scores: Record<string, TemplateFacetScoreDto>,
|
||||||
|
options?: GridEntriesWithFacetScoresOptions,
|
||||||
|
): TemplateGridCardEntry[] {
|
||||||
|
const cap =
|
||||||
|
options?.compactRecommendedLimit ?? TEMPLATE_GRID_COMPACT_RECOMMENDED_LIMIT;
|
||||||
|
const recommendedSlugs = deriveRecommendedTemplateSlugs(
|
||||||
|
templates,
|
||||||
|
scores,
|
||||||
|
cap,
|
||||||
|
);
|
||||||
|
return templates.map((t) => {
|
||||||
|
const base = ruleTemplateToGridEntry(t);
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
recommended: recommendedSlugs.has(t.slug),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const bySlug = (templates: RuleTemplateDto[]) =>
|
const bySlug = (templates: RuleTemplateDto[]) =>
|
||||||
new Map(templates.map((t) => [t.slug, t] as const));
|
new Map(templates.map((t) => [t.slug, t] as const));
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TemplateFacet" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"templateSlug" TEXT NOT NULL,
|
||||||
|
"group" TEXT NOT NULL,
|
||||||
|
"value" TEXT NOT NULL,
|
||||||
|
"matches" BOOLEAN NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "TemplateFacet_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "TemplateFacet_templateSlug_group_value_key" ON "TemplateFacet"("templateSlug", "group", "value");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "TemplateFacet_templateSlug_idx" ON "TemplateFacet"("templateSlug");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "TemplateFacet_group_value_matches_idx" ON "TemplateFacet"("group", "value", "matches");
|
||||||
@@ -111,3 +111,24 @@ model MethodFacet {
|
|||||||
@@index([section])
|
@@index([section])
|
||||||
@@index([group, value, matches])
|
@@index([group, value, matches])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Template-level recommendation matrix (Template Composition, cols G–Y). Canonical
|
||||||
|
/// JSON in `data/templates/templateFacet.json`; rebuilt at seed like
|
||||||
|
/// `MethodFacet`. One row per `(templateSlug, group, value)` where the matrix
|
||||||
|
/// marks a fit (✓). `GET /api/templates?facet.*` joins these rows to user facets.
|
||||||
|
/// See `docs/guides/template-recommendation-matrix.md` (parallel to `MethodFacet` §7).
|
||||||
|
model TemplateFacet {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
/// `RuleTemplate.slug` (e.g. `consensus`, `do-ocracy`).
|
||||||
|
templateSlug String
|
||||||
|
/// `size` | `orgType` | `scale` | `maturity` — same as `MethodFacet.group`.
|
||||||
|
group String
|
||||||
|
/// Canonical facet value id, e.g. `workersCoop`, `local`.
|
||||||
|
value String
|
||||||
|
/// `true` iff the JSON marks a fit; seed only writes `true` rows.
|
||||||
|
matches Boolean
|
||||||
|
|
||||||
|
@@unique([templateSlug, group, value])
|
||||||
|
@@index([templateSlug])
|
||||||
|
@@index([group, value, matches])
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { PrismaClient, type Prisma } from "@prisma/client";
|
import { PrismaClient, type Prisma } from "@prisma/client";
|
||||||
import { seedMethodFacets } from "./seed/methodFacets";
|
import { seedMethodFacets } from "./seed/methodFacets";
|
||||||
|
import { seedTemplateFacets } from "./seed/templateFacets";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Curated rule templates for GET /api/templates.
|
* Curated rule templates for GET /api/templates.
|
||||||
@@ -393,6 +394,12 @@ async function main() {
|
|||||||
.map(([section, count]) => `${section}=${count}`)
|
.map(([section, count]) => `${section}=${count}`)
|
||||||
.join(", ")}`,
|
.join(", ")}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const templateFacetSeed = await seedTemplateFacets(prisma);
|
||||||
|
// eslint-disable-next-line no-console -- seed CLI feedback
|
||||||
|
console.log(
|
||||||
|
`Seeded TemplateFacet rows: ${templateFacetSeed.rowCount}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { PrismaClient } from "@prisma/client";
|
||||||
|
import { FACET_GROUP_IDS } from "../../lib/server/validation/methodFacetsSchemas";
|
||||||
|
import { templateFacetFileSchema } from "../../lib/server/validation/templateFacetSchema";
|
||||||
|
|
||||||
|
const REPO_ROOT = path.resolve(__dirname, "..", "..");
|
||||||
|
const TEMPLATE_FACET_FILE = path.join(
|
||||||
|
REPO_ROOT,
|
||||||
|
"data",
|
||||||
|
"templates",
|
||||||
|
"templateFacet.json",
|
||||||
|
);
|
||||||
|
|
||||||
|
type TemplateFacetRow = {
|
||||||
|
templateSlug: string;
|
||||||
|
group: string;
|
||||||
|
value: string;
|
||||||
|
matches: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadTemplateFacets() {
|
||||||
|
const raw = await readFile(TEMPLATE_FACET_FILE, "utf8");
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
const result = templateFacetFileSchema.safeParse(parsed);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid template facet file ${TEMPLATE_FACET_FILE}: ${JSON.stringify(
|
||||||
|
result.error.flatten(),
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row per `(templateSlug, group, value)` where the matrix lists a fit (✓).
|
||||||
|
* Sparse: omitted cells are not stored (unlike `MethodFacet`, which materializes
|
||||||
|
* all cells for constant table density).
|
||||||
|
*/
|
||||||
|
function flattenTemplateFacets(
|
||||||
|
data: Awaited<ReturnType<typeof loadTemplateFacets>>,
|
||||||
|
): TemplateFacetRow[] {
|
||||||
|
const rows: TemplateFacetRow[] = [];
|
||||||
|
for (const [templateSlug, row] of Object.entries(data)) {
|
||||||
|
for (const group of FACET_GROUP_IDS) {
|
||||||
|
for (const value of row[group]) {
|
||||||
|
rows.push({ templateSlug, group, value, matches: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates and re-seeds the `TemplateFacet` table from
|
||||||
|
* `data/templates/templateFacet.json` (Template Composition-2, cols G–Y).
|
||||||
|
*/
|
||||||
|
export async function seedTemplateFacets(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
): Promise<{ rowCount: number }> {
|
||||||
|
const data = await loadTemplateFacets();
|
||||||
|
const rows = flattenTemplateFacets(data);
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.templateFacet.deleteMany(),
|
||||||
|
prisma.templateFacet.createMany({ data: rows }),
|
||||||
|
]);
|
||||||
|
return { rowCount: rows.length };
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { buildFacetQueryString } from "../../lib/create/buildFacetQueryString";
|
||||||
|
|
||||||
|
describe("buildFacetQueryString", () => {
|
||||||
|
it("maps community chip ids to facet.* query params", () => {
|
||||||
|
const qs = buildFacetQueryString({
|
||||||
|
selectedCommunitySizeIds: ["2"],
|
||||||
|
selectedOrganizationTypeIds: ["3"],
|
||||||
|
selectedScaleIds: ["1"],
|
||||||
|
selectedMaturityIds: ["1"],
|
||||||
|
});
|
||||||
|
const params = new URLSearchParams(qs);
|
||||||
|
expect(params.get("facet.size")).toBe("twoToFive");
|
||||||
|
expect(params.get("facet.orgType")).toBe("openSource");
|
||||||
|
expect(params.get("facet.scale")).toBe("local");
|
||||||
|
expect(params.get("facet.maturity")).toBe("earlyStage");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string when no selections", () => {
|
||||||
|
expect(buildFacetQueryString({})).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,10 @@ import {
|
|||||||
getStepIndex,
|
getStepIndex,
|
||||||
parseReviewReturnSearchParam,
|
parseReviewReturnSearchParam,
|
||||||
resolveCreateFlowBackTarget,
|
resolveCreateFlowBackTarget,
|
||||||
|
TEMPLATES_FACET_RECOMMEND_QUERY,
|
||||||
|
TEMPLATES_FACET_RECOMMEND_VALUE,
|
||||||
|
TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY,
|
||||||
|
TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE,
|
||||||
} from "../../app/(app)/create/utils/flowSteps";
|
} from "../../app/(app)/create/utils/flowSteps";
|
||||||
|
|
||||||
describe("flowSteps", () => {
|
describe("flowSteps", () => {
|
||||||
@@ -106,6 +110,12 @@ describe("flowSteps", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("review Create from template uses fromFlow and recommendTemplates together", () => {
|
||||||
|
expect(
|
||||||
|
`/templates?${TEMPLATE_REVIEW_FROM_CREATE_FLOW_QUERY}=${TEMPLATE_REVIEW_FROM_CREATE_FLOW_VALUE}&${TEMPLATES_FACET_RECOMMEND_QUERY}=${TEMPLATES_FACET_RECOMMEND_VALUE}`,
|
||||||
|
).toBe("/templates?fromFlow=1&recommendTemplates=1");
|
||||||
|
});
|
||||||
|
|
||||||
it("parseReviewReturnSearchParam accepts only final-review and edit-rule", () => {
|
it("parseReviewReturnSearchParam accepts only final-review and edit-rule", () => {
|
||||||
expect(
|
expect(
|
||||||
parseReviewReturnSearchParam(
|
parseReviewReturnSearchParam(
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const templateFindManyMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../../lib/server/env", () => ({
|
||||||
|
isDatabaseConfigured: () => true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../lib/server/db", () => ({
|
||||||
|
prisma: {
|
||||||
|
templateFacet: {
|
||||||
|
findMany: (...args: unknown[]) => templateFindManyMock(...args),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
getTemplateFacetSlugSet,
|
||||||
|
scoreTemplatesByTemplateFacets,
|
||||||
|
} from "../../lib/server/methodRecommendations";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
templateFindManyMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("scoreTemplatesByTemplateFacets", () => {
|
||||||
|
it("counts matches against TemplateFacet rows", async () => {
|
||||||
|
templateFindManyMock.mockResolvedValueOnce([
|
||||||
|
{ templateSlug: "consensus", group: "size", value: "oneMember" },
|
||||||
|
{ templateSlug: "consensus", group: "orgType", value: "dao" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const out = await scoreTemplatesByTemplateFacets({
|
||||||
|
templateSlugs: ["consensus", "unknown-slug"],
|
||||||
|
facets: {
|
||||||
|
size: ["oneMember"],
|
||||||
|
orgType: ["dao"],
|
||||||
|
scale: [],
|
||||||
|
maturity: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const consensus = out?.find((r) => r.templateSlug === "consensus");
|
||||||
|
const unknown = out?.find((r) => r.templateSlug === "unknown-slug");
|
||||||
|
expect(consensus?.score).toBe(2);
|
||||||
|
expect(consensus?.matchedFacets).toEqual([
|
||||||
|
"size:oneMember",
|
||||||
|
"orgType:dao",
|
||||||
|
]);
|
||||||
|
expect(unknown?.score).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns zero when no facets requested", async () => {
|
||||||
|
const out = await scoreTemplatesByTemplateFacets({
|
||||||
|
templateSlugs: ["consensus"],
|
||||||
|
facets: {},
|
||||||
|
});
|
||||||
|
expect(out?.[0]?.score).toBe(0);
|
||||||
|
expect(templateFindManyMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getTemplateFacetSlugSet", () => {
|
||||||
|
it("returns distinct template slugs", async () => {
|
||||||
|
templateFindManyMock.mockResolvedValueOnce([
|
||||||
|
{ templateSlug: "consensus" },
|
||||||
|
{ templateSlug: "do-ocracy" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const set = await getTemplateFacetSlugSet();
|
||||||
|
expect(set?.has("consensus")).toBe(true);
|
||||||
|
expect(set?.has("do-ocracy")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import {
|
||||||
|
deriveRecommendedTemplateSlugs,
|
||||||
|
gridEntriesWithFacetScores,
|
||||||
|
} from "../../lib/templates/templateGridPresentation";
|
||||||
|
import { fetchRankedTemplatesByFacets } from "../../lib/create/fetchTemplates";
|
||||||
|
import type { RuleTemplateDto } from "../../lib/create/fetchTemplates";
|
||||||
|
|
||||||
|
const minimalTemplate = (slug: string, title: string): RuleTemplateDto => ({
|
||||||
|
id: "x",
|
||||||
|
slug,
|
||||||
|
title,
|
||||||
|
category: null,
|
||||||
|
description: null,
|
||||||
|
body: null,
|
||||||
|
sortOrder: 0,
|
||||||
|
featured: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deriveRecommendedTemplateSlugs", () => {
|
||||||
|
it("returns at most limit slugs in the top score tier (API order for ties)", () => {
|
||||||
|
const templates = ["a", "b", "c", "d", "e", "f"].map((s) => ({ slug: s }));
|
||||||
|
const scores = Object.fromEntries(
|
||||||
|
["a", "b", "c", "d", "e", "f"].map((s) => [
|
||||||
|
s,
|
||||||
|
{ score: 1, matchedFacets: [] as string[] },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const set = deriveRecommendedTemplateSlugs(templates, scores, 5);
|
||||||
|
expect(set.size).toBe(5);
|
||||||
|
expect([...set]).toEqual(["a", "b", "c", "d", "e"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only recommends the highest score group, not lower tiers to fill the cap", () => {
|
||||||
|
const templates = ["a", "b", "c", "d", "e"].map((s) => ({ slug: s }));
|
||||||
|
const scores = {
|
||||||
|
a: { score: 4, matchedFacets: [] as string[] },
|
||||||
|
b: { score: 4, matchedFacets: [] as string[] },
|
||||||
|
c: { score: 3, matchedFacets: [] as string[] },
|
||||||
|
d: { score: 3, matchedFacets: [] as string[] },
|
||||||
|
e: { score: 3, matchedFacets: [] as string[] },
|
||||||
|
};
|
||||||
|
const set = deriveRecommendedTemplateSlugs(templates, scores, 5);
|
||||||
|
expect([...set]).toEqual(["a", "b"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("gridEntriesWithFacetScores", () => {
|
||||||
|
it("sets recommended true only for top compact matches (like card decks)", () => {
|
||||||
|
const t = minimalTemplate("do-ocracy", "Do-ocracy");
|
||||||
|
const [row] = gridEntriesWithFacetScores([t], {
|
||||||
|
"do-ocracy": { score: 3, matchedFacets: ["a"] },
|
||||||
|
});
|
||||||
|
expect(row.recommended).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mark lower-scoring templates recommended when a higher tier exists", () => {
|
||||||
|
const high = [
|
||||||
|
minimalTemplate("a", "A"),
|
||||||
|
minimalTemplate("b", "B"),
|
||||||
|
];
|
||||||
|
const low = minimalTemplate("c", "C");
|
||||||
|
const rows = gridEntriesWithFacetScores([...high, low], {
|
||||||
|
a: { score: 4, matchedFacets: [] },
|
||||||
|
b: { score: 4, matchedFacets: [] },
|
||||||
|
c: { score: 3, matchedFacets: [] },
|
||||||
|
});
|
||||||
|
const rec = rows.filter((r) => r.recommended).map((r) => r.slug);
|
||||||
|
expect(rec).toEqual(["a", "b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps top-tier recommended badges to compactRecommendedLimit", () => {
|
||||||
|
const slugs = ["a", "b", "c", "d", "e", "f"];
|
||||||
|
const templates = slugs.map((s) => minimalTemplate(s, s));
|
||||||
|
const scores = Object.fromEntries(
|
||||||
|
slugs.map((s) => [s, { score: 1, matchedFacets: [] as string[] }]),
|
||||||
|
);
|
||||||
|
const rows = gridEntriesWithFacetScores(templates, scores, {
|
||||||
|
compactRecommendedLimit: 5,
|
||||||
|
});
|
||||||
|
expect(rows.filter((r) => r.recommended).map((r) => r.slug)).toEqual([
|
||||||
|
"a",
|
||||||
|
"b",
|
||||||
|
"c",
|
||||||
|
"d",
|
||||||
|
"e",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets recommended false when score is zero or missing", () => {
|
||||||
|
const t = minimalTemplate("consensus", "Consensus");
|
||||||
|
const [a] = gridEntriesWithFacetScores([t], {
|
||||||
|
consensus: { score: 0, matchedFacets: [] },
|
||||||
|
});
|
||||||
|
const [b] = gridEntriesWithFacetScores([t], {});
|
||||||
|
expect(a.recommended).toBe(false);
|
||||||
|
expect(b.recommended).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fetchRankedTemplatesByFacets", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
global.fetch = vi.fn();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses ok JSON with templates and scores", async () => {
|
||||||
|
const template = minimalTemplate("s", "T");
|
||||||
|
vi.mocked(global.fetch).mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
templates: [template],
|
||||||
|
scores: { s: { score: 1, matchedFacets: ["size:oneMember"] } },
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
const r = await fetchRankedTemplatesByFacets({
|
||||||
|
facetQuery: "facet.size=oneMember",
|
||||||
|
});
|
||||||
|
expect("error" in r).toBe(false);
|
||||||
|
if (!("error" in r)) {
|
||||||
|
expect(r.templates).toEqual([template]);
|
||||||
|
expect(r.scores.s?.score).toBe(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns error when facetQuery is empty", async () => {
|
||||||
|
const r = await fetchRankedTemplatesByFacets({ facetQuery: "" });
|
||||||
|
expect("error" in r).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user