/** * Data access layer for Letters. * * Provides queries for browsing letters by year, recipient, and location. * Calls the BFF API for all data access (no direct Prisma usage). * * Recipients are stored as an array (e.g., ["Mukunda", "Janaki"]). * The recipient list aggregates individual names across all letters, * and searching for "Mukunda" finds all letters where Mukunda is * ANY recipient (individual or group). */ import { bff, bffList } from "@/lib/bff"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export type LetterSummary = { slug: string; recipients: string[]; recipientDisplay: string; date: string | null; year: number | null; month: number | null; location: string | null; }; export type LetterFull = { slug: string; recipients: string[]; recipientDisplay: string; date: string | null; dateRef: string | null; year: number | null; month: number | null; day: number | null; location: string | null; salutation: string | null; paragraphs: string[]; }; export type YearGroup = { year: number; count: number; }; export type RecipientGroup = { name: string; count: number; }; export type LocationGroup = { name: string; count: number; }; // --------------------------------------------------------------------------- // BFF response types (snake_case from Go) // --------------------------------------------------------------------------- type BffLetterSummary = { slug: string; recipients: string[]; recipient_display: string; date: string | null; year: number | null; month: number | null; location: string | null; }; type BffLetterFull = { slug: string; recipients: string[]; recipient_display: string; date: string | null; date_ref: string | null; year: number | null; month: number | null; day: number | null; location: string | null; salutation: string | null; paragraphs: string[]; }; type BffAggregation = { _id: string | number; count: number; }; // --------------------------------------------------------------------------- // HTML sanitisation // --------------------------------------------------------------------------- function sanitizeHtml(html: string): string { let clean = html.replace(/][\s\S]*?<\/script>/gi, ""); clean = clean.replace(/][\s\S]*?<\/style>/gi, ""); clean = clean.replace(/][\s\S]*?<\/iframe>/gi, ""); clean = clean.replace( /<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi, "", ); clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, ""); return clean; } // --------------------------------------------------------------------------- // Mappers (BFF snake_case → frontend camelCase) // --------------------------------------------------------------------------- function mapLetterSummary(item: BffLetterSummary): LetterSummary { return { slug: item.slug, recipients: item.recipients ?? [], recipientDisplay: item.recipient_display ?? "", date: item.date ?? null, year: item.year ?? null, month: item.month ?? null, location: item.location ?? null, }; } function mapLetterFull(item: BffLetterFull): LetterFull { return { slug: item.slug, recipients: item.recipients ?? [], recipientDisplay: item.recipient_display ?? "", date: item.date ?? null, dateRef: item.date_ref ?? null, year: item.year ?? null, month: item.month ?? null, day: item.day ?? null, location: item.location ?? null, salutation: item.salutation ?? null, paragraphs: (item.paragraphs ?? []).map(sanitizeHtml), }; } function mapYearGroup(item: BffAggregation): YearGroup { return { year: Number(item._id), count: item.count }; } function mapRecipientGroup(item: BffAggregation): RecipientGroup { return { name: String(item._id), count: item.count }; } function mapLocationGroup(item: BffAggregation): LocationGroup { return { name: String(item._id), count: item.count }; } // --------------------------------------------------------------------------- // Index / overview queries // --------------------------------------------------------------------------- export async function getLetterYears(): Promise { const raw = await bffList("/letters/years", { tags: ["letters"], }); return raw .map(mapYearGroup) .filter((g) => g.year >= 1940 && g.year <= 1980) .sort((a, b) => a.year - b.year); } /** * Get all unique individual recipients with letter counts. * * Each letter has a `recipients` array (e.g., ["Mukunda", "Janaki"]). * The BFF aggregation endpoint already aggregates across all letters so * that "Mukunda" appears once with count = total letters where Mukunda * is ANY recipient. */ export async function getLetterRecipients(): Promise { const raw = await bffList("/letters/recipients", { tags: ["letters"], }); return raw.map(mapRecipientGroup).sort((a, b) => b.count - a.count); } export async function getLetterLocations(): Promise { const raw = await bffList("/letters/locations", { tags: ["letters"], }); return raw.map(mapLocationGroup).sort((a, b) => b.count - a.count); } // --------------------------------------------------------------------------- // Letter list queries // --------------------------------------------------------------------------- export async function getLettersByYear(year: number): Promise { const raw = await bffList(`/letters/year/${year}`, { tags: ["letters"], }); return raw.map(mapLetterSummary); } /** * Get all letters where the given name is ANY recipient. * * The BFF endpoint handles the array-contains matching server-side. * Searching for "Mukunda" returns letters addressed to: * - "Mukunda" (individual) * - "Mukunda, Janaki" (group containing Mukunda) * - "Yamuna, Mukunda, Syamasundara" (group containing Mukunda) */ export async function getLettersByRecipient( recipient: string, ): Promise { const encoded = encodeURIComponent(recipient); const raw = await bffList(`/letters/recipient/${encoded}`, { tags: ["letters"], }); return raw.map(mapLetterSummary); } export async function getLettersByLocation( location: string, ): Promise { const encoded = encodeURIComponent(location); const raw = await bffList(`/letters/location/${encoded}`, { tags: ["letters"], }); return raw.map(mapLetterSummary); } // --------------------------------------------------------------------------- // Single letter // --------------------------------------------------------------------------- export async function getLetter(slug: string): Promise { const letter = await bff(`/letters/${slug}`, { tags: ["letters"], }); if (!letter) return null; return mapLetterFull(letter); } // --------------------------------------------------------------------------- // Navigation helpers // --------------------------------------------------------------------------- export async function getAdjacentLetters( slug: string, year: number | null, ): Promise<{ prev: LetterSummary | null; next: LetterSummary | null }> { if (!year) return { prev: null, next: null }; const letters = await getLettersByYear(year); const idx = letters.findIndex((l) => l.slug === slug); return { prev: idx > 0 ? letters[idx - 1] : null, next: idx < letters.length - 1 ? letters[idx + 1] : null, }; } export async function getTotalLetterCount(): Promise { const result = await bff<{ count: number }>("/letters", { tags: ["letters"], }); return result?.count ?? 0; }