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