Files
community-rule/lib/create/publishedDocumentToDisplaySections.ts
2026-05-08 20:32:24 -06:00

311 lines
10 KiB
TypeScript

import type {
CommunityRuleEntry,
CommunityRuleSection,
} from "../../app/components/type/CommunityRule/CommunityRule.types";
import type { PublishedMethodSelections } from "./buildPublishPayload";
import type { CustomMethodCardFieldBlock } from "./customMethodCardFieldBlocks";
import {
PUBLISH_FALLBACK_OVERVIEW_CATEGORY,
parseDocumentSectionsForDisplay,
} from "./buildPublishPayload";
import { resolveMethodPresetIdFromLabel } from "./buildFinalReviewCategories";
import {
communicationPresetFor,
conflictManagementPresetFor,
decisionApproachPresetFor,
membershipPresetFor,
mergeCoreValueDetailWithPresets,
} from "./finalReviewChipPresets";
import {
communityRuleEntryFromMethodChip,
formatScopePayload,
nonEmptyTrimmed,
RULE_SECTION_CATEGORY,
sectionFromCommunication,
sectionFromConflict,
sectionFromDecision,
sectionFromMembership,
} from "./ruleSectionsFromMethodSelections";
import {
templateCategoryToGroupKey,
type TemplateFacetGroupKey,
} from "./templateReviewMapping";
/** Legacy seed placeholder (removed from `prisma/seed.ts`); still hydrate older published rows. */
const TEMPLATE_COMPOSITION_SUGGESTED_BODY =
"Suggested focus for this governance area. Replace with your own language in the create flow.";
const CAT_VALUES = RULE_SECTION_CATEGORY.values;
const CAT_COMMUNICATION = RULE_SECTION_CATEGORY.communication;
const CAT_MEMBERSHIP = RULE_SECTION_CATEGORY.membership;
const CAT_DECISION = RULE_SECTION_CATEGORY.decisionMaking;
const CAT_CONFLICT = RULE_SECTION_CATEGORY.conflictManagement;
const CANONICAL_DISPLAY_SECTION_ORDER = [
CAT_VALUES,
CAT_COMMUNICATION,
CAT_MEMBERSHIP,
CAT_DECISION,
CAT_CONFLICT,
] as const;
const COMM_LABELS: Record<string, string> = {
corePrinciple: "Core Principle & Scope",
logisticsAdmin: "Logistics, Admin & Norms",
codeOfConduct: "Code of Conduct",
};
const MEM_LABELS: Record<string, string> = {
eligibility: "Eligibility & Philosophy",
joiningProcess: "Joining Process",
expectations: "Expectations & Removal",
};
const DEC_LABELS: Record<string, string> = {
corePrinciple: "Core Principle",
applicableScope: "Applicable Scope",
stepByStepInstructions: "Step-by-Step Instructions",
consensusLevel: "Consensus Level",
objectionsDeadlocks: "Objections & Deadlocks",
};
const CM_LABELS: Record<string, string> = {
corePrinciple: "Core Principle",
applicableScope: "Applicable Scope",
processProtocol: "Process Protocol",
restorationFallbacks: "Restoration & Fallbacks",
};
function needsPlaceholderPresetEnrichment(entry: CommunityRuleEntry): boolean {
if (entry.blocks && entry.blocks.length > 0) return false;
const b = (entry.body ?? "").trim();
if (b.length === 0) return true;
return b === TEMPLATE_COMPOSITION_SUGGESTED_BODY;
}
function presetRecordForMethodGroup(
groupKey: Exclude<TemplateFacetGroupKey, "coreValues">,
id: string,
): Record<string, unknown> {
switch (groupKey) {
case "communication":
return { ...communicationPresetFor(id) } as Record<string, unknown>;
case "membership":
return { ...membershipPresetFor(id) } as Record<string, unknown>;
case "decisionApproaches":
return { ...decisionApproachPresetFor(id) } as Record<string, unknown>;
case "conflictManagement":
return { ...conflictManagementPresetFor(id) } as Record<string, unknown>;
}
}
function enrichMethodEntryIfPlaceholder(
entry: CommunityRuleEntry,
categoryName: string,
): CommunityRuleEntry {
const groupKey = templateCategoryToGroupKey(categoryName);
if (!groupKey || groupKey === "coreValues") return entry;
if (!needsPlaceholderPresetEnrichment(entry)) return entry;
const id = resolveMethodPresetIdFromLabel(entry.title, groupKey);
if (!id) return entry;
const record = presetRecordForMethodGroup(groupKey, id);
const merged: Record<string, unknown> = { ...record };
if (groupKey === "decisionApproaches" || groupKey === "conflictManagement") {
const scope =
formatScopePayload(merged.selectedApplicableScope) ??
formatScopePayload(merged.applicableScope);
if (scope) merged.applicableScope = scope;
delete merged.selectedApplicableScope;
}
const labelByKey =
groupKey === "communication"
? COMM_LABELS
: groupKey === "membership"
? MEM_LABELS
: groupKey === "decisionApproaches"
? DEC_LABELS
: CM_LABELS;
const options =
groupKey === "decisionApproaches"
? { consensusLevelKey: "consensusLevel" as const }
: undefined;
const enriched = communityRuleEntryFromMethodChip(
entry.title,
merged,
labelByKey,
options,
);
return enriched ?? entry;
}
function enrichCoreValueEntryIfPlaceholder(
entry: CommunityRuleEntry,
): CommunityRuleEntry {
if (!needsPlaceholderPresetEnrichment(entry)) return entry;
const merged = mergeCoreValueDetailWithPresets("", entry.title, {
meaning: "",
signals: "",
});
const meaning = (merged.meaning ?? "").trim();
const signals = (merged.signals ?? "").trim();
const bodyParts: string[] = [];
if (meaning.length > 0) bodyParts.push(meaning);
if (signals.length > 0) bodyParts.push(signals);
const body = bodyParts.join("\n\n");
if (body.length === 0) return entry;
return { ...entry, body };
}
function enrichDisplaySection(section: CommunityRuleSection): CommunityRuleSection {
const groupKey = templateCategoryToGroupKey(section.categoryName);
if (groupKey === "coreValues") {
return {
...section,
entries: section.entries.map(enrichCoreValueEntryIfPlaceholder),
};
}
return {
...section,
entries: section.entries.map((e) =>
enrichMethodEntryIfPlaceholder(e, section.categoryName),
),
};
}
function sortSectionsCanonical(
sections: CommunityRuleSection[],
): CommunityRuleSection[] {
const order = CANONICAL_DISPLAY_SECTION_ORDER as readonly string[];
const rank = (name: string): number => {
const i = order.indexOf(name);
return i === -1 ? order.length : i;
};
return [...sections].sort((a, b) => {
const d = rank(a.categoryName) - rank(b.categoryName);
if (d !== 0) return d;
return a.categoryName.localeCompare(b.categoryName);
});
}
function sectionFromStoredCoreValues(
raw: unknown,
): CommunityRuleSection | null {
if (!Array.isArray(raw) || raw.length === 0) return null;
const entries: CommunityRuleEntry[] = [];
for (const row of raw) {
if (!row || typeof row !== "object") continue;
const o = row as Record<string, unknown>;
const chipId = typeof o.chipId === "string" ? o.chipId : "";
const label = nonEmptyTrimmed(o.label);
if (!label) continue;
const merged = mergeCoreValueDetailWithPresets(chipId, label, {
meaning: typeof o.meaning === "string" ? o.meaning : "",
signals: typeof o.signals === "string" ? o.signals : "",
});
const meaning = (merged.meaning ?? "").trim();
const signals = (merged.signals ?? "").trim();
const bodyParts: string[] = [];
if (meaning.length > 0) bodyParts.push(meaning);
if (signals.length > 0) bodyParts.push(signals);
const body = bodyParts.join("\n\n");
entries.push({ title: label, body });
}
if (entries.length === 0) return null;
return { categoryName: CAT_VALUES, entries };
}
function parseMethodSelectionsLoose(
document: Record<string, unknown>,
): PublishedMethodSelections | null {
const ms = document.methodSelections;
if (!ms || typeof ms !== "object" || Array.isArray(ms)) return null;
return ms as PublishedMethodSelections;
}
function parseCustomFieldBlocksByIdLoose(
document: Record<string, unknown>,
): Record<string, CustomMethodCardFieldBlock[]> | undefined {
const raw = document.customMethodCardFieldBlocksById;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
return raw as Record<string, CustomMethodCardFieldBlock[]>;
}
/**
* Full `CommunityRule` sections for a published `document` JSON blob: validated
* `document.sections` plus synthesized categories from `document.coreValues` and
* `document.methodSelections` when those categories are not already present.
* **Overview** sections (see `PUBLISH_FALLBACK_OVERVIEW_CATEGORY` in `buildPublishPayload`) from the publish fallback are dropped so the lockup
* header is the only intro; core value copy is the combined meaning + signals **body**
* under each value **title** (chip label).
*/
export function parsePublishedDocumentForCommunityRuleDisplay(
document: unknown,
): CommunityRuleSection[] {
if (!document || typeof document !== "object") return [];
const doc = document as Record<string, unknown>;
const hasPublishedCoreValues =
Array.isArray(doc.coreValues) && doc.coreValues.length > 0;
const base = parseDocumentSectionsForDisplay(doc).filter(
(s) =>
s.categoryName !== PUBLISH_FALLBACK_OVERVIEW_CATEGORY &&
!(hasPublishedCoreValues && s.categoryName === CAT_VALUES),
);
const seen = new Set(base.map((s) => s.categoryName));
const extra: CommunityRuleSection[] = [];
const valuesSection = sectionFromStoredCoreValues(doc.coreValues);
if (valuesSection && !seen.has(valuesSection.categoryName)) {
extra.push(valuesSection);
seen.add(valuesSection.categoryName);
}
let displaySections = [...base, ...extra];
const methodSelections = parseMethodSelectionsLoose(doc);
const customFieldBlocksById = parseCustomFieldBlocksByIdLoose(doc);
if (methodSelections) {
/**
* `document.sections` can lag `document.methodSelections` (e.g. API responses
* or older rows). Do not skip merging when the category already exists —
* that hid user-authored method cards on `/create/completed`.
*/
const replaceCategory = (fresh: CommunityRuleSection | null) => {
if (!fresh) return;
displaySections = displaySections.filter(
(s) => s.categoryName !== fresh.categoryName,
);
displaySections.push(fresh);
};
replaceCategory(
sectionFromCommunication(
methodSelections.communication ?? [],
customFieldBlocksById,
),
);
replaceCategory(
sectionFromMembership(
methodSelections.membership ?? [],
customFieldBlocksById,
),
);
replaceCategory(
sectionFromDecision(
methodSelections.decisionApproaches ?? [],
customFieldBlocksById,
),
);
replaceCategory(
sectionFromConflict(
methodSelections.conflictManagement ?? [],
customFieldBlocksById,
),
);
}
const combined = displaySections.map(enrichDisplaySection);
return sortSectionsCanonical(combined);
}