Keep guest-claim rules unlisted, match published-rule markup to Markdown, keep Share clickable, and let mobile titles scroll #81
No files matched your search
@@ -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
@@ -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. |
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+7
-4
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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 }],
|
||||
};
|
||||
@@ -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).
|
||||
|
||||
@@ -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() ?? "";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user