feat: implement bff layer
This commit is contained in:
+142
-134
@@ -1,16 +1,22 @@
|
||||
/**
|
||||
* Data access layer for Transcriptions.
|
||||
*
|
||||
* Uses Prisma to query the `transcriptions` collection in MongoDB.
|
||||
* Replaces the previous local-JSON-file approach so that data is always
|
||||
* served from the database (seeded via seed-transcriptions.ts).
|
||||
* Calls the Go BFF API instead of Prisma directly.
|
||||
* All responses are unwrapped from the BFF envelope by lib/bff.ts.
|
||||
*
|
||||
* Some documents store their content as a JSON string in `contentJson`
|
||||
* (a BSON wire-protocol workaround for very large nested arrays).
|
||||
* The `getTranscription()` helper transparently parses that fallback.
|
||||
* 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 { prisma } from "@/lib/db";
|
||||
import { bff, bffList } from "@/lib/bff";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -54,25 +60,37 @@ export type PlaceGroup = {
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Prisma select objects (keep queries lightweight)
|
||||
// BFF response types (snake_case from Go)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Fields needed for summary views (no content). */
|
||||
const SUMMARY_SELECT = {
|
||||
slug: true,
|
||||
name: true,
|
||||
type: true,
|
||||
date: true,
|
||||
year: true,
|
||||
month: true,
|
||||
day: true,
|
||||
place: true,
|
||||
context: true,
|
||||
reference: true,
|
||||
durationMinutes: true,
|
||||
participants: true,
|
||||
audioUrl: true,
|
||||
} as const;
|
||||
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
|
||||
@@ -90,49 +108,88 @@ function sanitizeHtml(html: string): string {
|
||||
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<YearGroup[]> {
|
||||
const transcriptions = await prisma.transcription.findMany({
|
||||
where: { year: { not: null }, isPublished: true },
|
||||
select: { year: true },
|
||||
});
|
||||
|
||||
const counts: Record<number, number> = {};
|
||||
for (const t of transcriptions) {
|
||||
if (t.year) {
|
||||
counts[t.year] = (counts[t.year] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.entries(counts)
|
||||
.map(([year, count]) => ({ year: Number(year), count }))
|
||||
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<PlaceGroup[]> {
|
||||
const transcriptions = await prisma.transcription.findMany({
|
||||
where: { place: { not: null }, isPublished: true },
|
||||
select: { place: true },
|
||||
});
|
||||
|
||||
const counts: Record<string, number> = {};
|
||||
for (const t of transcriptions) {
|
||||
if (t.place) {
|
||||
counts[t.place] = (counts[t.place] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.entries(counts)
|
||||
.map(([name, count]) => ({ name, count }))
|
||||
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 getTotalTranscriptionCount(): Promise<number> {
|
||||
return prisma.transcription.count({ where: { isPublished: true } });
|
||||
const res = await bff<{ count: number }>("/transcriptions", {
|
||||
tags: ["transcriptions"],
|
||||
});
|
||||
return res?.count ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -143,21 +200,18 @@ export async function getTranscriptionIndex(): Promise<{
|
||||
totalTranscriptions: number;
|
||||
typeDistribution: Record<string, number>;
|
||||
}> {
|
||||
const [total, lectures, conversations] = await Promise.all([
|
||||
prisma.transcription.count({ where: { isPublished: true } }),
|
||||
prisma.transcription.count({
|
||||
where: { isPublished: true, type: "lecture" },
|
||||
}),
|
||||
prisma.transcription.count({
|
||||
where: { isPublished: true, type: "conversation" },
|
||||
const [countRes, typesRes] = await Promise.all([
|
||||
bff<{ count: number }>("/transcriptions", { tags: ["transcriptions"] }),
|
||||
bff<{ lectures: number; conversations: number }>("/transcriptions/types", {
|
||||
tags: ["transcriptions"],
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
totalTranscriptions: total,
|
||||
totalTranscriptions: countRes?.count ?? 0,
|
||||
typeDistribution: {
|
||||
lecture: lectures,
|
||||
conversation: conversations,
|
||||
lecture: typesRes?.lectures ?? 0,
|
||||
conversation: typesRes?.conversations ?? 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -169,34 +223,29 @@ export async function getTranscriptionIndex(): Promise<{
|
||||
export async function getTranscriptionsByYear(
|
||||
year: number,
|
||||
): Promise<TranscriptionSummary[]> {
|
||||
const transcriptions = await prisma.transcription.findMany({
|
||||
where: { year, isPublished: true },
|
||||
orderBy: [{ month: "asc" }, { day: "asc" }],
|
||||
select: SUMMARY_SELECT,
|
||||
const raw = await bffList<BffSummary>(`/transcriptions/year/${year}`, {
|
||||
tags: ["transcriptions"],
|
||||
});
|
||||
return transcriptions;
|
||||
return raw.map(mapSummary);
|
||||
}
|
||||
|
||||
export async function getTranscriptionsByPlace(
|
||||
place: string,
|
||||
): Promise<TranscriptionSummary[]> {
|
||||
const transcriptions = await prisma.transcription.findMany({
|
||||
where: { place, isPublished: true },
|
||||
orderBy: [{ year: "asc" }, { month: "asc" }, { day: "asc" }],
|
||||
select: SUMMARY_SELECT,
|
||||
const encoded = encodeURIComponent(place);
|
||||
const raw = await bffList<BffSummary>(`/transcriptions/place/${encoded}`, {
|
||||
tags: ["transcriptions"],
|
||||
});
|
||||
return transcriptions;
|
||||
return raw.map(mapSummary);
|
||||
}
|
||||
|
||||
export async function getTranscriptionsByType(
|
||||
type: string,
|
||||
): Promise<TranscriptionSummary[]> {
|
||||
const transcriptions = await prisma.transcription.findMany({
|
||||
where: { type, isPublished: true },
|
||||
orderBy: [{ year: "asc" }, { month: "asc" }, { day: "asc" }],
|
||||
select: SUMMARY_SELECT,
|
||||
const raw = await bffList<BffSummary>(`/transcriptions/type/${type}`, {
|
||||
tags: ["transcriptions"],
|
||||
});
|
||||
return transcriptions;
|
||||
return raw.map(mapSummary);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -206,54 +255,11 @@ export async function getTranscriptionsByType(
|
||||
export async function getTranscription(
|
||||
slug: string,
|
||||
): Promise<TranscriptionFull | null> {
|
||||
const t = await prisma.transcription.findUnique({ where: { slug } });
|
||||
if (!t || !t.isPublished) return null;
|
||||
|
||||
// Content may be stored as embedded array or as a JSON string fallback
|
||||
let content: ContentBlock[] = [];
|
||||
|
||||
if (t.content && t.content.length > 0) {
|
||||
content = t.content.map((block) => ({
|
||||
text: sanitizeHtml(block.text),
|
||||
format: block.format,
|
||||
alignment: block.alignment,
|
||||
speaker: block.speaker ?? null,
|
||||
}));
|
||||
} else {
|
||||
// Check for contentJson fallback (BSON workaround for large documents)
|
||||
const raw = t as unknown as Record<string, unknown>;
|
||||
const contentJson = raw.contentJson;
|
||||
if (typeof contentJson === "string" && contentJson.length > 2) {
|
||||
try {
|
||||
const parsed = JSON.parse(contentJson) as ContentBlock[];
|
||||
content = parsed.map((block) => ({
|
||||
text: sanitizeHtml(block.text),
|
||||
format: block.format,
|
||||
alignment: block.alignment,
|
||||
speaker: block.speaker ?? null,
|
||||
}));
|
||||
} catch {
|
||||
// Ignore parse errors — return empty content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
slug: t.slug,
|
||||
name: t.name,
|
||||
type: t.type,
|
||||
date: t.date,
|
||||
year: t.year,
|
||||
month: t.month,
|
||||
day: t.day,
|
||||
place: t.place,
|
||||
context: t.context,
|
||||
reference: t.reference,
|
||||
durationMinutes: t.durationMinutes,
|
||||
participants: t.participants,
|
||||
audioUrl: t.audioUrl,
|
||||
content,
|
||||
};
|
||||
const raw = await bff<BffFull>(`/transcriptions/${slug}`, {
|
||||
tags: ["transcriptions"],
|
||||
});
|
||||
if (!raw) return null;
|
||||
return mapFull(raw);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -269,16 +275,18 @@ export async function getAdjacentTranscriptions(
|
||||
}> {
|
||||
if (!year) return { prev: null, next: null };
|
||||
|
||||
const yearTranscriptions = await prisma.transcription.findMany({
|
||||
where: { year, isPublished: true },
|
||||
orderBy: [{ month: "asc" }, { day: "asc" }, { slug: "asc" }],
|
||||
select: SUMMARY_SELECT,
|
||||
const res = await bff<{
|
||||
prev: BffSummary | null;
|
||||
next: BffSummary | null;
|
||||
}>(`/transcriptions/adjacent/${slug}`, {
|
||||
params: { year: String(year) },
|
||||
tags: ["transcriptions"],
|
||||
});
|
||||
|
||||
const idx = yearTranscriptions.findIndex((t) => t.slug === slug);
|
||||
if (!res) return { prev: null, next: null };
|
||||
|
||||
return {
|
||||
prev: idx > 0 ? yearTranscriptions[idx - 1] : null,
|
||||
next:
|
||||
idx < yearTranscriptions.length - 1 ? yearTranscriptions[idx + 1] : null,
|
||||
prev: res.prev ? mapSummary(res.prev) : null,
|
||||
next: res.next ? mapSummary(res.next) : null,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user