import { type FastifyInstance } from 'fastify'; import { parseIdParam } from './helpers'; import { getAllContentItems, getContentByChannelId, getChannelContentPaginated, setMonitored, bulkSetMonitored, } from '../../db/repositories/content-repository'; import type { PaginatedResponse, ApiResponse } from '../../types/api'; import type { ContentItem, ContentStatus, ContentType } from '../../types/index'; // ── JSON Schemas for Fastify Validation ── const bulkMonitoredBodySchema = { type: 'object' as const, required: ['ids', 'monitored'], properties: { ids: { type: 'array' as const, items: { type: 'number' as const }, minItems: 1 }, monitored: { type: 'boolean' as const }, }, additionalProperties: false, }; const toggleMonitoredBodySchema = { type: 'object' as const, required: ['monitored'], properties: { monitored: { type: 'boolean' as const }, }, additionalProperties: false, }; // ── Route Plugin ── /** * Content route plugin. * * Registers: * GET /api/v1/content — paginated content listing with optional filters * PATCH /api/v1/content/bulk/monitored — bulk toggle monitored state * PATCH /api/v1/content/:id/monitored — toggle single item monitored state * GET /api/v1/channel/:id/content — content items for a specific channel */ export async function contentRoutes(fastify: FastifyInstance): Promise { // ── GET /api/v1/content ── fastify.get<{ Querystring: { page?: string; pageSize?: string; status?: string; contentType?: string; channelId?: string; search?: string; }; }>('/api/v1/content', async (request, _reply) => { const page = Math.max(1, parseInt(request.query.page ?? '1', 10) || 1); const pageSize = Math.min( 100, Math.max(1, parseInt(request.query.pageSize ?? '20', 10) || 20) ); const filters: { status?: ContentStatus; contentType?: ContentType; channelId?: number; search?: string; } = {}; if (request.query.status) { filters.status = request.query.status as ContentStatus; } if (request.query.contentType) { filters.contentType = request.query.contentType as ContentType; } if (request.query.channelId) { const channelId = parseInt(request.query.channelId, 10); if (!isNaN(channelId)) filters.channelId = channelId; } if (request.query.search) { filters.search = request.query.search; } try { const result = await getAllContentItems(fastify.db, filters, page, pageSize); const response: PaginatedResponse = { success: true, data: result.items, pagination: { page, pageSize, totalItems: result.total, totalPages: Math.ceil(result.total / pageSize), }, }; return response; } catch (err) { request.log.error( { err, filters, page, pageSize }, '[content] Failed to fetch paginated content items' ); return _reply.status(500).send({ statusCode: 500, error: 'Internal Server Error', message: 'Failed to retrieve content items', }); } }); // ── PATCH /api/v1/content/bulk/monitored ── // NOTE: Must be registered BEFORE /api/v1/content/:id/* routes // to prevent Fastify from matching "bulk" as an :id param. fastify.patch<{ Body: { ids: number[]; monitored: boolean }; }>( '/api/v1/content/bulk/monitored', { schema: { body: bulkMonitoredBodySchema } }, async (request, reply) => { try { const count = await bulkSetMonitored( fastify.db, request.body.ids, request.body.monitored ); const response: ApiResponse<{ updated: number }> = { success: true, data: { updated: count }, }; return response; } catch (err) { request.log.error( { err, ids: request.body.ids }, '[content] Failed to bulk update monitored state' ); return reply.status(500).send({ statusCode: 500, error: 'Internal Server Error', message: 'Failed to bulk update monitored state', }); } } ); // ── PATCH /api/v1/content/:id/monitored ── fastify.patch<{ Params: { id: string }; Body: { monitored: boolean }; }>( '/api/v1/content/:id/monitored', { schema: { body: toggleMonitoredBodySchema } }, async (request, reply) => { const id = parseIdParam(request.params.id, reply, 'Content item ID'); if (id === null) return; try { const result = await setMonitored( fastify.db, id, request.body.monitored ); if (!result) { return reply.status(404).send({ statusCode: 404, error: 'Not Found', message: 'Content item not found', }); } const response: ApiResponse = { success: true, data: result, }; return response; } catch (err) { request.log.error( { err, id }, '[content] Failed to update monitored state' ); return reply.status(500).send({ statusCode: 500, error: 'Internal Server Error', message: 'Failed to update monitored state', }); } } ); // ── GET /api/v1/channel/:id/content ── fastify.get<{ Params: { id: string }; Querystring: { page?: string; pageSize?: string; search?: string; status?: string; contentType?: string; sortBy?: string; sortDirection?: string; }; }>('/api/v1/channel/:id/content', async (request, reply) => { const channelId = parseIdParam(request.params.id, reply, 'Channel ID'); if (channelId === null) return; const page = Math.max(1, parseInt(request.query.page ?? '1', 10) || 1); const pageSize = Math.min( 200, Math.max(1, parseInt(request.query.pageSize ?? '50', 10) || 50) ); // If no pagination params provided, return all items (backwards-compatible) const hasPaginationParams = request.query.page || request.query.pageSize || request.query.search || request.query.status || request.query.contentType || request.query.sortBy; try { if (!hasPaginationParams) { // Legacy mode: return all items as flat array (backwards-compatible) const items = await getContentByChannelId(fastify.db, channelId); const response: ApiResponse = { success: true, data: items, }; return response; } // Paginated mode with filters const result = await getChannelContentPaginated( fastify.db, channelId, { search: request.query.search || undefined, status: (request.query.status as ContentStatus) || undefined, contentType: (request.query.contentType as ContentType) || undefined, sortBy: request.query.sortBy as 'title' | 'publishedAt' | 'status' | 'duration' | 'fileSize' | 'downloadedAt' | undefined, sortDirection: (request.query.sortDirection as 'asc' | 'desc') || undefined, }, page, pageSize ); const response: PaginatedResponse = { success: true, data: result.items, pagination: { page, pageSize, totalItems: result.total, totalPages: Math.ceil(result.total / pageSize), }, }; return response; } catch (err) { request.log.error( { err, channelId }, '[content] Failed to fetch content for channel' ); return reply.status(500).send({ statusCode: 500, error: 'Internal Server Error', message: `Failed to retrieve content for channel ${channelId}`, }); } }); }