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:
adilalloandCursor committed 2026-09-17 17:42:01 -06:00
1 parent 77b0ec8ad8
commit 1445cb7e1e
27 files changed
+524 -86

No files matched your search

+14 -9
View File
@@ -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",
+15
View File
@@ -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,
});
});
});
+36
View File
@@ -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 }],
});
});
});
+103
View File
@@ -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();
});
});
+34 -1
View File
@@ -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" });
+31 -13
View File
@@ -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);
});
});
+9
View File
@@ -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");