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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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: [] },
|
||||
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" });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user