feat: implement bff layer

This commit is contained in:
gosangam
2026-05-10 00:35:14 +05:30
parent 5951eb5e18
commit 32da38b24f
91 changed files with 2434 additions and 11784 deletions
+212 -129
View File
@@ -1,13 +1,11 @@
/**
* Data access layer for server components.
*
* These functions query MongoDB via Prisma and are called directly by
* Next.js server components. The /api/* routes exist for external consumers
* and for client-side verse-navigation fetching.
* These functions call the Go BFF API server instead of querying the DB
* directly. The BFF handles sanitization, pagination, and caching.
*/
import { prisma } from "@/lib/db";
import type { JsonValue } from "@prisma/client/runtime/library";
import { bff, bffList, bffOrThrow } from "@/lib/bff";
// ---------------------------------------------------------------------------
// Types
@@ -33,6 +31,12 @@ export type VerseRecord = {
synonyms: { word: string; meanings: string[] }[];
translation: string;
purport: string[];
/** Alias for bookTitle — used by ISO/BS/NOI pages */
title?: string;
/** Alias for verse number — used by NOI pages as "text number" */
text?: number;
/** Alias for verse/chapter number — used by ISO pages as "mantra number" */
mantra?: number;
};
export type VerseIndexEntry = {
@@ -73,28 +77,133 @@ export type ProseChapterRecord = {
paragraphs: string[];
};
// ---------------------------------------------------------------------------
// HTML sanitisation
// ---------------------------------------------------------------------------
function sanitizeHtml(html: string): string {
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
clean = clean.replace(/<iframe[\s>][\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, "");
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
return clean;
}
function sanitizeParagraphs(paragraphs: string[] | undefined): string[] {
if (!paragraphs || !Array.isArray(paragraphs)) return [];
return paragraphs.map(sanitizeHtml);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Build a BFF path for verse-related endpoints.
* Books like BG and SB1962 have no division (null), so we skip it.
* Books like SB and CC have a division (canto / līlā).
*/
function versePath(
book: string,
division: string | null,
chapter: number,
): string {
if (division) return `/books/${book}/${division}/${chapter}`;
return `/books/${book}/${chapter}`;
}
/**
* Map a BFF verse response (snake_case) to the frontend VerseRecord (camelCase).
*/
function mapVerse(v: Record<string, unknown>): VerseRecord {
const bookTitle = String(v.book_title ?? v.bookTitle ?? "");
const chapter = Number(v.chapter ?? 0);
const verse = Number(v.verse ?? 0);
return {
id: String(v.id ?? ""),
book: String(v.book ?? ""),
division: v.division ? String(v.division) : null,
chapter,
verse,
verseEnd:
v.verse_end != null
? Number(v.verse_end)
: v.verseEnd != null
? Number(v.verseEnd)
: null,
reference: String(v.reference ?? ""),
bookTitle,
devanagari: String(v.devanagari ?? ""),
bengali: v.bengali ? String(v.bengali) : null,
transliteration: String(v.transliteration ?? ""),
synonyms: (v.synonyms as { word: string; meanings: string[] }[]) ?? [],
translation: String(v.translation ?? ""),
purport: (v.purport as string[]) ?? [],
// Aliases for page backwards compatibility
title: bookTitle,
text: verse,
mantra: chapter,
};
}
/**
* Map a BFF verse-index entry (snake_case) to VerseIndexEntry (camelCase).
*/
function mapVerseIndex(v: Record<string, unknown>): VerseIndexEntry {
return {
verse: Number(v.verse ?? 0),
verseEnd: v.verse_end ? Number(v.verse_end) : null,
reference: String(v.reference ?? ""),
};
}
// ---------------------------------------------------------------------------
// Book meta
// ---------------------------------------------------------------------------
/**
* Get all book metas — each has { book, ...data } where data is the intro.json blob.
*
* The BFF returns { id, book, data: { title, author, chapters, ... } }.
* We flatten it to { book, title, author, chapters, ... } for the frontend.
*/
export async function getAllBooks(): Promise<BookMeta[]> {
const metas = await prisma.bookMeta.findMany();
return metas.map((m) => ({
book: m.book,
...(m.data as Record<string, unknown>),
}));
const raw = await bffList<{ book: string; data: Record<string, unknown> }>(
"/books",
{ tags: ["books"] },
);
return raw.map(flattenBookMeta);
}
/**
* Get a single book's meta by slug ("bg", "sb", "cc").
*/
export async function getBookMeta(book: string): Promise<BookMeta | null> {
const meta = await prisma.bookMeta.findUnique({ where: { book } });
if (!meta) return null;
return { book: meta.book, ...(meta.data as Record<string, unknown>) };
const raw = await bff<{ book: string; data: Record<string, unknown> }>(
`/books/${book}`,
{ tags: [`book-${book}`] },
);
if (!raw) return null;
return flattenBookMeta(raw);
}
/**
* Flatten a BFF book meta response.
* BFF returns: { id, book, data: { title, author, chapters, ... } }
* Frontend expects: { book, title, author, chapters, ... }
*/
function flattenBookMeta(raw: {
book: string;
data: Record<string, unknown>;
}): BookMeta {
const data = raw.data && typeof raw.data === "object" ? raw.data : {};
return { book: raw.book, ...data } as BookMeta;
}
// ---------------------------------------------------------------------------
@@ -115,17 +224,14 @@ export async function getChapterVerseIndex(
division: string | null,
chapter: number,
): Promise<VerseIndexEntry[]> {
const entries = await prisma.verse.findMany({
where: { book, division, chapter },
orderBy: { verse: "asc" },
select: {
verse: true,
verseEnd: true,
reference: true,
},
const path = versePath(book, division, chapter);
const res = await bff<{
verses?: Record<string, unknown>[];
}>(path, {
tags: [`verses-${book}`],
});
return entries;
if (!res || !res.verses) return [];
return res.verses.map(mapVerseIndex);
}
// ---------------------------------------------------------------------------
@@ -147,27 +253,41 @@ export async function getChapterVerses(
page: number = 1,
limit: number = DEFAULT_PAGE_SIZE,
): Promise<PaginatedVerses> {
const skip = (page - 1) * limit;
const path = versePath(book, division, chapter);
const where = { book, division, chapter };
const raw = await bff<{
verses: Record<string, unknown>[];
total: number;
page: number;
limit: number;
totalPages: number;
hasMore: boolean;
}>(`${path}/verses`, {
params: {
page: String(page),
limit: String(limit),
},
tags: [`verses-${book}`],
});
const [verses, total] = await Promise.all([
prisma.verse.findMany({
where,
orderBy: { verse: "asc" },
skip,
take: limit,
}),
prisma.verse.count({ where }),
]);
if (!raw) {
return {
verses: [],
total: 0,
page,
limit,
totalPages: 0,
hasMore: false,
};
}
return {
verses: verses as unknown as VerseRecord[],
total,
page,
limit,
totalPages: Math.ceil(total / limit),
hasMore: skip + verses.length < total,
verses: (raw.verses ?? []).map(mapVerse),
total: raw.total,
page: raw.page,
limit: raw.limit,
totalPages: raw.totalPages,
hasMore: raw.hasMore,
};
}
@@ -184,11 +304,11 @@ export async function getSingleVerse(
chapter: number,
verse: number,
): Promise<VerseRecord | null> {
const result = await prisma.verse.findFirst({
where: { book, division, chapter, verse },
const path = versePath(book, division, chapter);
const raw = await bff<Record<string, unknown>>(`${path}/${verse}`, {
tags: [`verses-${book}`],
});
return result as unknown as VerseRecord | null;
return raw ? mapVerse(raw) : null;
}
// ---------------------------------------------------------------------------
@@ -199,109 +319,76 @@ export async function getSingleVerse(
* Get all prose chapters/segments for a given book and volume,
* optionally filtered by type ("chapter" | "intro-segment").
* Ordered by the `order` field.
*
* For KB (which has real volumes), uses /api/v1/books/kb/{volume}.
* For other books (volume=0 sentinel), uses /api/v1/books/{code}/chapters.
*/
export async function getProseChapters(
book: string,
volume: number,
type?: string,
): Promise<ProseChapterRecord[]> {
const where: Record<string, unknown> = { book, volume };
if (type) where.type = type;
// KB has real volumes (1, 2, 3, …); other prose books use volume=0 as sentinel
const path =
book === "kb" && volume > 0
? `/books/kb/${volume}`
: `/books/${book}/chapters`;
const results = await prisma.proseChapter.findMany({
where,
orderBy: { order: "asc" },
const params: Record<string, string> = {};
if (type) params.type = type;
const chapters = await bffList<ProseChapterRecord>(path, {
params: Object.keys(params).length > 0 ? params : undefined,
tags: [`book-${book}`],
});
return results.map(normalizeProse);
return chapters.map((ch) => ({
...ch,
paragraphs: sanitizeParagraphs(ch.paragraphs),
id: ch.id ?? "",
book: ch.book ?? book,
volume: ch.volume ?? volume,
chapter: ch.chapter ?? null,
title: ch.title ?? "",
type: ch.type ?? "chapter",
slug: ch.slug ?? "",
order: ch.order ?? 0,
}));
}
/**
* Get a single prose chapter by book + volume + slug.
*
* For KB, uses /api/v1/books/kb/{volume}/{slug}.
* For other books, uses /api/v1/books/{code}/chapters/{slug}.
*/
export async function getProseChapter(
book: string,
volume: number,
slug: string,
): Promise<ProseChapterRecord | null> {
const result = await prisma.proseChapter.findUnique({
where: {
book_volume_slug: { book, volume, slug },
},
const path =
book === "kb" && volume > 0
? `/books/kb/${volume}/${slug}`
: `/books/${book}/chapters/${slug}`;
const chapter = await bff<ProseChapterRecord>(path, {
tags: [`book-${book}`],
});
return result ? normalizeProse(result) : null;
}
/**
* Normalize a prose chapter from the DB.
* Some chapters were stored with paragraphs as a JSON string (fallback
* for BSON wire-protocol corruption). This normalizes them back to arrays.
*/
function normalizeProse(doc: {
id: string;
book: string;
volume: number;
chapter: number | null;
title: string;
type: string;
slug: string;
order: number;
paragraphs: string[] | JsonValue;
}): ProseChapterRecord {
let paragraphs: string[];
if (typeof doc.paragraphs === "string") {
// Was stored as stringified JSON — parse it back
try {
paragraphs = JSON.parse(doc.paragraphs);
} catch {
paragraphs = [doc.paragraphs];
}
} else if (Array.isArray(doc.paragraphs)) {
paragraphs = doc.paragraphs as string[];
} else {
paragraphs = [];
}
// Sanitize HTML — strip dangerous tags and event handlers
paragraphs = paragraphs.map(sanitizeHtml);
if (!chapter) return null;
return {
id: doc.id,
book: doc.book,
volume: doc.volume,
chapter: doc.chapter,
title: doc.title,
type: doc.type,
slug: doc.slug,
order: doc.order,
paragraphs,
...chapter,
paragraphs: sanitizeParagraphs(chapter.paragraphs),
id: chapter.id ?? "",
book: chapter.book ?? book,
volume: chapter.volume ?? volume,
chapter: chapter.chapter ?? null,
title: chapter.title ?? "",
type: chapter.type ?? "chapter",
slug: chapter.slug ?? slug,
order: chapter.order ?? 0,
};
}
/**
* Sanitize an HTML string by stripping dangerous tags and attributes.
* Keeps safe formatting tags: <i>, <b>, <em>, <strong>, <br>, <div>, <span>.
*/
function sanitizeHtml(html: string): string {
// Remove <script>...</script>, <style>...</style>, <iframe>...</iframe>
let clean = html.replace(/<script[\s>][\s\S]*?<\/script>/gi, "");
clean = clean.replace(/<style[\s>][\s\S]*?<\/style>/gi, "");
clean = clean.replace(/<iframe[\s>][\s\S]*?<\/iframe>/gi, "");
// Remove lone <script>, <style>, <iframe> opening/closing tags
clean = clean.replace(
/<\/?(?:script|style|iframe|object|embed|form|input|textarea|select|button)[^>]*>/gi,
"",
);
// Remove event handler attributes (onclick, onload, onerror, etc.)
clean = clean.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
// Remove javascript: URLs
clean = clean.replace(/href\s*=\s*"javascript:[^"]*"/gi, 'href=""');
clean = clean.replace(/href\s*=\s*'javascript:[^']*'/gi, "href=''");
return clean;
}
// ---------------------------------------------------------------------------
// Glossary
// ---------------------------------------------------------------------------
@@ -310,9 +397,5 @@ function sanitizeHtml(html: string): string {
* Get all glossary entries, ordered alphabetically by term.
*/
export async function getAllGlossaryEntries(): Promise<GlossaryRecord[]> {
const entries = await prisma.glossaryEntry.findMany({
orderBy: { term: "asc" },
});
return entries as unknown as GlossaryRecord[];
return bffList<GlossaryRecord>("/glossary", { tags: ["glossary"] });
}