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 <cursoragent@cursor.com>
This commit is contained in:
1 parent
77b0ec8ad8
commit
1445cb7e1e
27 files changed
+524
-86
No files matched your search
@@ -34,7 +34,7 @@ Valid enum slices for Storybook / guards: `ALERT_*_OPTIONS` in `lib/propNormaliz
|
|||||||
## Choosing toast vs banner
|
## Choosing toast vs banner
|
||||||
|
|
||||||
- **`toast`** — transient edge / bottom emphasis (e.g. completed flow), strong bottom border accent.
|
- **`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`)
|
## Exemptions (do not force `Alert`)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -90,7 +90,7 @@ All routes return JSON. Non-`GET` requests expect
|
|||||||
|
|
||||||
| Method | Path | Purpose |
|
| 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 | `/api/rules/me` | Owner's published rules. |
|
||||||
| GET, PATCH, DELETE | `/api/rules/[id]` | Public read; owner update / delete. |
|
| GET, PATCH, DELETE | `/api/rules/[id]` | Public read; owner update / delete. |
|
||||||
| POST | `/api/rules/[id]/duplicate` | Owner clone. |
|
| POST | `/api/rules/[id]/duplicate` | Owner clone. |
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ import {
|
|||||||
} from "./utils/createFlowFooterClassNames";
|
} from "./utils/createFlowFooterClassNames";
|
||||||
import {
|
import {
|
||||||
CREATE_FLOW_MD_CENTERED_MAIN_CLASS,
|
CREATE_FLOW_MD_CENTERED_MAIN_CLASS,
|
||||||
|
CREATE_FLOW_FIXED_TOP_BANNER_OFFSET_CLASS,
|
||||||
CREATE_FLOW_PAGE_GUTTER_CLASS,
|
CREATE_FLOW_PAGE_GUTTER_CLASS,
|
||||||
CREATE_FLOW_SCROLL_REGION_CLASS,
|
CREATE_FLOW_SCROLL_REGION_CLASS,
|
||||||
CREATE_FLOW_VIEWPORT_FRAME_CLASS,
|
CREATE_FLOW_VIEWPORT_FRAME_CLASS,
|
||||||
@@ -679,7 +680,7 @@ function CreateFlowLayoutContent({
|
|||||||
<div className={`${CREATE_FLOW_VIEWPORT_FRAME_CLASS} bg-black`}>
|
<div className={`${CREATE_FLOW_VIEWPORT_FRAME_CLASS} bg-black`}>
|
||||||
{topBanners.length > 0 ? (
|
{topBanners.length > 0 ? (
|
||||||
<div
|
<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"
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
{topBanners.map((b) => (
|
{topBanners.map((b) => (
|
||||||
@@ -791,7 +792,7 @@ function CreateFlowLayoutContent({
|
|||||||
}
|
}
|
||||||
onExit={(opts) => void handleExit(opts)}
|
onExit={(opts) => void handleExit(opts)}
|
||||||
buttonPalette={isCompletedStep ? "inverse" : undefined}
|
buttonPalette={isCompletedStep ? "inverse" : undefined}
|
||||||
className={`shrink-0 ${
|
className={`relative z-[210] shrink-0 ${
|
||||||
isCompletedStep ? "!bg-[var(--color-teal-teal50,#c9fef9)]" : ""
|
isCompletedStep ? "!bg-[var(--color-teal-teal50,#c9fef9)]" : ""
|
||||||
}`.trim()}
|
}`.trim()}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ import {
|
|||||||
import { useCreateFlow } from "./context/CreateFlowContext";
|
import { useCreateFlow } from "./context/CreateFlowContext";
|
||||||
import { fetchDraftFromServer } from "../../../lib/create/api";
|
import { fetchDraftFromServer } from "../../../lib/create/api";
|
||||||
import messages from "../../../messages/en/index";
|
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 Alert from "../../components/modals/Alert";
|
||||||
import {
|
import {
|
||||||
isValidStep,
|
isValidStep,
|
||||||
@@ -166,7 +169,9 @@ export function SignedInDraftHydration({
|
|||||||
if (!loadingHydration) return null;
|
if (!loadingHydration) return null;
|
||||||
|
|
||||||
return (
|
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]">
|
<div className="pointer-events-auto w-full max-w-[960px]">
|
||||||
<Alert
|
<Alert
|
||||||
type="banner"
|
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";
|
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
|
* 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.
|
* stays on-screen; `max-h-dvh` keeps the flex column from overflowing it.
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import { notFound } from "next/navigation";
|
|||||||
import messages from "../../../../messages/en/index";
|
import messages from "../../../../messages/en/index";
|
||||||
import { getPublicPublishedRuleById } from "../../../../lib/server/publishedRules";
|
import { getPublicPublishedRuleById } from "../../../../lib/server/publishedRules";
|
||||||
import { parsePublishedDocumentForCommunityRuleDisplay } from "../../../../lib/create/publishedDocumentToDisplaySections";
|
import { parsePublishedDocumentForCommunityRuleDisplay } from "../../../../lib/create/publishedDocumentToDisplaySections";
|
||||||
import { routeMetadata } from "../../../../lib/siteMetadata";
|
import {
|
||||||
|
robotsForPublishedRule,
|
||||||
|
routeMetadata,
|
||||||
|
} from "../../../../lib/siteMetadata";
|
||||||
import CommunityRule from "../../../components/type/CommunityRule";
|
import CommunityRule from "../../../components/type/CommunityRule";
|
||||||
import HeaderLockup from "../../../components/type/HeaderLockup";
|
import HeaderLockup from "../../../components/type/HeaderLockup";
|
||||||
|
|
||||||
@@ -26,9 +29,11 @@ export async function generateMetadata({
|
|||||||
typeof rule.summary === "string" && rule.summary.trim().length > 0
|
typeof rule.summary === "string" && rule.summary.trim().length > 0
|
||||||
? rule.summary
|
? rule.summary
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const robots = robotsForPublishedRule(rule.isListed);
|
||||||
return routeMetadata(`/rules/${rule.id}`, {
|
return routeMetadata(`/rules/${rule.id}`, {
|
||||||
title: rule.title,
|
title: rule.title,
|
||||||
description,
|
description,
|
||||||
|
...(robots ? { robots } : {}),
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: rule.title,
|
title: rule.title,
|
||||||
description,
|
description,
|
||||||
@@ -58,7 +63,7 @@ export default async function PublicRuleDetailPage({ params }: PageProps) {
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
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)]">
|
<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
|
<HeaderLockup
|
||||||
title={rule.title}
|
title={rule.title}
|
||||||
@@ -69,6 +74,6 @@ export default async function PublicRuleDetailPage({ params }: PageProps) {
|
|||||||
/>
|
/>
|
||||||
<CommunityRule sections={sections} />
|
<CommunityRule sections={sections} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+5
-2
@@ -6,6 +6,7 @@ import Share from "../../../../../components/modals/Share";
|
|||||||
import Alert from "../../../../../components/modals/Alert";
|
import Alert from "../../../../../components/modals/Alert";
|
||||||
import { CreateFlowHeaderLockup } from "../../../../../(app)/create/components/CreateFlowHeaderLockup";
|
import { CreateFlowHeaderLockup } from "../../../../../(app)/create/components/CreateFlowHeaderLockup";
|
||||||
import {
|
import {
|
||||||
|
CREATE_FLOW_FIXED_TOP_BANNER_OFFSET_CLASS,
|
||||||
CREATE_FLOW_MD_UP_GRID_CELL_CLASS,
|
CREATE_FLOW_MD_UP_GRID_CELL_CLASS,
|
||||||
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
|
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
|
||||||
} from "../../../../../(app)/create/components/createFlowLayoutTokens";
|
} from "../../../../../(app)/create/components/createFlowLayoutTokens";
|
||||||
@@ -41,7 +42,9 @@ export function UseCaseCompletedRuleView({
|
|||||||
style={{ background: pageBg }}
|
style={{ background: pageBg }}
|
||||||
>
|
>
|
||||||
{actionBanner ? (
|
{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]">
|
<div className="pointer-events-auto w-full max-w-[639px]">
|
||||||
<Alert
|
<Alert
|
||||||
type="banner"
|
type="banner"
|
||||||
@@ -72,7 +75,7 @@ export function UseCaseCompletedRuleView({
|
|||||||
duplicateAriaLabel={duplicateAriaLabel}
|
duplicateAriaLabel={duplicateAriaLabel}
|
||||||
exitLabel={exitLabel}
|
exitLabel={exitLabel}
|
||||||
buttonPalette="inverse"
|
buttonPalette="inverse"
|
||||||
className="shrink-0 !bg-transparent"
|
className="relative z-[21] shrink-0 !bg-transparent"
|
||||||
onShare={onShareOpen}
|
onShare={onShareOpen}
|
||||||
onDuplicate={onDuplicate}
|
onDuplicate={onDuplicate}
|
||||||
onExit={onExit}
|
onExit={onExit}
|
||||||
|
|||||||
+10
-17
@@ -1,7 +1,11 @@
|
|||||||
import type { Prisma } from "@prisma/client";
|
import type { Prisma } from "@prisma/client";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "../../../lib/server/db";
|
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 {
|
import {
|
||||||
hashSessionToken,
|
hashSessionToken,
|
||||||
hashRuleClaimToken,
|
hashRuleClaimToken,
|
||||||
@@ -12,6 +16,7 @@ import { rateLimitKey } from "../../../lib/server/rateLimit";
|
|||||||
import {
|
import {
|
||||||
dbUnavailable,
|
dbUnavailable,
|
||||||
errorJson,
|
errorJson,
|
||||||
|
internalError,
|
||||||
rateLimited,
|
rateLimited,
|
||||||
serverMisconfigured,
|
serverMisconfigured,
|
||||||
} from "../../../lib/server/responses";
|
} from "../../../lib/server/responses";
|
||||||
@@ -48,22 +53,10 @@ export const GET = apiRoute("rules.list", async (request: NextRequest) => {
|
|||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const take = Math.min(Number(searchParams.get("limit") ?? "50") || 50, 100);
|
const take = Math.min(Number(searchParams.get("limit") ?? "50") || 50, 100);
|
||||||
|
|
||||||
/**
|
const rules = await listListedPublishedRules(take);
|
||||||
* Public catalog: owned rules only (last touched first). Guest / orphan rows
|
if (rules === null) {
|
||||||
* (`userId` null) stay reachable at `/rules/[id]` and `GET /api/rules/[id]`.
|
return internalError("Failed to list rules");
|
||||||
*/
|
}
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({ rules });
|
return NextResponse.json({ rules });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,17 +39,20 @@ function CommunityRuleView({
|
|||||||
categoryName={ruleSection.categoryName}
|
categoryName={ruleSection.categoryName}
|
||||||
showRail={!useCardStyle}
|
showRail={!useCardStyle}
|
||||||
>
|
>
|
||||||
{ruleSection.entries.map((entry, entryIndex) => {
|
<ul className="m-0 flex min-w-0 list-none flex-col gap-6 p-0">
|
||||||
const hasBlocks = Boolean(entry.blocks?.length);
|
{ruleSection.entries.map((entry, entryIndex) => {
|
||||||
return (
|
const hasBlocks = Boolean(entry.blocks?.length);
|
||||||
<TextBlock
|
return (
|
||||||
key={entryIndex}
|
<li key={entryIndex} className="min-w-0">
|
||||||
title={entry.title}
|
<TextBlock
|
||||||
body={entry.body}
|
title={entry.title}
|
||||||
rows={hasBlocks ? entry.blocks : undefined}
|
body={entry.body}
|
||||||
/>
|
rows={hasBlocks ? entry.blocks : undefined}
|
||||||
);
|
/>
|
||||||
})}
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
</Section>
|
</Section>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,21 +21,24 @@ function SectionView({
|
|||||||
}: SectionProps) {
|
}: SectionProps) {
|
||||||
const inner = (
|
const inner = (
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-4" style={{ gap: "16px" }}>
|
<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 className="flex min-w-0 flex-col gap-6">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!showRail) {
|
if (!showRail) {
|
||||||
return (
|
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}
|
{inner}
|
||||||
</div>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<section
|
||||||
className={`flex min-w-0 items-stretch gap-3 ${className}`.trim()}
|
className={`flex min-w-0 items-stretch gap-3 ${className}`.trim()}
|
||||||
style={{ gap: "12px" }}
|
style={{ gap: "12px" }}
|
||||||
data-name="Section"
|
data-name="Section"
|
||||||
@@ -46,7 +49,7 @@ function SectionView({
|
|||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
{inner}
|
{inner}
|
||||||
</div>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ function LabeledRowView({ row }: { row: TextRow }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-w-0 flex-col gap-2">
|
<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 ? (
|
{imageSrc ? (
|
||||||
<>
|
<>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element -- same-origin or absolute upload URL */}
|
{/* 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;
|
const hasRows = rows && rows.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<article
|
||||||
className={`flex min-w-0 flex-col gap-2 ${className}`.trim()}
|
className={`flex min-w-0 flex-col gap-2 ${className}`.trim()}
|
||||||
data-name="TextBlock"
|
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">
|
<div className="flex min-w-0 flex-col gap-3">
|
||||||
{body.trim().length > 0 ? <ParagraphGroup text={body} /> : null}
|
{body.trim().length > 0 ? <ParagraphGroup text={body} /> : null}
|
||||||
{hasRows
|
{hasRows ? (
|
||||||
? rows!.map((row, i) => <LabeledRowView key={i} row={row} />)
|
<ul className="m-0 flex min-w-0 list-none flex-col gap-3 p-0">
|
||||||
: null}
|
{rows!.map((row, i) => (
|
||||||
|
<li key={i} className="min-w-0">
|
||||||
|
<LabeledRowView row={row} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -1,6 +1,8 @@
|
|||||||
import type { MetadataRoute } from "next";
|
import type { MetadataRoute } from "next";
|
||||||
import { buildPublicSitemap } from "../lib/publicSitemap";
|
import { buildPublicSitemap } from "../lib/publicSitemap";
|
||||||
|
import { listListedPublishedRulesForSitemap } from "../lib/server/publishedRules";
|
||||||
|
|
||||||
export default function sitemap(): MetadataRoute.Sitemap {
|
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||||
return buildPublicSitemap();
|
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`).
|
- **`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.
|
- **`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.
|
- **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).
|
- **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.
|
- **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;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public marketing URLs for `/sitemap.xml`. Omits signed-in product routes
|
* Public marketing URLs for `/sitemap.xml`, plus listed published rules when
|
||||||
* (`/create`, `/login`, `/profile`), admin, and `/rules/[id]` (those ids are
|
* the caller supplies them. Omits signed-in product routes (`/create`,
|
||||||
* not known without the database).
|
* `/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) => ({
|
const entries: MetadataRoute.Sitemap = STATIC_PUBLIC_PATHS.map((path) => ({
|
||||||
url: sitemapUrl(path),
|
url: sitemapUrl(path),
|
||||||
}));
|
}));
|
||||||
@@ -38,5 +40,12 @@ export function buildPublicSitemap(): MetadataRoute.Sitemap {
|
|||||||
entries.push({ url: sitemapUrl(`/use-cases/${slug}/rule`) });
|
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;
|
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 { prisma } from "./db";
|
||||||
import { isDatabaseConfigured } from "./env";
|
import { isDatabaseConfigured } from "./env";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Public fields safe to expose via the unauthenticated rule detail surfaces
|
* 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 = {
|
const PUBLISHED_RULE_PUBLIC_SELECT = {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -21,6 +26,8 @@ export type PublicPublishedRule = {
|
|||||||
document: unknown;
|
document: unknown;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: 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 {
|
try {
|
||||||
const rule = await prisma.publishedRule.findUnique({
|
const rule = await prisma.publishedRule.findUnique({
|
||||||
where: { id },
|
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 {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -64,6 +80,61 @@ export type OwnerPublishedRuleListItem = {
|
|||||||
updatedAt: Date;
|
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**,
|
* Lists published rules owned by the given user (**most recently updated first**,
|
||||||
* then stable `id` tie-break).
|
* 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;
|
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 {
|
function hostnameFromHostHeader(host: string | null | undefined): string {
|
||||||
if (!host) return "";
|
if (!host) return "";
|
||||||
return host.split(",")[0]?.trim().split(":")[0]?.toLowerCase() ?? "";
|
return host.split(",")[0]?.trim().split(":")[0]?.toLowerCase() ?? "";
|
||||||
|
|||||||
@@ -72,4 +72,41 @@ describe("CommunityRule", () => {
|
|||||||
expect(screen.getByText("Quorum")).toBeInTheDocument();
|
expect(screen.getByText("Quorum")).toBeInTheDocument();
|
||||||
expect(screen.getByText("60%")).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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
import {
|
import {
|
||||||
componentTestSuite,
|
componentTestSuite,
|
||||||
type ComponentTestSuiteConfig,
|
type ComponentTestSuiteConfig,
|
||||||
@@ -15,7 +16,10 @@ const config: ComponentTestSuiteConfig<Props> = {
|
|||||||
categoryName: "Decision making",
|
categoryName: "Decision making",
|
||||||
showRail: true,
|
showRail: true,
|
||||||
children: (
|
children: (
|
||||||
<TextBlock title="How proposals pass" body="Important decisions require alignment." />
|
<TextBlock
|
||||||
|
title="How proposals pass"
|
||||||
|
body="Important decisions require alignment."
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
} as Props,
|
} as Props,
|
||||||
requiredProps: ["categoryName", "children"],
|
requiredProps: ["categoryName", "children"],
|
||||||
@@ -27,4 +31,21 @@ const config: ComponentTestSuiteConfig<Props> = {
|
|||||||
|
|
||||||
describe("Section", () => {
|
describe("Section", () => {
|
||||||
componentTestSuite<Props>(config);
|
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("Quorum")).toBeInTheDocument();
|
||||||
expect(screen.getByText("60%")).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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
CREATE_FLOW_MD_CENTERED_MAIN_CLASS,
|
CREATE_FLOW_MD_CENTERED_MAIN_CLASS,
|
||||||
CREATE_FLOW_MD_CENTERED_SHELL_CLASS,
|
CREATE_FLOW_MD_CENTERED_SHELL_CLASS,
|
||||||
CREATE_FLOW_PAGE_GUTTER_CLASS,
|
CREATE_FLOW_PAGE_GUTTER_CLASS,
|
||||||
|
CREATE_FLOW_FIXED_TOP_BANNER_OFFSET_CLASS,
|
||||||
CREATE_FLOW_VIEWPORT_FRAME_CLASS,
|
CREATE_FLOW_VIEWPORT_FRAME_CLASS,
|
||||||
getCreateFlowContentMaxClass,
|
getCreateFlowContentMaxClass,
|
||||||
} from "../../app/(app)/create/components/createFlowLayoutTokens";
|
} 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");
|
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", () => {
|
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("h-dvh");
|
||||||
expect(CREATE_FLOW_VIEWPORT_FRAME_CLASS).toContain("max-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", () => {
|
it("aligns the action bar max-width with the step content column", () => {
|
||||||
expect(
|
expect(getCreateFlowContentMaxClass({ step: "community-name" })).toBe(
|
||||||
getCreateFlowContentMaxClass({ step: "community-name" }),
|
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
|
||||||
).toBe(CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS);
|
);
|
||||||
expect(
|
expect(getCreateFlowContentMaxClass({ step: "community-structure" })).toBe(
|
||||||
getCreateFlowContentMaxClass({ step: "community-structure" }),
|
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
|
||||||
).toBe(CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS);
|
);
|
||||||
expect(
|
expect(
|
||||||
getCreateFlowContentMaxClass({ step: "communication-methods" }),
|
getCreateFlowContentMaxClass({ step: "communication-methods" }),
|
||||||
).toBe(CREATE_FLOW_WIDE_MAX_CLASS);
|
).toBe(CREATE_FLOW_WIDE_MAX_CLASS);
|
||||||
expect(
|
expect(getCreateFlowContentMaxClass({ step: "final-review" })).toBe(
|
||||||
getCreateFlowContentMaxClass({ step: "final-review" }),
|
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
|
||||||
).toBe(CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS);
|
);
|
||||||
expect(
|
expect(
|
||||||
getCreateFlowContentMaxClass({
|
getCreateFlowContentMaxClass({
|
||||||
step: "informational",
|
step: "informational",
|
||||||
|
|||||||
@@ -36,4 +36,19 @@ describe("buildPublicSitemap", () => {
|
|||||||
expect(urls.some((url) => url.includes("/monitor"))).toBe(false);
|
expect(urls.some((url) => url.includes("/monitor"))).toBe(false);
|
||||||
expect(urls.some((url) => url.includes("/api/"))).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: [] },
|
document: { sections: [] },
|
||||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||||
updatedAt: new Date("2026-01-02T00:00:00Z"),
|
updatedAt: new Date("2026-01-02T00:00:00Z"),
|
||||||
|
userId: "user-1",
|
||||||
|
claimTokenHash: null,
|
||||||
};
|
};
|
||||||
findUniqueMock.mockResolvedValueOnce(row);
|
findUniqueMock.mockResolvedValueOnce(row);
|
||||||
const res = await GET(
|
const res = await GET(
|
||||||
@@ -103,16 +105,47 @@ describe("GET /api/rules/[id]", () => {
|
|||||||
);
|
);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const body = (await res.json()) as {
|
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;
|
viewerIsOwner: boolean;
|
||||||
};
|
};
|
||||||
expect(body.rule.id).toBe("rule-1");
|
expect(body.rule.id).toBe("rule-1");
|
||||||
expect(body.rule.title).toBe("Mutual Aid Mondays");
|
expect(body.rule.title).toBe("Mutual Aid Mondays");
|
||||||
expect(body.rule.summary).toBe("A grassroots community in Denver.");
|
expect(body.rule.summary).toBe("A grassroots community in Denver.");
|
||||||
|
expect(body.rule.isListed).toBe(true);
|
||||||
expect(body.viewerIsOwner).toBe(false);
|
expect(body.viewerIsOwner).toBe(false);
|
||||||
expect(findUniqueMock).toHaveBeenCalledTimes(1);
|
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 () => {
|
it("returns viewerIsOwner true when the signed-in user owns the rule", async () => {
|
||||||
isDatabaseConfiguredMock.mockReturnValue(true);
|
isDatabaseConfiguredMock.mockReturnValue(true);
|
||||||
getSessionUserMock.mockResolvedValue({ id: "user-1", email: "a@b.c" });
|
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";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const isDatabaseConfiguredMock = vi.fn();
|
const isDatabaseConfiguredMock = vi.fn();
|
||||||
const findManyMock = vi.fn();
|
const listListedMock = vi.fn();
|
||||||
|
|
||||||
vi.mock("../../lib/server/env", () => ({
|
vi.mock("../../lib/server/env", () => ({
|
||||||
isDatabaseConfigured: () => isDatabaseConfiguredMock(),
|
isDatabaseConfigured: () => isDatabaseConfiguredMock(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../lib/server/publishedRules", () => ({
|
||||||
|
listListedPublishedRules: (...args: unknown[]) => listListedMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../../lib/server/db", () => ({
|
vi.mock("../../lib/server/db", () => ({
|
||||||
prisma: {
|
prisma: {
|
||||||
publishedRule: {
|
publishedRule: {
|
||||||
findMany: (...args: unknown[]) => findManyMock(...args),
|
findMany: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -20,7 +25,7 @@ import { GET } from "../../app/api/rules/route";
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
isDatabaseConfiguredMock.mockReset();
|
isDatabaseConfiguredMock.mockReset();
|
||||||
findManyMock.mockReset();
|
listListedMock.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("GET /api/rules", () => {
|
describe("GET /api/rules", () => {
|
||||||
@@ -31,12 +36,12 @@ describe("GET /api/rules", () => {
|
|||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
expect(res.status).toBe(503);
|
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);
|
isDatabaseConfiguredMock.mockReturnValue(true);
|
||||||
findManyMock.mockResolvedValueOnce([
|
listListedMock.mockResolvedValueOnce([
|
||||||
{
|
{
|
||||||
id: "r1",
|
id: "r1",
|
||||||
title: "Owned",
|
title: "Owned",
|
||||||
@@ -52,13 +57,26 @@ describe("GET /api/rules", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(findManyMock).toHaveBeenCalledWith(
|
expect(listListedMock).toHaveBeenCalledWith(10);
|
||||||
expect.objectContaining({
|
|
||||||
where: { userId: { not: null } },
|
|
||||||
take: 10,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const body = (await res.json()) as { rules: Array<{ id: string }> };
|
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 metadataMessages from "../../messages/en/metadata.json";
|
||||||
import {
|
import {
|
||||||
buildRobots,
|
buildRobots,
|
||||||
|
NO_INDEX_ROBOTS,
|
||||||
PRODUCTION_SITE_ORIGIN,
|
PRODUCTION_SITE_ORIGIN,
|
||||||
resolveRequestHost,
|
resolveRequestHost,
|
||||||
|
robotsForPublishedRule,
|
||||||
routeMetadata,
|
routeMetadata,
|
||||||
sitemapUrl,
|
sitemapUrl,
|
||||||
} from "../../lib/siteMetadata";
|
} 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", () => {
|
describe("page title segments", () => {
|
||||||
it("uses a shared em-dash template and does not bake a site suffix into page titles", () => {
|
it("uses a shared em-dash template and does not bake a site suffix into page titles", () => {
|
||||||
expect(metadataMessages.titleTemplate).toBe("%s — CommunityRule");
|
expect(metadataMessages.titleTemplate).toBe("%s — CommunityRule");
|
||||||
|
|||||||
Reference in new issue
Block a user