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>
227 lines
6.6 KiB
TypeScript
227 lines
6.6 KiB
TypeScript
import {
|
|
LISTED_PUBLISHED_RULE_WHERE,
|
|
isListedPublishedRule,
|
|
} from "../publishedRuleAccess";
|
|
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` and `claimTokenHash`
|
|
* stay server-side; callers get {@link PublicPublishedRule.isListed} instead.
|
|
*/
|
|
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;
|
|
/** False for guest-claim rows; true for owned and orphaned-after-delete rows. */
|
|
isListed: boolean;
|
|
};
|
|
|
|
/**
|
|
* 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,
|
|
userId: true,
|
|
claimTokenHash: true,
|
|
},
|
|
});
|
|
if (!rule) return null;
|
|
const { userId, claimTokenHash, ...publicFields } = rule;
|
|
return {
|
|
...publicFields,
|
|
isListed: isListedPublishedRule({ userId, claimTokenHash }),
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Metadata for signed-in “my rules” profile list (no full `document` JSON). */
|
|
const PUBLISHED_RULE_OWNER_LIST_SELECT = {
|
|
id: true,
|
|
title: true,
|
|
summary: true,
|
|
createdAt: true,
|
|
updatedAt: true,
|
|
} as const;
|
|
|
|
export type OwnerPublishedRuleListItem = {
|
|
id: string;
|
|
title: string;
|
|
summary: string | null;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
};
|
|
|
|
const LISTED_RULE_SITEMAP_SELECT = {
|
|
id: true,
|
|
updatedAt: true,
|
|
} as const;
|
|
|
|
const LISTED_PUBLISHED_RULE_SITEMAP_CAP = 10_000;
|
|
|
|
/**
|
|
* Public catalog of listed published rules (**most recently updated first**,
|
|
* then stable `id` tie-break). Guest-claim rows are omitted.
|
|
* Returns `null` when the database is not configured or the query throws.
|
|
*/
|
|
export async function listListedPublishedRules(
|
|
take: number,
|
|
): Promise<OwnerPublishedRuleListItem[] | null> {
|
|
if (!isDatabaseConfigured()) return null;
|
|
const clamped = Math.min(Math.max(0, take), 100);
|
|
if (clamped === 0) return [];
|
|
try {
|
|
return await prisma.publishedRule.findMany({
|
|
where: LISTED_PUBLISHED_RULE_WHERE,
|
|
orderBy: [{ updatedAt: "desc" }, { id: "asc" }],
|
|
take: clamped,
|
|
select: PUBLISHED_RULE_OWNER_LIST_SELECT,
|
|
});
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Listed published-rule ids for `/sitemap.xml`. Empty when the database is
|
|
* not configured or the query throws (marketing URLs still render).
|
|
*/
|
|
export async function listListedPublishedRulesForSitemap(
|
|
take: number = LISTED_PUBLISHED_RULE_SITEMAP_CAP,
|
|
): Promise<Array<{ id: string; updatedAt: Date }>> {
|
|
if (!isDatabaseConfigured()) return [];
|
|
const clamped = Math.min(
|
|
Math.max(0, take),
|
|
LISTED_PUBLISHED_RULE_SITEMAP_CAP,
|
|
);
|
|
if (clamped === 0) return [];
|
|
try {
|
|
return await prisma.publishedRule.findMany({
|
|
where: LISTED_PUBLISHED_RULE_WHERE,
|
|
orderBy: [{ updatedAt: "desc" }, { id: "asc" }],
|
|
take: clamped,
|
|
select: LISTED_RULE_SITEMAP_SELECT,
|
|
});
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Lists published rules owned by the given user (**most recently updated first**,
|
|
* then stable `id` tie-break).
|
|
* Returns `null` when the database is not configured or the query throws.
|
|
*/
|
|
export async function listPublishedRulesForUser(
|
|
userId: string,
|
|
take: number,
|
|
): Promise<OwnerPublishedRuleListItem[] | null> {
|
|
if (!isDatabaseConfigured()) return null;
|
|
if (typeof userId !== "string" || userId.trim() === "") return null;
|
|
const clamped = Math.min(Math.max(0, take), 100);
|
|
if (clamped === 0) return [];
|
|
try {
|
|
return await prisma.publishedRule.findMany({
|
|
where: { userId },
|
|
orderBy: [{ updatedAt: "desc" }, { id: "asc" }],
|
|
take: clamped,
|
|
select: PUBLISHED_RULE_OWNER_LIST_SELECT,
|
|
});
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export type ProfileRuleListItem = OwnerPublishedRuleListItem & {
|
|
role: "owner" | "stakeholder";
|
|
};
|
|
|
|
/**
|
|
* Published rules the user can access as an **accepted** stakeholder (`userId` set).
|
|
* Same metadata shape as {@link listPublishedRulesForUser}; no `document`.
|
|
*/
|
|
export async function listStakeholderRulesForUser(
|
|
userId: string,
|
|
take: number,
|
|
): Promise<OwnerPublishedRuleListItem[] | null> {
|
|
if (!isDatabaseConfigured()) return null;
|
|
if (typeof userId !== "string" || userId.trim() === "") return null;
|
|
const clamped = Math.min(Math.max(0, take), 100);
|
|
if (clamped === 0) return [];
|
|
try {
|
|
const rows = await prisma.ruleStakeholder.findMany({
|
|
where: { userId },
|
|
take: clamped,
|
|
orderBy: [{ rule: { updatedAt: "desc" } }, { id: "asc" }],
|
|
select: {
|
|
rule: { select: PUBLISHED_RULE_OWNER_LIST_SELECT },
|
|
},
|
|
});
|
|
return rows.map((r) => r.rule);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Profile list: owned rules plus stakeholder access, **owner wins** if both,
|
|
* sorted by `updatedAt` desc (then `id`).
|
|
*/
|
|
export async function listProfileRulesForUser(
|
|
userId: string,
|
|
take: number,
|
|
): Promise<ProfileRuleListItem[] | null> {
|
|
const cap = Math.min(Math.max(0, take), 100);
|
|
if (cap === 0) return [];
|
|
/** Merge then slice so ordering is global by `updatedAt`. */
|
|
const fetchCap = 100;
|
|
const [owned, stakeholderRules] = await Promise.all([
|
|
listPublishedRulesForUser(userId, fetchCap),
|
|
listStakeholderRulesForUser(userId, fetchCap),
|
|
]);
|
|
if (owned === null || stakeholderRules === null) return null;
|
|
const ownerIds = new Set(owned.map((r) => r.id));
|
|
const stakeholderOnly = stakeholderRules.filter((r) => !ownerIds.has(r.id));
|
|
const combined: ProfileRuleListItem[] = [
|
|
...owned.map((r) => ({ ...r, role: "owner" as const })),
|
|
...stakeholderOnly.map((r) => ({
|
|
...r,
|
|
role: "stakeholder" as const,
|
|
})),
|
|
];
|
|
combined.sort((a, b) => {
|
|
const t = b.updatedAt.getTime() - a.updatedAt.getTime();
|
|
if (t !== 0) return t;
|
|
return a.id.localeCompare(b.id);
|
|
});
|
|
return combined.slice(0, cap);
|
|
}
|