/** * Data access layer for Transcriptions. * * Calls the Go BFF API instead of Prisma directly. * All responses are unwrapped from the BFF envelope by lib/bff.ts. * * BFF endpoints used: * GET /api/v1/transcriptions → { count } * GET /api/v1/transcriptions/years → [{ _id, count }] * GET /api/v1/transcriptions/places → [{ _id, count }] * GET /api/v1/transcriptions/types → { lectures, conversations } * GET /api/v1/transcriptions/year/{year} → [TranscriptionSummary] * GET /api/v1/transcriptions/place/{place} → [TranscriptionSummary] * GET /api/v1/transcriptions/type/{type} → [TranscriptionSummary] * GET /api/v1/transcriptions/{slug} → TranscriptionFull * GET /api/v1/transcriptions/adjacent/{slug}?year=N → { prev, next } */ import { bff, bffList } from "@/lib/bff"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export type TranscriptionSummary = { slug: string; name: string; type: string; date: string | null; year: number | null; month: number | null; day: number | null; place: string | null; context: string | null; reference: string | null; durationMinutes: number | null; participants: string[]; audioUrl: string | null; }; export type ContentBlock = { text: string; format: string; alignment: string; speaker: string | null; }; export type TranscriptionFull = TranscriptionSummary & { content: ContentBlock[]; }; export type YearGroup = { year: number; count: number; }; export type PlaceGroup = { name: string; count: number; }; export type BookGroup = { code: string; title: string; count: number; }; // --------------------------------------------------------------------------- // BFF response types (snake_case from Go) // --------------------------------------------------------------------------- type BffSummary = { id?: string; slug: string; name: string; type: string; date?: string | null; year?: number | null; month?: number | null; day?: number | null; place?: string | null; context?: string | null; reference?: string | null; duration_minutes?: number | null; audio_url?: string | null; participants?: string[]; }; type BffFull = BffSummary & { content?: BffContentBlock[]; content_json?: string; }; type BffContentBlock = { text: string; format: string; alignment: string; speaker?: string | null; }; // --------------------------------------------------------------------------- // Sanitize // --------------------------------------------------------------------------- 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 (snake_case BFF → camelCase frontend) // --------------------------------------------------------------------------- function mapSummary(s: BffSummary): TranscriptionSummary { return { slug: s.slug, name: s.name, type: s.type, date: s.date ?? null, year: s.year ?? null, month: s.month ?? null, day: s.day ?? null, place: s.place ?? null, context: s.context ?? null, reference: s.reference ?? null, durationMinutes: s.duration_minutes ?? null, participants: s.participants ?? [], audioUrl: s.audio_url ?? null, }; } function mapContentBlock(b: BffContentBlock): ContentBlock { return { text: sanitizeHtml(b.text), format: b.format, alignment: b.alignment, speaker: b.speaker ?? null, }; } function mapFull(t: BffFull): TranscriptionFull { let content: ContentBlock[] = []; if (t.content && t.content.length > 0) { content = t.content.map(mapContentBlock); } else if (t.content_json && t.content_json.length > 2) { // contentJson fallback (BSON workaround for very large nested arrays) try { const parsed = JSON.parse(t.content_json) as BffContentBlock[]; content = parsed.map(mapContentBlock); } catch { // Ignore parse errors — return empty content } } return { ...mapSummary(t), content, }; } // --------------------------------------------------------------------------- // Index / overview queries // --------------------------------------------------------------------------- export async function getTranscriptionYears(): Promise { const raw = await bffList<{ _id: number; count: number }>( "/transcriptions/years", { tags: ["transcriptions"] }, ); return raw .map((item) => ({ year: item._id, count: item.count })) .filter((g) => g.year >= 1960 && g.year <= 1980) .sort((a, b) => a.year - b.year); } export async function getTranscriptionPlaces(): Promise { const raw = await bffList<{ _id: string; count: number }>( "/transcriptions/places", { tags: ["transcriptions"] }, ); return raw .map((item) => ({ name: item._id, count: item.count })) .sort((a, b) => b.count - a.count); } export async function getTranscriptionBooks(): Promise { const raw = await bffList<{ _id: string; title: string; count: number }>( "/transcriptions/books", { tags: ["transcriptions"] }, ); return raw .map((item) => ({ code: item._id, title: item.title, count: item.count })) .sort((a, b) => b.count - a.count); } export async function getTotalTranscriptionCount(): Promise { const res = await bff<{ count: number }>("/transcriptions", { tags: ["transcriptions"], }); return res?.count ?? 0; } /** * Return a lightweight index object with type distribution counts. * Used by the index page to show lecture/conversation totals. */ export async function getTranscriptionIndex(): Promise<{ totalTranscriptions: number; typeDistribution: Record; }> { const [countRes, typesRes] = await Promise.all([ bff<{ count: number }>("/transcriptions", { tags: ["transcriptions"] }), bff<{ lectures: number; conversations: number }>("/transcriptions/types", { tags: ["transcriptions"], }), ]); return { totalTranscriptions: countRes?.count ?? 0, typeDistribution: { lecture: typesRes?.lectures ?? 0, conversation: typesRes?.conversations ?? 0, }, }; } // --------------------------------------------------------------------------- // List queries (return summaries without content) // --------------------------------------------------------------------------- export async function getTranscriptionsByYear( year: number, ): Promise { const raw = await bffList(`/transcriptions/year/${year}`, { tags: ["transcriptions"], }); return raw.map(mapSummary); } export async function getTranscriptionsByPlace( place: string, ): Promise { const encoded = encodeURIComponent(place); const raw = await bffList(`/transcriptions/place/${encoded}`, { tags: ["transcriptions"], }); return raw.map(mapSummary); } export async function getTranscriptionsByType( type: string, ): Promise { const raw = await bffList(`/transcriptions/type/${type}`, { tags: ["transcriptions"], }); return raw.map(mapSummary); } export async function getTranscriptionsByBook( code: string, ): Promise { const raw = await bffList( `/transcriptions/book/${encodeURIComponent(code)}`, { tags: ["transcriptions"] }, ); return raw.map(mapSummary); } // --------------------------------------------------------------------------- // Single transcription // --------------------------------------------------------------------------- export async function getTranscription( slug: string, ): Promise { const raw = await bff(`/transcriptions/${slug}`, { tags: ["transcriptions"], }); if (!raw) return null; return mapFull(raw); } // --------------------------------------------------------------------------- // Navigation helpers // --------------------------------------------------------------------------- export async function getAdjacentTranscriptions( slug: string, year: number | null, ): Promise<{ prev: TranscriptionSummary | null; next: TranscriptionSummary | null; }> { if (!year) return { prev: null, next: null }; const res = await bff<{ prev: BffSummary | null; next: BffSummary | null; }>(`/transcriptions/adjacent/${slug}`, { params: { year: String(year) }, tags: ["transcriptions"], }); if (!res) return { prev: null, next: null }; return { prev: res.prev ? mapSummary(res.prev) : null, next: res.next ? mapSummary(res.next) : null, }; }