// Database-backed reads for problems.//// Split by cacheability, which is what makes Partial Prerendering work here://// - Curated content and vote TALLIES are public and identical for everyone,// so they live behind `use cache` + `cacheTag` and end up in the static// shell. `revalidateTag` in the vote action refreshes them.// - The viewer's OWN vote is per-request and must never be cached publicly.// It is read dynamically (see `src/app/actions/vote.ts`) and applied on the// client, so one person's vote can never leak into another's page.//// `'use cache: private'` would also fit the second case, but it is still marked// experimental in Next 16 (it depends on runtime prefetching, which is not// stable), so this deliberately uses a plain dynamic read instead. import type { Prisma } from "@prisma/client";import { cacheLife, cacheTag } from "next/cache";import { prisma } from "@/lib/prisma";import { activityTag, commentsTag, subjectWhere, type Subject,} from "@/lib/subject";import type { ProfileLinks } from "@/lib/profile-links";import { CHANGELOG_TYPES, collapseBursts, type ActivityView, type SiteActivityView,} from "@/lib/activity";import { relationKind } from "@/lib/relation-kinds";import { relativeFallback } from "@/lib/relative-time";import { formatCommentDate, formatCommentDateTime, renderCommentHtml,} from "@/lib/comment-render";import { texToHtml } from "@/components/TeX";import type { CommentView } from "@/lib/comments";import { resolveSnapshot } from "@/lib/identity";import type { ProvenanceView } from "@/lib/provenance";import type { AiContribution, FieldGroup, PublicationStatus, ResolutionMethod, ProblemWithTrends, ProblemWithVotes, ResolutionStatus, SolveType, VerificationStatus,} from "@/lib/problems"; export type { ProblemWithTrends, ProblemWithVotes }; /// Exported so the reviewer preview route can read unpublished rows with/// exactly the shape the public reader uses.export const PROBLEM_SELECT = { slug: true, name: true, shortName: true, problemNumber: true, field: true, fieldGroup: true, statement: true, posedBy: true, yearPosed: true, solveType: true, resolution: true, claimIssueNote: true, aiContribution: true, solveDate: true, model: true, modelMaker: true, humanCollaborators: true, aiRole: true, verification: true, verificationNote: true, publication: true, resolutionMethod: true, citations: true, citationsPaper: true, citationsSource: true, citationsUrl: true, renownLangs: true, renownNote: true, significance: true, significanceNote: true, solveCostUsd: true, solveCostNote: true, resultNote: true, ageNote: true, sourceUrl: true, sourceName: true, createdAt: true, updatedAt: true, links: { select: { label: true, url: true, kind: true }, orderBy: { position: "asc" }, }, // Outgoing only, in the { to, kind, note } shape the edit form carries. // The dialog MUST seed from this: an editor seeded empty would read as // "remove every relation" on the next unrelated save. The entry page's // display needs both directions plus target metadata, which is a separate // per-entry query (`getRelations`) rather than a join on every list row. relationsFrom: { select: { kind: true, note: true, to: { select: { slug: true } } }, orderBy: { position: "asc" }, }, upvotes: true, downvotes: true, submittedBy: { select: { pseudonym: true } }, _count: { select: { comments: true } },} as const; type ProblemRow = Prisma.ProblemGetPayload<{ select: typeof PROBLEM_SELECT }>; // `solveType` and `verification` are stored as strings (see the note in// prisma/schema.prisma). Every writer validates them first - the seed runs them// through `assertProblem` - so the casts here restore the union types the rest// of the app is written against rather than re-validating on every read.export function toProblem(r: ProblemRow): ProblemWithVotes { return { slug: r.slug, name: r.name, shortName: r.shortName, problemNumber: r.problemNumber, field: r.field, fieldGroup: r.fieldGroup as FieldGroup | null, statement: r.statement, posedBy: r.posedBy, yearPosed: r.yearPosed, solveType: r.solveType as SolveType, resolution: r.resolution as ResolutionStatus, claimIssueNote: r.claimIssueNote, aiContribution: r.aiContribution as AiContribution | null, solveDate: r.solveDate, model: r.model, modelMaker: r.modelMaker, humanCollaborators: r.humanCollaborators, aiRole: r.aiRole, verification: r.verification as VerificationStatus, verificationNote: r.verificationNote, publication: r.publication as PublicationStatus | null, resolutionMethod: r.resolutionMethod as ResolutionMethod | null, citations: r.citations, citationsPaper: r.citationsPaper, citationsSource: r.citationsSource, citationsUrl: r.citationsUrl, renownLangs: r.renownLangs, renownNote: r.renownNote, significance: r.significance, significanceNote: r.significanceNote, solveCostUsd: r.solveCostUsd, solveCostNote: r.solveCostNote, resultNote: r.resultNote, ageNote: r.ageNote, sourceUrl: r.sourceUrl, sourceName: r.sourceName, links: r.links.map((l) => ({ label: l.label, url: l.url, kind: l.kind })), relations: r.relationsFrom.map((x) => ({ to: x.to.slug, kind: x.kind, note: x.note, })), upvotes: r.upvotes, downvotes: r.downvotes, score: r.upvotes - r.downvotes, commentCount: r._count.comments, // Null for the curated baseline; a pseudonym for community submissions. submittedBy: r.submittedBy?.pseudonym ?? null, addedAt: r.createdAt.toISOString(), changedAt: r.updatedAt.toISOString(), };} const DAY_MS = 24 * 60 * 60 * 1000; /// Per-problem engagement inside a time window, keyed by problem id.interface WindowCounts { score: Map<string, number>; comments: Map<string, number>;} /// Aggregates votes and comments newer than `since`.////// Two grouped queries rather than one per problem - at 75 entries this is two/// round trips regardless of list size, and the whole thing sits inside the/// cached read so it runs at most once a minute.async function countsSince(since: Date): Promise<WindowCounts> { const [votes, comments] = await Promise.all([ prisma.problemVote.groupBy({ by: ["problemId", "vote"], where: { createdAt: { gte: since } }, _count: { _all: true }, }), prisma.comment.groupBy({ by: ["problemId"], where: { createdAt: { gte: since } }, _count: { _all: true }, }), ]); const score = new Map<string, number>(); for (const row of votes) { // Net score: an up adds, a down subtracts. const delta = row.vote === "up" ? row._count._all : -row._count._all; score.set(row.problemId, (score.get(row.problemId) ?? 0) + delta); } const commentCounts = new Map<string, number>(); for (const row of comments) { // Record comments group under a null problemId; entry counts skip them. if (row.problemId !== null) commentCounts.set(row.problemId, row._count._all); } return { score, comments: commentCounts };} /// Every publicly listed problem. Pending and rejected submissions are excluded./// How many entries were ADDED to the catalog in the last seven days, and in/// the seven before them. By createdAt, not solve date: this measures the/// record's own growth. Lives behind "use cache" because that is the one/// place a prerendered page may read the clock - same pattern as the vote/// trend windows above, and a minute of staleness is nothing against a/// seven-day window./// Today's UTC date as YYYY-MM-DD, for charts that window their x axis to/// "the last N months" and so need to know where now is.////// Server-side and passed down as a prop rather than read in the browser: the/// charts are client components but still render on the server, and a clock/// read during a client render would put a different final bucket in the/// hydrated HTML than the server produced. Behind "use cache" for the same/// reason as getEntryFlow - it is where a prerendered page may read the clock./// Refreshed hourly, which can leave the date stale for at most an hour after/// midnight UTC; the cost of that is one week-bucket at the right edge.export async function getToday(): Promise<string> { "use cache"; cacheLife("hours"); return new Date().toISOString().slice(0, 10);} export async function getEntryFlow(): Promise<{ week: number; prevWeek: number;}> { "use cache"; cacheTag("problems"); cacheLife("hours"); const now = Date.now(); const [week, prevWeek] = await Promise.all([ prisma.problem.count({ where: { status: "published", createdAt: { gte: new Date(now - 7 * DAY_MS) }, }, }), prisma.problem.count({ where: { status: "published", createdAt: { gte: new Date(now - 14 * DAY_MS), lt: new Date(now - 7 * DAY_MS), }, }, }), ]); return { week, prevWeek };} export async function getPublishedProblems(): Promise<ProblemWithTrends[]> { "use cache"; cacheTag("problems"); cacheLife("hours"); // `cacheLife("hours")` means these window edges are up to an hour stale. // Immaterial for a 7- or 30-day window, and still fine for the 24-hour one, // where an hour is 4% of the window and only shifts which entries sit just // inside the boundary. An edit or a new entry does not wait for the hour: // `updateTag("problems")` drops this the moment anything published changes. const now = Date.now(); const [rows, day, threeDay, week, month] = await Promise.all([ prisma.problem.findMany({ where: { status: "published" }, select: { ...PROBLEM_SELECT, id: true }, orderBy: { solveDate: "desc" }, }), countsSince(new Date(now - DAY_MS)), countsSince(new Date(now - 3 * DAY_MS)), countsSince(new Date(now - 7 * DAY_MS)), countsSince(new Date(now - 30 * DAY_MS)), ]); return rows.map((row) => ({ ...toProblem(row), score24h: day.score.get(row.id) ?? 0, score3d: threeDay.score.get(row.id) ?? 0, score7d: week.score.get(row.id) ?? 0, score30d: month.score.get(row.id) ?? 0, comments24h: day.comments.get(row.id) ?? 0, comments3d: threeDay.comments.get(row.id) ?? 0, comments7d: week.comments.get(row.id) ?? 0, comments30d: month.comments.get(row.id) ?? 0, }));} /// One problem by its public slug, or null when it does not exist or is not/// published. Tagged individually so a vote only busts that entry's page.export async function getProblemBySlug( slug: string,): Promise<ProblemWithVotes | null> { "use cache"; cacheTag("problems", `problem-${slug}`); cacheLife("hours"); const row = await prisma.problem.findFirst({ where: { slug, status: "published" }, select: PROBLEM_SELECT, }); return row ? toProblem(row) : null;} /// One row of an entry's "Related entries" block, direction already resolved:/// `label` is the kind's forward or inverse reading depending on which side/// this entry is, so the component never thinks about direction at all.export interface RelationView { label: string; kind: string; slug: string; /// Pre-rendered with texToHtml - both titles may carry $...$ and the row is /// a client component, which must not ship KaTeX. shortNameHtml: string; nameHtml: string; note: string; solveDate: string; significance: number | null; resolution: string;} /// Both directions of an entry's typed relations, display-ready.////// Cached under the entry's own tag: an edit to a relation updates BOTH/// sides' `problem-<slug>` tags (see update-problem.ts), so this drops/// exactly when either end changes.export async function getRelations(slug: string): Promise<RelationView[]> { "use cache"; cacheTag("problems", `problem-${slug}`); cacheLife("hours"); const TARGET = { slug: true, shortName: true, name: true, solveDate: true, significance: true, resolution: true, status: true, } as const; const rows = await prisma.problemRelation.findMany({ where: { OR: [{ from: { slug } }, { to: { slug } }] }, select: { kind: true, note: true, position: true, from: { select: TARGET }, to: { select: TARGET }, }, orderBy: [{ position: "asc" }, { createdAt: "asc" }], }); const out: RelationView[] = []; for (const r of rows) { const outgoing = r.from.slug === slug; const other = outgoing ? r.to : r.from; // A relation to an entry that later unpublished must not render a dead // link. The row survives (the entry may come back); the display skips it. if (other.status !== "published") continue; const spec = relationKind(r.kind); out.push({ label: (outgoing ? spec?.forward : spec?.inverse) ?? r.kind, kind: r.kind, slug: other.slug, shortNameHtml: texToHtml(other.shortName), nameHtml: texToHtml(other.name), note: r.note, solveDate: other.solveDate, significance: other.significance, resolution: other.resolution, }); } return out;} /// The discussion on one entry, oldest first.////// Public and identical for everyone, so it is cached and lands in the entry/// page's static shell (which also means comments get indexed). Whether the/// viewer may edit a given comment is decided on the client by comparing/// `authorId` against their own id.export async function getComments(subject: Subject): Promise<CommentView[]> { "use cache"; cacheTag(commentsTag(subject)); cacheLife("hours"); // Flat and chronological; the client builds the tree and applies the // reader's sort (src/lib/comment-tree.ts). One list serves every order. const rows = await prisma.comment.findMany({ where: subjectWhere(subject), orderBy: { createdAt: "asc" }, select: { id: true, userId: true, userName: true, body: true, createdAt: true, editedAt: true, parentId: true, upvotes: true, downvotes: true, deletedAt: true, user: { select: { pseudonym: true } }, }, }); return rows.map((c) => { const deleted = c.deletedAt !== null; return { id: c.id, authorId: c.userId, authorName: resolveSnapshot(c.userName, c.userId !== null), authorPseudonym: c.user?.pseudonym ?? null, html: deleted ? "" : renderCommentHtml(c.body), source: deleted ? "" : c.body, createdAt: formatCommentDateTime(c.createdAt), createdAtIso: c.createdAt.toISOString(), edited: c.editedAt !== null, parentId: c.parentId, upvotes: c.upvotes, downvotes: c.downvotes, deleted, }; });} /// Per-field AI provenance for one entry, for the markers on the entry page./// Cached with the entry: provenance changes only when a field is rewritten,/// and every such write drops the problem tag.export async function getProvenance(slug: string): Promise<ProvenanceView[]> { "use cache"; cacheTag("problems", `problem-${slug}`); cacheLife("hours"); const rows = await prisma.fieldProvenance.findMany({ where: { problem: { slug } }, select: { field: true, model: true, source: true, userName: true, userId: true, createdAt: true, }, }); return rows.map((r) => ({ field: r.field, model: r.model, source: r.source, reviewedBy: resolveSnapshot(r.userName, r.userId !== null), date: formatCommentDate(r.createdAt), }));} /// The changelog for one entry, newest first.export async function getActivity(subject: Subject): Promise<ActivityView[]> { "use cache"; cacheTag(activityTag(subject)); cacheLife("hours"); const rows = await prisma.problemActivity.findMany({ where: { ...subjectWhere(subject), type: { in: [...CHANGELOG_TYPES] } }, orderBy: { createdAt: "desc" }, take: 200, select: { id: true, userId: true, userName: true, type: true, field: true, oldValue: true, newValue: true, createdAt: true, user: { select: { pseudonym: true } }, }, }); return collapseBursts( rows.map((a) => ({Lines 1–500 of 1296. Load more
// Database-backed reads for problems. // // Split by cacheability, which is what makes Partial Prerendering work here: // // - Curated content and vote TALLIES are public and identical for everyone, // so they live behind `use cache` + `cacheTag` and end up in the static // shell. `revalidateTag` in the vote action refreshes them. // - The viewer's OWN vote is per-request and must never be cached publicly. // It is read dynamically (see `src/app/actions/vote.ts`) and applied on the // client, so one person's vote can never leak into another's page. // // `'use cache: private'` would also fit the second case, but it is still marked // experimental in Next 16 (it depends on runtime prefetching, which is not // stable), so this deliberately uses a plain dynamic read instead. import type { Prisma } from "@prisma/client"; import { cacheLife, cacheTag } from "next/cache"; import { prisma } from "@/lib/prisma"; import { activityTag, commentsTag, subjectWhere, type Subject, } from "@/lib/subject"; import type { ProfileLinks } from "@/lib/profile-links"; import { CHANGELOG_TYPES, collapseBursts, type ActivityView, type SiteActivityView, } from "@/lib/activity"; import { relationKind } from "@/lib/relation-kinds"; import { relativeFallback } from "@/lib/relative-time"; import { formatCommentDate, formatCommentDateTime, renderCommentHtml, } from "@/lib/comment-render"; import { texToHtml } from "@/components/TeX"; import type { CommentView } from "@/lib/comments"; import { resolveSnapshot } from "@/lib/identity"; import type { ProvenanceView } from "@/lib/provenance"; import type { AiContribution, FieldGroup, PublicationStatus, ResolutionMethod, ProblemWithTrends, ProblemWithVotes, ResolutionStatus, SolveType, VerificationStatus, } from "@/lib/problems"; export type { ProblemWithTrends, ProblemWithVotes }; /// Exported so the reviewer preview route can read unpublished rows with /// exactly the shape the public reader uses. export const PROBLEM_SELECT = { slug: true, name: true, shortName: true, problemNumber: true, field: true, fieldGroup: true, statement: true, posedBy: true, yearPosed: true, solveType: true, resolution: true, claimIssueNote: true, aiContribution: true, solveDate: true, model: true, modelMaker: true, humanCollaborators: true, aiRole: true, verification: true, verificationNote: true, publication: true, resolutionMethod: true, citations: true, citationsPaper: true, citationsSource: true, citationsUrl: true, renownLangs: true, renownNote: true, significance: true, significanceNote: true, solveCostUsd: true, solveCostNote: true, resultNote: true, ageNote: true, sourceUrl: true, sourceName: true, createdAt: true, updatedAt: true, links: { select: { label: true, url: true, kind: true }, orderBy: { position: "asc" }, }, // Outgoing only, in the { to, kind, note } shape the edit form carries. // The dialog MUST seed from this: an editor seeded empty would read as // "remove every relation" on the next unrelated save. The entry page's // display needs both directions plus target metadata, which is a separate // per-entry query (`getRelations`) rather than a join on every list row. relationsFrom: { select: { kind: true, note: true, to: { select: { slug: true } } }, orderBy: { position: "asc" }, }, upvotes: true, downvotes: true, submittedBy: { select: { pseudonym: true } }, _count: { select: { comments: true } }, } as const; type ProblemRow = Prisma.ProblemGetPayload<{ select: typeof PROBLEM_SELECT }>; // `solveType` and `verification` are stored as strings (see the note in // prisma/schema.prisma). Every writer validates them first - the seed runs them // through `assertProblem` - so the casts here restore the union types the rest // of the app is written against rather than re-validating on every read. export function toProblem(r: ProblemRow): ProblemWithVotes { return { slug: r.slug, name: r.name, shortName: r.shortName, problemNumber: r.problemNumber, field: r.field, fieldGroup: r.fieldGroup as FieldGroup | null, statement: r.statement, posedBy: r.posedBy, yearPosed: r.yearPosed, solveType: r.solveType as SolveType, resolution: r.resolution as ResolutionStatus, claimIssueNote: r.claimIssueNote, aiContribution: r.aiContribution as AiContribution | null, solveDate: r.solveDate, model: r.model, modelMaker: r.modelMaker, humanCollaborators: r.humanCollaborators, aiRole: r.aiRole, verification: r.verification as VerificationStatus, verificationNote: r.verificationNote, publication: r.publication as PublicationStatus | null, resolutionMethod: r.resolutionMethod as ResolutionMethod | null, citations: r.citations, citationsPaper: r.citationsPaper, citationsSource: r.citationsSource, citationsUrl: r.citationsUrl, renownLangs: r.renownLangs, renownNote: r.renownNote, significance: r.significance, significanceNote: r.significanceNote, solveCostUsd: r.solveCostUsd, solveCostNote: r.solveCostNote, resultNote: r.resultNote, ageNote: r.ageNote, sourceUrl: r.sourceUrl, sourceName: r.sourceName, links: r.links.map((l) => ({ label: l.label, url: l.url, kind: l.kind })), relations: r.relationsFrom.map((x) => ({ to: x.to.slug, kind: x.kind, note: x.note, })), upvotes: r.upvotes, downvotes: r.downvotes, score: r.upvotes - r.downvotes, commentCount: r._count.comments, // Null for the curated baseline; a pseudonym for community submissions. submittedBy: r.submittedBy?.pseudonym ?? null, addedAt: r.createdAt.toISOString(), changedAt: r.updatedAt.toISOString(), }; } const DAY_MS = 24 * 60 * 60 * 1000; /// Per-problem engagement inside a time window, keyed by problem id. interface WindowCounts { score: Map<string, number>; comments: Map<string, number>; } /// Aggregates votes and comments newer than `since`. /// /// Two grouped queries rather than one per problem - at 75 entries this is two /// round trips regardless of list size, and the whole thing sits inside the /// cached read so it runs at most once a minute. async function countsSince(since: Date): Promise<WindowCounts> { const [votes, comments] = await Promise.all([ prisma.problemVote.groupBy({ by: ["problemId", "vote"], where: { createdAt: { gte: since } }, _count: { _all: true }, }), prisma.comment.groupBy({ by: ["problemId"], where: { createdAt: { gte: since } }, _count: { _all: true }, }), ]); const score = new Map<string, number>(); for (const row of votes) { // Net score: an up adds, a down subtracts. const delta = row.vote === "up" ? row._count._all : -row._count._all; score.set(row.problemId, (score.get(row.problemId) ?? 0) + delta); } const commentCounts = new Map<string, number>(); for (const row of comments) { // Record comments group under a null problemId; entry counts skip them. if (row.problemId !== null) commentCounts.set(row.problemId, row._count._all); } return { score, comments: commentCounts }; } /// Every publicly listed problem. Pending and rejected submissions are excluded. /// How many entries were ADDED to the catalog in the last seven days, and in /// the seven before them. By createdAt, not solve date: this measures the /// record's own growth. Lives behind "use cache" because that is the one /// place a prerendered page may read the clock - same pattern as the vote /// trend windows above, and a minute of staleness is nothing against a /// seven-day window. /// Today's UTC date as YYYY-MM-DD, for charts that window their x axis to /// "the last N months" and so need to know where now is. /// /// Server-side and passed down as a prop rather than read in the browser: the /// charts are client components but still render on the server, and a clock /// read during a client render would put a different final bucket in the /// hydrated HTML than the server produced. Behind "use cache" for the same /// reason as getEntryFlow - it is where a prerendered page may read the clock. /// Refreshed hourly, which can leave the date stale for at most an hour after /// midnight UTC; the cost of that is one week-bucket at the right edge. export async function getToday(): Promise<string> { "use cache"; cacheLife("hours"); return new Date().toISOString().slice(0, 10); } export async function getEntryFlow(): Promise<{ week: number; prevWeek: number; }> { "use cache"; cacheTag("problems"); cacheLife("hours"); const now = Date.now(); const [week, prevWeek] = await Promise.all([ prisma.problem.count({ where: { status: "published", createdAt: { gte: new Date(now - 7 * DAY_MS) }, }, }), prisma.problem.count({ where: { status: "published", createdAt: { gte: new Date(now - 14 * DAY_MS), lt: new Date(now - 7 * DAY_MS), }, }, }), ]); return { week, prevWeek }; } export async function getPublishedProblems(): Promise<ProblemWithTrends[]> { "use cache"; cacheTag("problems"); cacheLife("hours"); // `cacheLife("hours")` means these window edges are up to an hour stale. // Immaterial for a 7- or 30-day window, and still fine for the 24-hour one, // where an hour is 4% of the window and only shifts which entries sit just // inside the boundary. An edit or a new entry does not wait for the hour: // `updateTag("problems")` drops this the moment anything published changes. const now = Date.now(); const [rows, day, threeDay, week, month] = await Promise.all([ prisma.problem.findMany({ where: { status: "published" }, select: { ...PROBLEM_SELECT, id: true }, orderBy: { solveDate: "desc" }, }), countsSince(new Date(now - DAY_MS)), countsSince(new Date(now - 3 * DAY_MS)), countsSince(new Date(now - 7 * DAY_MS)), countsSince(new Date(now - 30 * DAY_MS)), ]); return rows.map((row) => ({ ...toProblem(row), score24h: day.score.get(row.id) ?? 0, score3d: threeDay.score.get(row.id) ?? 0, score7d: week.score.get(row.id) ?? 0, score30d: month.score.get(row.id) ?? 0, comments24h: day.comments.get(row.id) ?? 0, comments3d: threeDay.comments.get(row.id) ?? 0, comments7d: week.comments.get(row.id) ?? 0, comments30d: month.comments.get(row.id) ?? 0, })); } /// One problem by its public slug, or null when it does not exist or is not /// published. Tagged individually so a vote only busts that entry's page. export async function getProblemBySlug( slug: string, ): Promise<ProblemWithVotes | null> { "use cache"; cacheTag("problems", `problem-${slug}`); cacheLife("hours"); const row = await prisma.problem.findFirst({ where: { slug, status: "published" }, select: PROBLEM_SELECT, }); return row ? toProblem(row) : null; } /// One row of an entry's "Related entries" block, direction already resolved: /// `label` is the kind's forward or inverse reading depending on which side /// this entry is, so the component never thinks about direction at all. export interface RelationView { label: string; kind: string; slug: string; /// Pre-rendered with texToHtml - both titles may carry $...$ and the row is /// a client component, which must not ship KaTeX. shortNameHtml: string; nameHtml: string; note: string; solveDate: string; significance: number | null; resolution: string; } /// Both directions of an entry's typed relations, display-ready. /// /// Cached under the entry's own tag: an edit to a relation updates BOTH /// sides' `problem-<slug>` tags (see update-problem.ts), so this drops /// exactly when either end changes. export async function getRelations(slug: string): Promise<RelationView[]> { "use cache"; cacheTag("problems", `problem-${slug}`); cacheLife("hours"); const TARGET = { slug: true, shortName: true, name: true, solveDate: true, significance: true, resolution: true, status: true, } as const; const rows = await prisma.problemRelation.findMany({ where: { OR: [{ from: { slug } }, { to: { slug } }] }, select: { kind: true, note: true, position: true, from: { select: TARGET }, to: { select: TARGET }, }, orderBy: [{ position: "asc" }, { createdAt: "asc" }], }); const out: RelationView[] = []; for (const r of rows) { const outgoing = r.from.slug === slug; const other = outgoing ? r.to : r.from; // A relation to an entry that later unpublished must not render a dead // link. The row survives (the entry may come back); the display skips it. if (other.status !== "published") continue; const spec = relationKind(r.kind); out.push({ label: (outgoing ? spec?.forward : spec?.inverse) ?? r.kind, kind: r.kind, slug: other.slug, shortNameHtml: texToHtml(other.shortName), nameHtml: texToHtml(other.name), note: r.note, solveDate: other.solveDate, significance: other.significance, resolution: other.resolution, }); } return out; } /// The discussion on one entry, oldest first. /// /// Public and identical for everyone, so it is cached and lands in the entry /// page's static shell (which also means comments get indexed). Whether the /// viewer may edit a given comment is decided on the client by comparing /// `authorId` against their own id. export async function getComments(subject: Subject): Promise<CommentView[]> { "use cache"; cacheTag(commentsTag(subject)); cacheLife("hours"); // Flat and chronological; the client builds the tree and applies the // reader's sort (src/lib/comment-tree.ts). One list serves every order. const rows = await prisma.comment.findMany({ where: subjectWhere(subject), orderBy: { createdAt: "asc" }, select: { id: true, userId: true, userName: true, body: true, createdAt: true, editedAt: true, parentId: true, upvotes: true, downvotes: true, deletedAt: true, user: { select: { pseudonym: true } }, }, }); return rows.map((c) => { const deleted = c.deletedAt !== null; return { id: c.id, authorId: c.userId, authorName: resolveSnapshot(c.userName, c.userId !== null), authorPseudonym: c.user?.pseudonym ?? null, html: deleted ? "" : renderCommentHtml(c.body), source: deleted ? "" : c.body, createdAt: formatCommentDateTime(c.createdAt), createdAtIso: c.createdAt.toISOString(), edited: c.editedAt !== null, parentId: c.parentId, upvotes: c.upvotes, downvotes: c.downvotes, deleted, }; }); } /// Per-field AI provenance for one entry, for the markers on the entry page. /// Cached with the entry: provenance changes only when a field is rewritten, /// and every such write drops the problem tag. export async function getProvenance(slug: string): Promise<ProvenanceView[]> { "use cache"; cacheTag("problems", `problem-${slug}`); cacheLife("hours"); const rows = await prisma.fieldProvenance.findMany({ where: { problem: { slug } }, select: { field: true, model: true, source: true, userName: true, userId: true, createdAt: true, }, }); return rows.map((r) => ({ field: r.field, model: r.model, source: r.source, reviewedBy: resolveSnapshot(r.userName, r.userId !== null), date: formatCommentDate(r.createdAt), })); } /// The changelog for one entry, newest first. export async function getActivity(subject: Subject): Promise<ActivityView[]> { "use cache"; cacheTag(activityTag(subject)); cacheLife("hours"); const rows = await prisma.problemActivity.findMany({ where: { ...subjectWhere(subject), type: { in: [...CHANGELOG_TYPES] } }, orderBy: { createdAt: "desc" }, take: 200, select: { id: true, userId: true, userName: true, type: true, field: true, oldValue: true, newValue: true, createdAt: true, user: { select: { pseudonym: true } }, }, }); return collapseBursts( rows.map((a) => ({