diff --git a/apps/web/src/app/(app)/orgs/[slug]/impact/page.tsx b/apps/web/src/app/(app)/orgs/[slug]/impact/page.tsx
index 5e417c94..8b6cc01b 100644
--- a/apps/web/src/app/(app)/orgs/[slug]/impact/page.tsx
+++ b/apps/web/src/app/(app)/orgs/[slug]/impact/page.tsx
@@ -16,6 +16,7 @@ import {
import { Card, CardHeader } from "@/components/ui/Card"
import { StatGrid, StatTile } from "@/components/ui/Bento"
import { OrgTabs } from "@/components/OrgTabs"
+import { ExportButton } from "@/components/export/ExportButton"
import { formatCents, PLAN_LINE_WHERE } from "@/lib/finance"
import { currentTerm } from "@/lib/tenant/term"
@@ -118,11 +119,15 @@ export default async function ImpactPage({
return (
-
-
{org.name}
-
- Impact. A shareable summary of what this club has achieved, built from its own record.
-
+
+
+
{org.name}
+
+ Impact. A shareable summary of what this club has achieved, built from its own
+ record.
+
+
+
diff --git a/apps/web/src/app/(app)/orgs/[slug]/members/page.tsx b/apps/web/src/app/(app)/orgs/[slug]/members/page.tsx
index b8d784aa..8f4a77a4 100644
--- a/apps/web/src/app/(app)/orgs/[slug]/members/page.tsx
+++ b/apps/web/src/app/(app)/orgs/[slug]/members/page.tsx
@@ -12,6 +12,7 @@ import { Card, CardHeader } from "@/components/ui/Card"
import { AssignmentBadge, Badge } from "@/components/ui/Badge"
import { Avatar } from "@/components/ui/Avatar"
import { OrgTabs } from "@/components/OrgTabs"
+import { ExportButton } from "@/components/export/ExportButton"
import { EmailLink } from "@/components/EmailLink"
import { ClubImageEditor } from "@/components/ClubImageEditor"
import { ConfirmSubmit } from "@/components/ui/ConfirmDialog"
@@ -123,6 +124,7 @@ export default async function MembersPage({
canUpload={storageConfigured()}
/>
)}
+
diff --git a/apps/web/src/app/(app)/orgs/[slug]/memory/page.tsx b/apps/web/src/app/(app)/orgs/[slug]/memory/page.tsx
index 7488d9f0..9dc7940b 100644
--- a/apps/web/src/app/(app)/orgs/[slug]/memory/page.tsx
+++ b/apps/web/src/app/(app)/orgs/[slug]/memory/page.tsx
@@ -31,6 +31,7 @@ import { MemoryProvenance, type MovementView } from "@/components/memory/MemoryP
import { Card, CardHeader } from "@/components/ui/Card"
import { Badge } from "@/components/ui/Badge"
import { OrgTabs } from "@/components/OrgTabs"
+import { ExportButton } from "@/components/export/ExportButton"
import { AddMemoryCardForm } from "@/components/memory/AddMemoryCardForm"
import { aiConfigured } from "@/lib/ai"
import { createMemoryCard } from "./actions"
@@ -323,11 +324,20 @@ export default async function MemoryPage({
return (
-
-
{org.name}
-
- Institutional memory. Knowledge that outlives every board.
-
+
+
+
{org.name}
+
+ Institutional memory. Knowledge that outlives every board.
+
+
+ {/*
+ The export lists every card and quotes only the ordinary ones — a
+ card marked above standard, and the retired Credential type, are
+ named with their contents withheld. The rule lives in the route,
+ beside the read; see api/export/orgs/[slug]/memory.
+ */}
+
diff --git a/apps/web/src/app/api/export/orgs/[slug]/documents/route.ts b/apps/web/src/app/api/export/orgs/[slug]/documents/route.ts
new file mode 100644
index 00000000..83789c4a
--- /dev/null
+++ b/apps/web/src/app/api/export/orgs/[slug]/documents/route.ts
@@ -0,0 +1,110 @@
+import { notFound } from "next/navigation"
+import { auth } from "@/lib/auth"
+import { db } from "@/lib/db"
+import { canViewOrg, getUserContext } from "@/lib/rbac"
+import { withTenantScope } from "@/lib/tenant-scope"
+import { formatInZone } from "@/lib/time"
+import { fileTypeLabel } from "@/lib/uploads"
+import { reportContext, reportResponse } from "@/lib/export/report-response"
+import {
+ clubDocumentsDocument,
+ documentsReportFilename,
+ type DocumentEntry,
+} from "@/lib/export/reports/club-documents"
+
+/**
+ * A club's document library as an INDEX — the catalogue, never the files.
+ *
+ * The gate is `canViewOrg`, the same predicate the documents page applies.
+ *
+ * `objectKey` is not selected. It is the raw storage key the schema says beside
+ * itself never to hand out, and a report is exactly the kind of artefact that
+ * would carry one out of the building unnoticed. The page's own list makes the
+ * same choice and says so.
+ */
+export const dynamic = "force-dynamic"
+
+/** One more than will be printed, so a cap that BIT can be reported honestly. */
+const LIST_CAP = 300
+
+function formatBytes(n?: number | null) {
+ if (!n) return "—"
+ if (n < 1024) return `${n} B`
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`
+ return `${(n / 1024 / 1024).toFixed(1)} MB`
+}
+
+export async function GET(_request: Request, { params }: { params: Promise<{ slug: string }> }) {
+ const { slug } = await params
+ const session = await auth()
+ if (!session?.user?.id) return new Response("Sign in to download this report", { status: 401 })
+ const userId = session.user.id
+
+ return withTenantScope(userId, async () => {
+ const { org, institutionName, generatedAt, preparedFor, timeZone, now } = await reportContext(
+ slug,
+ userId
+ )
+ const ctx = await getUserContext(userId)
+ if (!canViewOrg(ctx, org)) notFound()
+
+ const COLUMNS = {
+ title: true,
+ mimeType: true,
+ sizeBytes: true,
+ createdById: true,
+ createdAt: true,
+ } as const
+
+ const [live, archived] = await Promise.all([
+ db.document.findMany({
+ where: { organizationId: org.id, isArchived: false },
+ select: COLUMNS,
+ orderBy: { createdAt: "desc" },
+ take: LIST_CAP + 1,
+ }),
+ db.document.findMany({
+ where: { organizationId: org.id, isArchived: true },
+ select: COLUMNS,
+ orderBy: { createdAt: "desc" },
+ take: LIST_CAP + 1,
+ }),
+ ])
+
+ const filerIds = [...new Set([...live, ...archived].flatMap((d) => (d.createdById ? [d.createdById] : [])))]
+ const filers = new Map(
+ (
+ await db.user.findMany({
+ where: { id: { in: filerIds } },
+ select: { id: true, name: true, email: true },
+ })
+ ).map((u) => [u.id, u.name ?? u.email ?? "—"])
+ )
+
+ const truncated: string[] = []
+ const cut = (rows: T[], what: string): T[] => {
+ if (rows.length <= LIST_CAP) return rows
+ truncated.push(`${what} (${LIST_CAP} of ${rows.length}+ shown)`)
+ return rows.slice(0, LIST_CAP)
+ }
+
+ const entry = (d: (typeof live)[number]): DocumentEntry => ({
+ title: d.title,
+ kind: fileTypeLabel(d.mimeType),
+ size: formatBytes(d.sizeBytes),
+ filedBy: d.createdById ? (filers.get(d.createdById) ?? "—") : "—",
+ filed: formatInZone(d.createdAt, timeZone, { dateStyle: "medium" }),
+ })
+
+ const doc = clubDocumentsDocument({
+ clubName: org.name,
+ generatedAt,
+ preparedFor,
+ live: cut(live, "documents on file").map(entry),
+ archived: cut(archived, "archived documents").map(entry),
+ truncated,
+ })
+
+ return reportResponse(doc, documentsReportFilename(org.name, now), institutionName)
+ })
+}
diff --git a/apps/web/src/app/api/export/orgs/[slug]/impact/route.ts b/apps/web/src/app/api/export/orgs/[slug]/impact/route.ts
new file mode 100644
index 00000000..4a5f1a55
--- /dev/null
+++ b/apps/web/src/app/api/export/orgs/[slug]/impact/route.ts
@@ -0,0 +1,125 @@
+import { notFound } from "next/navigation"
+import { auth } from "@/lib/auth"
+import { db } from "@/lib/db"
+import { canViewOrg, getUserContext } from "@/lib/rbac"
+import { withTenantScope } from "@/lib/tenant-scope"
+import { countCurrentHolders, summariseSeats } from "@/lib/seats"
+import { loadSeatFacts } from "@/lib/seats-data"
+import { currentTerm } from "@/lib/tenant/term"
+import { formatCents, PLAN_LINE_WHERE } from "@/lib/finance"
+import { reportContext, reportResponse } from "@/lib/export/report-response"
+import { clubImpactDocument, impactReportFilename } from "@/lib/export/reports/club-impact"
+
+/**
+ * A club's impact summary as a branded PDF — the export with a real audience
+ * outside Tenure: a sponsor, a dean, a national chapter.
+ *
+ * The gate is `canViewOrg`, the same predicate the impact page applies.
+ *
+ * ── EVERY FIGURE IS COMPUTED THE WAY THE PAGE COMPUTES IT ───────────────────
+ *
+ * Same loaders (`loadSeatFacts`, `summariseSeats`, `countCurrentHolders`), same
+ * `PLAN_LINE_WHERE` so retired budget lines are out of both, same bucketing of
+ * approval statuses including CANCELLED — which the page had to be corrected to
+ * count, and a second implementation here would have been free to make that
+ * mistake again in private.
+ *
+ * The one thing this does NOT do is recompute the narrative sentence. It is
+ * built here from the same parts in the same order as the page, because a
+ * document and a screen that disagree about the same year is worse than either
+ * being wrong alone.
+ */
+export const dynamic = "force-dynamic"
+
+export async function GET(_request: Request, { params }: { params: Promise<{ slug: string }> }) {
+ const { slug } = await params
+ const session = await auth()
+ if (!session?.user?.id) return new Response("Sign in to download this report", { status: 401 })
+ const userId = session.user.id
+
+ return withTenantScope(userId, async () => {
+ const { org, institutionName, generatedAt, preparedFor, now, evaluatedAt } =
+ await reportContext(slug, userId)
+ const ctx = await getUserContext(userId)
+ if (!canViewOrg(ctx, org)) notFound()
+
+ const academicYear = currentTerm()
+ const [eventsPublished, approvalGroups, budgetLines, memoryCards, seatFacts, collaborations] =
+ await Promise.all([
+ db.event.count({ where: { organizationId: org.id, status: "PUBLISHED" } }),
+ db.approvalRequest.groupBy({
+ by: ["status"],
+ where: { organizationId: org.id },
+ _count: { _all: true },
+ }),
+ db.budgetLine.findMany({
+ where: { organizationId: org.id, academicYear, ...PLAN_LINE_WHERE },
+ select: { budgetedCents: true, actualCents: true },
+ }),
+ db.memoryRecord.count({ where: { organizationId: org.id, isArchived: false } }),
+ loadSeatFacts({ organizationId: org.id }, evaluatedAt),
+ db.collabInterest.count({ where: { organizationId: org.id, status: "APPROVED" } }),
+ ])
+
+ const countBy = Object.fromEntries(approvalGroups.map((g) => [g.status, g._count._all]))
+ const approved = countBy["APPROVED"] ?? 0
+ const rejected = countBy["REJECTED"] ?? 0
+ const cancelled = countBy["CANCELLED"] ?? 0
+ const inFlight =
+ (countBy["PENDING_PRESIDENT"] ?? 0) +
+ (countBy["PENDING_OSE"] ?? 0) +
+ (countBy["NEEDS_CHANGES"] ?? 0) +
+ (countBy["DRAFT"] ?? 0)
+
+ // A withdrawn request was never decided, so folding it in here would
+ // depress a rate it says nothing about.
+ const decided = approved + rejected
+ const approvalRatePct = decided > 0 ? Math.round((approved / decided) * 100) : null
+
+ const budgeted = budgetLines.reduce((s, l) => s + l.budgetedCents, 0)
+ const actual = budgetLines.reduce((s, l) => s + l.actualCents, 0)
+
+ const seats = summariseSeats(seatFacts)
+ // The page uses `shortName ?? name`. Same sentence means same name.
+ const name = org.shortName ?? org.name
+
+ const narrative = [
+ `${name} published ${eventsPublished} event${eventsPublished === 1 ? "" : "s"}`,
+ `cleared ${approved} approval${approved === 1 ? "" : "s"}${
+ approvalRatePct !== null ? ` (${approvalRatePct}% approval rate)` : ""
+ }`,
+ budgeted > 0 ? `delivered ${formatCents(actual)} of a ${formatCents(budgeted)} budget` : null,
+ `and captured ${memoryCards} knowledge card${
+ memoryCards === 1 ? "" : "s"
+ } for the next board`,
+ ]
+ .filter(Boolean)
+ .join(", ")
+
+ const doc = clubImpactDocument({
+ clubName: org.name,
+ academicYear,
+ generatedAt,
+ preparedFor,
+ narrative,
+ eventsPublished,
+ approved,
+ rejected,
+ cancelled,
+ inFlight,
+ approvalRatePct,
+ budgetedLabel: budgeted > 0 ? formatCents(budgeted) : "No budget set",
+ actualLabel: formatCents(actual),
+ // Null rather than 0 when there is no budget: 0% is a claim about
+ // spending discipline, and "no budget set" is a different fact.
+ budgetUsedPct: budgeted > 0 ? Math.round((actual / budgeted) * 100) : null,
+ activeMembers: countCurrentHolders(seatFacts),
+ seatsFilled: seats.filled,
+ seatsTotal: seats.total,
+ memoryCards,
+ collaborations,
+ })
+
+ return reportResponse(doc, impactReportFilename(org.name, now), institutionName)
+ })
+}
diff --git a/apps/web/src/app/api/export/orgs/[slug]/memory/route.ts b/apps/web/src/app/api/export/orgs/[slug]/memory/route.ts
new file mode 100644
index 00000000..c71156b6
--- /dev/null
+++ b/apps/web/src/app/api/export/orgs/[slug]/memory/route.ts
@@ -0,0 +1,181 @@
+import { notFound } from "next/navigation"
+import { auth } from "@/lib/auth"
+import { db } from "@/lib/db"
+import { canViewOrg, getUserContext } from "@/lib/rbac"
+import { canSeeMemoryCard } from "@/lib/memory"
+import { withTenantScope } from "@/lib/tenant-scope"
+import { formatInZone } from "@/lib/time"
+import { renderCardText } from "@/lib/schemas/card-content"
+import { reportContext, reportResponse } from "@/lib/export/report-response"
+import {
+ clubMemoryDocument,
+ memoryReportFilename,
+ withholdingReason,
+ type MemoryEntry,
+} from "@/lib/export/reports/club-memory"
+
+/**
+ * A club's institutional memory as a branded PDF.
+ *
+ * The gate is `canViewOrg`, the same predicate the memory page applies.
+ *
+ * ── The two withholdings, and why they are HERE and not in the builder ──────
+ *
+ * The report builder decides how a withheld card LOOKS. This route decides
+ * WHICH cards are withheld, because that is a question about the record and the
+ * rules over it, and it must be answered where the record is read — before the
+ * text has been anywhere near a document that travels.
+ *
+ * · `isAboveStandard` is fail-closed by design: any sensitivity that is not
+ * exactly "standard" restricts the card, including a typo or a value from a
+ * vocabulary that does not exist yet. Nothing in the product writes that
+ * column today, so this rule bites on nothing right now — which is exactly
+ * when it should be written, rather than the day after something starts
+ * setting it.
+ *
+ * · `CREDENTIAL` is the retired type whose bodies were stored unencrypted.
+ * The memory page already withholds them on screen; a PDF that printed them
+ * would become the one place in Tenure that hands them out.
+ *
+ * Both are LISTED either way. A silent omission is what makes somebody trust a
+ * document they should not.
+ */
+export const dynamic = "force-dynamic"
+
+/** One more than will be printed, so a cap that BIT can be reported. */
+const LIST_CAP = 300
+
+const KIND_LABEL: Record = {
+ LESSON: "Lesson",
+ PLAYBOOK: "Playbook",
+ CONTACT: "Contact",
+ DECISION: "Decision",
+ VENDOR: "Vendor",
+ CREDENTIAL: "Credential (retired)",
+}
+
+export async function GET(_request: Request, { params }: { params: Promise<{ slug: string }> }) {
+ const { slug } = await params
+ const session = await auth()
+ if (!session?.user?.id) return new Response("Sign in to download this report", { status: 401 })
+ const userId = session.user.id
+
+ return withTenantScope(userId, async () => {
+ const { org, institutionName, generatedAt, preparedFor, timeZone, now } = await reportContext(
+ slug,
+ userId
+ )
+ const ctx = await getUserContext(userId)
+ if (!canViewOrg(ctx, org)) notFound()
+
+ /*
+ * ONE MORE THAN WILL BE PRINTED, and `roleId` comes with the row.
+ *
+ * The cap: this used to have no `take` at all, so a club with a large
+ * memory library rendered every card body into one PDF on the request
+ * thread. The documents export in this same change caps at 300 and says so;
+ * this one did not, which is the inconsistency rather than the rule.
+ */
+ const [cardRows, archivedRows] = await Promise.all([
+ db.memoryRecord.findMany({
+ where: { organizationId: org.id, isArchived: false },
+ select: {
+ title: true,
+ type: true,
+ content: true,
+ sensitivity: true,
+ roleId: true,
+ authorId: true,
+ updatedAt: true,
+ role: { select: { name: true } },
+ },
+ orderBy: { updatedAt: "desc" },
+ take: LIST_CAP + 1,
+ }),
+ // Archived cards are counted, not printed — but the COUNT is still a
+ // disclosure, so it is counted over the same visibility test rather than
+ // as a bare `count()`. Two columns per row, no bodies.
+ db.memoryRecord.findMany({
+ where: { organizationId: org.id, isArchived: true },
+ select: { roleId: true, sensitivity: true },
+ }),
+ ])
+
+ /*
+ * THE PAGE'S OWN GATE, ASKED AGAIN — this was missing, and it was the
+ * serious defect in the first version of this route.
+ *
+ * `canViewOrg` above admits anybody who can open the club. The memory PAGE
+ * then filters every card through `canSeeMemoryCard`, which is what keeps a
+ * seat's role-scoped cards to that seat's holder, the president and the
+ * OSE. The export skipped that step entirely, so an ordinary club member
+ * could DOWNLOAD cards the page will not show them — and the document's
+ * own "withheld is visible" section made it look considered.
+ *
+ * A download route is a public URL. Every test the page applies has to be
+ * applied here or the export is a way around them.
+ */
+ const visible = cardRows.filter((card) =>
+ canSeeMemoryCard(ctx, { roleId: card.roleId, sensitivity: card.sensitivity }, org)
+ )
+ /*
+ * TRUNCATION IS MEASURED ON WHAT WAS READ, NOT ON WHAT SURVIVED THE FILTER.
+ *
+ * The cap is applied by the DATABASE, before visibility is known — there is
+ * no `where` that expresses `canSeeMemoryCard`, because it reads the
+ * viewer's seats. So a club with a thousand cards, of which this reader may
+ * see forty, returns 301 rows and forty visible ones.
+ *
+ * `visible.length > LIST_CAP` is false there, and the document would have
+ * claimed to hold every card this person can see while quietly stopping at
+ * row 301. That is the silent-coverage failure this file spends three
+ * paragraphs warning about, introduced by the fix for the previous one.
+ *
+ * `cardRows.length > LIST_CAP` says "there were more cards than we read",
+ * which is the honest claim and the fail-safe direction: it can over-report
+ * a cut when the unread remainder was invisible anyway, and it can never
+ * under-report coverage. Over-reporting sends a reader to Tenure; the other
+ * way tells them they have everything.
+ */
+ const truncated = cardRows.length > LIST_CAP
+ const cards = visible.slice(0, LIST_CAP)
+ const archivedCount = archivedRows.filter((card) =>
+ canSeeMemoryCard(ctx, { roleId: card.roleId, sensitivity: card.sensitivity }, org)
+ ).length
+
+ const authorIds = [...new Set(cards.flatMap((c) => (c.authorId ? [c.authorId] : [])))]
+ const authors = new Map(
+ (
+ await db.user.findMany({
+ where: { id: { in: authorIds } },
+ select: { id: true, name: true, email: true },
+ })
+ ).map((u) => [u.id, u.name ?? u.email ?? "Unknown"])
+ )
+
+ const entries: MemoryEntry[] = cards.map((card) => {
+ const withheldReason = withholdingReason(card.type, card.sensitivity)
+
+ return {
+ title: card.title,
+ kind: KIND_LABEL[card.type] ?? card.type,
+ seat: card.role?.name ?? "The club",
+ author: card.authorId ? (authors.get(card.authorId) ?? "Unknown") : "Unknown",
+ updated: formatInZone(card.updatedAt, timeZone, { dateStyle: "medium" }),
+ body: withheldReason === null ? renderCardText(card.type, card.content) : null,
+ withheldReason,
+ }
+ })
+
+ const doc = clubMemoryDocument({
+ clubName: org.name,
+ generatedAt,
+ preparedFor,
+ cards: entries,
+ archivedCount,
+ truncated,
+ })
+
+ return reportResponse(doc, memoryReportFilename(org.name, now), institutionName)
+ })
+}
diff --git a/apps/web/src/app/api/export/orgs/[slug]/roster/route.ts b/apps/web/src/app/api/export/orgs/[slug]/roster/route.ts
new file mode 100644
index 00000000..59f528e2
--- /dev/null
+++ b/apps/web/src/app/api/export/orgs/[slug]/roster/route.ts
@@ -0,0 +1,126 @@
+import { notFound } from "next/navigation"
+import { auth } from "@/lib/auth"
+import { db } from "@/lib/db"
+import { canViewOrg, getUserContext } from "@/lib/rbac"
+import { withTenantScope } from "@/lib/tenant-scope"
+import { withEffectiveStatus } from "@/lib/effective-status"
+import { currentTerm } from "@/lib/tenant/term"
+import { formatInZone } from "@/lib/time"
+import { ASSIGNMENT_STATUS_LABEL } from "@/lib/approval-labels"
+import { reportContext, reportResponse } from "@/lib/export/report-response"
+import {
+ clubRosterDocument,
+ rosterReportFilename,
+ type RosterAlumnus,
+ type RosterSeat,
+} from "@/lib/export/reports/club-roster"
+
+/**
+ * A club's roster as a branded PDF.
+ *
+ * The gate is `canViewOrg` — the same predicate the members page applies, asked
+ * again because a download route is a public URL.
+ *
+ * EVERY STANDING IS NARROWED BY ITS OWN DATES before it is printed. The members
+ * page does this and says why; a document handed to an advisor must not say
+ * "Active" beside somebody the server refuses on every request.
+ */
+export const dynamic = "force-dynamic"
+
+const standing = (status: keyof typeof ASSIGNMENT_STATUS_LABEL) => {
+ const word = ASSIGNMENT_STATUS_LABEL[status]
+ return word.charAt(0).toUpperCase() + word.slice(1)
+}
+
+export async function GET(_request: Request, { params }: { params: Promise<{ slug: string }> }) {
+ const { slug } = await params
+ const session = await auth()
+ if (!session?.user?.id) return new Response("Sign in to download this report", { status: 401 })
+ const userId = session.user.id
+
+ return withTenantScope(userId, async () => {
+ const { org, institutionName, generatedAt, preparedFor, timeZone, now, evaluatedAt } =
+ await reportContext(slug, userId)
+ const ctx = await getUserContext(userId)
+ if (!canViewOrg(ctx, org)) notFound()
+
+ const [roles, advisors] = await Promise.all([
+ db.role.findMany({
+ where: { organizationId: org.id },
+ select: {
+ name: true,
+ assignments: {
+ // Every holding, with its DATES, so the standing below is a
+ // decision rather than a stored label.
+ select: {
+ status: true,
+ startDate: true,
+ endDate: true,
+ user: { select: { name: true, email: true } },
+ },
+ orderBy: { startDate: "desc" },
+ },
+ },
+ orderBy: [{ seatOrder: "asc" }, { scope: "asc" }],
+ }),
+ db.organizationAdvisor.findMany({
+ where: { organizationId: org.id },
+ select: { person: { select: { name: true, email: true, affiliation: true } } },
+ }),
+ ])
+
+ const day = (d: Date) => formatInZone(d, timeZone, { dateStyle: "medium" })
+
+ const seats: RosterSeat[] = []
+ const alumni: RosterAlumnus[] = []
+ const vacantSeats: string[] = []
+
+ for (const role of roles) {
+ const narrowed = role.assignments.map((a) => ({
+ ...a,
+ effective: withEffectiveStatus(a, evaluatedAt).status,
+ }))
+ const held = narrowed.filter((a) => a.effective !== "ALUMNI")
+
+ for (const a of held) {
+ seats.push({
+ seat: role.name,
+ holder: a.user.name ?? a.user.email ?? "—",
+ email: a.user.email ?? "—",
+ standing: standing(a.effective),
+ since: day(a.startDate),
+ until: a.endDate ? day(a.endDate) : null,
+ })
+ }
+ for (const a of narrowed.filter((x) => x.effective === "ALUMNI")) {
+ alumni.push({
+ seat: role.name,
+ holder: a.user.name ?? a.user.email ?? "—",
+ term: `${day(a.startDate)} – ${a.endDate ? day(a.endDate) : "—"}`,
+ })
+ }
+ // A seat with no current holder. Named, because a roster that silently
+ // omits its vacancies reads as a complete board.
+ if (held.length === 0) vacantSeats.push(role.name)
+ }
+
+ const doc = clubRosterDocument({
+ clubName: org.name,
+ academicYear: currentTerm(),
+ generatedAt,
+ preparedFor,
+ seats,
+ alumni,
+ advisors: advisors.map((a) => ({
+ name: a.person.name,
+ // The directory's own word for where an advisor sits — "Ainslie OSE",
+ // "Faculty". A hardcoded "Advisor" beside every name would say less
+ // than the column already holds.
+ role: a.person.affiliation ?? "Advisor",
+ })),
+ vacantSeats,
+ })
+
+ return reportResponse(doc, rosterReportFilename(org.name, now), institutionName)
+ })
+}
diff --git a/apps/web/src/components/export/ExportButton.tsx b/apps/web/src/components/export/ExportButton.tsx
new file mode 100644
index 00000000..c579a874
--- /dev/null
+++ b/apps/web/src/components/export/ExportButton.tsx
@@ -0,0 +1,70 @@
+import { Download } from "@/components/ui/icons"
+
+/**
+ * THE ONE EXPORT CONTROL.
+ *
+ * ── The report ──────────────────────────────────────────────────────────────
+ *
+ * "there are no export options for modules, tools etc to be exported out as
+ * pdf" — and later, more precisely: "where there is data, info, knowledge
+ * there should be export option all in pdf".
+ *
+ * That was accurate. Before this, exactly ONE surface in Tenure could produce a
+ * document — a club's finance page — and its button was written inline on that
+ * page. Five other club modules held real records and offered no way to take
+ * them out.
+ *
+ * ── Why a component and not five buttons ────────────────────────────────────
+ *
+ * The industry pattern the owner named — SAP, Jira, Intuit — is not "every
+ * screen gets an export somewhere". It is that the export affordance is the
+ * SAME control, in the SAME position, with the SAME wording, on every module
+ * that has one; so a person learns it once and then never has to look for it
+ * again. Five hand-written anchors drift in padding, icon size, label and
+ * behaviour within a release, and the drift is the thing that reads as amateur.
+ *
+ * So the markup lives here once, and every module passes a path and a noun.
+ *
+ * ── Why a plain anchor, and not a fetch ─────────────────────────────────────
+ *
+ * Inherited from the finance page's original, and it was right there:
+ *
+ * The route answers with `Content-Disposition: attachment`, so the browser
+ * saves the file and the page does not move. A download that is an ordinary
+ * link keeps working with the middle button, with a right-click, and with a
+ * screen reader that announces it as a file rather than as a control that does
+ * something unexplained. A fetch-and-blob version has to reimplement all three
+ * and gets the third wrong.
+ *
+ * `download` says it is a file. The template-literal href hides the URL from
+ * `no-html-link-for-pages`, so the lint does not demand a `` here — but
+ * the attribute is what makes the element honest, and a `` would be wrong
+ * for a download in any case.
+ */
+export function ExportButton({
+ href,
+ /**
+ * What the file IS, in the reader's words — "budget", "roster", "impact
+ * summary". Rendered as "Export PDF" with the noun in the accessible name, so
+ * the visible control stays identical across modules while a screen reader
+ * announces which of them it is. Two controls that both say only "Export PDF"
+ * on a page are indistinguishable to somebody who cannot see which section
+ * they sit in.
+ */
+ label,
+}: {
+ href: string
+ label: string
+}) {
+ return (
+
+ Export PDF
+
+ )
+}
diff --git a/apps/web/src/lib/capability-registry/surfaces.ts b/apps/web/src/lib/capability-registry/surfaces.ts
index 12ffcfe8..e9429da4 100644
--- a/apps/web/src/lib/capability-registry/surfaces.ts
+++ b/apps/web/src/lib/capability-registry/surfaces.ts
@@ -142,6 +142,22 @@ export const API_PENDING_BINDING: Readonly> = {
// and `canViewFinance` per budget — the pages' own tests — so a club that
// fails the first is never queried at all.
"/api/export/workspace": "no export capability registered; gated per club by canViewOrg",
+ // The five module exports, deferred for the reason the budget report above
+ // is. There is no per-module capability in the registry to point at — the
+ // one reporting capability, `collaboration.reporting`, is
+ // INSTITUTION-WIDE analytics by its own name, and binding a club's roster to
+ // it would make the registry describe something that is not there.
+ //
+ // Each route's own gate is `canViewOrg` — the module TAB's gate, re-asked in
+ // the route rather than assumed from the fact that a link was rendered,
+ // because a download URL is public and can be forwarded or guessed. So a
+ // deferred binding here is a missing registry ROW, not an unguarded
+ // endpoint, and each refusal is `notFound()` for the same reason the pages
+ // use it: confirming that a club exists is itself an answer.
+ "/api/export/orgs/[slug]/roster": "club-module export — no per-module capability registered; gate is canViewOrg",
+ "/api/export/orgs/[slug]/documents": "club-module export — no per-module capability registered; gate is canViewOrg",
+ "/api/export/orgs/[slug]/memory": "club-module export — no per-module capability registered; gate is canViewOrg",
+ "/api/export/orgs/[slug]/impact": "club-module export — no per-module capability registered; gate is canViewOrg",
"/api/integrations/slack/callback": "integrations capability not yet registered",
// The personal Slack callback, deferred for the reason the shared callback
// is — not the reason its workspace sibling is. It runs on a redirect from
diff --git a/apps/web/src/lib/export/report-response.ts b/apps/web/src/lib/export/report-response.ts
new file mode 100644
index 00000000..dcec0911
--- /dev/null
+++ b/apps/web/src/lib/export/report-response.ts
@@ -0,0 +1,141 @@
+import { notFound } from "next/navigation"
+import { db } from "@/lib/db"
+import { getUserContext } from "@/lib/rbac"
+import { institutionTimeZone } from "@/lib/institution-time"
+import { formatInZone, zoneAbbreviation } from "@/lib/time"
+import { renderDocument, unrepresentableStrings, type BrandedDocument } from "@/lib/export/document"
+
+/**
+ * THE PARTS EVERY CLUB REPORT NEEDS, ASKED ONCE.
+ *
+ * SERVER ONLY — this reaches `lib/db` through `lib/rbac`. Nothing here may be
+ * imported from a `"use client"` file: doing so puts Prisma in the browser
+ * bundle and fails the build with `UnhandledSchemeError: node:async_hooks`,
+ * three layers from the cause.
+ *
+ * ── Why this exists ─────────────────────────────────────────────────────────
+ *
+ * Six export routes now share the same preamble — resolve the club, re-ask the
+ * page's permission, read the institution's clock, stamp the document, note any
+ * character the fonts cannot draw, and answer with the right headers. Written
+ * out six times, the interesting parts are the ones that drift: the fifth route
+ * is where somebody forgets `no-store`, or stamps in UTC, or returns 403 where
+ * the others return 404 and so confirms a club exists to somebody who was
+ * guessing.
+ *
+ * ── The two decisions worth restating, because they are easy to get wrong ───
+ *
+ * A DOWNLOAD ROUTE IS A PUBLIC URL. The permission the page applies is asked
+ * again here, by the caller, rather than assumed from the fact that a link was
+ * rendered. Anything else makes an export a way around a permission. Each route
+ * passes its own predicate because they genuinely differ — finance is not the
+ * roster — and `notFound()` is the refusal, because telling a stranger that a
+ * club exists but is not theirs is itself an answer.
+ *
+ * THE CLOCK IS THE INSTITUTION'S. Production runs on `node:20-alpine` with no
+ * TZ set, so the server's own clock stamps in UTC and would put a 9 PM figure
+ * on the following day — in a document whose whole purpose is to be evidence
+ * that reads the same to everyone who opens it later.
+ */
+export interface ReportContext {
+ readonly org: { id: string; name: string; shortName: string | null; institutionId: string }
+ readonly institutionName: string
+ readonly generatedAt: string
+ readonly preparedFor: string
+ readonly timeZone: string
+ readonly now: Date
+ readonly evaluatedAt: Date
+}
+
+export async function reportContext(slug: string, userId: string): Promise {
+ const org = await db.organization.findUnique({
+ where: { slug },
+ // `shortName` because the impact page builds its sentence from
+ // `shortName ?? name`, and a document that names the club differently
+ // from the screen it came from is the drift this file exists to stop.
+ select: { id: true, name: true, shortName: true, institutionId: true },
+ })
+ if (!org) notFound()
+
+ const [ctx, institution, timeZone, me] = await Promise.all([
+ getUserContext(userId),
+ db.institution.findUnique({ where: { id: org.institutionId }, select: { name: true } }),
+ institutionTimeZone(org.institutionId),
+ db.user.findUnique({ where: { id: userId }, select: { name: true, email: true } }),
+ ])
+
+ const now = new Date()
+ return {
+ org,
+ institutionName: institution?.name ?? "Tenure",
+ generatedAt: `${formatInZone(now, timeZone, {
+ dateStyle: "long",
+ timeStyle: "short",
+ })} ${zoneAbbreviation(timeZone, now)}`,
+ preparedFor: me?.name ?? me?.email ?? "the club",
+ timeZone,
+ now,
+ // The instant every permission on this request is judged at, so a report
+ // and the page it came from cannot disagree about who holds a seat.
+ evaluatedAt: ctx.evaluatedAt,
+ }
+}
+
+/** The context AND the caller's authority, so a route asks its own question. */
+export async function reportViewer(userId: string) {
+ return getUserContext(userId)
+}
+
+/**
+ * Render, caveat, and answer.
+ *
+ * The base-14 PDF fonts are WinAnsi and substitute what they cannot draw, so a
+ * name in a script they do not cover comes back spelled with question marks.
+ * Saying so IN the document beats letting a reader find their own name that way
+ * and conclude the report is broken — and it is appended HERE so no route can
+ * be the one that forgets.
+ *
+ * The caveat lands in "What is not in this document" when the report has that
+ * section, which every one of these does, and as its own section otherwise.
+ */
+export function reportResponse(
+ doc: BrandedDocument,
+ filename: string,
+ institutionName: string
+): Response {
+ const lost = unrepresentableStrings(doc)
+ const caveat =
+ "Some characters here cannot be drawn by the standard document fonts and appear as a " +
+ "question mark. The affected entries are shown in full in Tenure, and nothing has been " +
+ "omitted."
+
+ const EDGES = "What is not in this document"
+ const withCaveat =
+ lost.length === 0
+ ? doc
+ : doc.sections.some((s) => s.heading === EDGES)
+ ? {
+ ...doc,
+ sections: doc.sections.map((s) =>
+ s.heading === EDGES
+ ? { ...s, blocks: [...s.blocks, { kind: "paragraph" as const, text: caveat }] }
+ : s
+ ),
+ }
+ : {
+ ...doc,
+ sections: [
+ ...doc.sections,
+ { heading: "About this document", blocks: [{ kind: "note" as const, text: caveat }] },
+ ],
+ }
+
+ return new Response(new Uint8Array(renderDocument(withCaveat, institutionName)), {
+ headers: {
+ "Content-Type": "application/pdf",
+ "Content-Disposition": `attachment; filename="${filename}"`,
+ // A club's record is not a thing to leave in a shared cache.
+ "Cache-Control": "private, no-store",
+ },
+ })
+}
diff --git a/apps/web/src/lib/export/reports/club-documents.ts b/apps/web/src/lib/export/reports/club-documents.ts
new file mode 100644
index 00000000..61e5c00f
--- /dev/null
+++ b/apps/web/src/lib/export/reports/club-documents.ts
@@ -0,0 +1,131 @@
+import type { BrandedDocument, Section } from "@/lib/export/document"
+import { filenameDate, reportFilename } from "@/lib/export/reports/filenames"
+
+/**
+ * THE DOCUMENT LIBRARY, AS AN INDEX.
+ *
+ * ── What this is, and the one thing it is emphatically not ──────────────────
+ *
+ * It is a CATALOGUE: what the club holds, who filed it, when, and how big it
+ * is. It is not the files. Bundling the contents would mean deciding, in a
+ * single click, that every access rule those files sit behind may be
+ * re-litigated by whoever ends up holding the PDF — and Tenure decides document
+ * access per file, at the moment of opening.
+ *
+ * That is not a limitation to apologise for; it is the answer to the question
+ * an index is actually asked. "What does this club have on file, and is the
+ * sponsorship agreement among it" is answered here. "Give me the sponsorship
+ * agreement" is answered by opening it, where the rules still apply.
+ *
+ * The document says so plainly rather than letting a reader infer it from an
+ * absence, because an index that looks like it might have been the files is
+ * worse than one that says it never was.
+ *
+ * ── The archive is a section, not a filter ─────────────────────────────────
+ *
+ * Archived documents are the club's record too — an index that omits them
+ * answers "what is current", which is a different question and one nobody asked
+ * of a catalogue. They are separated, and labelled.
+ */
+
+export interface DocumentEntry {
+ readonly title: string
+ /** The word a person uses — "PDF", "Word document" — never a Content-Type. */
+ readonly kind: string
+ readonly size: string
+ readonly filedBy: string
+ readonly filed: string
+}
+
+export interface ClubDocumentsReport {
+ readonly clubName: string
+ readonly generatedAt: string
+ readonly preparedFor: string
+ readonly live: readonly DocumentEntry[]
+ readonly archived: readonly DocumentEntry[]
+ /** Set when a list was capped, naming what was cut. */
+ readonly truncated: readonly string[]
+}
+
+const COLUMNS = [
+ { header: "Document", weight: 0.4 },
+ { header: "Type", weight: 0.16 },
+ { header: "Size", weight: 0.12, align: "right" as const },
+ { header: "Filed by", weight: 0.18 },
+ { header: "Filed", weight: 0.14 },
+]
+
+const row = (d: DocumentEntry) => [d.title, d.kind, d.size, d.filedBy, d.filed]
+
+export function clubDocumentsDocument(report: ClubDocumentsReport): BrandedDocument {
+ const sections: Section[] = [
+ {
+ heading: "On file",
+ subtitle: "Everything this club currently holds in Tenure.",
+ blocks: [
+ {
+ kind: "table",
+ columns: COLUMNS,
+ rows: report.live.map(row),
+ empty: "This club has filed no documents yet.",
+ },
+ ],
+ },
+ ]
+
+ if (report.archived.length > 0) {
+ sections.push({
+ heading: "Archived",
+ subtitle: "Kept as part of the record, and no longer current.",
+ blocks: [{ kind: "table", columns: COLUMNS, rows: report.archived.map(row) }],
+ })
+ }
+
+ sections.push({
+ heading: "What is not in this document",
+ subtitle: "So that what IS in it can be relied on.",
+ blocks: [
+ {
+ kind: "note",
+ text:
+ "This is an index, not the files. Titles, types and dates are listed; the documents " +
+ "themselves stay in Tenure, where who may open each one is decided per file. A " +
+ "person holding this list has not thereby been given the contents.",
+ },
+ ...(report.truncated.length > 0
+ ? [
+ {
+ kind: "paragraph" as const,
+ text: `These lists were long enough to be cut, and the rest are in Tenure: ${report.truncated.join(
+ "; "
+ )}.`,
+ },
+ ]
+ : []),
+ ],
+ })
+
+ return {
+ title: "Document index",
+ subtitle: report.clubName,
+ meta: [
+ { label: "Club", value: report.clubName },
+ // "shown" when a list was cut. `live.length` is then what is IN this
+ // document, not what the club holds, and a cover fact that says "On file:
+ // 300" over a capped list is the document contradicting its own caveat.
+ { label: report.truncated.length ? "On file (shown)" : "On file", value: String(report.live.length) },
+ {
+ label: report.truncated.length ? "Archived (shown)" : "Archived",
+ value: String(report.archived.length),
+ },
+ { label: "Prepared for", value: report.preparedFor },
+ { label: "Generated", value: report.generatedAt },
+ ],
+ sections,
+ footer: `${report.clubName} · Document index · ${report.generatedAt}`,
+ }
+}
+
+export function documentsReportFilename(clubName: string, at: Date): string {
+ return reportFilename(clubName, "documents", filenameDate(at))
+}
diff --git a/apps/web/src/lib/export/reports/club-impact.ts b/apps/web/src/lib/export/reports/club-impact.ts
new file mode 100644
index 00000000..a1e42142
--- /dev/null
+++ b/apps/web/src/lib/export/reports/club-impact.ts
@@ -0,0 +1,174 @@
+import type { BrandedDocument, Section } from "@/lib/export/document"
+import { filenameDate, reportFilename } from "@/lib/export/reports/filenames"
+
+/**
+ * THE IMPACT SUMMARY — the export with an actual audience outside Tenure.
+ *
+ * The other reports are a club's own record. This one is the page a club shows
+ * to somebody who does not use Tenure and never will: a sponsor deciding
+ * whether to fund next year, a dean approving a budget, a national chapter
+ * asking what the year looked like.
+ *
+ * ── Every number carries its denominator ────────────────────────────────────
+ *
+ * An impact page is where a product is most tempted to flatter, and a report a
+ * club hands to a sponsor is where flattery becomes a problem somebody else
+ * pays for. So:
+ *
+ * · the approval rate is stated WITH the decided count it came from. "92%"
+ * from twelve decisions and "92%" from two are different claims, and only
+ * one of them survives a follow-up question.
+ * · spend is stated against the budget, not alone.
+ * · a rate computed from nothing is not printed as 0% — it is printed as "no
+ * decisions yet", because 0% is a claim and "nothing happened" is a fact.
+ *
+ * The narrative sentence is the club's own, taken verbatim from the page rather
+ * than re-composed here, so the document and the screen cannot drift into
+ * saying different things about the same year.
+ */
+
+export interface ClubImpactReport {
+ readonly clubName: string
+ readonly academicYear: string
+ readonly generatedAt: string
+ readonly preparedFor: string
+ /** The sentence the impact page shows, unchanged. */
+ readonly narrative: string
+ readonly eventsPublished: number
+ readonly approved: number
+ readonly rejected: number
+ readonly cancelled: number
+ readonly inFlight: number
+ /** Null when nothing has been decided — never zero standing in for unknown. */
+ readonly approvalRatePct: number | null
+ readonly budgetedLabel: string
+ readonly actualLabel: string
+ readonly budgetUsedPct: number | null
+ readonly activeMembers: number
+ readonly seatsFilled: number
+ readonly seatsTotal: number
+ readonly memoryCards: number
+ readonly collaborations: number
+}
+
+export function clubImpactDocument(report: ClubImpactReport): BrandedDocument {
+ const decided = report.approved + report.rejected
+
+ const sections: Section[] = [
+ {
+ heading: "The year in a sentence",
+ blocks: [{ kind: "paragraph", text: report.narrative }],
+ },
+ {
+ heading: "People",
+ blocks: [
+ {
+ kind: "facts",
+ items: [
+ { label: "Active members", value: String(report.activeMembers) },
+ {
+ label: "Board seats filled",
+ value: `${report.seatsFilled} of ${report.seatsTotal}`,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ heading: "Activity",
+ blocks: [
+ {
+ kind: "facts",
+ items: [
+ { label: "Events published", value: String(report.eventsPublished) },
+ { label: "Collaborations", value: String(report.collaborations) },
+ { label: "Knowledge cards recorded", value: String(report.memoryCards) },
+ ],
+ },
+ ],
+ },
+ {
+ heading: "Requests through the approval chain",
+ subtitle: "Every request this club has put through Tenure.",
+ blocks: [
+ {
+ kind: "table",
+ columns: [
+ { header: "Outcome", weight: 0.6 },
+ { header: "Requests", weight: 0.4, align: "right" },
+ ],
+ rows: [
+ ["Approved", String(report.approved)],
+ ["Rejected", String(report.rejected)],
+ ["Cancelled", String(report.cancelled)],
+ ["Still in flight", String(report.inFlight)],
+ ],
+ total: [
+ "Total",
+ String(report.approved + report.rejected + report.cancelled + report.inFlight),
+ ],
+ },
+ {
+ kind: "note",
+ text:
+ report.approvalRatePct === null
+ ? "No request has been decided yet, so there is no approval rate to report. A " +
+ "withdrawn request was never decided and is not counted as a refusal."
+ : `Approval rate ${report.approvalRatePct}%, from ${decided} decided request` +
+ `${decided === 1 ? "" : "s"}. Cancelled and in-flight requests are excluded ` +
+ `from that rate: a request nobody decided is not a refusal.`,
+ },
+ ],
+ },
+ {
+ heading: "Money",
+ blocks: [
+ {
+ kind: "facts",
+ items: [
+ { label: "Budget for the year", value: report.budgetedLabel },
+ { label: "Spent so far", value: report.actualLabel },
+ {
+ label: "Used",
+ value:
+ report.budgetUsedPct === null
+ ? "No budget set for this year"
+ : `${report.budgetUsedPct}%`,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ heading: "What is not in this document",
+ subtitle: "So that what IS in it can be relied on.",
+ blocks: [
+ {
+ kind: "note",
+ text:
+ "These are this club's figures for the academic year named on the cover, taken " +
+ "when the document was generated. Spending reflects what has been recorded in " +
+ "Tenure; a commitment that has not yet been filed is not in it. The club's " +
+ "budget report shows the same money line by line.",
+ },
+ ],
+ },
+ ]
+
+ return {
+ title: "Impact summary",
+ subtitle: report.clubName,
+ meta: [
+ { label: "Club", value: report.clubName },
+ { label: "Academic year", value: report.academicYear },
+ { label: "Prepared for", value: report.preparedFor },
+ { label: "Generated", value: report.generatedAt },
+ ],
+ sections,
+ footer: `${report.clubName} · Impact summary · ${report.academicYear}`,
+ }
+}
+
+export function impactReportFilename(clubName: string, at: Date): string {
+ return reportFilename(clubName, "impact", filenameDate(at))
+}
diff --git a/apps/web/src/lib/export/reports/club-memory.ts b/apps/web/src/lib/export/reports/club-memory.ts
new file mode 100644
index 00000000..807358d5
--- /dev/null
+++ b/apps/web/src/lib/export/reports/club-memory.ts
@@ -0,0 +1,240 @@
+import { isAboveStandard } from "@/lib/memory-sensitivity"
+import type { BrandedDocument, Section } from "@/lib/export/document"
+import { filenameDate, reportFilename } from "@/lib/export/reports/filenames"
+
+/**
+ * INSTITUTIONAL MEMORY, AS A DOCUMENT — and the disclosure decision in it.
+ *
+ * ── Why this one is not simply "print what is on the page" ──────────────────
+ *
+ * The memory tab is the product's whole thesis: a departing Treasurer leaves
+ * the budget history AND the reasoning. So a club wanting that reasoning on
+ * paper — for a board retreat, for a successor who has not been given a login
+ * yet, for an advisor reviewing a year — is asking for exactly what Tenure is
+ * for, and refusing would be refusing the point.
+ *
+ * But a PDF leaves. It is emailed, printed, and forwarded, and none of those
+ * steps re-ask a permission question. The workspace export states the rule this
+ * one has to live up to: a bulk export is the wrong place to re-decide who may
+ * read what.
+ *
+ * ── The resolution: everything is LISTED, not everything is QUOTED ──────────
+ *
+ * Every card the reader can already see appears here with its title, its kind,
+ * the seat it belongs to, who wrote it and when. That is the index, and it is
+ * the part that makes the document useful for finding things.
+ *
+ * The BODY is included only where the body is ordinary. Two kinds are withheld,
+ * and the document says so beside each one rather than quietly shortening the
+ * list:
+ *
+ * · ELEVATED SENSITIVITY. `isAboveStandard` is deliberately fail-closed —
+ * anything that is not exactly "standard" restricts the card, including a
+ * typo or a value from a vocabulary that does not exist yet. Applying it
+ * here means a card marked sensitive the day after this ships is withheld
+ * without anybody having to remember this file exists.
+ *
+ * · CREDENTIALS. The retired `CREDENTIAL` type holds secrets that were stored
+ * unencrypted, and the memory page already withholds their contents on
+ * screen. A report that printed them would be the one place in Tenure that
+ * hands them out, which is precisely the shape of defect that gets found
+ * later rather than now.
+ *
+ * WITHHELD IS VISIBLE. A reader is told the card exists and that its contents
+ * were not included — never shown a shorter list with nothing to indicate
+ * something was removed, because a silent omission is what makes a person trust
+ * a document that should not be trusted.
+ */
+
+/**
+ * WHY A CARD'S CONTENTS ARE WITHHELD, or null when they are not.
+ *
+ * A pure function, deliberately, and this is the same lesson the club roster
+ * taught an hour before it: a rule that lives inside a route handler is a rule
+ * no unit test can reach, and an unreachable rule is one that gets half-applied
+ * without anything failing. This one decides whether a secret leaves the
+ * building, so it is the last rule that should be unreachable.
+ *
+ * `isAboveStandard` is imported rather than restated. It is fail-closed — any
+ * sensitivity that is not exactly "standard" restricts the card, including a
+ * typo or a value from a vocabulary nobody has invented yet — and a
+ * security-relevant predicate spelled out twice is two predicates waiting to
+ * disagree.
+ *
+ * ORDER MATTERS: credential first. A CREDENTIAL card whose sensitivity is
+ * "standard" must still be withheld, and asking about sensitivity first would
+ * fall through to quoting it.
+ */
+export function withholdingReason(type: string, sensitivity: string): string | null {
+ if (type === "CREDENTIAL") {
+ return (
+ "The contents of this card are withheld. It was created when Tenure offered a " +
+ "\u201CCredential\u201D type and its body was stored unencrypted, so it is not included " +
+ "in a document that leaves Tenure. Rotate whatever it unlocks and move the secret into " +
+ "a password manager."
+ )
+ }
+ if (isAboveStandard(sensitivity)) {
+ return (
+ "The contents of this card are withheld because it is marked more sensitive than " +
+ "standard. It remains readable in Tenure by whoever the card's own rules allow."
+ )
+ }
+ return null
+}
+
+export interface MemoryEntry {
+ readonly title: string
+ /** The kind, in the product's words — "Lesson", "Playbook". */
+ readonly kind: string
+ readonly seat: string
+ readonly author: string
+ readonly updated: string
+ /**
+ * The card's text, or null when it is withheld. Null is the signal; the
+ * reason travels beside it so the document can say WHICH rule applied.
+ */
+ readonly body: string | null
+ readonly withheldReason: string | null
+}
+
+export interface ClubMemoryReport {
+ readonly clubName: string
+ readonly generatedAt: string
+ readonly preparedFor: string
+ readonly cards: readonly MemoryEntry[]
+ readonly archivedCount: number
+ /** True when the card list was cut. Said in the document, never silently. */
+ readonly truncated?: boolean
+}
+
+export function clubMemoryDocument(report: ClubMemoryReport): BrandedDocument {
+ const withheld = report.cards.filter((c) => c.body === null)
+
+ const sections: Section[] = [
+ {
+ heading: "What this club knows",
+ subtitle: "Every card on this club's memory, most recently updated first.",
+ blocks: [
+ {
+ kind: "table",
+ columns: [
+ { header: "Card", weight: 0.4 },
+ { header: "Kind", weight: 0.16 },
+ { header: "Seat", weight: 0.2 },
+ { header: "Written by", weight: 0.14 },
+ { header: "Updated", weight: 0.1 },
+ ],
+ rows: report.cards.map((c) => [c.title, c.kind, c.seat, c.author, c.updated]),
+ empty: "This club has recorded no institutional memory yet.",
+ },
+ ],
+ },
+ ]
+
+ /*
+ * The cards themselves, one subheading each.
+ *
+ * A table cannot hold a paragraph of reasoning legibly, and the reasoning is
+ * the thing worth exporting — so the index above answers "what is here" and
+ * this answers "what does it say".
+ */
+ if (report.cards.length > 0) {
+ sections.push({
+ heading: "The cards in full",
+ blocks: report.cards.flatMap((card) => [
+ { kind: "subheading" as const, text: card.title },
+ {
+ kind: "facts" as const,
+ items: [
+ { label: "Kind", value: card.kind },
+ { label: "Seat", value: card.seat },
+ { label: "Written by", value: card.author },
+ { label: "Updated", value: card.updated },
+ ],
+ },
+ card.body !== null
+ ? { kind: "paragraph" as const, text: card.body }
+ : {
+ kind: "note" as const,
+ text: card.withheldReason ?? "The contents of this card are not included.",
+ },
+ ]),
+ })
+ }
+
+ sections.push({
+ heading: "What is not in this document",
+ subtitle: "So that what IS in it can be relied on.",
+ blocks: [
+ {
+ kind: "note",
+ text:
+ "This is a snapshot taken when it was generated, not a live record. It covers the " +
+ "memory of this club only — cards held by other clubs, and cards you cannot open in " +
+ "Tenure, are not here and are not counted above.",
+ },
+ ...(withheld.length > 0
+ ? [
+ {
+ kind: "paragraph" as const,
+ text:
+ `${withheld.length} card${withheld.length === 1 ? " is" : "s are"} listed with ` +
+ `the contents withheld. ${
+ withheld.length === 1 ? "It is" : "They are"
+ } still readable in Tenure by whoever the card's own rules allow — being left ` +
+ `out of a document that travels is not the same as being restricted.`,
+ },
+ ]
+ : []),
+ ...(report.truncated
+ ? [
+ {
+ kind: "paragraph" as const,
+ text:
+ "This club has more cards than one document holds, so the list above was cut. " +
+ "The rest are in Tenure's memory tab.",
+ },
+ ]
+ : []),
+ ...(report.archivedCount > 0
+ ? [
+ {
+ kind: "paragraph" as const,
+ text:
+ `${report.archivedCount} archived card${
+ report.archivedCount === 1 ? "" : "s"
+ } are excluded. Archived memory is kept as part of the record and read in ` +
+ `Tenure's memory tab.`,
+ },
+ ]
+ : []),
+ ],
+ })
+
+ return {
+ title: "Institutional memory",
+ subtitle: report.clubName,
+ meta: [
+ { label: "Club", value: report.clubName },
+ {
+ // "Cards shown" when the list was cut: the number below it is what is IN
+ // this document, and calling that the club's total would be a claim the
+ // document cannot support.
+ label: report.truncated ? "Cards shown" : "Cards",
+ value: String(report.cards.length),
+ },
+ ...(withheld.length > 0
+ ? [{ label: "Contents withheld", value: String(withheld.length) }]
+ : []),
+ { label: "Prepared for", value: report.preparedFor },
+ { label: "Generated", value: report.generatedAt },
+ ],
+ sections,
+ footer: `${report.clubName} · Institutional memory · ${report.generatedAt}`,
+ }
+}
+
+export function memoryReportFilename(clubName: string, at: Date): string {
+ return reportFilename(clubName, "memory", filenameDate(at))
+}
diff --git a/apps/web/src/lib/export/reports/club-roster.ts b/apps/web/src/lib/export/reports/club-roster.ts
new file mode 100644
index 00000000..c1099b57
--- /dev/null
+++ b/apps/web/src/lib/export/reports/club-roster.ts
@@ -0,0 +1,167 @@
+import type { BrandedDocument, Section } from "@/lib/export/document"
+import { filenameDate, reportFilename } from "@/lib/export/reports/filenames"
+
+/**
+ * THE ROSTER, AS A DOCUMENT.
+ *
+ * A club's members page answers "who holds what, right now" — the single most
+ * asked-for record in a student organisation, and the one most often wanted
+ * OUTSIDE Tenure: attached to a room booking, handed to an advisor, filed with
+ * a national chapter, or pinned to a grant application that asks who runs the
+ * organisation.
+ *
+ * ── The standing that is printed is the EFFECTIVE one ───────────────────────
+ *
+ * Every row here carries a status the caller has already narrowed by the
+ * assignment's own dates. This report never sees a stored column, because the
+ * document a club hands to an advisor must not say "Active" beside somebody the
+ * server refuses on every request. The route is where that narrowing happens
+ * and `rosterStanding` is what does it.
+ *
+ * ── Alumni are a section, not an omission ───────────────────────────────────
+ *
+ * A roster with only current holders cannot answer "who ran this last year",
+ * which is most of why anybody keeps one. Past holders get their own section
+ * with the term they served, clearly separated so the two can never be read as
+ * one list.
+ */
+
+export interface RosterSeat {
+ readonly seat: string
+ readonly holder: string
+ readonly email: string
+ /** Already narrowed by the row's own dates — never a stored column. */
+ readonly standing: string
+ readonly since: string
+ readonly until: string | null
+}
+
+export interface RosterAlumnus {
+ readonly seat: string
+ readonly holder: string
+ readonly term: string
+}
+
+export interface RosterAdvisor {
+ readonly name: string
+ readonly role: string
+}
+
+export interface ClubRosterReport {
+ readonly clubName: string
+ readonly academicYear: string
+ readonly generatedAt: string
+ readonly preparedFor: string
+ readonly seats: readonly RosterSeat[]
+ readonly alumni: readonly RosterAlumnus[]
+ readonly advisors: readonly RosterAdvisor[]
+ readonly vacantSeats: readonly string[]
+}
+
+export function clubRosterDocument(report: ClubRosterReport): BrandedDocument {
+ const sections: Section[] = [
+ {
+ heading: "Who holds a seat today",
+ subtitle: "Standing is shown as it binds on the date this was generated.",
+ blocks: [
+ {
+ kind: "table",
+ columns: [
+ { header: "Seat", weight: 0.28 },
+ { header: "Held by", weight: 0.24 },
+ { header: "Contact", weight: 0.24 },
+ { header: "Standing", weight: 0.12 },
+ { header: "Since", weight: 0.12 },
+ ],
+ rows: report.seats.map((s) => [s.seat, s.holder, s.email, s.standing, s.since]),
+ empty: "No seat on this roster is currently held.",
+ },
+ ],
+ },
+ ]
+
+ /*
+ * A VACANT SEAT IS A FACT, and the one a reader most needs.
+ *
+ * The table above lists holdings, so a seat nobody holds simply is not in it
+ * — and a roster that silently omits the vacancies reads as a complete board.
+ * "Treasurer" missing from a list of eleven seats is invisible; "Treasurer —
+ * vacant" is the thing an advisor acts on.
+ */
+ if (report.vacantSeats.length > 0) {
+ sections.push({
+ heading: "Seats nobody holds",
+ blocks: [
+ {
+ kind: "note",
+ text:
+ "These seats exist on this club's roster and have no current holder. A seat " +
+ "whose term has not begun yet is listed above as incoming, not here.",
+ },
+ { kind: "bullets", items: [...report.vacantSeats] },
+ ],
+ })
+ }
+
+ if (report.advisors.length > 0) {
+ sections.push({
+ heading: "Advisors",
+ blocks: [
+ {
+ kind: "table",
+ columns: [{ header: "Name", weight: 0.6 }, { header: "Role", weight: 0.4 }],
+ rows: report.advisors.map((a) => [a.name, a.role]),
+ },
+ ],
+ })
+ }
+
+ sections.push({
+ heading: "Who has held a seat before",
+ subtitle: "The club's own history of its board.",
+ blocks: [
+ {
+ kind: "table",
+ columns: [
+ { header: "Seat", weight: 0.35 },
+ { header: "Held by", weight: 0.35 },
+ { header: "Term", weight: 0.3 },
+ ],
+ rows: report.alumni.map((a) => [a.seat, a.holder, a.term]),
+ empty: "No past holder is recorded for this club yet.",
+ },
+ ],
+ })
+
+ sections.push({
+ heading: "What is not in this document",
+ subtitle: "So that what IS in it can be relied on.",
+ blocks: [
+ {
+ kind: "note",
+ text:
+ "This is a snapshot taken when it was generated, not a live record. It lists the " +
+ "roster this club publishes in Tenure; a person's other seats, in other clubs, are " +
+ "their own record and are not here.",
+ },
+ ],
+ })
+
+ return {
+ title: "Roster",
+ subtitle: report.clubName,
+ meta: [
+ { label: "Club", value: report.clubName },
+ { label: "Academic year", value: report.academicYear },
+ { label: "Seats held", value: String(report.seats.length) },
+ { label: "Prepared for", value: report.preparedFor },
+ { label: "Generated", value: report.generatedAt },
+ ],
+ sections,
+ footer: `${report.clubName} · Roster · ${report.generatedAt}`,
+ }
+}
+
+export function rosterReportFilename(clubName: string, at: Date): string {
+ return reportFilename(clubName, "roster", filenameDate(at))
+}
diff --git a/apps/web/src/lib/export/reports/every-module-exports-a-real-document.test.ts b/apps/web/src/lib/export/reports/every-module-exports-a-real-document.test.ts
new file mode 100644
index 00000000..3b7c0407
--- /dev/null
+++ b/apps/web/src/lib/export/reports/every-module-exports-a-real-document.test.ts
@@ -0,0 +1,442 @@
+import { readFileSync } from "node:fs"
+import { join } from "node:path"
+import { renderDocument } from "@/lib/export/document"
+import { clubRosterDocument, rosterReportFilename } from "@/lib/export/reports/club-roster"
+import {
+ clubDocumentsDocument,
+ documentsReportFilename,
+} from "@/lib/export/reports/club-documents"
+import {
+ clubMemoryDocument,
+ memoryReportFilename,
+ withholdingReason,
+} from "@/lib/export/reports/club-memory"
+import { clubImpactDocument, impactReportFilename } from "@/lib/export/reports/club-impact"
+import { filenameDate, reportFilename, reportSlug } from "@/lib/export/reports/filenames"
+
+/**
+ * "where there is data, info, knowledge there should be export option all in
+ * pdf" — this checks the four new ones say true things.
+ *
+ * The bar is not "it produced bytes". These documents go to a sponsor, an
+ * advisor and a successor, so the tests below are about the claims a reader
+ * would ACT on: that a vacancy is visible, that a withheld card is still
+ * listed, that a rate carries its denominator, and that a percentage is not
+ * invented out of a zero denominator.
+ */
+
+const AT = new Date("2026-08-26T15:14:00Z")
+
+/** Everything a reader could actually read, as one string. */
+function flat(doc: ReturnType): string {
+ const parts: string[] = [doc.title, doc.subtitle ?? "", doc.footer]
+ for (const fact of doc.meta) parts.push(fact.label, fact.value)
+ for (const section of doc.sections) {
+ parts.push(section.heading, section.subtitle ?? "")
+ for (const block of section.blocks) {
+ if (block.kind === "paragraph" || block.kind === "note" || block.kind === "subheading") {
+ parts.push(block.text)
+ } else if (block.kind === "bullets") parts.push(...block.items)
+ else if (block.kind === "facts") {
+ for (const i of block.items) parts.push(i.label, i.value)
+ } else if (block.kind === "table") {
+ for (const c of block.columns) parts.push(c.header)
+ for (const r of block.rows) parts.push(...r)
+ if (block.total) parts.push(...block.total)
+ if (block.empty) parts.push(block.empty)
+ }
+ }
+ }
+ return parts.join("\n")
+}
+
+describe("the roster export", () => {
+ const base = {
+ clubName: "Analytics Society",
+ academicYear: "2026-27",
+ generatedAt: "26 August 2026 at 3:14 PM EDT",
+ preparedFor: "Priya Raman",
+ seats: [
+ {
+ seat: "President",
+ holder: "Priya Raman",
+ email: "priya@example.edu",
+ standing: "Active",
+ since: "1 Aug 2026",
+ until: null,
+ },
+ ],
+ alumni: [{ seat: "Treasurer", holder: "Dev Shah", term: "Aug 2025 – May 2026" }],
+ advisors: [{ name: "Dr Lena Ortiz", role: "Faculty" }],
+ vacantSeats: ["Treasurer"],
+ }
+
+ it("NAMES the seats nobody holds", () => {
+ // The defect this exists for: a table of holdings cannot show a vacancy,
+ // so a roster missing "Treasurer" reads as a complete board.
+ const text = flat(clubRosterDocument(base))
+
+ expect(text).toContain("Seats nobody holds")
+ expect(text).toContain("Treasurer")
+ })
+
+ it("does not claim a vacancy section when every seat is held", () => {
+ const text = flat(clubRosterDocument({ ...base, vacantSeats: [] }))
+
+ expect(text).not.toContain("Seats nobody holds")
+ })
+
+ it("keeps past holders in their own section, never mixed with current ones", () => {
+ const doc = clubRosterDocument(base)
+ const current = doc.sections.find((s) => s.heading === "Who holds a seat today")!
+ const past = doc.sections.find((s) => s.heading === "Who has held a seat before")!
+
+ expect(flat({ ...doc, sections: [current] })).not.toContain("Dev Shah")
+ expect(flat({ ...doc, sections: [past] })).toContain("Dev Shah")
+ })
+
+ it("says so rather than printing an empty table", () => {
+ const text = flat(clubRosterDocument({ ...base, seats: [], alumni: [], vacantSeats: [] }))
+
+ expect(text).toContain("No seat on this roster is currently held.")
+ })
+
+ it("renders as a PDF a reader will open", () => {
+ const bytes = renderDocument(clubRosterDocument(base))
+
+ expect(Buffer.from(bytes).toString("latin1").startsWith("%PDF-1.4")).toBe(true)
+ })
+})
+
+describe("the document index", () => {
+ const base = {
+ clubName: "Analytics Society",
+ generatedAt: "26 August 2026 at 3:14 PM EDT",
+ preparedFor: "Priya Raman",
+ live: [
+ {
+ title: "Sponsorship agreement",
+ kind: "PDF",
+ size: "241 KB",
+ filedBy: "Priya Raman",
+ filed: "3 Aug 2026",
+ },
+ ],
+ archived: [],
+ truncated: [],
+ }
+
+ it("says plainly that it is an index and not the files", () => {
+ // A list that LOOKS like it might have been the documents is worse than
+ // one that states it never was.
+ const text = flat(clubDocumentsDocument(base))
+
+ expect(text).toContain("This is an index, not the files")
+ })
+
+ it("reports a cap that bit, rather than truncating in silence", () => {
+ const text = flat(
+ clubDocumentsDocument({ ...base, truncated: ["documents on file (300 of 512+ shown)"] })
+ )
+
+ expect(text).toContain("300 of 512+ shown")
+ })
+
+ it("keeps the archive as its own section", () => {
+ const withArchive = clubDocumentsDocument({
+ ...base,
+ archived: [
+ { title: "Old constitution", kind: "PDF", size: "12 KB", filedBy: "—", filed: "2024" },
+ ],
+ })
+
+ expect(withArchive.sections.map((s) => s.heading)).toContain("Archived")
+ })
+})
+
+describe("the memory export — what it will and will not quote", () => {
+ const card = (over: Partial[0]["cards"][number]> = {}) => ({
+ title: "Do not book the ballroom before the sponsor money is confirmed",
+ kind: "Lesson",
+ seat: "President",
+ author: "Dev Shah",
+ updated: "3 Aug 2026",
+ body: "We lost $900 on a deposit.",
+ withheldReason: null,
+ ...over,
+ })
+
+ const base = {
+ clubName: "Analytics Society",
+ generatedAt: "26 August 2026 at 3:14 PM EDT",
+ preparedFor: "Priya Raman",
+ cards: [card()],
+ archivedCount: 0,
+ }
+
+ it("quotes an ordinary card in full", () => {
+ expect(flat(clubMemoryDocument(base))).toContain("We lost $900 on a deposit.")
+ })
+
+ it("LISTS a withheld card and never prints its body", () => {
+ // The whole point: a silent omission is what makes somebody trust a
+ // document they should not.
+ const withheld = card({
+ body: null,
+ withheldReason: "The contents of this card are withheld because it is marked sensitive.",
+ })
+ const text = flat(clubMemoryDocument({ ...base, cards: [withheld] }))
+
+ expect(text).toContain(withheld.title)
+ expect(text).toContain("withheld")
+ expect(text).not.toContain("We lost $900 on a deposit.")
+ })
+
+ it("counts the withheld cards on the cover, so the reader knows before reading", () => {
+ const doc = clubMemoryDocument({
+ ...base,
+ cards: [card(), card({ title: "Vendor login", body: null, withheldReason: "Withheld." })],
+ })
+
+ expect(doc.meta).toContainEqual({ label: "Contents withheld", value: "1" })
+ })
+
+ it("says nothing about withholding when nothing was withheld", () => {
+ const doc = clubMemoryDocument(base)
+
+ expect(doc.meta.map((m) => m.label)).not.toContain("Contents withheld")
+ })
+
+ it("declares the archived cards it excluded", () => {
+ expect(flat(clubMemoryDocument({ ...base, archivedCount: 4 }))).toContain(
+ "4 archived cards are excluded"
+ )
+ })
+})
+
+describe("the memory export is bounded, and says when it was cut", () => {
+ const card = () => ({
+ title: "A lesson",
+ kind: "Lesson",
+ seat: "President",
+ author: "Dev Shah",
+ updated: "3 Aug 2026",
+ body: "Something worth keeping.",
+ withheldReason: null,
+ })
+ const base = {
+ clubName: "Analytics Society",
+ generatedAt: "26 August 2026 at 3:14 PM EDT",
+ preparedFor: "Priya Raman",
+ cards: [card()],
+ archivedCount: 0,
+ }
+
+ it("says the list was cut, rather than ending quietly", () => {
+ const text = flat(clubMemoryDocument({ ...base, truncated: true }))
+
+ expect(text).toContain("cut")
+ expect(text).toContain("memory tab")
+ })
+
+ it("calls the cover figure a SHOWN count once the list is cut", () => {
+ // "Cards: 300" over a capped list is the document contradicting its own
+ // caveat page.
+ const cut = clubMemoryDocument({ ...base, truncated: true })
+ const whole = clubMemoryDocument(base)
+
+ expect(cut.meta).toContainEqual({ label: "Cards shown", value: "1" })
+ expect(whole.meta).toContainEqual({ label: "Cards", value: "1" })
+ })
+})
+
+describe("the memory route applies the PAGE's per-card gate", () => {
+ /*
+ * The serious finding on this change, and it was mine.
+ *
+ * `canViewOrg` admits anybody who can open the club. The memory PAGE then
+ * filters every card through `canSeeMemoryCard`, which is what keeps a seat's
+ * role-scoped cards to that seat's holder, the president and the OSE. The
+ * export skipped that step, so an ordinary club member could DOWNLOAD cards
+ * the page will not show them — and the document's own "withheld is visible"
+ * section made the omission look considered.
+ *
+ * A source scan, because the route is not callable from a unit test and the
+ * property that matters is that the call is THERE.
+ */
+ const route = readFileSync(
+ join(__dirname, "..", "..", "..", "app", "api", "export", "orgs", "[slug]", "memory", "route.ts"),
+ "utf8"
+ )
+
+ it("reads the real route", () => {
+ expect(route).toContain("clubMemoryDocument")
+ })
+
+ it("filters the cards it will print", () => {
+ expect(route).toContain("canSeeMemoryCard(ctx,")
+ expect(route).toContain("cardRows.filter")
+ })
+
+ it("counts only archived cards the reader could have seen", () => {
+ // A bare count() leaks "there are 12 archived cards" including ones scoped
+ // to seats this person does not hold.
+ expect(route).toContain("archivedRows.filter")
+ expect(route).not.toContain("db.memoryRecord.count(")
+ })
+
+ it("bounds the query and reports the cap", () => {
+ expect(route).toContain("take: LIST_CAP + 1")
+ expect(route).toContain("truncated")
+ })
+
+ it("measures the cut on what was READ, not on what survived the filter", () => {
+ /*
+ * Caught re-reading my own fix, and it was introduced BY that fix.
+ *
+ * The cap is applied by the database, before visibility is known — there is
+ * no `where` that expresses `canSeeMemoryCard`, since it reads the viewer's
+ * seats. So a club with a thousand cards, of which this reader may see
+ * forty, returns 301 rows and forty visible ones, and
+ * `visible.length > LIST_CAP` is FALSE. The document would then claim to
+ * hold every card this person can see while quietly stopping at row 301 —
+ * the silent-coverage failure this route spends three paragraphs warning
+ * about.
+ *
+ * `cardRows.length` is the honest measure and the fail-safe direction: it
+ * can over-report a cut when the unread remainder was invisible anyway, and
+ * it can never under-report coverage.
+ */
+ expect(route).toContain("const truncated = cardRows.length > LIST_CAP")
+ expect(route).not.toContain("const truncated = visible.length > LIST_CAP")
+ })
+})
+
+describe("which memory cards may be quoted at all", () => {
+ /*
+ * This is the rule that decides whether a secret leaves the building, so it
+ * is tested directly rather than through a route nothing can call.
+ */
+ it("quotes an ordinary standard card", () => {
+ expect(withholdingReason("LESSON", "standard")).toBeNull()
+ })
+
+ it("WITHHOLDS a credential even when its sensitivity says standard", () => {
+ // Order matters: asking about sensitivity first would quote this.
+ expect(withholdingReason("CREDENTIAL", "standard")).toContain("withheld")
+ })
+
+ it("withholds anything marked above standard", () => {
+ expect(withholdingReason("LESSON", "confidential")).toContain("withheld")
+ })
+
+ it("is FAIL-CLOSED: a typo restricts the card rather than widening it", () => {
+ // A value from a vocabulary nobody has invented yet must not read as safe.
+ expect(withholdingReason("LESSON", "standrd")).toContain("withheld")
+ expect(withholdingReason("LESSON", "")).toContain("withheld")
+ expect(withholdingReason("LESSON", "STANDARD-ISH")).toContain("withheld")
+ })
+
+ it("tolerates the casing and padding a stored column really carries", () => {
+ expect(withholdingReason("LESSON", " Standard ")).toBeNull()
+ })
+})
+
+describe("the impact summary — every number with its denominator", () => {
+ const base = {
+ clubName: "Analytics Society",
+ academicYear: "2026-27",
+ generatedAt: "26 August 2026 at 3:14 PM EDT",
+ preparedFor: "Priya Raman",
+ narrative: "Analytics Society published 7 events",
+ eventsPublished: 7,
+ approved: 11,
+ rejected: 1,
+ cancelled: 2,
+ inFlight: 3,
+ approvalRatePct: 92,
+ budgetedLabel: "$4,000.00",
+ actualLabel: "$1,200.00",
+ budgetUsedPct: 30,
+ activeMembers: 24,
+ seatsFilled: 9,
+ seatsTotal: 11,
+ memoryCards: 6,
+ collaborations: 2,
+ }
+
+ it("states the approval rate WITH the number of decisions behind it", () => {
+ // "92%" from twelve decisions and "92%" from two are different claims.
+ const text = flat(clubImpactDocument(base))
+
+ expect(text).toContain("Approval rate 92%")
+ expect(text).toContain("12 decided requests")
+ })
+
+ it("refuses to print a rate when nothing has been decided", () => {
+ const text = flat(
+ clubImpactDocument({ ...base, approved: 0, rejected: 0, approvalRatePct: null })
+ )
+
+ expect(text).toContain("No request has been decided yet")
+ expect(text).not.toContain("Approval rate 0%")
+ })
+
+ it("says 'no budget set' rather than 0% used", () => {
+ const text = flat(
+ clubImpactDocument({
+ ...base,
+ budgetedLabel: "No budget set",
+ budgetUsedPct: null,
+ })
+ )
+
+ expect(text).toContain("No budget set for this year")
+ })
+
+ it("foots the approval table to the real total, cancellations included", () => {
+ const doc = clubImpactDocument(base)
+ const table = doc.sections
+ .flatMap((s) => s.blocks)
+ .find((b) => b.kind === "table")! as Extract<
+ (typeof doc.sections)[number]["blocks"][number],
+ { kind: "table" }
+ >
+
+ // 11 + 1 + 2 + 3. Cancelled is in the total even though it is out of the rate.
+ expect(table.total).toEqual(["Total", "17"])
+ })
+
+ it("shows seats filled against seats that exist", () => {
+ expect(flat(clubImpactDocument(base))).toContain("9 of 11")
+ })
+})
+
+describe("what a downloaded file is called", () => {
+ it("carries the club, the kind and the date", () => {
+ expect(rosterReportFilename("Analytics Society", AT)).toBe(
+ "analytics-society-roster-2026-08-26.pdf"
+ )
+ expect(documentsReportFilename("Analytics Society", AT)).toBe(
+ "analytics-society-documents-2026-08-26.pdf"
+ )
+ expect(memoryReportFilename("Analytics Society", AT)).toBe(
+ "analytics-society-memory-2026-08-26.pdf"
+ )
+ expect(impactReportFilename("Analytics Society", AT)).toBe(
+ "analytics-society-impact-2026-08-26.pdf"
+ )
+ })
+
+ it("is safe on every filesystem", () => {
+ const name = reportFilename('Société / "Analytics" <2026>', "roster", filenameDate(AT))
+
+ expect(name).not.toMatch(/[:/\\?*"<>|]/)
+ })
+
+ it("never produces a bare extension", () => {
+ // A name of nothing but punctuation must not save as ".pdf".
+ expect(reportSlug("///")).toBe("club")
+ expect(reportFilename("///", "roster", "2026-08-26")).toBe("club-roster-2026-08-26.pdf")
+ })
+})
diff --git a/apps/web/src/lib/export/reports/filenames.ts b/apps/web/src/lib/export/reports/filenames.ts
new file mode 100644
index 00000000..49d2f798
--- /dev/null
+++ b/apps/web/src/lib/export/reports/filenames.ts
@@ -0,0 +1,46 @@
+/**
+ * What a downloaded report is CALLED.
+ *
+ * Every export lands in one folder alongside whatever else a student has
+ * downloaded that week, and the filename is the only thing distinguishing them
+ * there. So it carries the club, what the document is, and — where the document
+ * is a snapshot rather than a statement about a fixed period — the date.
+ *
+ * One function because there are now six of these. The finance report had its
+ * own slug logic; a second copy would have been the moment the two started
+ * disagreeing about what to do with an apostrophe.
+ *
+ * THE RULES, and each one is a filesystem that refuses otherwise:
+ * · NFKD then strip non-word characters — "Society für Analytics" must not
+ * put a combining mark in a filename, and `:` `/` `\` `?` `*` `"` `<` `>`
+ * `|` are each forbidden on at least one of macOS, Windows and Linux.
+ * · spaces to hyphens, lower case, so it is typeable and quotable in a shell.
+ * · an empty result falls back to "club" rather than producing ".pdf", which
+ * some browsers save as an extensionless file.
+ */
+export function reportSlug(name: string): string {
+ return (
+ name
+ .normalize("NFKD")
+ .replace(/[^\w\s-]/g, "")
+ .trim()
+ .replace(/\s+/g, "-")
+ .toLowerCase() || "club"
+ )
+}
+
+/**
+ * `--.pdf`.
+ *
+ * The qualifier is an academic year for a report ABOUT a period, and a date for
+ * a snapshot of how things stand — those are different claims and the filename
+ * should not blur them.
+ */
+export function reportFilename(clubName: string, kind: string, qualifier: string): string {
+ return `${reportSlug(clubName)}-${kind}-${qualifier.replace(/[^\w-]/g, "-")}.pdf`
+}
+
+/** `2026-08-26`, for a snapshot's filename. Sortable, and unambiguous. */
+export function filenameDate(at: Date): string {
+ return at.toISOString().slice(0, 10)
+}