From 1445cb7e1e882c01ad4104b1280e754f7b300639 Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:42:01 -0600 Subject: [PATCH 1/2] Keep listed published rules public and guest-claim rules unlisted, with semantic document markup and share chrome that stays clickable. Guest publishes remain share-by-link but stay out of the catalog and sitemap, and their pages send noindex until claimed. The published-rule view now uses heading and list markup matching Markdown export, and top banners sit below the header so Share is not covered. Co-authored-by: Cursor --- .cursor/rules/alerts.mdc | 2 +- CONTRIBUTING.md | 2 +- app/(app)/create/CreateFlowLayoutClient.tsx | 5 +- app/(app)/create/SignedInDraftHydration.tsx | 9 +- .../components/createFlowLayoutTokens.ts | 6 + app/(marketing)/rules/[id]/page.tsx | 11 +- .../_components/UseCaseCompletedRule.view.tsx | 7 +- app/api/rules/route.ts | 27 ++--- .../type/CommunityRule/CommunityRule.view.tsx | 25 +++-- app/components/type/Section/Section.view.tsx | 13 ++- .../type/TextBlock/TextBlock.view.tsx | 20 ++-- app/sitemap.ts | 6 +- docs/guides/backend-roadmap.md | 2 +- lib/publicSitemap.ts | 17 ++- lib/publishedRuleAccess.ts | 24 ++++ lib/server/publishedRules.ts | 77 ++++++++++++- lib/siteMetadata.ts | 10 ++ tests/components/CommunityRule.test.tsx | 37 +++++++ tests/components/Section.test.tsx | 25 ++++- tests/components/TextBlock.test.tsx | 20 ++++ tests/unit/createFlowLayoutTokens.test.ts | 23 ++-- tests/unit/publicSitemap.test.ts | 15 +++ tests/unit/publishedRuleAccess.test.ts | 36 ++++++ tests/unit/publishedRules.test.ts | 103 ++++++++++++++++++ tests/unit/rulesByIdRoute.test.ts | 35 +++++- tests/unit/rulesListRoute.test.ts | 44 +++++--- tests/unit/siteMetadata.test.ts | 9 ++ 27 files changed, 524 insertions(+), 86 deletions(-) create mode 100644 lib/publishedRuleAccess.ts create mode 100644 tests/unit/publishedRuleAccess.test.ts create mode 100644 tests/unit/publishedRules.test.ts 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..ebec8db 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"; @@ -41,7 +42,9 @@ export function UseCaseCompletedRuleView({ style={{ background: pageBg }} > {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 ( -
+
{inner} -
+ ); } 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/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/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"); -- 2.53.0 From 4b5ec70447bd394358e68581bd33705f1e0a074d Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:01:33 -0600 Subject: [PATCH 2/2] Let completed-rule titles scroll away on small screens. The heading still stays in the left column on desktop; on mobile it moves with the document instead of pinning to the top of the viewport. Co-authored-by: Cursor --- .../screens/completed/CompletedScreen.tsx | 2 +- .../_components/UseCaseCompletedRule.view.tsx | 4 ++-- tests/components/CompletedPage.test.tsx | 18 ++++++++++++++++++ tests/pages/use-cases-completed-rule.test.jsx | 14 ++++++++++++++ 4 files changed, 35 insertions(+), 3 deletions(-) diff --git a/app/(app)/create/screens/completed/CompletedScreen.tsx b/app/(app)/create/screens/completed/CompletedScreen.tsx index ad7d604..b5e7ce8 100644 --- a/app/(app)/create/screens/completed/CompletedScreen.tsx +++ b/app/(app)/create/screens/completed/CompletedScreen.tsx @@ -179,7 +179,7 @@ export function CompletedScreen() { />
{/* - 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. */}
{ 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/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", -- 2.53.0