Public rule and detail page added

This commit is contained in:
adilallo
2026-04-21 22:35:49 -06:00
parent 2d58887a15
commit 0e7a57052b
4 changed files with 226 additions and 0 deletions
+72
View File
@@ -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<Metadata> {
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 (
<div 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}
description={description}
justification="left"
size="L"
palette="inverse"
/>
<CommunityRuleDocument sections={sections} />
</div>
</div>
);
}
+21
View File
@@ -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 });
}
+48
View File
@@ -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<PublicPublishedRule | null> {
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;
}
}
+85
View File
@@ -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.");
});
});