import { Router } from 'express'; import prisma from '../database.js'; import PDFDocument from 'pdfkit'; import { join } from 'path'; import { generateTodaysSummary } from '../services/mantisSummarizer.js'; import { FieldType } from '@prisma/client'; const router = Router(); const __dirname = new URL('.', import.meta.url).pathname.replace(/\/$/, ''); // Helper function for consistent error handling const handlePrismaError = (res, err, context) => { console.error(`Error ${context}:`, err.message); // Basic error handling, can be expanded (e.g., check for Prisma-specific error codes) if (err.code === 'P2025') { // Prisma code for record not found return res.status(404).json({ error: `${context}: Record not found` }); } res.status(500).json({ error: `Failed to ${context}: ${err.message}` }); }; // --- Forms API --- // // GET /api/forms - List all forms router.get('/forms', async(req, res) => { try { const forms = await prisma.form.findMany({ orderBy: { createdAt: 'desc', }, select: { // Select only necessary fields id: true, title: true, description: true, createdAt: true, } }); res.json(forms); } catch (err) { handlePrismaError(res, err, 'fetch forms'); } }); // POST /api/forms - Create a new form router.post('/forms', async(req, res) => { const { title, description, categories } = req.body; if (!title) { return res.status(400).json({ error: 'Form title is required' }); } try { const newForm = await prisma.form.create({ data: { title, description, categories: { create: categories?.map((category, catIndex) => ({ name: category.name, sortOrder: catIndex, fields: { create: category.fields?.map((field, fieldIndex) => { // Validate field type against Prisma Enum if (!Object.values(FieldType).includes(field.type)) { throw new Error(`Invalid field type: ${field.type}`); } if (!field.label) { throw new Error('Field label is required'); } return { label: field.label, type: field.type, description: field.description || null, sortOrder: fieldIndex, }; }) || [], }, })) || [], }, }, select: { // Return basic form info id: true, title: true, description: true, } }); res.status(201).json(newForm); } catch (err) { handlePrismaError(res, err, 'create form'); } }); // GET /api/forms/:id - Get a specific form with its structure router.get('/forms/:id', async(req, res) => { const { id } = req.params; const formId = parseInt(id, 10); if (isNaN(formId)) { return res.status(400).json({ error: 'Invalid form ID' }); } try { const form = await prisma.form.findUnique({ where: { id: formId }, include: { categories: { orderBy: { sortOrder: 'asc' }, include: { fields: { orderBy: { sortOrder: 'asc' }, }, }, }, }, }); if (!form) { return res.status(404).json({ error: 'Form not found' }); } res.json(form); } catch (err) { handlePrismaError(res, err, `fetch form ${formId}`); } }); // DELETE /api/forms/:id - Delete a specific form and all related data router.delete('/forms/:id', async(req, res) => { const { id } = req.params; const formId = parseInt(id, 10); if (isNaN(formId)) { return res.status(400).json({ error: 'Invalid form ID' }); } try { // Prisma automatically handles cascading deletes based on schema relations (onDelete: Cascade) const deletedForm = await prisma.form.delete({ where: { id: formId }, }); res.status(200).json({ message: `Form ${formId} and all related data deleted successfully.` }); } catch (err) { handlePrismaError(res, err, `delete form ${formId}`); } }); // PUT /api/forms/:id - Update an existing form router.put('/forms/:id', async(req, res) => { const { id } = req.params; const formId = parseInt(id, 10); const { title, description, categories } = req.body; if (isNaN(formId)) { return res.status(400).json({ error: 'Invalid form ID' }); } if (!title) { return res.status(400).json({ error: 'Form title is required' }); } try { // Use a transaction to ensure atomicity: delete old structure, update form, create new structure const result = await prisma.$transaction(async(tx) => { // 1. Check if form exists (optional, delete/update will fail if not found anyway) const existingForm = await tx.form.findUnique({ where: { id: formId } }); if (!existingForm) { throw { code: 'P2025' }; // Simulate Prisma not found error } // 2. Delete existing categories (fields and response values cascade) await tx.category.deleteMany({ where: { formId: formId } }); // 3. Update form details and recreate categories/fields in one go const updatedForm = await tx.form.update({ where: { id: formId }, data: { title, description, categories: { create: categories?.map((category, catIndex) => ({ name: category.name, sortOrder: catIndex, fields: { create: category.fields?.map((field, fieldIndex) => { if (!Object.values(FieldType).includes(field.type)) { throw new Error(`Invalid field type: ${field.type}`); } if (!field.label) { throw new Error('Field label is required'); } return { label: field.label, type: field.type, description: field.description || null, sortOrder: fieldIndex, }; }) || [], }, })) || [], }, }, select: { // Return basic form info id: true, title: true, description: true, } }); return updatedForm; }); res.status(200).json(result); } catch (err) { handlePrismaError(res, err, `update form ${formId}`); } }); // --- Responses API --- // // POST /api/forms/:id/responses - Submit a response for a form router.post('/forms/:id/responses', async(req, res) => { const { id } = req.params; const formId = parseInt(id, 10); const { values } = req.body; // values is expected to be { fieldId: value, ... } if (isNaN(formId)) { return res.status(400).json({ error: 'Invalid form ID' }); } if (!values || typeof values !== 'object' || Object.keys(values).length === 0) { return res.status(400).json({ error: 'Response values are required' }); } try { // Use transaction to ensure response and values are created together const result = await prisma.$transaction(async(tx) => { // 1. Verify form exists const form = await tx.form.findUnique({ where: { id: formId }, select: { id: true } }); if (!form) { throw { code: 'P2025' }; // Simulate Prisma not found error } // 2. Create the response record const newResponse = await tx.response.create({ data: { formId: formId, }, select: { id: true } }); // 3. Prepare response values data const responseValuesData = []; const fieldIds = Object.keys(values).map(k => parseInt(k, 10)); // Optional: Verify all field IDs belong to the form (more robust) const validFields = await tx.field.findMany({ where: { id: { 'in': fieldIds }, category: { formId: formId } }, select: { id: true } }); const validFieldIds = new Set(validFields.map(f => f.id)); for (const fieldIdStr in values) { const fieldId = parseInt(fieldIdStr, 10); if (validFieldIds.has(fieldId)) { const value = values[fieldIdStr]; responseValuesData.push({ responseId: newResponse.id, fieldId: fieldId, value: (value === null || typeof value === 'undefined') ? null : String(value), }); } else { console.warn(`Attempted to submit value for field ${fieldId} not belonging to form ${formId}`); // Decide whether to throw an error or just skip invalid fields // throw new Error(`Field ${fieldId} does not belong to form ${formId}`); } } // 4. Create all response values if (responseValuesData.length > 0) { await tx.responseValue.createMany({ data: responseValuesData, }); } return { responseId: newResponse.id }; }); res.status(201).json(result); } catch (err) { handlePrismaError(res, err, `submit response for form ${formId}`); } }); // GET /api/forms/:id/responses - Get all responses for a form router.get('/forms/:id/responses', async(req, res) => { const { id } = req.params; const formId = parseInt(id, 10); if (isNaN(formId)) { return res.status(400).json({ error: 'Invalid form ID' }); } try { // 1. Check if form exists const formExists = await prisma.form.findUnique({ where: { id: formId }, select: { id: true } }); if (!formExists) { return res.status(404).json({ error: 'Form not found' }); } // 2. Fetch responses with their values and related field info const responses = await prisma.response.findMany({ where: { formId: formId }, orderBy: { submittedAt: 'desc' }, include: { responseValues: { include: { field: { select: { label: true, type: true, category: { select: { sortOrder: true } }, sortOrder: true } // Include sort orders } } } } }); // 3. Group data similar to the old structure for frontend compatibility const groupedResponses = responses.map(response => ({ id: response.id, submittedAt: response.submittedAt, values: response.responseValues .sort((a, b) => { // Sort by category order, then field order const catSort = a.field.category.sortOrder - b.field.category.sortOrder; if (catSort !== 0) return catSort; return a.field.sortOrder - b.field.sortOrder; }) .reduce((acc, rv) => { acc[rv.fieldId] = { label: rv.field.label, type: rv.field.type, value: rv.value }; return acc; }, {}) })); res.json(groupedResponses); } catch (err) { handlePrismaError(res, err, `fetch responses for form ${formId}`); } }); // GET /responses/:responseId/export/pdf - Export response as PDF router.get('/responses/:responseId/export/pdf', async(req, res) => { const { responseId: responseIdStr } = req.params; const responseId = parseInt(responseIdStr, 10); if (isNaN(responseId)) { return res.status(400).json({ error: 'Invalid response ID' }); } try { // 1. Fetch the response, form title, form structure, and values in one go const responseData = await prisma.response.findUnique({ where: { id: responseId }, include: { form: { select: { title: true, categories: { orderBy: { sortOrder: 'asc' }, include: { fields: { orderBy: { sortOrder: 'asc' }, select: { id: true, label: true, type: true, description: true } } } } } }, responseValues: { select: { fieldId: true, value: true } } } }); if (!responseData) { return res.status(404).json({ error: 'Response not found' }); } const formTitle = responseData.form.title; const categories = responseData.form.categories; const responseValues = responseData.responseValues.reduce((acc, rv) => { acc[rv.fieldId] = (rv.value === null || typeof rv.value === 'undefined') ? '' : String(rv.value); return acc; }, {}); // 4. Generate PDF using pdfkit (logic remains largely the same) const doc = new PDFDocument({ margin: 50, size: 'A4' }); const fontsDir = join(__dirname, '../../public/fonts'); doc.registerFont('Roboto-Bold', join(fontsDir, 'Roboto-Bold.ttf')); doc.registerFont('Roboto-SemiBold', join(fontsDir, 'Roboto-SemiBold.ttf')); doc.registerFont('Roboto-Italics', join(fontsDir, 'Roboto-Italic.ttf')); doc.registerFont('Roboto-Regular', join(fontsDir, 'Roboto-Regular.ttf')); res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Disposition', `inline; filename=response_${responseId}_${formTitle.replace(/[\s\\/]/g, '_') || 'form'}.pdf`); doc.pipe(res); // --- PDF Content (remains the same as before) --- doc.fontSize(18).font('Roboto-Bold').text(formTitle, { align: 'center' }); doc.moveDown(); for (const category of categories) { if (category.name) { doc.fontSize(14).font('Roboto-Bold').text(category.name); doc.moveDown(0.5); } for (const field of category.fields) { const value = responseValues[field.id] || ''; doc.fontSize(12).font('Roboto-SemiBold').text(field.label + ':', { continued: false }); if (field.description) { doc.fontSize(9).font('Roboto-Italics').text(field.description); } doc.moveDown(0.2); doc.fontSize(11).font('Roboto-Regular'); if (field.type === 'textarea') { const textHeight = doc.heightOfString(value, { width: 500 }); doc.rect(doc.x, doc.y, 500, Math.max(textHeight + 10, 30)).stroke(); doc.text(value, doc.x + 5, doc.y + 5, { width: 490 }); doc.y += Math.max(textHeight + 10, 30) + 10; } else if (field.type === 'date') { let formattedDate = ''; if (value) { try { const dateObj = new Date(value + 'T00:00:00'); if (!isNaN(dateObj.getTime())) { const day = String(dateObj.getDate()).padStart(2, '0'); const month = String(dateObj.getMonth() + 1).padStart(2, '0'); const year = dateObj.getFullYear(); formattedDate = `${day}/${month}/${year}`; } else { formattedDate = value; } } catch (e) { console.error('Error formatting date:', value, e); formattedDate = value; } } doc.text(formattedDate || ' '); doc.lineCap('butt').moveTo(doc.x, doc.y).lineTo(doc.x + 500, doc.y).stroke(); doc.moveDown(1.5); } else if (field.type === 'boolean') { const displayValue = value === 'true' ? 'Yes' : (value === 'false' ? 'No' : ' '); doc.text(displayValue); doc.lineCap('butt').moveTo(doc.x, doc.y).lineTo(doc.x + 500, doc.y).stroke(); doc.moveDown(1.5); } else { doc.text(value || ' '); doc.lineCap('butt').moveTo(doc.x, doc.y).lineTo(doc.x + 500, doc.y).stroke(); doc.moveDown(1.5); } } doc.moveDown(1); } doc.end(); } catch (err) { console.error(`Error generating PDF for response ${responseId}:`, err.message); if (!res.headersSent) { // Use the helper function handlePrismaError(res, err, `generate PDF for response ${responseId}`); } else { console.error('Headers already sent, could not send JSON error for PDF generation failure.'); res.end(); } } }); // --- Mantis Summary API Route --- // // GET /api/mantis-summary/today - Get today's summary specifically router.get('/mantis-summary/today', async(req, res) => { try { const today = new Date(); today.setHours(0, 0, 0, 0); // Set to start of day UTC for comparison const todaySummary = await prisma.mantisSummary.findUnique({ where: { summaryDate: today }, select: { summaryDate: true, summaryText: true, generatedAt: true } }); if (todaySummary) { res.json(todaySummary); } else { res.status(404).json({ message: `No Mantis summary found for today (${today.toISOString().split('T')[0]}).` }); } } catch (error) { handlePrismaError(res, error, 'fetch today\'s Mantis summary'); } }); // GET /api/mantis-summaries - Get ALL summaries from the DB, with pagination router.get('/mantis-summaries', async(req, res) => { const page = parseInt(req.query.page, 10) || 1; const limit = parseInt(req.query.limit, 10) || 10; const skip = (page - 1) * limit; try { const [summaries, totalItems] = await prisma.$transaction([ prisma.mantisSummary.findMany({ orderBy: { summaryDate: 'desc' }, take: limit, skip: skip, select: { id: true, summaryDate: true, summaryText: true, generatedAt: true } }), prisma.mantisSummary.count() ]); res.json({ summaries, total: totalItems }); } catch (error) { handlePrismaError(res, error, 'fetch paginated Mantis summaries'); } }); // POST /api/mantis-summaries/generate - Trigger summary generation router.post('/mantis-summaries/generate', async(req, res) => { try { // Trigger generation asynchronously, don't wait for it generateTodaysSummary() .then(() => { console.log('Summary generation process finished successfully (async).'); }) .catch(error => { console.error('Background summary generation failed:', error); }); res.status(202).json({ message: 'Summary generation started.' }); } catch (error) { handlePrismaError(res, error, 'initiate Mantis summary generation'); } }); // --- Settings API --- // // GET /api/settings/:key - Get a specific setting value router.get('/settings/:key', async(req, res) => { const { key } = req.params; try { const setting = await prisma.setting.findUnique({ where: { key: key }, select: { value: true } }); if (setting !== null) { res.json({ key, value: setting.value }); } else { res.json({ key, value: '' }); // Return empty value if not found } } catch (err) { handlePrismaError(res, err, `fetch setting '${key}'`); } }); // PUT /api/settings/:key - Update or create a specific setting router.put('/settings/:key', async(req, res) => { const { key } = req.params; const { value } = req.body; if (typeof value === 'undefined') { return res.status(400).json({ error: 'Setting value is required in the request body' }); } try { const upsertedSetting = await prisma.setting.upsert({ where: { key: key }, update: { value: String(value) }, create: { key: key, value: String(value) }, select: { key: true, value: true } // Select to return the updated/created value }); res.status(200).json(upsertedSetting); } catch (err) { handlePrismaError(res, err, `update setting '${key}'`); } }); export default router;