mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50:09 +00:00
cc8e106a2b
Introduce per-user configurable home page sections and a unified Audible cache/category model. Adds Prisma models (UserHomeSection, AudibleCacheCategory) and migrations to create tables and remove legacy popular/new_release flags; updates schema.prisma accordingly. Add API routes for user home sections, live Audible categories, and category-based audiobook listing, and refactor popular/new-releases/covers routes to read from AudibleCacheCategory. Frontend: new HomeSection component, HomeSectionConfigModal, useHomeSections hook, and homepage changes to render dynamic sections plus image fallback to a placeholder SVG. Also add placeholder_cover.svg and tests for home sections and the audible refresh processor.
203 lines
6.1 KiB
TypeScript
203 lines
6.1 KiB
TypeScript
/**
|
|
* Component: User Home Sections API Route
|
|
* Documentation: documentation/features/home-sections.md
|
|
*
|
|
* Per-user configurable home page sections.
|
|
* GET returns sections + next refresh time.
|
|
* PUT saves full section config (delete-and-recreate in transaction).
|
|
*/
|
|
|
|
import { NextRequest, NextResponse } from 'next/server';
|
|
import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth';
|
|
import { prisma } from '@/lib/db';
|
|
import { z } from 'zod';
|
|
import { RMABLogger } from '@/lib/utils/logger';
|
|
|
|
const logger = RMABLogger.create('API.User.HomeSections');
|
|
|
|
const MAX_SECTIONS = 10;
|
|
|
|
const VALID_SECTION_TYPES = ['popular', 'new_releases', 'category'] as const;
|
|
|
|
const SectionSchema = z.object({
|
|
sectionType: z.enum(VALID_SECTION_TYPES),
|
|
categoryId: z.string().optional().nullable(),
|
|
categoryName: z.string().optional().nullable(),
|
|
sortOrder: z.number().int().min(0),
|
|
});
|
|
|
|
const PutBodySchema = z.object({
|
|
sections: z.array(SectionSchema).max(MAX_SECTIONS),
|
|
});
|
|
|
|
/**
|
|
* Create default home sections for a new user (Popular + New Releases).
|
|
*/
|
|
async function ensureDefaultSections(userId: string) {
|
|
const existing = await prisma.userHomeSection.findMany({
|
|
where: { userId },
|
|
select: { id: true },
|
|
take: 1,
|
|
});
|
|
|
|
if (existing.length > 0) return;
|
|
|
|
await prisma.userHomeSection.createMany({
|
|
data: [
|
|
{ userId, sectionType: 'popular', sortOrder: 0 },
|
|
{ userId, sectionType: 'new_releases', sortOrder: 1 },
|
|
],
|
|
});
|
|
}
|
|
|
|
/**
|
|
* GET /api/user/home-sections
|
|
* Returns the user's configured home sections + next scheduled refresh time.
|
|
*/
|
|
export async function GET(request: NextRequest) {
|
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
|
try {
|
|
if (!req.user) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
}
|
|
|
|
await ensureDefaultSections(req.user.id);
|
|
|
|
const sections = await prisma.userHomeSection.findMany({
|
|
where: { userId: req.user.id },
|
|
orderBy: { sortOrder: 'asc' },
|
|
});
|
|
|
|
// Get next refresh time from scheduled jobs
|
|
let nextRefresh: string | null = null;
|
|
try {
|
|
const scheduledJob = await prisma.scheduledJob.findFirst({
|
|
where: { type: 'audible_refresh', enabled: true },
|
|
select: { nextRun: true },
|
|
});
|
|
nextRefresh = scheduledJob?.nextRun?.toISOString() || null;
|
|
} catch {
|
|
// Non-critical — just omit nextRefresh
|
|
}
|
|
|
|
return NextResponse.json({
|
|
success: true,
|
|
sections: sections.map((s) => ({
|
|
id: s.id,
|
|
sectionType: s.sectionType,
|
|
categoryId: s.categoryId,
|
|
categoryName: s.categoryName,
|
|
sortOrder: s.sortOrder,
|
|
})),
|
|
nextRefresh,
|
|
});
|
|
} catch (error) {
|
|
logger.error('Failed to get home sections', {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
return NextResponse.json(
|
|
{ error: 'FetchError', message: 'Failed to fetch home sections' },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* PUT /api/user/home-sections
|
|
* Replaces all home sections for the user (delete-and-recreate in transaction).
|
|
* Validates: max 10 sections, no duplicate sections, category sections need categoryId.
|
|
*/
|
|
export async function PUT(request: NextRequest) {
|
|
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
|
try {
|
|
if (!req.user) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
}
|
|
|
|
const body = await req.json();
|
|
const { sections } = PutBodySchema.parse(body);
|
|
|
|
// Validate category sections have categoryId
|
|
for (const section of sections) {
|
|
if (section.sectionType === 'category' && !section.categoryId) {
|
|
return NextResponse.json(
|
|
{ error: 'ValidationError', message: 'Category sections require a categoryId' },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
}
|
|
|
|
// Check for duplicate section types (only one popular, one new_releases, unique categories)
|
|
const seen = new Set<string>();
|
|
for (const section of sections) {
|
|
const key =
|
|
section.sectionType === 'category'
|
|
? `category:${section.categoryId}`
|
|
: section.sectionType;
|
|
if (seen.has(key)) {
|
|
return NextResponse.json(
|
|
{ error: 'ValidationError', message: `Duplicate section: ${key}` },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
seen.add(key);
|
|
}
|
|
|
|
const userId = req.user.id;
|
|
|
|
// Delete-and-recreate in a transaction
|
|
await prisma.$transaction(async (tx) => {
|
|
await tx.userHomeSection.deleteMany({ where: { userId } });
|
|
|
|
if (sections.length > 0) {
|
|
await tx.userHomeSection.createMany({
|
|
data: sections.map((s, idx) => ({
|
|
userId,
|
|
sectionType: s.sectionType,
|
|
categoryId: s.sectionType === 'category' ? s.categoryId : null,
|
|
categoryName: s.sectionType === 'category' ? s.categoryName : null,
|
|
sortOrder: idx,
|
|
})),
|
|
});
|
|
}
|
|
});
|
|
|
|
// Return the saved sections
|
|
const saved = await prisma.userHomeSection.findMany({
|
|
where: { userId },
|
|
orderBy: { sortOrder: 'asc' },
|
|
});
|
|
|
|
logger.info(`User ${userId} updated home sections (${saved.length} sections)`);
|
|
|
|
return NextResponse.json({
|
|
success: true,
|
|
sections: saved.map((s) => ({
|
|
id: s.id,
|
|
sectionType: s.sectionType,
|
|
categoryId: s.categoryId,
|
|
categoryName: s.categoryName,
|
|
sortOrder: s.sortOrder,
|
|
})),
|
|
});
|
|
} catch (error) {
|
|
logger.error('Failed to save home sections', {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
|
|
if (error instanceof z.ZodError) {
|
|
return NextResponse.json(
|
|
{ error: 'ValidationError', details: error.errors },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
return NextResponse.json(
|
|
{ error: 'SaveError', message: 'Failed to save home sections' },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
});
|
|
}
|