Public rule and detail page added
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user