From 0e7a57052bd157774c64bc96ad7b14e1e5294f60 Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Tue, 21 Apr 2026 22:35:49 -0600 Subject: [PATCH] Public rule and detail page added --- app/(marketing)/rules/[id]/page.tsx | 72 ++++++++++++++++++++++++ app/api/rules/[id]/route.ts | 21 +++++++ lib/server/publishedRules.ts | 48 ++++++++++++++++ tests/unit/rulesByIdRoute.test.ts | 85 +++++++++++++++++++++++++++++ 4 files changed, 226 insertions(+) create mode 100644 app/(marketing)/rules/[id]/page.tsx create mode 100644 app/api/rules/[id]/route.ts create mode 100644 lib/server/publishedRules.ts create mode 100644 tests/unit/rulesByIdRoute.test.ts diff --git a/app/(marketing)/rules/[id]/page.tsx b/app/(marketing)/rules/[id]/page.tsx new file mode 100644 index 0000000..e8f4f62 --- /dev/null +++ b/app/(marketing)/rules/[id]/page.tsx @@ -0,0 +1,72 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { getPublicPublishedRuleById } from "../../../../lib/server/publishedRules"; +import { parseDocumentSectionsForDisplay } from "../../../../lib/create/buildPublishPayload"; +import CommunityRuleDocument from "../../../components/sections/CommunityRuleDocument"; +import HeaderLockup from "../../../components/type/HeaderLockup"; + +interface PageProps { + params: Promise<{ id: string }>; +} + +export async function generateMetadata({ + params, +}: PageProps): Promise { + const { id } = await params; + const rule = await getPublicPublishedRuleById(id); + if (!rule) { + return { + title: "Rule Not Found", + description: "The requested CommunityRule could not be found.", + }; + } + const description = + typeof rule.summary === "string" && rule.summary.trim().length > 0 + ? rule.summary + : undefined; + return { + title: rule.title, + description, + openGraph: { + title: rule.title, + description, + type: "article", + url: `https://communityrule.com/rules/${rule.id}`, + siteName: "CommunityRule", + }, + twitter: { + card: "summary_large_image", + title: rule.title, + description, + }, + }; +} + +export default async function PublicRuleDetailPage({ params }: PageProps) { + const { id } = await params; + const rule = await getPublicPublishedRuleById(id); + if (!rule) { + notFound(); + } + + const sections = parseDocumentSectionsForDisplay(rule.document); + const description = + typeof rule.summary === "string" && rule.summary.trim().length > 0 + ? rule.summary + : undefined; + + return ( +
+
+ + +
+
+ ); +} diff --git a/app/api/rules/[id]/route.ts b/app/api/rules/[id]/route.ts new file mode 100644 index 0000000..9b7330e --- /dev/null +++ b/app/api/rules/[id]/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { isDatabaseConfigured } from "../../../../lib/server/env"; +import { dbUnavailable } from "../../../../lib/server/responses"; +import { getPublicPublishedRuleById } from "../../../../lib/server/publishedRules"; + +type RouteContext = { params: Promise<{ id: string }> }; + +export async function GET(_request: Request, context: RouteContext) { + if (!isDatabaseConfigured()) { + return dbUnavailable(); + } + + const { id } = await context.params; + + const rule = await getPublicPublishedRuleById(id); + if (!rule) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + return NextResponse.json({ rule }); +} diff --git a/lib/server/publishedRules.ts b/lib/server/publishedRules.ts new file mode 100644 index 0000000..e458a64 --- /dev/null +++ b/lib/server/publishedRules.ts @@ -0,0 +1,48 @@ +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. + */ +const PUBLISHED_RULE_PUBLIC_SELECT = { + id: true, + title: true, + summary: true, + document: true, + createdAt: true, + updatedAt: true, +} as const; + +export type PublicPublishedRule = { + id: string; + title: string; + summary: string | null; + document: unknown; + createdAt: Date; + updatedAt: Date; +}; + +/** + * Fetch a single published rule by id for public read surfaces. + * + * Returns `null` when the database is not configured, the id does not match + * any row, or the query throws — callers render a 404 in all missing cases + * and are expected to surface the "DB not configured" state separately if + * they care about distinguishing it (the API route does; the page does not). + */ +export async function getPublicPublishedRuleById( + id: string, +): Promise { + if (!isDatabaseConfigured()) return null; + if (typeof id !== "string" || id.trim() === "") return null; + try { + const rule = await prisma.publishedRule.findUnique({ + where: { id }, + select: PUBLISHED_RULE_PUBLIC_SELECT, + }); + return rule; + } catch { + return null; + } +} diff --git a/tests/unit/rulesByIdRoute.test.ts b/tests/unit/rulesByIdRoute.test.ts new file mode 100644 index 0000000..5d415c7 --- /dev/null +++ b/tests/unit/rulesByIdRoute.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const isDatabaseConfiguredMock = vi.fn(); +const findUniqueMock = vi.fn(); + +vi.mock("../../lib/server/env", () => ({ + isDatabaseConfigured: () => isDatabaseConfiguredMock(), +})); + +vi.mock("../../lib/server/db", () => ({ + prisma: { + publishedRule: { + findUnique: (...args: unknown[]) => findUniqueMock(...args), + }, + }, +})); + +import { GET } from "../../app/api/rules/[id]/route"; + +function makeContext(id: string) { + return { params: Promise.resolve({ id }) }; +} + +beforeEach(() => { + isDatabaseConfiguredMock.mockReset(); + findUniqueMock.mockReset(); +}); + +describe("GET /api/rules/[id]", () => { + it("returns 503 when the database is not configured", async () => { + isDatabaseConfiguredMock.mockReturnValue(false); + const res = await GET(new Request("https://x.test/api/rules/abc"), makeContext("abc")); + expect(res.status).toBe(503); + expect(findUniqueMock).not.toHaveBeenCalled(); + }); + + it("returns 404 when no published rule matches the id", async () => { + isDatabaseConfiguredMock.mockReturnValue(true); + findUniqueMock.mockResolvedValueOnce(null); + const res = await GET( + new Request("https://x.test/api/rules/missing"), + makeContext("missing"), + ); + expect(res.status).toBe(404); + const body = (await res.json()) as { error: string }; + expect(typeof body.error).toBe("string"); + expect(findUniqueMock).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: "missing" } }), + ); + }); + + it("returns 404 when the query throws (swallowed by helper)", async () => { + isDatabaseConfiguredMock.mockReturnValue(true); + findUniqueMock.mockRejectedValueOnce(new Error("db down")); + const res = await GET( + new Request("https://x.test/api/rules/broken"), + makeContext("broken"), + ); + expect(res.status).toBe(404); + }); + + it("returns 200 with { rule } when a published rule exists", async () => { + isDatabaseConfiguredMock.mockReturnValue(true); + const row = { + id: "rule-1", + title: "Mutual Aid Mondays", + summary: "A grassroots community in Denver.", + document: { sections: [] }, + createdAt: new Date("2026-01-01T00:00:00Z"), + updatedAt: new Date("2026-01-02T00:00:00Z"), + }; + findUniqueMock.mockResolvedValueOnce(row); + const res = await GET( + new Request("https://x.test/api/rules/rule-1"), + makeContext("rule-1"), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + rule: { id: string; title: string; summary: string | null }; + }; + 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."); + }); +});