From 8f932e95cdfc172f06b0b0d4f29f63c8805f3c41 Mon Sep 17 00:00:00 2001
From: adilallo <39313955+adilallo@users.noreply.github.com>
Date: Tue, 7 Apr 2026 22:26:25 -0600
Subject: [PATCH] Wire Publish rule from create flow
---
CONTRIBUTING.md | 4 +-
app/components/modals/Login/LoginForm.tsx | 11 +-
app/create/CreateFlowLayoutClient.tsx | 285 ++++++++++++++++++++++
app/create/CreateFlowLayoutGate.tsx | 26 ++
app/create/PostLoginDraftTransfer.tsx | 54 +++-
app/create/completed/page.tsx | 37 ++-
app/create/final-review/page.tsx | 32 ++-
app/create/layout.tsx | 195 +--------------
lib/create/api.ts | 72 ++++--
lib/create/buildPublishPayload.ts | 84 +++++++
lib/create/lastPublishedRule.ts | 50 ++++
messages/en/create/publish.json | 6 +
messages/en/index.ts | 2 +
tests/components/FinalReviewPage.test.tsx | 50 +++-
tests/unit/buildPublishPayload.test.ts | 112 +++++++++
tests/unit/publishRule.test.ts | 71 ++++++
16 files changed, 839 insertions(+), 252 deletions(-)
create mode 100644 app/create/CreateFlowLayoutClient.tsx
create mode 100644 app/create/CreateFlowLayoutGate.tsx
create mode 100644 lib/create/buildPublishPayload.ts
create mode 100644 lib/create/lastPublishedRule.ts
create mode 100644 messages/en/create/publish.json
create mode 100644 tests/unit/buildPublishPayload.test.ts
create mode 100644 tests/unit/publishRule.test.ts
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 84832bf..909ba75 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -25,7 +25,7 @@ Use `npx prisma studio` to inspect the database.
| GET | `/api/auth/magic-link/verify` | Validate token, set session cookie, redirect |
| POST | `/api/auth/logout` | Clear session |
| GET / PUT | `/api/drafts/me` | Load or save create-flow JSON (authenticated) |
-| GET / POST | `/api/rules` | List or publish rules |
+| GET / POST | `/api/rules` | List or publish rules (each **Finalize** creates a new published row until an update/edit-published API exists) |
| GET | `/api/templates` | List curated templates |
### Email magic link (sign-in)
@@ -39,7 +39,7 @@ Use `npx prisma studio` to inspect the database.
### Optional draft sync
-Set `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` in `.env` so **signed-in** users can persist create-flow drafts to Postgres via **Save & Exit** and so **anonymous** progress can be **uploaded after magic-link sign-in** from the save-progress exit modal. Without it, server **PUT** `/api/drafts/me` is skipped; anonymous work stays in **browser `localStorage`** only.
+Set `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` in `.env` so **signed-in** users can persist create-flow drafts to Postgres via **Save & Exit** and so **anonymous** progress can be **uploaded after magic-link sign-in** from the save-progress exit modal. Without it, server **PUT** `/api/drafts/me` is skipped; anonymous work stays in **browser `localStorage`**, but after sign-in with a `?syncDraft=1` return URL the app still **merges that local draft into the in-memory create flow** (no server write) so you can continue and publish.
## Frontend / tests
diff --git a/app/components/modals/Login/LoginForm.tsx b/app/components/modals/Login/LoginForm.tsx
index 8d0eb5b..250c437 100644
--- a/app/components/modals/Login/LoginForm.tsx
+++ b/app/components/modals/Login/LoginForm.tsx
@@ -103,7 +103,7 @@ export default function LoginForm({
}
return;
}
- if (isSaveProgress) {
+ if (isSaveProgress || nextPath.includes("syncDraft=1")) {
setTransferPendingFlag();
}
setEmail(trimmed);
@@ -113,7 +113,14 @@ export default function LoginForm({
} finally {
setSubmitting(false);
}
- }, [email, isSaveProgress, magicLinkNextPath, nextParam, stripErrorQuery, t]);
+ }, [
+ email,
+ isSaveProgress,
+ magicLinkNextPath,
+ nextParam,
+ stripErrorQuery,
+ t,
+ ]);
const urlErrorMessage =
errorParam === "expired_link"
diff --git a/app/create/CreateFlowLayoutClient.tsx b/app/create/CreateFlowLayoutClient.tsx
new file mode 100644
index 0000000..290c433
--- /dev/null
+++ b/app/create/CreateFlowLayoutClient.tsx
@@ -0,0 +1,285 @@
+"use client";
+
+import {
+ Suspense,
+ useCallback,
+ useEffect,
+ useState,
+ type ReactNode,
+} from "react";
+import { usePathname, useRouter } from "next/navigation";
+import { CreateFlowProvider, useCreateFlow } from "./context/CreateFlowContext";
+import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation";
+import { useCreateFlowExit } from "./hooks/useCreateFlowExit";
+import CreateFlowTopNav from "../components/utility/CreateFlowTopNav";
+import { getStepIndex } from "./utils/flowSteps";
+import CreateFlowFooter from "../components/utility/CreateFlowFooter";
+import Button from "../components/buttons/Button";
+import { buildPublishPayload } from "../../lib/create/buildPublishPayload";
+import { fetchAuthSession, publishRule } from "../../lib/create/api";
+import { writeLastPublishedRule } from "../../lib/create/lastPublishedRule";
+import messages from "../../messages/en/index";
+import { useAuthModal } from "../contexts/AuthModalContext";
+import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer";
+import { SignedInDraftHydration } from "./SignedInDraftHydration";
+import Alert from "../components/modals/Alert";
+import {
+ CreateFlowDraftSaveBannerProvider,
+ useCreateFlowDraftSaveBanner,
+} from "./context/CreateFlowDraftSaveBannerContext";
+
+/** First step where Save & Exit is offered (after informational + name / `text`). */
+const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("select");
+
+function CreateFlowSessionShell({ children }: { children: ReactNode }) {
+ const [sessionUser, setSessionUser] = useState<
+ { id: string; email: string } | null | undefined
+ >(undefined);
+
+ useEffect(() => {
+ let cancelled = false;
+ void fetchAuthSession().then(({ user }) => {
+ if (!cancelled) setSessionUser(user);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ const sessionResolved = sessionUser !== undefined;
+ const enableAnonymousPersistence = sessionResolved && sessionUser === null;
+
+ return (
+
+
+
+ {children}
+
+
+
+ );
+}
+
+function CreateFlowLayoutContent({
+ children,
+ sessionUser,
+ sessionResolved,
+}: {
+ children: ReactNode;
+ sessionUser: { id: string; email: string } | null | undefined;
+ sessionResolved: boolean;
+}) {
+ const router = useRouter();
+ const pathname = usePathname();
+ const { openLogin } = useAuthModal();
+ const {
+ currentStep,
+ nextStep,
+ previousStep,
+ goToNextStep,
+ goToPreviousStep,
+ } = useCreateFlowNavigation();
+ const { state, clearState } = useCreateFlow();
+ const { draftSaveBannerMessage, setDraftSaveBannerMessage } =
+ useCreateFlowDraftSaveBanner();
+ const [publishBannerMessage, setPublishBannerMessage] = useState<
+ string | null
+ >(null);
+ const [isPublishing, setIsPublishing] = useState(false);
+
+ const handleFinalize = useCallback(async () => {
+ setPublishBannerMessage(null);
+ const payloadResult = buildPublishPayload(state);
+ if (payloadResult.ok === false) {
+ setPublishBannerMessage(
+ payloadResult.error === "missingCommunityName"
+ ? messages.create.publish.missingCommunityName
+ : payloadResult.error,
+ );
+ return;
+ }
+ const { title, summary, document: ruleDocument } = payloadResult;
+ setIsPublishing(true);
+ const publishResult = await publishRule({
+ title,
+ summary,
+ document: ruleDocument,
+ });
+ setIsPublishing(false);
+ if (publishResult.ok === true) {
+ writeLastPublishedRule({
+ id: publishResult.id,
+ title,
+ summary: summary ?? null,
+ document: ruleDocument,
+ });
+ router.push("/create/completed");
+ return;
+ }
+ if (publishResult.status === 401) {
+ openLogin({
+ variant: "default",
+ nextPath: "/create/final-review?syncDraft=1",
+ backdropVariant: "blurredYellow",
+ });
+ return;
+ }
+ setPublishBannerMessage(
+ publishResult.error.trim() !== ""
+ ? publishResult.error
+ : messages.create.publish.genericPublishFailed,
+ );
+ }, [state, router, openLogin]);
+
+ const runAuthenticatedExit = useCreateFlowExit({
+ state,
+ currentStep,
+ clearState,
+ router,
+ user: sessionUser ?? null,
+ setDraftSaveBannerMessage,
+ });
+
+ const handleExit = async (opts?: { saveDraft?: boolean }) => {
+ const saveDraft = opts?.saveDraft ?? false;
+ if (!sessionResolved) return;
+
+ if (sessionUser === null) {
+ if (saveDraft) return;
+ openLogin({
+ variant: "saveProgress",
+ nextPath: `${pathname ?? "/create/informational"}?syncDraft=1`,
+ backdropVariant: "blurredYellow",
+ });
+ return;
+ }
+
+ if (!sessionUser) return;
+ await runAuthenticatedExit(opts);
+ };
+
+ const isCompletedStep = currentStep === "completed";
+ const isRightRailStep = currentStep === "right-rail";
+ const useFullHeightMain = isCompletedStep || isRightRailStep;
+ const stepIdx = currentStep != null ? getStepIndex(currentStep) : -1;
+ const saveDraftOnExit =
+ Boolean(sessionUser) && stepIdx >= SAVE_EXIT_FROM_STEP_INDEX;
+
+ const hasErrorOverlays =
+ Boolean(draftSaveBannerMessage) || Boolean(publishBannerMessage);
+
+ return (
+
+ {hasErrorOverlays ? (
+
+ {draftSaveBannerMessage ? (
+
+
setDraftSaveBannerMessage(null)}
+ className="w-full"
+ />
+
+ ) : null}
+ {publishBannerMessage ? (
+
+
setPublishBannerMessage(null)}
+ className="w-full"
+ />
+
+ ) : null}
+
+ ) : null}
+
+
+
+
+
+
+
router.push("/create/final-review")
+ : undefined
+ }
+ onExit={(opts) => void handleExit(opts)}
+ buttonPalette={isCompletedStep ? "inverse" : undefined}
+ className={`shrink-0 ${
+ isCompletedStep ? "!bg-[var(--color-teal-teal50,#c9fef9)]" : ""
+ }`.trim()}
+ />
+
+ {children}
+
+ {!isCompletedStep && (
+ {
+ if (currentStep === "final-review") {
+ void handleFinalize();
+ } else {
+ goToNextStep();
+ }
+ }}
+ >
+ {currentStep === "final-review"
+ ? isPublishing
+ ? messages.create.publish.finalizeButtonPublishing
+ : "Finalize CommunityRule"
+ : currentStep === "confirm-stakeholders"
+ ? "Confirm Stakeholders"
+ : "Next"}
+
+ ) : null
+ }
+ onBackClick={previousStep ? goToPreviousStep : undefined}
+ />
+ )}
+
+ );
+}
+
+export default function CreateFlowLayoutClient({
+ children,
+}: {
+ children: ReactNode;
+}) {
+ return {children};
+}
diff --git a/app/create/CreateFlowLayoutGate.tsx b/app/create/CreateFlowLayoutGate.tsx
new file mode 100644
index 0000000..8441c27
--- /dev/null
+++ b/app/create/CreateFlowLayoutGate.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import dynamic from "next/dynamic";
+import type { ReactNode } from "react";
+
+const CreateFlowLayoutClient = dynamic(
+ () => import("./CreateFlowLayoutClient"),
+ {
+ ssr: false,
+ loading: () => (
+
+ ),
+ },
+);
+
+export default function CreateFlowLayoutGate({
+ children,
+}: {
+ children: ReactNode;
+}) {
+ return {children};
+}
diff --git a/app/create/PostLoginDraftTransfer.tsx b/app/create/PostLoginDraftTransfer.tsx
index e140715..154346a 100644
--- a/app/create/PostLoginDraftTransfer.tsx
+++ b/app/create/PostLoginDraftTransfer.tsx
@@ -16,7 +16,8 @@ const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
/**
* After magic-link verify, redirects to `/create/...?syncDraft=1` with session cookie.
- * Uploads anonymous localStorage draft to `RuleDraft` once, then hydrates context.
+ * With backend sync: PUT draft once then hydrates context. Without sync: hydrates from
+ * `create-flow-anonymous` localStorage only (no server write).
*/
export function PostLoginDraftTransfer({
sessionUser,
@@ -38,19 +39,46 @@ export function PostLoginDraftTransfer({
if (attemptedRef.current) return;
if (!SYNC_ENABLED) {
- if (attemptedRef.current) return;
attemptedRef.current = true;
- // eslint-disable-next-line react-hooks/set-state-in-effect -- sync-off path: show one-shot error then strip query
- setTransferError(
- "Saving to your account is not available (server sync is disabled). Your progress stays on this device.",
- );
- if (pathname) {
- const params = new URLSearchParams(searchParams.toString());
- params.delete("syncDraft");
- const q = params.toString();
- router.replace(q ? `${pathname}?${q}` : pathname);
- }
- return;
+ let cancelled = false;
+ void (async () => {
+ const local = readAnonymousCreateFlowState();
+ const pending = hasTransferPendingFlag();
+
+ if (Object.keys(local).length === 0 && !pending) {
+ const params = new URLSearchParams(searchParams.toString());
+ params.delete("syncDraft");
+ const q = params.toString();
+ if (pathname) {
+ router.replace(q ? `${pathname}?${q}` : pathname);
+ }
+ attemptedRef.current = false;
+ return;
+ }
+
+ const segment = pathname?.split("/").pop() ?? "";
+ const step = isValidStep(segment) ? segment : undefined;
+ const payload = {
+ ...local,
+ ...(step ? { currentStep: step } : {}),
+ };
+
+ if (cancelled) return;
+ clearAnonymousCreateFlowStorage();
+ replaceState(payload);
+
+ if (cancelled) return;
+ if (pathname) {
+ const params = new URLSearchParams(searchParams.toString());
+ params.delete("syncDraft");
+ const q = params.toString();
+ router.replace(q ? `${pathname}?${q}` : pathname);
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ };
}
attemptedRef.current = true;
diff --git a/app/create/completed/page.tsx b/app/create/completed/page.tsx
index 80cc5ba..5b86299 100644
--- a/app/create/completed/page.tsx
+++ b/app/create/completed/page.tsx
@@ -6,9 +6,12 @@ import HeaderLockup from "../../components/type/HeaderLockup";
import CommunityRuleDocument from "../../components/sections/CommunityRuleDocument";
import type { CommunityRuleDocumentSection } from "../../components/sections/CommunityRuleDocument/CommunityRuleDocument.types";
import Alert from "../../components/modals/Alert";
+import { parseDocumentSectionsForDisplay } from "../../../lib/create/buildPublishPayload";
+import { readLastPublishedRule } from "../../../lib/create/lastPublishedRule";
-const TITLE = "Mutual Aid Mondays";
-const DESCRIPTION =
+/** Demo copy when `/create/completed` is opened without a prior publish in this tab. */
+const FALLBACK_TITLE = "Mutual Aid Mondays";
+const FALLBACK_DESCRIPTION =
"Mutual Aid Monday is a grassroots community in Denver, founded in November 2020 by Kelsang Virya, dedicated to supporting neighbors experiencing homelessness.";
const TOAST_TITLE = "This is what folks see when you share your CommunityRule";
@@ -91,6 +94,12 @@ const COMPLETED_RULE_SECTIONS: CommunityRuleDocumentSection[] = [
export default function CompletedPage() {
const [isMounted, setIsMounted] = useState(false);
const [toastDismissed, setToastDismissed] = useState(false);
+ const [headerTitle, setHeaderTitle] = useState(FALLBACK_TITLE);
+ const [headerDescription, setHeaderDescription] = useState<
+ string | undefined
+ >(FALLBACK_DESCRIPTION);
+ const [documentSections, setDocumentSections] =
+ useState(COMPLETED_RULE_SECTIONS);
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
useEffect(() => {
@@ -98,6 +107,18 @@ export default function CompletedPage() {
setIsMounted(true);
}, []);
+ useEffect(() => {
+ const stored = readLastPublishedRule();
+ if (!stored) return;
+ const parsed = parseDocumentSectionsForDisplay(stored.document);
+ if (parsed.length === 0) return;
+ setDocumentSections(parsed);
+ setHeaderTitle(stored.title);
+ const sum =
+ typeof stored.summary === "string" ? stored.summary.trim() : "";
+ setHeaderDescription(sum.length > 0 ? sum : undefined);
+ }, []);
+
const showDesktopLayout = !isMounted || isMdOrLarger;
if (showDesktopLayout) {
@@ -108,8 +129,8 @@ export default function CompletedPage() {
{/* Left column: community title + header, centered, does not scroll */}
@@ -159,14 +180,14 @@ export default function CompletedPage() {
diff --git a/app/create/final-review/page.tsx b/app/create/final-review/page.tsx
index 26d5ddb..f00fe84 100644
--- a/app/create/final-review/page.tsx
+++ b/app/create/final-review/page.tsx
@@ -1,18 +1,19 @@
"use client";
-import { useState, useEffect } from "react";
+import { useState, useEffect, useMemo } from "react";
import { useMediaQuery } from "../../hooks/useMediaQuery";
import HeaderLockup from "../../components/type/HeaderLockup";
import RuleCard from "../../components/cards/RuleCard";
import type { Category } from "../../components/cards/RuleCard/RuleCard.types";
+import { useCreateFlow } from "../context/CreateFlowContext";
const TITLE = "Review your CommunityRule";
const DESCRIPTION =
"Here's what other people will see. Make sure everything looks good before you finalize everything. Once the rule is finalized, you must use one of your decision-making mechanisms to edit it again.";
-const RULE_CARD_TITLE = "Mutual Aid Mondays";
-const RULE_CARD_DESCRIPTION =
- "Mutual Aid Monday is a grassroots community in Denver, founded in November 2020 by Kelsang Virya, dedicated to supporting neighbors experiencing homelessness.";
+const RULE_CARD_TITLE_FALLBACK = "Your community";
+const RULE_CARD_DESCRIPTION_FALLBACK =
+ "Add a short description of your community on earlier steps when that field is available. For now, this card shows your community name.";
/** Static categories for final review (read-only display). */
const FINAL_REVIEW_CATEGORIES: Category[] = [
@@ -55,9 +56,20 @@ const FINAL_REVIEW_CATEGORIES: Category[] = [
* Figma: 20907-212767 (full-size), 20976-220705 (small breakpoint).
*/
export default function FinalReviewPage() {
+ const { state } = useCreateFlow();
const [isMounted, setIsMounted] = useState(false);
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
+ const ruleCardTitle = useMemo(() => {
+ const t = typeof state.title === "string" ? state.title.trim() : "";
+ return t.length > 0 ? t : RULE_CARD_TITLE_FALLBACK;
+ }, [state.title]);
+
+ const ruleCardDescription = useMemo(() => {
+ const s = typeof state.summary === "string" ? state.summary.trim() : "";
+ return s.length > 0 ? s : RULE_CARD_DESCRIPTION_FALLBACK;
+ }, [state.summary]);
+
// Avoid flash: only use breakpoint after mount so SSR and first paint use same layout (desktop).
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: defer layout breakpoint until after mount to prevent flash
@@ -80,13 +92,13 @@ export default function FinalReviewPage() {
{}}
@@ -107,13 +119,13 @@ export default function FinalReviewPage() {
size="M"
/>
{}}
diff --git a/app/create/layout.tsx b/app/create/layout.tsx
index 7069cb2..bca407c 100644
--- a/app/create/layout.tsx
+++ b/app/create/layout.tsx
@@ -1,193 +1,6 @@
-"use client";
+import type { ReactNode } from "react";
+import CreateFlowLayoutGate from "./CreateFlowLayoutGate";
-import { Suspense, useEffect, useState, type ReactNode } from "react";
-import { usePathname, useRouter } from "next/navigation";
-import { CreateFlowProvider, useCreateFlow } from "./context/CreateFlowContext";
-import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation";
-import { useCreateFlowExit } from "./hooks/useCreateFlowExit";
-import CreateFlowTopNav from "../components/utility/CreateFlowTopNav";
-import { getStepIndex } from "./utils/flowSteps";
-import CreateFlowFooter from "../components/utility/CreateFlowFooter";
-import Button from "../components/buttons/Button";
-import { fetchAuthSession } from "../../lib/create/api";
-import messages from "../../messages/en/index";
-import { useAuthModal } from "../contexts/AuthModalContext";
-import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer";
-import { SignedInDraftHydration } from "./SignedInDraftHydration";
-import Alert from "../components/modals/Alert";
-import {
- CreateFlowDraftSaveBannerProvider,
- useCreateFlowDraftSaveBanner,
-} from "./context/CreateFlowDraftSaveBannerContext";
-
-/** First step where Save & Exit is offered (after informational + name / `text`). */
-const SAVE_EXIT_FROM_STEP_INDEX = getStepIndex("select");
-
-function CreateFlowSessionShell({ children }: { children: ReactNode }) {
- const [sessionUser, setSessionUser] = useState<
- { id: string; email: string } | null | undefined
- >(undefined);
-
- useEffect(() => {
- let cancelled = false;
- void fetchAuthSession().then(({ user }) => {
- if (!cancelled) setSessionUser(user);
- });
- return () => {
- cancelled = true;
- };
- }, []);
-
- const sessionResolved = sessionUser !== undefined;
- const enableAnonymousPersistence = sessionResolved && sessionUser === null;
-
- return (
-
-
-
- {children}
-
-
-
- );
-}
-
-function CreateFlowLayoutContent({
- children,
- sessionUser,
- sessionResolved,
-}: {
- children: ReactNode;
- sessionUser: { id: string; email: string } | null | undefined;
- sessionResolved: boolean;
-}) {
- const router = useRouter();
- const pathname = usePathname();
- const { openLogin } = useAuthModal();
- const {
- currentStep,
- nextStep,
- previousStep,
- goToNextStep,
- goToPreviousStep,
- } = useCreateFlowNavigation();
- const { state, clearState } = useCreateFlow();
- const { draftSaveBannerMessage, setDraftSaveBannerMessage } =
- useCreateFlowDraftSaveBanner();
-
- const runAuthenticatedExit = useCreateFlowExit({
- state,
- currentStep,
- clearState,
- router,
- user: sessionUser ?? null,
- setDraftSaveBannerMessage,
- });
-
- const handleExit = async (opts?: { saveDraft?: boolean }) => {
- const saveDraft = opts?.saveDraft ?? false;
- if (!sessionResolved) return;
-
- if (sessionUser === null) {
- if (saveDraft) return;
- openLogin({
- variant: "saveProgress",
- nextPath: `${pathname ?? "/create/informational"}?syncDraft=1`,
- backdropVariant: "blurredYellow",
- });
- return;
- }
-
- if (!sessionUser) return;
- await runAuthenticatedExit(opts);
- };
-
- const isCompletedStep = currentStep === "completed";
- const isRightRailStep = currentStep === "right-rail";
- const useFullHeightMain = isCompletedStep || isRightRailStep;
- const stepIdx = currentStep != null ? getStepIndex(currentStep) : -1;
- const saveDraftOnExit =
- Boolean(sessionUser) && stepIdx >= SAVE_EXIT_FROM_STEP_INDEX;
-
- return (
-
- {draftSaveBannerMessage ? (
-
-
setDraftSaveBannerMessage(null)}
- className="w-full max-w-[960px] mx-auto"
- />
-
- ) : null}
-
-
-
-
-
-
-
router.push("/create/final-review")
- : undefined
- }
- onExit={(opts) => void handleExit(opts)}
- buttonPalette={isCompletedStep ? "inverse" : undefined}
- className={
- isCompletedStep ? "!bg-[var(--color-teal-teal50,#c9fef9)]" : undefined
- }
- />
-
- {children}
-
- {!isCompletedStep && (
-
- {currentStep === "final-review"
- ? "Finalize CommunityRule"
- : currentStep === "confirm-stakeholders"
- ? "Confirm Stakeholders"
- : "Next"}
-
- ) : null
- }
- onBackClick={previousStep ? goToPreviousStep : undefined}
- />
- )}
-
- );
-}
-
-export default function CreateFlowLayout({
- children,
-}: {
- children: ReactNode;
-}) {
- return {children};
+export default function CreateFlowLayout({ children }: { children: ReactNode }) {
+ return {children};
}
diff --git a/lib/create/api.ts b/lib/create/api.ts
index f674ae0..ca2acc6 100644
--- a/lib/create/api.ts
+++ b/lib/create/api.ts
@@ -83,6 +83,21 @@ export async function fetchDraftFromServer(): Promise {
const DRAFT_SAVE_NETWORK_ERROR =
"Something went wrong. Check your connection and try again.";
+const PUBLISH_FAILED_FALLBACK =
+ "Something went wrong. Check your connection or try again.";
+
+/** Parse JSON body; empty or invalid bodies return `null` (avoids `response.json()` throws). */
+async function safeParseJsonResponse(res: Response): Promise {
+ const text = await res.text();
+ const trimmed = text.trim();
+ if (!trimmed) return null;
+ try {
+ return JSON.parse(trimmed) as unknown;
+ } catch {
+ return null;
+ }
+}
+
export type SaveDraftResult =
| { ok: true }
| { ok: false; message: string; status?: number };
@@ -131,23 +146,44 @@ export async function publishRule(input: {
title: string;
summary?: string;
document: Record;
-}): Promise<{ ok: true; id: string; title: string } | { error: string }> {
- const res = await fetch("/api/rules", {
- method: "POST",
- credentials: "include",
- headers: jsonHeaders,
- body: JSON.stringify({
- title: input.title,
- summary: input.summary,
- document: input.document,
- }),
- });
- const data = await parseJson<{
- error?: string;
- rule?: { id: string; title: string };
- }>(res);
- if (!res.ok || !data.rule) {
- return { error: readApiErrorMessage(data) };
+}): Promise<
+ | { ok: true; id: string; title: string }
+ | { ok: false; error: string; status?: number }
+> {
+ try {
+ const res = await fetch("/api/rules", {
+ method: "POST",
+ credentials: "include",
+ headers: jsonHeaders,
+ body: JSON.stringify({
+ title: input.title,
+ summary: input.summary,
+ document: input.document,
+ }),
+ });
+ const data = (await safeParseJsonResponse(res)) as {
+ error?: string | { message?: string };
+ rule?: { id: string; title: string };
+ } | null;
+ const rule = data && typeof data === "object" ? data.rule : undefined;
+ if (!res.ok || !rule) {
+ const fromBody =
+ data && typeof data === "object" ? readApiErrorMessage(data) : null;
+ const msg =
+ fromBody && fromBody !== "Request failed"
+ ? fromBody
+ : res.statusText?.trim() || PUBLISH_FAILED_FALLBACK;
+ return {
+ ok: false as const,
+ error: msg,
+ status: res.status,
+ };
+ }
+ return { ok: true, id: rule.id, title: rule.title };
+ } catch {
+ return {
+ ok: false as const,
+ error: DRAFT_SAVE_NETWORK_ERROR,
+ };
}
- return { ok: true, id: data.rule.id, title: data.rule.title };
}
diff --git a/lib/create/buildPublishPayload.ts b/lib/create/buildPublishPayload.ts
new file mode 100644
index 0000000..f4a7868
--- /dev/null
+++ b/lib/create/buildPublishPayload.ts
@@ -0,0 +1,84 @@
+import type { CreateFlowState } from "../../app/create/types";
+import type { CommunityRuleDocumentSection } from "../../app/components/sections/CommunityRuleDocument/CommunityRuleDocument.types";
+
+function isDocumentEntry(x: unknown): x is { title: string; body: string } {
+ if (!x || typeof x !== "object") return false;
+ const o = x as Record;
+ return typeof o.title === "string" && typeof o.body === "string";
+}
+
+function isDocumentSection(x: unknown): x is CommunityRuleDocumentSection {
+ if (!x || typeof x !== "object") return false;
+ const o = x as Record;
+ if (typeof o.categoryName !== "string") return false;
+ if (!Array.isArray(o.entries)) return false;
+ return o.entries.every(isDocumentEntry);
+}
+
+/** Narrow `CreateFlowState.sections` into Community Rule document sections. */
+export function parseSectionsFromCreateFlowState(
+ state: CreateFlowState,
+): CommunityRuleDocumentSection[] {
+ const raw = state.sections;
+ if (!Array.isArray(raw)) return [];
+ const out: CommunityRuleDocumentSection[] = [];
+ for (const x of raw) {
+ if (isDocumentSection(x)) out.push(x);
+ }
+ return out;
+}
+
+export type BuildPublishPayloadResult =
+ | {
+ ok: true;
+ title: string;
+ summary?: string;
+ document: Record;
+ }
+ | { ok: false; error: string };
+
+const FALLBACK_CATEGORY = "Overview";
+
+const DEFAULT_FALLBACK_BODY =
+ "This CommunityRule was created in the create flow. Add more detail in a future edit.";
+
+export function buildPublishPayload(
+ state: CreateFlowState,
+): BuildPublishPayloadResult {
+ const title = typeof state.title === "string" ? state.title.trim() : "";
+ if (!title) {
+ return { ok: false, error: "missingCommunityName" };
+ }
+
+ let summary: string | undefined;
+ if (typeof state.summary === "string") {
+ const t = state.summary.trim();
+ if (t.length > 0) summary = t;
+ }
+
+ let sections = parseSectionsFromCreateFlowState(state);
+ if (sections.length === 0) {
+ const body = summary ?? DEFAULT_FALLBACK_BODY;
+ sections = [
+ {
+ categoryName: FALLBACK_CATEGORY,
+ entries: [{ title: "Community", body }],
+ },
+ ];
+ }
+
+ if (summary !== undefined) {
+ return { ok: true, title, summary, document: { sections } };
+ }
+ return { ok: true, title, document: { sections } };
+}
+
+/** Read `document.sections` from a stored published payload for display. */
+export function parseDocumentSectionsForDisplay(
+ document: unknown,
+): CommunityRuleDocumentSection[] {
+ if (!document || typeof document !== "object") return [];
+ const sections = (document as Record).sections;
+ if (!Array.isArray(sections)) return [];
+ return sections.filter(isDocumentSection);
+}
diff --git a/lib/create/lastPublishedRule.ts b/lib/create/lastPublishedRule.ts
new file mode 100644
index 0000000..5093116
--- /dev/null
+++ b/lib/create/lastPublishedRule.ts
@@ -0,0 +1,50 @@
+/**
+ * Bridges final-review → completed without query strings.
+ * Replace with GET /api/rules/[id] (CR-81) when public rule fetch exists.
+ */
+export const CREATE_FLOW_LAST_PUBLISHED_KEY = "createFlow.lastPublished";
+
+export type StoredLastPublishedRule = {
+ id: string;
+ title: string;
+ summary?: string | null;
+ document: Record;
+};
+
+export function writeLastPublishedRule(data: StoredLastPublishedRule): void {
+ if (typeof sessionStorage === "undefined") return;
+ sessionStorage.setItem(CREATE_FLOW_LAST_PUBLISHED_KEY, JSON.stringify(data));
+}
+
+export function readLastPublishedRule(): StoredLastPublishedRule | null {
+ if (typeof sessionStorage === "undefined") return null;
+ const raw = sessionStorage.getItem(CREATE_FLOW_LAST_PUBLISHED_KEY);
+ if (!raw) return null;
+ try {
+ const parsed = JSON.parse(raw) as unknown;
+ if (!parsed || typeof parsed !== "object") return null;
+ const o = parsed as Record;
+ if (typeof o.id !== "string" || typeof o.title !== "string") return null;
+ const doc = o.document;
+ if (doc === null || typeof doc !== "object" || Array.isArray(doc)) {
+ return null;
+ }
+ const summaryVal = o.summary;
+ let summary: string | null | undefined;
+ if (typeof summaryVal === "string") {
+ summary = summaryVal;
+ } else if (summaryVal === null) {
+ summary = null;
+ } else {
+ summary = undefined;
+ }
+ return {
+ id: o.id,
+ title: o.title,
+ summary,
+ document: doc as Record,
+ };
+ } catch {
+ return null;
+ }
+}
diff --git a/messages/en/create/publish.json b/messages/en/create/publish.json
new file mode 100644
index 0000000..f428743
--- /dev/null
+++ b/messages/en/create/publish.json
@@ -0,0 +1,6 @@
+{
+ "finalizeBannerTitle": "Couldn't publish",
+ "missingCommunityName": "Add a community name before finalizing.",
+ "finalizeButtonPublishing": "Publishing…",
+ "genericPublishFailed": "Something went wrong. Try again."
+}
diff --git a/messages/en/index.ts b/messages/en/index.ts
index 367413d..b4dca81 100644
--- a/messages/en/index.ts
+++ b/messages/en/index.ts
@@ -20,6 +20,7 @@ import metadata from "./metadata.json";
import communication from "./create/communication.json";
import createTopNav from "./create/topNav.json";
import createDraftHydration from "./create/draftHydration.json";
+import createPublish from "./create/publish.json";
export default {
common,
@@ -45,6 +46,7 @@ export default {
communication,
topNav: createTopNav,
draftHydration: createDraftHydration,
+ publish: createPublish,
},
navigation,
metadata,
diff --git a/tests/components/FinalReviewPage.test.tsx b/tests/components/FinalReviewPage.test.tsx
index 52ace8d..4906f9c 100644
--- a/tests/components/FinalReviewPage.test.tsx
+++ b/tests/components/FinalReviewPage.test.tsx
@@ -1,7 +1,31 @@
+import { useLayoutEffect } from "react";
import { describe, it, expect } from "vitest";
-import { renderWithProviders as render, screen } from "../utils/test-utils";
+import {
+ renderWithProviders as render,
+ screen,
+ waitFor,
+} from "../utils/test-utils";
import "@testing-library/jest-dom/vitest";
import FinalReviewPage from "../../app/create/final-review/page";
+import { useCreateFlow } from "../../app/create/context/CreateFlowContext";
+
+const FALLBACK_CARD_TITLE = "Your community";
+const FALLBACK_CARD_DESCRIPTION_SNIPPET =
+ "Add a short description of your community";
+
+function FinalReviewWithFlowState({
+ title,
+ summary,
+}: {
+ title: string;
+ summary?: string;
+}) {
+ const { replaceState } = useCreateFlow();
+ useLayoutEffect(() => {
+ replaceState({ title, ...(summary !== undefined ? { summary } : {}) });
+ }, [replaceState, title, summary]);
+ return ;
+}
describe("FinalReviewPage", () => {
it("renders without crashing", () => {
@@ -27,17 +51,27 @@ describe("FinalReviewPage", () => {
).toBeInTheDocument();
});
- it("renders RuleCard with title", () => {
+ it("renders RuleCard with fallback title when context has no name", () => {
render();
- expect(screen.getByText("Mutual Aid Mondays")).toBeInTheDocument();
+ expect(screen.getByText(FALLBACK_CARD_TITLE)).toBeInTheDocument();
});
- it("renders RuleCard with description", () => {
+ it("renders RuleCard with fallback description when context has no summary", () => {
render();
expect(
- screen.getByText(
- /Mutual Aid Monday is a grassroots community in Denver, founded in November 2020 by Kelsang Virya, dedicated to supporting neighbors experiencing homelessness./i,
- ),
+ screen.getByText(new RegExp(FALLBACK_CARD_DESCRIPTION_SNIPPET, "i")),
+ ).toBeInTheDocument();
+ });
+
+ it("renders RuleCard title from create flow state", async () => {
+ render(
+ ,
+ );
+ await waitFor(() => {
+ expect(screen.getByText("Oak Park Commons")).toBeInTheDocument();
+ });
+ expect(
+ screen.getByText(/Local mutual aid\./i),
).toBeInTheDocument();
});
@@ -46,7 +80,7 @@ describe("FinalReviewPage", () => {
const buttons = screen.getAllByRole("button");
expect(buttons.length).toBeGreaterThanOrEqual(1);
expect(
- buttons.some((el) => el.textContent?.includes("Mutual Aid Mondays")),
+ buttons.some((el) => el.textContent?.includes(FALLBACK_CARD_TITLE)),
).toBe(true);
});
diff --git a/tests/unit/buildPublishPayload.test.ts b/tests/unit/buildPublishPayload.test.ts
new file mode 100644
index 0000000..34d349b
--- /dev/null
+++ b/tests/unit/buildPublishPayload.test.ts
@@ -0,0 +1,112 @@
+import { describe, it, expect } from "vitest";
+import {
+ buildPublishPayload,
+ parseDocumentSectionsForDisplay,
+ parseSectionsFromCreateFlowState,
+} from "../../lib/create/buildPublishPayload";
+import type { CreateFlowState } from "../../app/create/types";
+
+describe("buildPublishPayload", () => {
+ it("returns error when title missing", () => {
+ expect(buildPublishPayload({})).toEqual({
+ ok: false,
+ error: "missingCommunityName",
+ });
+ });
+
+ it("returns error when title is whitespace only", () => {
+ expect(buildPublishPayload({ title: " \n\t " })).toEqual({
+ ok: false,
+ error: "missingCommunityName",
+ });
+ });
+
+ it("returns title and fallback Overview section when no sections", () => {
+ const r = buildPublishPayload({ title: "Oak Park Commons" });
+ expect(r.ok).toBe(true);
+ if (!r.ok) return;
+ expect(r.title).toBe("Oak Park Commons");
+ expect(r.summary).toBeUndefined();
+ expect(r.document).toEqual({
+ sections: [
+ {
+ categoryName: "Overview",
+ entries: [
+ {
+ title: "Community",
+ body: "This CommunityRule was created in the create flow. Add more detail in a future edit.",
+ },
+ ],
+ },
+ ],
+ });
+ });
+
+ it("includes trimmed summary in payload and uses it as fallback section body", () => {
+ const r = buildPublishPayload({
+ title: " My Group ",
+ summary: " We organize locally. ",
+ });
+ expect(r.ok).toBe(true);
+ if (!r.ok) return;
+ expect(r.title).toBe("My Group");
+ expect(r.summary).toBe("We organize locally.");
+ expect(r.document).toEqual({
+ sections: [
+ {
+ categoryName: "Overview",
+ entries: [{ title: "Community", body: "We organize locally." }],
+ },
+ ],
+ });
+ });
+
+ it("uses valid state.sections when present", () => {
+ const sections: CreateFlowState["sections"] = [
+ {
+ categoryName: "Values",
+ entries: [{ title: "A", body: "B" }],
+ },
+ ];
+ const r = buildPublishPayload({ title: "T", sections });
+ expect(r.ok).toBe(true);
+ if (!r.ok) return;
+ expect(r.document).toEqual({ sections });
+ });
+
+ it("filters invalid section entries from state.sections", () => {
+ const r = buildPublishPayload({
+ title: "T",
+ sections: [
+ { categoryName: "Values", entries: [{ title: "A", body: "B" }] },
+ { bad: true } as unknown as Record,
+ ],
+ });
+ expect(r.ok).toBe(true);
+ if (!r.ok) return;
+ expect(r.document).toEqual({
+ sections: [{ categoryName: "Values", entries: [{ title: "A", body: "B" }] }],
+ });
+ });
+});
+
+describe("parseDocumentSectionsForDisplay", () => {
+ it("returns empty for non-object", () => {
+ expect(parseDocumentSectionsForDisplay(null)).toEqual([]);
+ });
+
+ it("parses valid sections array", () => {
+ const doc = {
+ sections: [
+ { categoryName: "X", entries: [{ title: "t", body: "b" }] },
+ ],
+ };
+ expect(parseDocumentSectionsForDisplay(doc)).toEqual(doc.sections);
+ });
+});
+
+describe("parseSectionsFromCreateFlowState", () => {
+ it("returns empty when sections missing", () => {
+ expect(parseSectionsFromCreateFlowState({})).toEqual([]);
+ });
+});
diff --git a/tests/unit/publishRule.test.ts b/tests/unit/publishRule.test.ts
new file mode 100644
index 0000000..1663b41
--- /dev/null
+++ b/tests/unit/publishRule.test.ts
@@ -0,0 +1,71 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { publishRule } from "../../lib/create/api";
+
+const input = {
+ title: "T",
+ document: { sections: [] as unknown[] },
+};
+
+describe("publishRule", () => {
+ const originalFetch = globalThis.fetch;
+
+ beforeEach(() => {
+ vi.resetAllMocks();
+ });
+
+ afterEach(() => {
+ globalThis.fetch = originalFetch;
+ });
+
+ it("returns ok on 200 with rule", async () => {
+ globalThis.fetch = vi.fn().mockResolvedValue(
+ new Response(JSON.stringify({ rule: { id: "r1", title: "T" } }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ }),
+ );
+ const result = await publishRule(input);
+ expect(result).toEqual({ ok: true, id: "r1", title: "T" });
+ });
+
+ it("does not throw when body is empty (e.g. connection reset)", async () => {
+ globalThis.fetch = vi.fn().mockResolvedValue(
+ new Response("", {
+ status: 503,
+ statusText: "Service Unavailable",
+ }),
+ );
+ const result = await publishRule(input);
+ expect(result.ok).toBe(false);
+ if (result.ok === false) {
+ expect(result.status).toBe(503);
+ expect(result.error).toBe("Service Unavailable");
+ }
+ });
+
+ it("parses validation error when JSON present", async () => {
+ globalThis.fetch = vi.fn().mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ error: { code: "validation_error", message: "title required" },
+ }),
+ { status: 400, headers: { "Content-Type": "application/json" } },
+ ),
+ );
+ const result = await publishRule(input);
+ expect(result).toEqual({
+ ok: false,
+ error: "title required",
+ status: 400,
+ });
+ });
+
+ it("returns network message when fetch rejects", async () => {
+ globalThis.fetch = vi.fn().mockRejectedValue(new Error("offline"));
+ const result = await publishRule(input);
+ expect(result).toEqual({
+ ok: false,
+ error: "Something went wrong. Check your connection and try again.",
+ });
+ });
+});