diff --git a/.cursor/rules/alerts.mdc b/.cursor/rules/alerts.mdc
index dfd2dc5..10186fc 100644
--- a/.cursor/rules/alerts.mdc
+++ b/.cursor/rules/alerts.mdc
@@ -34,7 +34,7 @@ Valid enum slices for Storybook / guards: `ALERT_*_OPTIONS` in `lib/propNormaliz
## Choosing toast vs banner
- **`toast`** — transient edge / bottom emphasis (e.g. completed flow), strong bottom border accent.
-- **`banner`** — rounded block; for **page / shell / modal** messaging, mount inside a **`fixed`** (or equivalent) overlay wrapper with `pointer-events-none` on the outer layer and `pointer-events-auto` on the alert so layout chrome does not reflow when the message appears (see `CreateFlowLayoutClient` `topBanners`, profile overlays, `LoginForm`, `PostLoginDraftTransfer`).
+- **`banner`** — rounded block; for **page / shell / modal** messaging, mount inside a **`fixed`** (or equivalent) overlay wrapper with `pointer-events-none` on the outer layer and `pointer-events-auto` on the alert so layout chrome does not reflow when the message appears. Place that wrapper **below the header** (`CreateFlowLayoutClient` `topBanners` uses `CREATE_FLOW_FIXED_TOP_BANNER_OFFSET_CLASS`; same idea on profile overlays) so header actions such as Share stay clickable. See also `SignedInDraftHydration`, `LoginForm`, `PostLoginDraftTransfer`.
## Exemptions (do not force `Alert`)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 6f0dce1..7a56c98 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -90,7 +90,7 @@ All routes return JSON. Non-`GET` requests expect
| Method | Path | Purpose |
| --- | --- | --- |
-| GET, POST | `/api/rules` | List or publish rules. |
+| GET, POST | `/api/rules` | Public catalog of listed rules, or publish. |
| GET | `/api/rules/me` | Owner's published rules. |
| GET, PATCH, DELETE | `/api/rules/[id]` | Public read; owner update / delete. |
| POST | `/api/rules/[id]/duplicate` | Owner clone. |
diff --git a/app/(app)/create/CreateFlowLayoutClient.tsx b/app/(app)/create/CreateFlowLayoutClient.tsx
index 1115f6b..b682fde 100644
--- a/app/(app)/create/CreateFlowLayoutClient.tsx
+++ b/app/(app)/create/CreateFlowLayoutClient.tsx
@@ -77,6 +77,7 @@ import {
} from "./utils/createFlowFooterClassNames";
import {
CREATE_FLOW_MD_CENTERED_MAIN_CLASS,
+ CREATE_FLOW_FIXED_TOP_BANNER_OFFSET_CLASS,
CREATE_FLOW_PAGE_GUTTER_CLASS,
CREATE_FLOW_SCROLL_REGION_CLASS,
CREATE_FLOW_VIEWPORT_FRAME_CLASS,
@@ -679,7 +680,7 @@ function CreateFlowLayoutContent({
{topBanners.length > 0 ? (
{topBanners.map((b) => (
@@ -791,7 +792,7 @@ function CreateFlowLayoutContent({
}
onExit={(opts) => void handleExit(opts)}
buttonPalette={isCompletedStep ? "inverse" : undefined}
- className={`shrink-0 ${
+ className={`relative z-[210] shrink-0 ${
isCompletedStep ? "!bg-[var(--color-teal-teal50,#c9fef9)]" : ""
}`.trim()}
/>
diff --git a/app/(app)/create/SignedInDraftHydration.tsx b/app/(app)/create/SignedInDraftHydration.tsx
index 8b6e806..edf431a 100644
--- a/app/(app)/create/SignedInDraftHydration.tsx
+++ b/app/(app)/create/SignedInDraftHydration.tsx
@@ -11,7 +11,10 @@ import {
import { useCreateFlow } from "./context/CreateFlowContext";
import { fetchDraftFromServer } from "../../../lib/create/api";
import messages from "../../../messages/en/index";
-import { CREATE_FLOW_PAGE_GUTTER_CLASS } from "./components/createFlowLayoutTokens";
+import {
+ CREATE_FLOW_FIXED_TOP_BANNER_OFFSET_CLASS,
+ CREATE_FLOW_PAGE_GUTTER_CLASS,
+} from "./components/createFlowLayoutTokens";
import Alert from "../../components/modals/Alert";
import {
isValidStep,
@@ -166,7 +169,9 @@ export function SignedInDraftHydration({
if (!loadingHydration) return null;
return (
-
+
0
? rule.summary
: undefined;
+ const robots = robotsForPublishedRule(rule.isListed);
return routeMetadata(`/rules/${rule.id}`, {
title: rule.title,
description,
+ ...(robots ? { robots } : {}),
openGraph: {
title: rule.title,
description,
@@ -58,7 +63,7 @@ export default async function PublicRuleDetailPage({ params }: PageProps) {
: undefined;
return (
-
+
);
}
diff --git a/app/(marketing-case-study)/use-cases/[slug]/rule/_components/UseCaseCompletedRule.view.tsx b/app/(marketing-case-study)/use-cases/[slug]/rule/_components/UseCaseCompletedRule.view.tsx
index b8437c1..9b2d6ec 100644
--- a/app/(marketing-case-study)/use-cases/[slug]/rule/_components/UseCaseCompletedRule.view.tsx
+++ b/app/(marketing-case-study)/use-cases/[slug]/rule/_components/UseCaseCompletedRule.view.tsx
@@ -6,6 +6,7 @@ import Share from "../../../../../components/modals/Share";
import Alert from "../../../../../components/modals/Alert";
import { CreateFlowHeaderLockup } from "../../../../../(app)/create/components/CreateFlowHeaderLockup";
import {
+ CREATE_FLOW_FIXED_TOP_BANNER_OFFSET_CLASS,
CREATE_FLOW_MD_UP_GRID_CELL_CLASS,
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
} from "../../../../../(app)/create/components/createFlowLayoutTokens";
@@ -33,7 +34,7 @@ export function UseCaseCompletedRuleView({
return (
<>
{/*
- Mobile: grid scrolls (title sticky at top of scrollport).
+ Mobile: grid scrolls (title and rule together).
Desktop: viewport-tall columns; rule scrolls in the right column only.
*/}
{actionBanner ? (
-
+
{
const { searchParams } = new URL(request.url);
const take = Math.min(Number(searchParams.get("limit") ?? "50") || 50, 100);
- /**
- * Public catalog: owned rules only (last touched first). Guest / orphan rows
- * (`userId` null) stay reachable at `/rules/[id]` and `GET /api/rules/[id]`.
- */
- const rules = await prisma.publishedRule.findMany({
- where: { userId: { not: null } },
- orderBy: [{ updatedAt: "desc" }, { id: "asc" }],
- take,
- select: {
- id: true,
- title: true,
- summary: true,
- createdAt: true,
- updatedAt: true,
- },
- });
+ const rules = await listListedPublishedRules(take);
+ if (rules === null) {
+ return internalError("Failed to list rules");
+ }
return NextResponse.json({ rules });
});
diff --git a/app/components/type/CommunityRule/CommunityRule.view.tsx b/app/components/type/CommunityRule/CommunityRule.view.tsx
index 7e253a1..647ef23 100644
--- a/app/components/type/CommunityRule/CommunityRule.view.tsx
+++ b/app/components/type/CommunityRule/CommunityRule.view.tsx
@@ -39,17 +39,20 @@ function CommunityRuleView({
categoryName={ruleSection.categoryName}
showRail={!useCardStyle}
>
- {ruleSection.entries.map((entry, entryIndex) => {
- const hasBlocks = Boolean(entry.blocks?.length);
- return (
-
- );
- })}
+
+ {ruleSection.entries.map((entry, entryIndex) => {
+ const hasBlocks = Boolean(entry.blocks?.length);
+ return (
+ -
+
+
+ );
+ })}
+
))}
diff --git a/app/components/type/Section/Section.view.tsx b/app/components/type/Section/Section.view.tsx
index 1a7becb..d106701 100644
--- a/app/components/type/Section/Section.view.tsx
+++ b/app/components/type/Section/Section.view.tsx
@@ -21,21 +21,24 @@ function SectionView({
}: SectionProps) {
const inner = (
-
{categoryName}
+
{categoryName}
{children}
);
if (!showRail) {
return (
-
+
+
);
}
return (
-
{inner}
-
+
);
}
diff --git a/app/components/type/TextBlock/TextBlock.view.tsx b/app/components/type/TextBlock/TextBlock.view.tsx
index f213787..7f441e5 100644
--- a/app/components/type/TextBlock/TextBlock.view.tsx
+++ b/app/components/type/TextBlock/TextBlock.view.tsx
@@ -50,7 +50,7 @@ function LabeledRowView({ row }: { row: TextRow }) {
return (
-
{row.label}
+
{row.label}
{imageSrc ? (
<>
{/* eslint-disable-next-line @next/next/no-img-element -- same-origin or absolute upload URL */}
@@ -88,18 +88,24 @@ function TextBlockView({
const hasRows = rows && rows.length > 0;
return (
-
-
{title}
+
{title}
{body.trim().length > 0 ?
: null}
- {hasRows
- ? rows!.map((row, i) =>
)
- : null}
+ {hasRows ? (
+
+ {rows!.map((row, i) => (
+ -
+
+
+ ))}
+
+ ) : null}
-
+
);
}
diff --git a/app/sitemap.ts b/app/sitemap.ts
index d07698c..a336ebc 100644
--- a/app/sitemap.ts
+++ b/app/sitemap.ts
@@ -1,6 +1,8 @@
import type { MetadataRoute } from "next";
import { buildPublicSitemap } from "../lib/publicSitemap";
+import { listListedPublishedRulesForSitemap } from "../lib/server/publishedRules";
-export default function sitemap(): MetadataRoute.Sitemap {
- return buildPublicSitemap();
+export default async function sitemap(): Promise
{
+ const listedRules = await listListedPublishedRulesForSitemap();
+ return buildPublicSitemap(listedRules);
}
diff --git a/docs/guides/backend-roadmap.md b/docs/guides/backend-roadmap.md
index 0b8e528..1f59fd5 100644
--- a/docs/guides/backend-roadmap.md
+++ b/docs/guides/backend-roadmap.md
@@ -121,7 +121,7 @@ Match the current API behavior; tighten as product evolves:
- **`GET /api/drafts/me` / `PUT /api/drafts/me`:** Authenticated user only; draft is **scoped to that user** (`userId`).
- **`POST /api/rules`:** Session optional. Signed-in publishes store **`userId`** (owner) and may send stakeholder invites. Guests create an unlisted row (`userId` null) plus hashed **`claimTokenHash`** and httpOnly **`cr_rule_claim`**, rate-limited per IP; `/rules/[id]` still works. Invites are ignored without a session. Sign-in (`createSessionForUser`) claims matching ownerless rows.
-- **`GET /api/rules`:** **Public list** of **owned** published rules (metadata: id, title, summary, timestamps)—no auth required today. Ownerless rows are omitted. **Authenticated “my rules”** uses **`GET /api/rules/me`** (see §1 profile / account table).
+- **`GET /api/rules`:** **Public catalog of listed published rules** (metadata: id, title, summary, timestamps)—no auth required. **Listed** = owned (`userId` set) or orphaned after account delete (`userId` null and `claimTokenHash` null). **Unlisted** guest-claim rows (`userId` null and `claimTokenHash` set) are omitted from this list and from `/sitemap.xml`, and their `/rules/[id]` pages send `noindex`; the share URL still works. **Authenticated “my rules”** uses **`GET /api/rules/me`** (see §1 profile / account table).
- **Profile / owner scope (planned):** Authenticated **list own rules**, **delete own rule**, **duplicate own rule**—required for the signed-in dashboard in design; **v1 shipped handlers** may not include these until that work lands.
- **Delete account (planned):** Authenticated endpoint + UX to remove the user record per policy (cascade vs orphan `PublishedRule`, drafts, sessions)—Ticket 15. **Change email** is **not** part of that milestone; implement via **[CR-103](https://linear.app/community-rule/issue/CR-103/backend-change-account-email-verify-new-address-conflict-session)** (Ticket 20 — verified email updates).
- **v1 (shipped today):** No **editing** or **deleting** published rules via API in current handlers; no **sharing** or **collaborative ownership**—treat each rule as **owned by one user** until product defines more.
diff --git a/lib/publicSitemap.ts b/lib/publicSitemap.ts
index 8d155e4..d59a17f 100644
--- a/lib/publicSitemap.ts
+++ b/lib/publicSitemap.ts
@@ -17,11 +17,13 @@ const STATIC_PUBLIC_PATHS = [
] as const;
/**
- * Public marketing URLs for `/sitemap.xml`. Omits signed-in product routes
- * (`/create`, `/login`, `/profile`), admin, and `/rules/[id]` (those ids are
- * not known without the database).
+ * Public marketing URLs for `/sitemap.xml`, plus listed published rules when
+ * the caller supplies them. Omits signed-in product routes (`/create`,
+ * `/login`, `/profile`), admin, and unlisted guest-claim `/rules/[id]`.
*/
-export function buildPublicSitemap(): MetadataRoute.Sitemap {
+export function buildPublicSitemap(
+ listedRules: ReadonlyArray<{ id: string; updatedAt: Date }> = [],
+): MetadataRoute.Sitemap {
const entries: MetadataRoute.Sitemap = STATIC_PUBLIC_PATHS.map((path) => ({
url: sitemapUrl(path),
}));
@@ -38,5 +40,12 @@ export function buildPublicSitemap(): MetadataRoute.Sitemap {
entries.push({ url: sitemapUrl(`/use-cases/${slug}/rule`) });
}
+ for (const rule of listedRules) {
+ entries.push({
+ url: sitemapUrl(`/rules/${encodeURIComponent(rule.id)}`),
+ lastModified: rule.updatedAt,
+ });
+ }
+
return entries;
}
diff --git a/lib/publishedRuleAccess.ts b/lib/publishedRuleAccess.ts
new file mode 100644
index 0000000..eddb75a
--- /dev/null
+++ b/lib/publishedRuleAccess.ts
@@ -0,0 +1,24 @@
+import type { Prisma } from "@prisma/client";
+
+/**
+ * Access model for published CommunityRules:
+ *
+ * - **Readable without sign-in:** every published row is share-by-link at
+ * `/rules/[id]` and `GET /api/rules/[id]`.
+ * - **Listed** (public catalog `GET /api/rules`, sitemap, indexable): owned
+ * rows, and ownerless rows that are *not* waiting on a guest claim
+ * (including rules orphaned when an account is deleted).
+ * - **Unlisted** (omitted from the catalog and sitemap, `noindex`): guest
+ * publishes with `userId` null and `claimTokenHash` set, until claimed.
+ */
+export function isListedPublishedRule(row: {
+ userId: string | null;
+ claimTokenHash: string | null;
+}): boolean {
+ return row.userId != null || row.claimTokenHash == null;
+}
+
+/** Prisma `where` for the public catalog and sitemap. */
+export const LISTED_PUBLISHED_RULE_WHERE: Prisma.PublishedRuleWhereInput = {
+ OR: [{ userId: { not: null } }, { claimTokenHash: null }],
+};
diff --git a/lib/server/publishedRules.ts b/lib/server/publishedRules.ts
index 6f1ecf0..f6d67ea 100644
--- a/lib/server/publishedRules.ts
+++ b/lib/server/publishedRules.ts
@@ -1,9 +1,14 @@
+import {
+ LISTED_PUBLISHED_RULE_WHERE,
+ isListedPublishedRule,
+} from "../publishedRuleAccess";
import { prisma } from "./db";
import { isDatabaseConfigured } from "./env";
/**
* Public fields safe to expose via the unauthenticated rule detail surfaces
- * (`GET /api/rules/[id]` and `/rules/[id]`). `userId` is intentionally omitted.
+ * (`GET /api/rules/[id]` and `/rules/[id]`). `userId` and `claimTokenHash`
+ * stay server-side; callers get {@link PublicPublishedRule.isListed} instead.
*/
const PUBLISHED_RULE_PUBLIC_SELECT = {
id: true,
@@ -21,6 +26,8 @@ export type PublicPublishedRule = {
document: unknown;
createdAt: Date;
updatedAt: Date;
+ /** False for guest-claim rows; true for owned and orphaned-after-delete rows. */
+ isListed: boolean;
};
/**
@@ -39,9 +46,18 @@ export async function getPublicPublishedRuleById(
try {
const rule = await prisma.publishedRule.findUnique({
where: { id },
- select: PUBLISHED_RULE_PUBLIC_SELECT,
+ select: {
+ ...PUBLISHED_RULE_PUBLIC_SELECT,
+ userId: true,
+ claimTokenHash: true,
+ },
});
- return rule;
+ if (!rule) return null;
+ const { userId, claimTokenHash, ...publicFields } = rule;
+ return {
+ ...publicFields,
+ isListed: isListedPublishedRule({ userId, claimTokenHash }),
+ };
} catch {
return null;
}
@@ -64,6 +80,61 @@ export type OwnerPublishedRuleListItem = {
updatedAt: Date;
};
+const LISTED_RULE_SITEMAP_SELECT = {
+ id: true,
+ updatedAt: true,
+} as const;
+
+const LISTED_PUBLISHED_RULE_SITEMAP_CAP = 10_000;
+
+/**
+ * Public catalog of listed published rules (**most recently updated first**,
+ * then stable `id` tie-break). Guest-claim rows are omitted.
+ * Returns `null` when the database is not configured or the query throws.
+ */
+export async function listListedPublishedRules(
+ take: number,
+): Promise {
+ if (!isDatabaseConfigured()) return null;
+ const clamped = Math.min(Math.max(0, take), 100);
+ if (clamped === 0) return [];
+ try {
+ return await prisma.publishedRule.findMany({
+ where: LISTED_PUBLISHED_RULE_WHERE,
+ orderBy: [{ updatedAt: "desc" }, { id: "asc" }],
+ take: clamped,
+ select: PUBLISHED_RULE_OWNER_LIST_SELECT,
+ });
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Listed published-rule ids for `/sitemap.xml`. Empty when the database is
+ * not configured or the query throws (marketing URLs still render).
+ */
+export async function listListedPublishedRulesForSitemap(
+ take: number = LISTED_PUBLISHED_RULE_SITEMAP_CAP,
+): Promise> {
+ if (!isDatabaseConfigured()) return [];
+ const clamped = Math.min(
+ Math.max(0, take),
+ LISTED_PUBLISHED_RULE_SITEMAP_CAP,
+ );
+ if (clamped === 0) return [];
+ try {
+ return await prisma.publishedRule.findMany({
+ where: LISTED_PUBLISHED_RULE_WHERE,
+ orderBy: [{ updatedAt: "desc" }, { id: "asc" }],
+ take: clamped,
+ select: LISTED_RULE_SITEMAP_SELECT,
+ });
+ } catch {
+ return [];
+ }
+}
+
/**
* Lists published rules owned by the given user (**most recently updated first**,
* then stable `id` tie-break).
diff --git a/lib/siteMetadata.ts b/lib/siteMetadata.ts
index c8db317..bd18c1f 100644
--- a/lib/siteMetadata.ts
+++ b/lib/siteMetadata.ts
@@ -12,6 +12,16 @@ const INDEXABLE_HOSTS = new Set([
export const NO_INDEX_ROBOTS = { index: false, follow: false } as const;
+/** Unlisted (guest-claim) rule pages stay shareable but must not be indexed. */
+export function robotsForPublishedRule(isListed: boolean):
+ | {
+ index: false;
+ follow: false;
+ }
+ | undefined {
+ return isListed ? undefined : NO_INDEX_ROBOTS;
+}
+
function hostnameFromHostHeader(host: string | null | undefined): string {
if (!host) return "";
return host.split(",")[0]?.trim().split(":")[0]?.toLowerCase() ?? "";
diff --git a/tests/components/CommunityRule.test.tsx b/tests/components/CommunityRule.test.tsx
index b6e9c26..1843674 100644
--- a/tests/components/CommunityRule.test.tsx
+++ b/tests/components/CommunityRule.test.tsx
@@ -72,4 +72,41 @@ describe("CommunityRule", () => {
expect(screen.getByText("Quorum")).toBeInTheDocument();
expect(screen.getByText("60%")).toBeInTheDocument();
});
+
+ it("exposes category, entry, and block labels as headings and entries as a list", () => {
+ render(
+ ,
+ );
+ expect(
+ screen.getByRole("heading", { level: 2, name: "Membership" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("heading", { level: 3, name: "Joining" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("heading", { level: 3, name: "Leaving" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("heading", { level: 4, name: "Notice" }),
+ ).toBeInTheDocument();
+ expect(screen.getAllByRole("list").length).toBeGreaterThanOrEqual(1);
+ expect(screen.getAllByRole("listitem")).toHaveLength(3);
+ });
});
diff --git a/tests/components/CompletedPage.test.tsx b/tests/components/CompletedPage.test.tsx
index aefdff6..6e0dc01 100644
--- a/tests/components/CompletedPage.test.tsx
+++ b/tests/components/CompletedPage.test.tsx
@@ -67,6 +67,24 @@ describe("CompletedScreen", () => {
expect(screen.queryByText("Values")).not.toBeInTheDocument();
});
+ it("does not pin the title on small screens", () => {
+ sessionStorage.setItem(
+ CREATE_FLOW_LAST_PUBLISHED_KEY,
+ JSON.stringify(storedRuleFixture),
+ );
+ render();
+ const heading = screen.getByRole("heading", {
+ name: "Fixture Community Rule",
+ });
+ const column = heading.closest("div[class*='md:justify-center']");
+ expect(column).toBeTruthy();
+ expect(column.className).not.toMatch(/\bsticky\b/);
+ const ruleColumn = screen
+ .getByText("Fixture value title")
+ .closest("div[class*='md:overflow-y-auto']");
+ expect(ruleColumn?.className).toMatch(/max-md:shrink-0/);
+ });
+
it("renders header and document from sessionStorage", () => {
sessionStorage.setItem(
CREATE_FLOW_LAST_PUBLISHED_KEY,
diff --git a/tests/components/Section.test.tsx b/tests/components/Section.test.tsx
index 30aea35..7ca7789 100644
--- a/tests/components/Section.test.tsx
+++ b/tests/components/Section.test.tsx
@@ -1,4 +1,5 @@
-import { describe } from "vitest";
+import { describe, expect, it } from "vitest";
+import { render, screen } from "@testing-library/react";
import {
componentTestSuite,
type ComponentTestSuiteConfig,
@@ -15,7 +16,10 @@ const config: ComponentTestSuiteConfig = {
categoryName: "Decision making",
showRail: true,
children: (
-
+
),
} as Props,
requiredProps: ["categoryName", "children"],
@@ -27,4 +31,21 @@ const config: ComponentTestSuiteConfig = {
describe("Section", () => {
componentTestSuite(config);
+
+ it("renders the category as a level-2 heading inside a section", () => {
+ render(
+ ,
+ );
+ expect(
+ screen.getByRole("heading", { level: 2, name: "Decision making" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("heading", { level: 3, name: "How proposals pass" }),
+ ).toBeInTheDocument();
+ });
});
diff --git a/tests/components/TextBlock.test.tsx b/tests/components/TextBlock.test.tsx
index 462ad45..049702a 100644
--- a/tests/components/TextBlock.test.tsx
+++ b/tests/components/TextBlock.test.tsx
@@ -58,4 +58,24 @@ describe("TextBlock", () => {
expect(screen.getByText("Quorum")).toBeInTheDocument();
expect(screen.getByText("60%")).toBeInTheDocument();
});
+
+ it("renders the title as a level-3 heading and labeled rows as a list", () => {
+ render(
+ ,
+ );
+ expect(
+ screen.getByRole("heading", { level: 3, name: "Forum proposals" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("heading", { level: 4, name: "Quorum" }),
+ ).toBeInTheDocument();
+ expect(screen.getAllByRole("listitem")).toHaveLength(2);
+ });
});
diff --git a/tests/pages/use-cases-completed-rule.test.jsx b/tests/pages/use-cases-completed-rule.test.jsx
index 4796fc4..cb0ccb2 100644
--- a/tests/pages/use-cases-completed-rule.test.jsx
+++ b/tests/pages/use-cases-completed-rule.test.jsx
@@ -90,6 +90,20 @@ describe("UseCaseCompletedRulePage", () => {
},
);
+ test("title column is sticky from md up only", async () => {
+ const fixture = messages.pages.useCasesCompletedRules.foodNotBombs;
+ render(
+ await UseCaseCompletedRulePage({
+ params: Promise.resolve({ slug: "food-not-bombs" }),
+ }),
+ );
+
+ const heading = screen.getByRole("heading", { name: fixture.title });
+ const column = heading.closest("div[class*='md:sticky']");
+ expect(column).toBeTruthy();
+ expect(column.className).not.toMatch(/\bmax-md:sticky\b/);
+ });
+
test("marketing message bundle resolves Share (not the dotted create.topNav key)", () => {
expect(getTranslation(marketingMessages, "create.topNav.share")).toBe(
"Share",
diff --git a/tests/unit/createFlowLayoutTokens.test.ts b/tests/unit/createFlowLayoutTokens.test.ts
index e66943e..bc829e5 100644
--- a/tests/unit/createFlowLayoutTokens.test.ts
+++ b/tests/unit/createFlowLayoutTokens.test.ts
@@ -7,6 +7,7 @@ import {
CREATE_FLOW_MD_CENTERED_MAIN_CLASS,
CREATE_FLOW_MD_CENTERED_SHELL_CLASS,
CREATE_FLOW_PAGE_GUTTER_CLASS,
+ CREATE_FLOW_FIXED_TOP_BANNER_OFFSET_CLASS,
CREATE_FLOW_VIEWPORT_FRAME_CLASS,
getCreateFlowContentMaxClass,
} from "../../app/(app)/create/components/createFlowLayoutTokens";
@@ -29,6 +30,10 @@ describe("createFlowLayoutTokens", () => {
expect(CREATE_FLOW_PAGE_GUTTER_CLASS).toBe("px-5 md:px-12 lg:px-16");
});
+ it("offsets fixed top banners below the create-flow header", () => {
+ expect(CREATE_FLOW_FIXED_TOP_BANNER_OFFSET_CLASS).toBe("top-14 md:top-16");
+ });
+
it("sizes the wizard frame with dynamic viewport height", () => {
expect(CREATE_FLOW_VIEWPORT_FRAME_CLASS).toContain("h-dvh");
expect(CREATE_FLOW_VIEWPORT_FRAME_CLASS).toContain("max-h-dvh");
@@ -42,18 +47,18 @@ describe("createFlowLayoutTokens", () => {
});
it("aligns the action bar max-width with the step content column", () => {
- expect(
- getCreateFlowContentMaxClass({ step: "community-name" }),
- ).toBe(CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS);
- expect(
- getCreateFlowContentMaxClass({ step: "community-structure" }),
- ).toBe(CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS);
+ expect(getCreateFlowContentMaxClass({ step: "community-name" })).toBe(
+ CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
+ );
+ expect(getCreateFlowContentMaxClass({ step: "community-structure" })).toBe(
+ CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
+ );
expect(
getCreateFlowContentMaxClass({ step: "communication-methods" }),
).toBe(CREATE_FLOW_WIDE_MAX_CLASS);
- expect(
- getCreateFlowContentMaxClass({ step: "final-review" }),
- ).toBe(CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS);
+ expect(getCreateFlowContentMaxClass({ step: "final-review" })).toBe(
+ CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
+ );
expect(
getCreateFlowContentMaxClass({
step: "informational",
diff --git a/tests/unit/publicSitemap.test.ts b/tests/unit/publicSitemap.test.ts
index 2b247b7..42ea13a 100644
--- a/tests/unit/publicSitemap.test.ts
+++ b/tests/unit/publicSitemap.test.ts
@@ -36,4 +36,19 @@ describe("buildPublicSitemap", () => {
expect(urls.some((url) => url.includes("/monitor"))).toBe(false);
expect(urls.some((url) => url.includes("/api/"))).toBe(false);
});
+
+ it("appends listed published-rule URLs and omits them when none are supplied", () => {
+ const withoutRules = buildPublicSitemap().map((entry) => entry.url);
+ expect(withoutRules.some((url) => url.includes("/rules/"))).toBe(false);
+
+ const updatedAt = new Date("2026-09-01T00:00:00.000Z");
+ const withRules = buildPublicSitemap([{ id: "rule-1", updatedAt }]);
+ const listed = withRules.find(
+ (entry) => entry.url === sitemapUrl("/rules/rule-1"),
+ );
+ expect(listed).toEqual({
+ url: sitemapUrl("/rules/rule-1"),
+ lastModified: updatedAt,
+ });
+ });
});
diff --git a/tests/unit/publishedRuleAccess.test.ts b/tests/unit/publishedRuleAccess.test.ts
new file mode 100644
index 0000000..ec6cc1d
--- /dev/null
+++ b/tests/unit/publishedRuleAccess.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from "vitest";
+import {
+ LISTED_PUBLISHED_RULE_WHERE,
+ isListedPublishedRule,
+} from "../../lib/publishedRuleAccess";
+
+describe("isListedPublishedRule", () => {
+ it("lists owned rules", () => {
+ expect(
+ isListedPublishedRule({ userId: "user-1", claimTokenHash: null }),
+ ).toBe(true);
+ });
+
+ it("lists ownerless rows that are not waiting on a guest claim", () => {
+ expect(isListedPublishedRule({ userId: null, claimTokenHash: null })).toBe(
+ true,
+ );
+ });
+
+ it("keeps guest-claim rows unlisted", () => {
+ expect(
+ isListedPublishedRule({
+ userId: null,
+ claimTokenHash: "claim-hash",
+ }),
+ ).toBe(false);
+ });
+});
+
+describe("LISTED_PUBLISHED_RULE_WHERE", () => {
+ it("matches owned rows or rows with no claim token", () => {
+ expect(LISTED_PUBLISHED_RULE_WHERE).toEqual({
+ OR: [{ userId: { not: null } }, { claimTokenHash: null }],
+ });
+ });
+});
diff --git a/tests/unit/publishedRules.test.ts b/tests/unit/publishedRules.test.ts
new file mode 100644
index 0000000..8f05d27
--- /dev/null
+++ b/tests/unit/publishedRules.test.ts
@@ -0,0 +1,103 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const isDatabaseConfiguredMock = vi.fn();
+const findUniqueMock = vi.fn();
+const findManyMock = vi.fn();
+
+vi.mock("../../lib/server/env", () => ({
+ isDatabaseConfigured: () => isDatabaseConfiguredMock(),
+}));
+
+vi.mock("../../lib/server/db", () => ({
+ prisma: {
+ publishedRule: {
+ findUnique: (...args: unknown[]) => findUniqueMock(...args),
+ findMany: (...args: unknown[]) => findManyMock(...args),
+ },
+ },
+}));
+
+import {
+ getPublicPublishedRuleById,
+ listListedPublishedRules,
+ listListedPublishedRulesForSitemap,
+} from "../../lib/server/publishedRules";
+import { LISTED_PUBLISHED_RULE_WHERE } from "../../lib/publishedRuleAccess";
+
+beforeEach(() => {
+ isDatabaseConfiguredMock.mockReset();
+ findUniqueMock.mockReset();
+ findManyMock.mockReset();
+});
+
+describe("getPublicPublishedRuleById", () => {
+ it("returns isListed false for guest-claim rows without exposing claim tokens", async () => {
+ isDatabaseConfiguredMock.mockReturnValue(true);
+ findUniqueMock.mockResolvedValueOnce({
+ id: "guest-1",
+ title: "Guest rule",
+ summary: null,
+ document: {},
+ createdAt: new Date("2026-01-01T00:00:00.000Z"),
+ updatedAt: new Date("2026-01-02T00:00:00.000Z"),
+ userId: null,
+ claimTokenHash: "secret-hash",
+ });
+
+ const rule = await getPublicPublishedRuleById("guest-1");
+ expect(rule).toMatchObject({
+ id: "guest-1",
+ title: "Guest rule",
+ isListed: false,
+ });
+ expect(rule).not.toHaveProperty("userId");
+ expect(rule).not.toHaveProperty("claimTokenHash");
+ });
+
+ it("returns isListed true for owned rules", async () => {
+ isDatabaseConfiguredMock.mockReturnValue(true);
+ findUniqueMock.mockResolvedValueOnce({
+ id: "owned-1",
+ title: "Owned",
+ summary: "Hi",
+ document: {},
+ createdAt: new Date("2026-01-01T00:00:00.000Z"),
+ updatedAt: new Date("2026-01-02T00:00:00.000Z"),
+ userId: "user-1",
+ claimTokenHash: null,
+ });
+
+ const rule = await getPublicPublishedRuleById("owned-1");
+ expect(rule?.isListed).toBe(true);
+ });
+});
+
+describe("listListedPublishedRules", () => {
+ it("queries the listed-rule where clause", async () => {
+ isDatabaseConfiguredMock.mockReturnValue(true);
+ findManyMock.mockResolvedValueOnce([]);
+
+ await listListedPublishedRules(10);
+
+ expect(findManyMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: LISTED_PUBLISHED_RULE_WHERE,
+ take: 10,
+ }),
+ );
+ });
+
+ it("returns null when the database is not configured", async () => {
+ isDatabaseConfiguredMock.mockReturnValue(false);
+ expect(await listListedPublishedRules(10)).toBeNull();
+ expect(findManyMock).not.toHaveBeenCalled();
+ });
+});
+
+describe("listListedPublishedRulesForSitemap", () => {
+ it("returns an empty list when the database is not configured", async () => {
+ isDatabaseConfiguredMock.mockReturnValue(false);
+ expect(await listListedPublishedRulesForSitemap()).toEqual([]);
+ expect(findManyMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/unit/rulesByIdRoute.test.ts b/tests/unit/rulesByIdRoute.test.ts
index f29eeb3..db9d85f 100644
--- a/tests/unit/rulesByIdRoute.test.ts
+++ b/tests/unit/rulesByIdRoute.test.ts
@@ -95,6 +95,8 @@ describe("GET /api/rules/[id]", () => {
document: { sections: [] },
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-02T00:00:00Z"),
+ userId: "user-1",
+ claimTokenHash: null,
};
findUniqueMock.mockResolvedValueOnce(row);
const res = await GET(
@@ -103,16 +105,47 @@ describe("GET /api/rules/[id]", () => {
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
- rule: { id: string; title: string; summary: string | null };
+ rule: {
+ id: string;
+ title: string;
+ summary: string | null;
+ isListed: boolean;
+ };
viewerIsOwner: boolean;
};
expect(body.rule.id).toBe("rule-1");
expect(body.rule.title).toBe("Mutual Aid Mondays");
expect(body.rule.summary).toBe("A grassroots community in Denver.");
+ expect(body.rule.isListed).toBe(true);
expect(body.viewerIsOwner).toBe(false);
expect(findUniqueMock).toHaveBeenCalledTimes(1);
});
+ it("marks guest-claim rules as unlisted without exposing the claim token", async () => {
+ isDatabaseConfiguredMock.mockReturnValue(true);
+ findUniqueMock.mockResolvedValueOnce({
+ id: "guest-1",
+ title: "Unlisted",
+ summary: null,
+ document: {},
+ createdAt: new Date("2026-01-01T00:00:00Z"),
+ updatedAt: new Date("2026-01-02T00:00:00Z"),
+ userId: null,
+ claimTokenHash: "secret-hash",
+ });
+ const res = await GET(
+ new NextRequest("https://x.test/api/rules/guest-1"),
+ makeContext("guest-1"),
+ );
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as {
+ rule: { isListed: boolean; claimTokenHash?: unknown; userId?: unknown };
+ };
+ expect(body.rule.isListed).toBe(false);
+ expect(body.rule).not.toHaveProperty("claimTokenHash");
+ expect(body.rule).not.toHaveProperty("userId");
+ });
+
it("returns viewerIsOwner true when the signed-in user owns the rule", async () => {
isDatabaseConfiguredMock.mockReturnValue(true);
getSessionUserMock.mockResolvedValue({ id: "user-1", email: "a@b.c" });
diff --git a/tests/unit/rulesListRoute.test.ts b/tests/unit/rulesListRoute.test.ts
index 813fa49..c765ebd 100644
--- a/tests/unit/rulesListRoute.test.ts
+++ b/tests/unit/rulesListRoute.test.ts
@@ -2,16 +2,21 @@ import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
const isDatabaseConfiguredMock = vi.fn();
-const findManyMock = vi.fn();
+const listListedMock = vi.fn();
vi.mock("../../lib/server/env", () => ({
isDatabaseConfigured: () => isDatabaseConfiguredMock(),
}));
+vi.mock("../../lib/server/publishedRules", () => ({
+ listListedPublishedRules: (...args: unknown[]) => listListedMock(...args),
+}));
+
vi.mock("../../lib/server/db", () => ({
prisma: {
publishedRule: {
- findMany: (...args: unknown[]) => findManyMock(...args),
+ findMany: vi.fn(),
+ create: vi.fn(),
},
},
}));
@@ -20,7 +25,7 @@ import { GET } from "../../app/api/rules/route";
beforeEach(() => {
isDatabaseConfiguredMock.mockReset();
- findManyMock.mockReset();
+ listListedMock.mockReset();
});
describe("GET /api/rules", () => {
@@ -31,12 +36,12 @@ describe("GET /api/rules", () => {
undefined,
);
expect(res.status).toBe(503);
- expect(findManyMock).not.toHaveBeenCalled();
+ expect(listListedMock).not.toHaveBeenCalled();
});
- it("lists owned rules only", async () => {
+ it("lists listed rules from the shared helper", async () => {
isDatabaseConfiguredMock.mockReturnValue(true);
- findManyMock.mockResolvedValueOnce([
+ listListedMock.mockResolvedValueOnce([
{
id: "r1",
title: "Owned",
@@ -52,13 +57,26 @@ describe("GET /api/rules", () => {
);
expect(res.status).toBe(200);
- expect(findManyMock).toHaveBeenCalledWith(
- expect.objectContaining({
- where: { userId: { not: null } },
- take: 10,
- }),
- );
+ expect(listListedMock).toHaveBeenCalledWith(10);
const body = (await res.json()) as { rules: Array<{ id: string }> };
- expect(body.rules).toEqual([{ id: "r1", title: "Owned", summary: null, createdAt: expect.any(String), updatedAt: expect.any(String) }]);
+ expect(body.rules).toEqual([
+ {
+ id: "r1",
+ title: "Owned",
+ summary: null,
+ createdAt: expect.any(String),
+ updatedAt: expect.any(String),
+ },
+ ]);
+ });
+
+ it("returns 500 when the list helper fails", async () => {
+ isDatabaseConfiguredMock.mockReturnValue(true);
+ listListedMock.mockResolvedValueOnce(null);
+ const res = await GET(
+ new NextRequest("https://x.test/api/rules"),
+ undefined,
+ );
+ expect(res.status).toBe(500);
});
});
diff --git a/tests/unit/siteMetadata.test.ts b/tests/unit/siteMetadata.test.ts
index cb8a84d..60605e4 100644
--- a/tests/unit/siteMetadata.test.ts
+++ b/tests/unit/siteMetadata.test.ts
@@ -2,8 +2,10 @@ import { afterEach, describe, expect, it } from "vitest";
import metadataMessages from "../../messages/en/metadata.json";
import {
buildRobots,
+ NO_INDEX_ROBOTS,
PRODUCTION_SITE_ORIGIN,
resolveRequestHost,
+ robotsForPublishedRule,
routeMetadata,
sitemapUrl,
} from "../../lib/siteMetadata";
@@ -103,6 +105,13 @@ describe("buildRobots", () => {
});
});
+describe("robotsForPublishedRule", () => {
+ it("leaves listed rules indexable and noindexes unlisted guest-claim pages", () => {
+ expect(robotsForPublishedRule(true)).toBeUndefined();
+ expect(robotsForPublishedRule(false)).toEqual(NO_INDEX_ROBOTS);
+ });
+});
+
describe("page title segments", () => {
it("uses a shared em-dash template and does not bake a site suffix into page titles", () => {
expect(metadataMessages.titleTemplate).toBe("%s — CommunityRule");