1296 lines · 40.7 KB · TypeScript
RawDownload
// 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.