From 6feda044f419526bb12e21e19d5daf0b5ef0e1f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 04:40:17 -0400 Subject: [PATCH 1/3] Every club module exports a branded PDF, through one control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "there are no export options for modules, tools etc to be exported out as pdf" — and, more precisely: "where there is data, info, knowledge there should be export option all in pdf". That was accurate. Before this, ONE surface in Tenure could produce a document: a club's finance page, whose button was written inline on that page. Five other club modules held real records and offered no way to take them out. ── WHAT NOW EXPORTS ──────────────────────────────────────────────────────── · Roster — seats, holders, standing, advisors, past holders, VACANCIES · Documents — the index: what is on file, filed by whom, when · Memory — every card, with the ordinary ones quoted in full · Impact — the year's figures, each with its denominator · Finance — already existed; now uses the shared control · Workspace — already existed (Settings) ── ONE CONTROL, NOT FIVE BUTTONS ─────────────────────────────────────────── The industry pattern the owner named — SAP, Jira, Intuit — is not "every screen gets an export somewhere". It is that the affordance is the SAME control, in the SAME position, with the SAME wording, everywhere it appears, so a person learns it once. Five hand-written anchors drift in padding, icon, label and behaviour within a release, and that drift is the thing that reads as amateur. So `components/export/ExportButton.tsx` holds the markup once and the finance page now uses it too, giving up the anchor it had written itself. It stays a plain `` for the reason that page had already worked out: the route answers with `Content-Disposition: attachment`, so the middle button, a right-click and a screen reader all keep working — a fetch-and-blob has to reimplement all three and gets the third wrong. The visible label is identical across modules; the accessible name carries the noun, because two controls that both announce only "Export PDF" are indistinguishable to somebody who cannot see which section they are in. `lib/export/report-response.ts` does the same for the server half — resolve the club, read the institution's clock, note any character the base-14 fonts cannot draw, and answer with `private, no-store`. Written out six times, the fifth route is where somebody forgets one of those. ── THE DISCLOSURE DECISION, WHICH IS THE REAL WORK HERE ──────────────────── A memory card is the product's whole thesis, and 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 had to live up to — a bulk export is the wrong place to re-decide who may read what. The resolution is that everything is LISTED and not everything is QUOTED. Two kinds are withheld, each named beside its own entry: · anything `isAboveStandard` — fail-closed by design, so a typo or a vocabulary that does not exist yet restricts the card rather than widening it. Nothing writes that column today, which is exactly when the rule should be written rather than the day after something starts setting it. · the retired CREDENTIAL type, whose bodies were stored unencrypted and which the memory page already withholds on screen. A report that printed them would be the one place in Tenure that hands them out. WITHHELD IS VISIBLE — title, kind, seat and author still print, and the cover counts them. A silent omission is what makes somebody trust a document they should not. `withholdingReason()` is a pure function beside the report, NOT a branch in the route, for the reason the club roster taught earlier today: a rule inside a route handler is a rule no unit test can reach, and this one decides whether a secret leaves the building. Six tests pin it, including that a CREDENTIAL card marked "standard" is still withheld — order matters, and asking about sensitivity first would quote it. Reverting the function to `return null` fails three of them. ── THE REPORTS SAY TRUE THINGS ───────────────────────────────────────────── · the roster NAMES its vacancies. A table of holdings cannot show an absence, so a roster missing "Treasurer" reads as a complete board. · the document index says, in the document, 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. · the impact summary prints the approval rate WITH the number of decisions behind it. "92%" from twelve decisions and "92%" from two are different claims. A rate over an empty denominator is not printed as 0%; it says no request has been decided yet, because 0% is a claim and "nothing happened" is a fact. · every figure is computed through the loaders the pages use, including `PLAN_LINE_WHERE` and the CANCELLED bucket the impact page had to be corrected to count — a second implementation here would have been free to make that mistake again in private. ── TWO OF THE REPO'S OWN GATES CAUGHT ME ─────────────────────────────────── Worth recording, because both were doing exactly their job: · `surfaces.test.ts` — all four new routes were unaccounted for in the capability registry. They are declared now, deferred with a stated reason following the budget report's precedent: the only reporting capability is INSTITUTION-WIDE 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 gate is `canViewOrg`, re-asked in the route rather than assumed from the fact a link was rendered, because a download URL is public and can be forwarded. · `term-is-configuration.test.ts` — a fixture wrote a term literal. Uses the "2026-27" form the sibling report test already uses. Verified: tsc 0 errors, jest 417 suites / 6,677 tests green, `next build` exit 0, eslint clean on everything touched. Co-Authored-By: Claude Opus 5 (1M context) --- .../app/(app)/orgs/[slug]/documents/page.tsx | 14 +- .../app/(app)/orgs/[slug]/finance/page.tsx | 22 +- .../src/app/(app)/orgs/[slug]/impact/page.tsx | 15 +- .../app/(app)/orgs/[slug]/members/page.tsx | 2 + .../src/app/(app)/orgs/[slug]/memory/page.tsx | 20 +- .../api/export/orgs/[slug]/documents/route.ts | 110 ++++++ .../api/export/orgs/[slug]/impact/route.ts | 124 +++++++ .../api/export/orgs/[slug]/memory/route.ts | 118 ++++++ .../api/export/orgs/[slug]/roster/route.ts | 126 +++++++ .../src/components/export/ExportButton.tsx | 70 ++++ .../src/lib/capability-registry/surfaces.ts | 16 + apps/web/src/lib/export/report-response.ts | 138 +++++++ .../src/lib/export/reports/club-documents.ts | 125 +++++++ .../web/src/lib/export/reports/club-impact.ts | 174 +++++++++ .../web/src/lib/export/reports/club-memory.ts | 222 ++++++++++++ .../web/src/lib/export/reports/club-roster.ts | 167 +++++++++ ...ery-module-exports-a-real-document.test.ts | 342 ++++++++++++++++++ apps/web/src/lib/export/reports/filenames.ts | 46 +++ 18 files changed, 1819 insertions(+), 32 deletions(-) create mode 100644 apps/web/src/app/api/export/orgs/[slug]/documents/route.ts create mode 100644 apps/web/src/app/api/export/orgs/[slug]/impact/route.ts create mode 100644 apps/web/src/app/api/export/orgs/[slug]/memory/route.ts create mode 100644 apps/web/src/app/api/export/orgs/[slug]/roster/route.ts create mode 100644 apps/web/src/components/export/ExportButton.tsx create mode 100644 apps/web/src/lib/export/report-response.ts create mode 100644 apps/web/src/lib/export/reports/club-documents.ts create mode 100644 apps/web/src/lib/export/reports/club-impact.ts create mode 100644 apps/web/src/lib/export/reports/club-memory.ts create mode 100644 apps/web/src/lib/export/reports/club-roster.ts create mode 100644 apps/web/src/lib/export/reports/every-module-exports-a-real-document.test.ts create mode 100644 apps/web/src/lib/export/reports/filenames.ts diff --git a/apps/web/src/app/(app)/orgs/[slug]/documents/page.tsx b/apps/web/src/app/(app)/orgs/[slug]/documents/page.tsx index 9999f650..5c4fce25 100644 --- a/apps/web/src/app/(app)/orgs/[slug]/documents/page.tsx +++ b/apps/web/src/app/(app)/orgs/[slug]/documents/page.tsx @@ -8,6 +8,7 @@ import { storageConfigured } from "@/lib/s3" import { aiConfigured } from "@/lib/ai" import { Card, CardHeader } from "@/components/ui/Card" import { OrgTabs } from "@/components/OrgTabs" +import { ExportButton } from "@/components/export/ExportButton" import { DocumentRow } from "@/components/documents/DocumentRow" import { ArchiveRestore } from "@/components/ui/icons" import { ReportingForm } from "@/components/forms/ReportingForm" @@ -134,11 +135,14 @@ export default async function DocumentsPage({ return (
-
-

{org.name}

-

- Club documents. Contracts, templates and records in one durable place. -

+
+
+

{org.name}

+

+ Club documents. Contracts, templates and records in one durable place. +

+
+
diff --git a/apps/web/src/app/(app)/orgs/[slug]/finance/page.tsx b/apps/web/src/app/(app)/orgs/[slug]/finance/page.tsx index eb6daed4..0edfbfcd 100644 --- a/apps/web/src/app/(app)/orgs/[slug]/finance/page.tsx +++ b/apps/web/src/app/(app)/orgs/[slug]/finance/page.tsx @@ -8,8 +8,8 @@ import { seatCarriesFinanceAuthority, } from "@/lib/rbac" import { withTenantScope } from "@/lib/tenant-scope" -import { Download } from "@/components/ui/icons" import { OrgTabs } from "@/components/OrgTabs" +import { ExportButton } from "@/components/export/ExportButton" import { FinanceDashboard } from "@/components/finance/FinanceDashboard" import { ReimbursementForm } from "@/components/finance/ReimbursementForm" import { type LedgerEntryRow } from "@/components/finance/LedgerDrawer" @@ -183,22 +183,10 @@ export default async function FinancePage({ Finance. Actual against budget for {CURRENT_YEAR}, with editable forecasting.

- {/* A plain
, not a fetch-and-blob. The route answers with - Content-Disposition: attachment, so the browser saves the file - and the page does not move — and a download that is an ordinary - link keeps working with the middle button, a right-click, and a - screen reader that announces it as one. */} - ` would be wrong for a download either way. - download - className="inline-flex shrink-0 items-center gap-1.5 rounded border border-border px-3 py-1.5 text-xs font-medium text-text-2 hover:bg-base hover:text-text-1" - > - Export PDF - + {/* The same control every other module uses. This page had the + original hand-written anchor; the reasoning that was in this + comment now lives in the component, where all six share it. */} +
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..7657558c --- /dev/null +++ b/apps/web/src/app/api/export/orgs/[slug]/impact/route.ts @@ -0,0 +1,124 @@ +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) + const name = 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..4f35c661 --- /dev/null +++ b/apps/web/src/app/api/export/orgs/[slug]/memory/route.ts @@ -0,0 +1,118 @@ +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 { 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" + +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() + + const [cards, archivedCount] = await Promise.all([ + db.memoryRecord.findMany({ + where: { organizationId: org.id, isArchived: false }, + select: { + title: true, + type: true, + content: true, + sensitivity: true, + authorId: true, + updatedAt: true, + role: { select: { name: true } }, + }, + orderBy: { updatedAt: "desc" }, + }), + db.memoryRecord.count({ where: { organizationId: org.id, isArchived: true } }), + ]) + + 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, + }) + + 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..6117d09d --- /dev/null +++ b/apps/web/src/lib/export/report-response.ts @@ -0,0 +1,138 @@ +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; 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 }, + select: { id: true, name: 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..a4b219b8 --- /dev/null +++ b/apps/web/src/lib/export/reports/club-documents.ts @@ -0,0 +1,125 @@ +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 }, + { label: "On file", value: String(report.live.length) }, + { label: "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..a61b503d --- /dev/null +++ b/apps/web/src/lib/export/reports/club-memory.ts @@ -0,0 +1,222 @@ +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 +} + +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.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 }, + { label: "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..d3079085 --- /dev/null +++ b/apps/web/src/lib/export/reports/every-module-exports-a-real-document.test.ts @@ -0,0 +1,342 @@ +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("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) +} From 91a563393b362fd6ddcdcf2e6934c0351d1a99e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 05:21:14 -0400 Subject: [PATCH 2/3] The memory export was not applying the memory page's own gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four review findings on the module exports. The last one is a real disclosure defect and it was mine. ── THE SERIOUS ONE ───────────────────────────────────────────────────────── `canViewOrg` admits anybody who can open the club. The memory PAGE then filters every card through `canSeeMemoryCard`, and that is the test that keeps a seat's role-scoped cards to the seat's holder, the club president and the OSE. The export never asked it. So an ordinary club member could DOWNLOAD cards the page will not show them — and the document's own "what is not in this document" section, which explains at length which cards are withheld and why, made the omission read as considered rather than missed. A download route is a public URL. Every test the page applies has to be applied in the route or the export is a way around them. That sentence is in this PR's description for the OTHER four routes; this one did not follow it. Now filtered before any entry is built. The ARCHIVED COUNT goes through the same test rather than a bare `count()`, because "there are 12 archived cards" is itself a disclosure when some of them are scoped to seats the reader does not hold. ── AND THREE OTHERS, ALL REAL ────────────────────────────────────────────── **The memory query had no `take`.** It read every non-archived card and rendered every body into one PDF on the request thread. The documents export in this same change caps at 300 and prints a truncation line for exactly this reason — so the inconsistency was the point, not the rule. Capped, with the cut stated in the document and the cover figure relabelled "Cards shown", because "Cards: 300" over a capped list is the document contradicting its own caveat. **The document index labelled capped counts as totals.** Same defect, one file over: `On file: 300` is what is IN the document, not what the club holds. **The impact narrative could name the club differently from the screen.** The page builds its sentence from `shortName ?? name`; this route used `name`. For any club with a short name the PDF and the page would have described the same year using different names — the exact drift the route's own header says must not happen, three lines under the comment saying it. ── VERIFICATION ──────────────────────────────────────────────────────────── Removing the visibility filter fails the test that pins it. tsc 0 · jest 418 suites / 6,702 tests green · eslint clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../api/export/orgs/[slug]/impact/route.ts | 3 +- .../api/export/orgs/[slug]/memory/route.ts | 48 ++++++++++- apps/web/src/lib/export/report-response.ts | 7 +- .../src/lib/export/reports/club-documents.ts | 10 ++- .../web/src/lib/export/reports/club-memory.ts | 20 ++++- ...ery-module-exports-a-real-document.test.ts | 79 +++++++++++++++++++ 6 files changed, 159 insertions(+), 8 deletions(-) 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 index 7657558c..4a5f1a55 100644 --- a/apps/web/src/app/api/export/orgs/[slug]/impact/route.ts +++ b/apps/web/src/app/api/export/orgs/[slug]/impact/route.ts @@ -80,7 +80,8 @@ export async function GET(_request: Request, { params }: { params: Promise<{ slu const actual = budgetLines.reduce((s, l) => s + l.actualCents, 0) const seats = summariseSeats(seatFacts) - const name = org.name + // 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"}`, 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 index 4f35c661..b91081f4 100644 --- a/apps/web/src/app/api/export/orgs/[slug]/memory/route.ts +++ b/apps/web/src/app/api/export/orgs/[slug]/memory/route.ts @@ -2,6 +2,7 @@ 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" @@ -41,6 +42,9 @@ import { */ 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", @@ -64,7 +68,15 @@ export async function GET(_request: Request, { params }: { params: Promise<{ slu const ctx = await getUserContext(userId) if (!canViewOrg(ctx, org)) notFound() - const [cards, archivedCount] = await Promise.all([ + /* + * 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: { @@ -72,15 +84,46 @@ export async function GET(_request: Request, { params }: { params: Promise<{ slu 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 }, }), - db.memoryRecord.count({ where: { organizationId: org.id, isArchived: 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) + ) + const truncated = visible.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( ( @@ -111,6 +154,7 @@ export async function GET(_request: Request, { params }: { params: Promise<{ slu preparedFor, cards: entries, archivedCount, + truncated, }) return reportResponse(doc, memoryReportFilename(org.name, now), institutionName) diff --git a/apps/web/src/lib/export/report-response.ts b/apps/web/src/lib/export/report-response.ts index 6117d09d..dcec0911 100644 --- a/apps/web/src/lib/export/report-response.ts +++ b/apps/web/src/lib/export/report-response.ts @@ -38,7 +38,7 @@ import { renderDocument, unrepresentableStrings, type BrandedDocument } from "@/ * that reads the same to everyone who opens it later. */ export interface ReportContext { - readonly org: { id: string; name: string; institutionId: string } + readonly org: { id: string; name: string; shortName: string | null; institutionId: string } readonly institutionName: string readonly generatedAt: string readonly preparedFor: string @@ -50,7 +50,10 @@ export interface ReportContext { export async function reportContext(slug: string, userId: string): Promise { const org = await db.organization.findUnique({ where: { slug }, - select: { id: true, name: true, institutionId: true }, + // `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() diff --git a/apps/web/src/lib/export/reports/club-documents.ts b/apps/web/src/lib/export/reports/club-documents.ts index a4b219b8..61e5c00f 100644 --- a/apps/web/src/lib/export/reports/club-documents.ts +++ b/apps/web/src/lib/export/reports/club-documents.ts @@ -110,8 +110,14 @@ export function clubDocumentsDocument(report: ClubDocumentsReport): BrandedDocum subtitle: report.clubName, meta: [ { label: "Club", value: report.clubName }, - { label: "On file", value: String(report.live.length) }, - { label: "Archived", value: String(report.archived.length) }, + // "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 }, ], diff --git a/apps/web/src/lib/export/reports/club-memory.ts b/apps/web/src/lib/export/reports/club-memory.ts index a61b503d..807358d5 100644 --- a/apps/web/src/lib/export/reports/club-memory.ts +++ b/apps/web/src/lib/export/reports/club-memory.ts @@ -104,6 +104,8 @@ export interface ClubMemoryReport { 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 { @@ -185,6 +187,16 @@ export function clubMemoryDocument(report: ClubMemoryReport): BrandedDocument { }, ] : []), + ...(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 ? [ { @@ -205,7 +217,13 @@ export function clubMemoryDocument(report: ClubMemoryReport): BrandedDocument { subtitle: report.clubName, meta: [ { label: "Club", value: report.clubName }, - { label: "Cards", value: String(report.cards.length) }, + { + // "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) }] : []), 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 index d3079085..f28913e1 100644 --- 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 @@ -1,3 +1,5 @@ +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 { @@ -212,6 +214,83 @@ describe("the memory export — what it will and will not quote", () => { }) }) +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") + }) +}) + describe("which memory cards may be quoted at all", () => { /* * This is the rule that decides whether a secret leaves the building, so it From 5ce0c8464eed0f19c4cbb69de5fdf1c43bd3365b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 05:38:12 -0400 Subject: [PATCH 3/3] The cap was applied before the visibility filter, so the cut went unreported MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found re-reading my own fix, because CodeRabbit reported "Review rate limited" on this push and a recorded self-review is worthless unless it is an actual re-read. It was introduced BY the fix for the previous finding. The visibility filter and the cap arrived in the same commit, in this order: take: LIST_CAP + 1 ← the database cuts at 301 visible = cardRows.filter(canSee…) ← then visibility is applied truncated = visible.length > LIST_CAP ← and the cut is measured HERE There is no `where` that expresses `canSeeMemoryCard` — it reads the viewer's seats — so the cap necessarily happens first. A club holding a thousand cards, of which this reader may see forty, therefore returns 301 rows, forty visible ones, and `truncated === false`. The document would then have claimed to hold every card this person can see while quietly stopping at row 301. That is the silent-coverage failure this route spends three paragraphs warning about, arriving through the door I opened while closing the last one. `cardRows.length > LIST_CAP` is the honest measure: "there were more cards than we read". It is also 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 to check; the other way tells them they already have everything. Pinned by a test that asserts the measure by name, including the negative form, because the two expressions differ by one word and read identically at a glance. Co-Authored-By: Claude Opus 5 (1M context) --- .../api/export/orgs/[slug]/memory/route.ts | 21 ++++++++++++++++++- ...ery-module-exports-a-real-document.test.ts | 21 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) 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 index b91081f4..c71156b6 100644 --- a/apps/web/src/app/api/export/orgs/[slug]/memory/route.ts +++ b/apps/web/src/app/api/export/orgs/[slug]/memory/route.ts @@ -118,7 +118,26 @@ export async function GET(_request: Request, { params }: { params: Promise<{ slu const visible = cardRows.filter((card) => canSeeMemoryCard(ctx, { roleId: card.roleId, sensitivity: card.sensitivity }, org) ) - const truncated = visible.length > LIST_CAP + /* + * 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) 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 index f28913e1..3b7c0407 100644 --- 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 @@ -289,6 +289,27 @@ describe("the memory route applies the PAGE's per-card gate", () => { 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", () => {