Keep guest-claim rules unlisted, match published-rule markup to Markdown, keep Share clickable, and let mobile titles scroll #81

Merged
an.di merged 2 commits from adilallo/fix/CR-205-published-rule-page into main 2026-09-28 20:06:29 +00:00
30 changed files with 559 additions and 89 deletions

No files matched your search

+1 -1
View File
@@ -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`)
+1 -1
View File
@@ -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. |
+3 -2
View File
@@ -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({
<div className={`${CREATE_FLOW_VIEWPORT_FRAME_CLASS} bg-black`}>
{topBanners.length > 0 ? (
<div
className={`pointer-events-none fixed left-0 right-0 top-0 z-[200] flex flex-col gap-2 ${CREATE_FLOW_PAGE_GUTTER_CLASS} pt-[var(--spacing-measures-spacing-300,12px)]`}
className={`pointer-events-none fixed left-0 right-0 z-[200] flex flex-col gap-2 ${CREATE_FLOW_FIXED_TOP_BANNER_OFFSET_CLASS} ${CREATE_FLOW_PAGE_GUTTER_CLASS} pt-2`}
aria-live="polite"
>
{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()}
/>
+7 -2
View File
@@ -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 (
<div className={`pointer-events-none fixed left-0 right-0 top-14 z-[170] flex justify-center ${CREATE_FLOW_PAGE_GUTTER_CLASS} pt-2 md:top-16`}>
<div
className={`pointer-events-none fixed left-0 right-0 z-[170] flex justify-center ${CREATE_FLOW_FIXED_TOP_BANNER_OFFSET_CLASS} ${CREATE_FLOW_PAGE_GUTTER_CLASS} pt-2`}
>
<div className="pointer-events-auto w-full max-w-[960px]">
<Alert
type="banner"
@@ -8,6 +8,12 @@ import { CREATE_FLOW_SCREEN_REGISTRY } from "../utils/createFlowScreenRegistry";
*/
export const CREATE_FLOW_PAGE_GUTTER_CLASS = "px-5 md:px-12 lg:px-16";
/**
* Place fixed top alerts below {@link CreateFlowTopNav} so Share / export
* stay clickable (header is ~56px, ~64px from `md`).
*/
export const CREATE_FLOW_FIXED_TOP_BANNER_OFFSET_CLASS = "top-14 md:top-16";
/**
* Wizard viewport frame. `dvh` tracks mobile browser chrome so the action bar
* stays on-screen; `max-h-dvh` keeps the flex column from overflowing it.
@@ -179,7 +179,7 @@ export function CompletedScreen() {
/>
</div>
<div
className={`relative flex min-h-0 flex-col self-stretch overflow-x-hidden md:max-h-full md:overflow-y-auto ${CREATE_FLOW_SCROLL_REGION_CLASS} ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
className={`relative flex max-md:min-h-min max-md:shrink-0 flex-col overflow-x-hidden md:min-h-0 md:max-h-full md:self-stretch md:overflow-y-auto ${CREATE_FLOW_SCROLL_REGION_CLASS} ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
>
<div
className="pointer-events-none sticky top-0 z-10 hidden h-5 shrink-0 bg-gradient-to-b from-[var(--color-teal-teal50,#c9fef9)]/55 from-0% via-[var(--color-teal-teal50,#c9fef9)]/20 via-50% to-transparent md:block"
+8 -3
View File
@@ -3,7 +3,10 @@ import { notFound } from "next/navigation";
import messages from "../../../../messages/en/index";
import { getPublicPublishedRuleById } from "../../../../lib/server/publishedRules";
import { parsePublishedDocumentForCommunityRuleDisplay } from "../../../../lib/create/publishedDocumentToDisplaySections";
import { routeMetadata } from "../../../../lib/siteMetadata";
import {
robotsForPublishedRule,
routeMetadata,
} from "../../../../lib/siteMetadata";
import CommunityRule from "../../../components/type/CommunityRule";
import HeaderLockup from "../../../components/type/HeaderLockup";
@@ -26,9 +29,11 @@ export async function generateMetadata({
typeof rule.summary === "string" && rule.summary.trim().length > 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 (
<div className="min-h-screen bg-[var(--color-teal-teal50,#c9fef9)]">
<article className="min-h-screen bg-[var(--color-teal-teal50,#c9fef9)]">
<div className="mx-auto flex max-w-[1200px] flex-col gap-[var(--measures-spacing-1200,48px)] px-5 py-[var(--spacing-scale-048,48px)] md:px-12 md:py-[var(--spacing-scale-064,64px)]">
<HeaderLockup
title={rule.title}
@@ -69,6 +74,6 @@ export default async function PublicRuleDetailPage({ params }: PageProps) {
/>
<CommunityRule sections={sections} />
</div>
</div>
</article>
);
}
@@ -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.
*/}
<div
@@ -41,7 +42,9 @@ export function UseCaseCompletedRuleView({
style={{ background: pageBg }}
>
{actionBanner ? (
<div className="pointer-events-none fixed inset-x-0 top-0 z-20 flex justify-center px-5 pt-3">
<div
className={`pointer-events-none fixed inset-x-0 z-20 flex justify-center px-5 pt-2 ${CREATE_FLOW_FIXED_TOP_BANNER_OFFSET_CLASS}`}
>
<div className="pointer-events-auto w-full max-w-[639px]">
<Alert
type="banner"
@@ -72,7 +75,7 @@ export function UseCaseCompletedRuleView({
duplicateAriaLabel={duplicateAriaLabel}
exitLabel={exitLabel}
buttonPalette="inverse"
className="shrink-0 !bg-transparent"
className="relative z-[21] shrink-0 !bg-transparent"
onShare={onShareOpen}
onDuplicate={onDuplicate}
onExit={onExit}
@@ -81,7 +84,7 @@ export function UseCaseCompletedRuleView({
className={`mx-auto grid w-full min-h-0 flex-1 grid-cols-1 gap-4 px-5 max-md:max-w-[639px] max-md:gap-6 max-md:overflow-y-auto max-md:overscroll-y-contain max-md:pt-[var(--space-800)] max-md:pb-8 md:h-full md:flex-1 md:grid-cols-2 md:grid-rows-1 md:items-start md:justify-items-center md:gap-[var(--measures-spacing-1200,48px)] md:overflow-hidden md:px-12 md:py-0 ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`}
>
<div
className={`relative z-[1] flex flex-col justify-start max-md:sticky max-md:top-0 max-md:z-10 max-md:shrink-0 max-md:pb-4 md:sticky md:top-0 md:z-[1] md:flex md:h-[calc(100dvh-4rem)] md:max-h-[calc(100dvh-4rem)] md:flex-col md:justify-center md:self-start md:overflow-hidden md:pb-8 ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
className={`relative z-[1] flex flex-col justify-start max-md:shrink-0 max-md:pb-4 md:sticky md:top-0 md:z-[1] md:flex md:h-[calc(100dvh-4rem)] md:max-h-[calc(100dvh-4rem)] md:flex-col md:justify-center md:self-start md:overflow-hidden md:pb-8 ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
style={{ background: pageBg }}
>
<CreateFlowHeaderLockup
+10 -17
View File
@@ -1,7 +1,11 @@
import type { Prisma } from "@prisma/client";
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "../../../lib/server/db";
import { getSessionPepper, isDatabaseConfigured } from "../../../lib/server/env";
import { listListedPublishedRules } from "../../../lib/server/publishedRules";
import {
getSessionPepper,
isDatabaseConfigured,
} from "../../../lib/server/env";
import {
hashSessionToken,
hashRuleClaimToken,
@@ -12,6 +16,7 @@ import { rateLimitKey } from "../../../lib/server/rateLimit";
import {
dbUnavailable,
errorJson,
internalError,
rateLimited,
serverMisconfigured,
} from "../../../lib/server/responses";
@@ -48,22 +53,10 @@ export const GET = apiRoute("rules.list", async (request: NextRequest) => {
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 });
});
@@ -39,17 +39,20 @@ function CommunityRuleView({
categoryName={ruleSection.categoryName}
showRail={!useCardStyle}
>
{ruleSection.entries.map((entry, entryIndex) => {
const hasBlocks = Boolean(entry.blocks?.length);
return (
<TextBlock
key={entryIndex}
title={entry.title}
body={entry.body}
rows={hasBlocks ? entry.blocks : undefined}
/>
);
})}
<ul className="m-0 flex min-w-0 list-none flex-col gap-6 p-0">
{ruleSection.entries.map((entry, entryIndex) => {
const hasBlocks = Boolean(entry.blocks?.length);
return (
<li key={entryIndex} className="min-w-0">
<TextBlock
title={entry.title}
body={entry.body}
rows={hasBlocks ? entry.blocks : undefined}
/>
</li>
);
})}
</ul>
</Section>
))}
</div>
+8 -5
View File
@@ -21,21 +21,24 @@ function SectionView({
}: SectionProps) {
const inner = (
<div className="flex min-w-0 flex-1 flex-col gap-4" style={{ gap: "16px" }}>
<p className={CATEGORY_CLASS}>{categoryName}</p>
<h2 className={`${CATEGORY_CLASS} m-0`}>{categoryName}</h2>
<div className="flex min-w-0 flex-col gap-6">{children}</div>
</div>
);
if (!showRail) {
return (
<div className={`flex min-w-0 flex-col ${className}`.trim()} data-name="Section">
<section
className={`flex min-w-0 flex-col ${className}`.trim()}
data-name="Section"
>
{inner}
</div>
</section>
);
}
return (
<div
<section
className={`flex min-w-0 items-stretch gap-3 ${className}`.trim()}
style={{ gap: "12px" }}
data-name="Section"
@@ -46,7 +49,7 @@ function SectionView({
aria-hidden
/>
{inner}
</div>
</section>
);
}
@@ -50,7 +50,7 @@ function LabeledRowView({ row }: { row: TextRow }) {
return (
<div className="flex min-w-0 flex-col gap-2">
<p className={ROW_LABEL_CLASS}>{row.label}</p>
<h4 className={`${ROW_LABEL_CLASS} m-0`}>{row.label}</h4>
{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 (
<div
<article
className={`flex min-w-0 flex-col gap-2 ${className}`.trim()}
data-name="TextBlock"
>
<p className={`${ENTRY_TITLE_CLASS} w-full min-w-0`}>{title}</p>
<h3 className={`${ENTRY_TITLE_CLASS} m-0 w-full min-w-0`}>{title}</h3>
<div className="flex min-w-0 flex-col gap-3">
{body.trim().length > 0 ? <ParagraphGroup text={body} /> : null}
{hasRows
? rows!.map((row, i) => <LabeledRowView key={i} row={row} />)
: null}
{hasRows ? (
<ul className="m-0 flex min-w-0 list-none flex-col gap-3 p-0">
{rows!.map((row, i) => (
<li key={i} className="min-w-0">
<LabeledRowView row={row} />
</li>
))}
</ul>
) : null}
</div>
</div>
</article>
);
}
+4 -2
View File
@@ -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<MetadataRoute.Sitemap> {
const listedRules = await listListedPublishedRulesForSitemap();
return buildPublicSitemap(listedRules);
}
+1 -1
View File
@@ -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.
+13 -4
View File
@@ -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;
}
+24
View File
@@ -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 }],
};
+74 -3
View File
@@ -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<OwnerPublishedRuleListItem[] | null> {
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<Array<{ id: string; updatedAt: Date }>> {
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).
+10
View File
@@ -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() ?? "";
+37
View File
@@ -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(
<CommunityRule
sections={[
{
categoryName: "Membership",
entries: [
{
title: "Joining",
body: "Ask existing members.",
},
{
title: "Leaving",
body: "Give two weeks notice.",
blocks: [{ label: "Notice", body: "Written." }],
},
],
},
]}
/>,
);
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);
});
});
+18
View File
@@ -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(<CompletedScreen />);
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,
+23 -2
View File
@@ -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<Props> = {
categoryName: "Decision making",
showRail: true,
children: (
<TextBlock title="How proposals pass" body="Important decisions require alignment." />
<TextBlock
title="How proposals pass"
body="Important decisions require alignment."
/>
),
} as Props,
requiredProps: ["categoryName", "children"],
@@ -27,4 +31,21 @@ const config: ComponentTestSuiteConfig<Props> = {
describe("Section", () => {
componentTestSuite<Props>(config);
it("renders the category as a level-2 heading inside a section", () => {
render(
<Section categoryName="Decision making">
<TextBlock
title="How proposals pass"
body="Important decisions require alignment."
/>
</Section>,
);
expect(
screen.getByRole("heading", { level: 2, name: "Decision making" }),
).toBeInTheDocument();
expect(
screen.getByRole("heading", { level: 3, name: "How proposals pass" }),
).toBeInTheDocument();
});
});
+20
View File
@@ -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(
<TextBlock
title="Forum proposals"
body="Anyone can start a thread."
rows={[
{ label: "Quorum", body: "60%" },
{ label: "Threshold", body: "75%" },
]}
/>,
);
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);
});
});
@@ -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",
+14 -9
View File
@@ -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",
+15
View File
@@ -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,
});
});
});
+36
View File
@@ -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 }],
});
});
});
+103
View File
@@ -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();
});
});
+34 -1
View File
@@ -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" });
+31 -13
View File
@@ -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);
});
});
+9
View File
@@ -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");