Added linting and enforced code styling.

This commit is contained in:
Cameron Redmore 2025-04-25 08:14:48 +01:00
parent 8655eae39c
commit 86967b26cd
37 changed files with 3356 additions and 1875 deletions

View file

@ -1,20 +1,21 @@
import { Router } from 'express';
import prisma from '../database.js'; // Import Prisma client
import prisma from '../database.js';
import PDFDocument from 'pdfkit';
import { join } from 'path';
import { generateTodaysSummary } from '../services/mantisSummarizer.js'; // Keep mantisSummarizer import
import { generateAndStoreEmailSummary } from '../services/emailSummarizer.js'; // Import email summarizer function
import { FieldType } from '@prisma/client'; // Import generated FieldType enum
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) => {
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
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}` });
@ -23,8 +24,10 @@ const handlePrismaError = (res, err, context) => {
// --- Forms API --- //
// GET /api/forms - List all forms
router.get('/forms', async (req, res) => {
try {
router.get('/forms', async(req, res) =>
{
try
{
const forms = await prisma.form.findMany({
orderBy: {
createdAt: 'desc',
@ -37,20 +40,25 @@ router.get('/forms', async (req, res) => {
}
});
res.json(forms);
} catch (err) {
}
catch (err)
{
handlePrismaError(res, err, 'fetch forms');
}
});
// POST /api/forms - Create a new form
router.post('/forms', async (req, res) => {
router.post('/forms', async(req, res) =>
{
const { title, description, categories } = req.body;
if (!title) {
if (!title)
{
return res.status(400).json({ error: 'Form title is required' });
}
try {
try
{
const newForm = await prisma.form.create({
data: {
title,
@ -60,12 +68,15 @@ router.post('/forms', async (req, res) => {
name: category.name,
sortOrder: catIndex,
fields: {
create: category.fields?.map((field, fieldIndex) => {
create: category.fields?.map((field, fieldIndex) =>
{
// Validate field type against Prisma Enum
if (!Object.values(FieldType).includes(field.type)) {
if (!Object.values(FieldType).includes(field.type))
{
throw new Error(`Invalid field type: ${field.type}`);
}
if (!field.label) {
if (!field.label)
{
throw new Error('Field label is required');
}
return {
@ -86,21 +97,26 @@ router.post('/forms', async (req, res) => {
}
});
res.status(201).json(newForm);
} catch (err) {
}
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) => {
router.get('/forms/:id', async(req, res) =>
{
const { id } = req.params;
const formId = parseInt(id, 10);
if (isNaN(formId)) {
if (isNaN(formId))
{
return res.status(400).json({ error: 'Invalid form ID' });
}
try {
try
{
const form = await prisma.form.findUnique({
where: { id: formId },
include: {
@ -115,54 +131,68 @@ router.get('/forms/:id', async (req, res) => {
},
});
if (!form) {
if (!form)
{
return res.status(404).json({ error: 'Form not found' });
}
res.json(form);
} catch (err) {
}
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) => {
router.delete('/forms/:id', async(req, res) =>
{
const { id } = req.params;
const formId = parseInt(id, 10);
if (isNaN(formId)) {
if (isNaN(formId))
{
return res.status(400).json({ error: 'Invalid form ID' });
}
try {
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) {
}
catch (err)
{
handlePrismaError(res, err, `delete form ${formId}`);
}
});
// PUT /api/forms/:id - Update an existing form
router.put('/forms/:id', async (req, res) => {
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)) {
if (isNaN(formId))
{
return res.status(400).json({ error: 'Invalid form ID' });
}
if (!title) {
if (!title)
{
return res.status(400).json({ error: 'Form title is required' });
}
try {
try
{
// Use a transaction to ensure atomicity: delete old structure, update form, create new structure
const result = await prisma.$transaction(async (tx) => {
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) {
if (!existingForm)
{
throw { code: 'P2025' }; // Simulate Prisma not found error
}
@ -180,11 +210,14 @@ router.put('/forms/:id', async (req, res) => {
name: category.name,
sortOrder: catIndex,
fields: {
create: category.fields?.map((field, fieldIndex) => {
if (!Object.values(FieldType).includes(field.type)) {
create: category.fields?.map((field, fieldIndex) =>
{
if (!Object.values(FieldType).includes(field.type))
{
throw new Error(`Invalid field type: ${field.type}`);
}
if (!field.label) {
if (!field.label)
{
throw new Error('Field label is required');
}
return {
@ -208,7 +241,9 @@ router.put('/forms/:id', async (req, res) => {
});
res.status(200).json(result);
} catch (err) {
}
catch (err)
{
handlePrismaError(res, err, `update form ${formId}`);
}
});
@ -217,24 +252,30 @@ router.put('/forms/:id', async (req, res) => {
// --- Responses API --- //
// POST /api/forms/:id/responses - Submit a response for a form
router.post('/forms/:id/responses', async (req, res) => {
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)) {
if (isNaN(formId))
{
return res.status(400).json({ error: 'Invalid form ID' });
}
if (!values || typeof values !== 'object' || Object.keys(values).length === 0) {
if (!values || typeof values !== 'object' || Object.keys(values).length === 0)
{
return res.status(400).json({ error: 'Response values are required' });
}
try {
try
{
// Use transaction to ensure response and values are created together
const result = await prisma.$transaction(async (tx) => {
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) {
if (!form)
{
throw { code: 'P2025' }; // Simulate Prisma not found error
}
@ -253,23 +294,27 @@ router.post('/forms/:id/responses', async (req, res) => {
// Optional: Verify all field IDs belong to the form (more robust)
const validFields = await tx.field.findMany({
where: {
id: { in: fieldIds },
id: { 'in': fieldIds },
category: { formId: formId }
},
select: { id: true }
});
const validFieldIds = new Set(validFields.map(f => f.id));
for (const fieldIdStr in values) {
for (const fieldIdStr in values)
{
const fieldId = parseInt(fieldIdStr, 10);
if (validFieldIds.has(fieldId)) {
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 {
}
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}`);
@ -277,7 +322,8 @@ router.post('/forms/:id/responses', async (req, res) => {
}
// 4. Create all response values
if (responseValuesData.length > 0) {
if (responseValuesData.length > 0)
{
await tx.responseValue.createMany({
data: responseValuesData,
});
@ -287,24 +333,30 @@ router.post('/forms/:id/responses', async (req, res) => {
});
res.status(201).json(result);
} catch (err) {
}
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) => {
router.get('/forms/:id/responses', async(req, res) =>
{
const { id } = req.params;
const formId = parseInt(id, 10);
if (isNaN(formId)) {
if (isNaN(formId))
{
return res.status(400).json({ error: 'Invalid form ID' });
}
try {
try
{
// 1. Check if form exists
const formExists = await prisma.form.findUnique({ where: { id: formId }, select: { id: true } });
if (!formExists) {
if (!formExists)
{
return res.status(404).json({ error: 'Form not found' });
}
@ -328,13 +380,15 @@ router.get('/forms/:id/responses', async (req, res) => {
id: response.id,
submittedAt: response.submittedAt,
values: response.responseValues
.sort((a, b) => {
.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) => {
.reduce((acc, rv) =>
{
acc[rv.fieldId] = {
label: rv.field.label,
type: rv.field.type,
@ -345,22 +399,27 @@ router.get('/forms/:id/responses', async (req, res) => {
}));
res.json(groupedResponses);
} catch (err) {
}
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) => {
router.get('/responses/:responseId/export/pdf', async(req, res) =>
{
const { responseId: responseIdStr } = req.params;
const responseId = parseInt(responseIdStr, 10);
if (isNaN(responseId)) {
if (isNaN(responseId))
{
return res.status(400).json({ error: 'Invalid response ID' });
}
try {
try
{
// 1. Fetch the response, form title, form structure, and values in one go
const responseData = await prisma.response.findUnique({
where: { id: responseId },
@ -385,13 +444,15 @@ router.get('/responses/:responseId/export/pdf', async (req, res) => {
}
});
if (!responseData) {
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) => {
const responseValues = responseData.responseValues.reduce((acc, rv) =>
{
acc[rv.fieldId] = (rv.value === null || typeof rv.value === 'undefined') ? '' : String(rv.value);
return acc;
}, {});
@ -414,39 +475,53 @@ router.get('/responses/:responseId/export/pdf', async (req, res) => {
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 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) {
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);
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') {
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') {
}
else if (field.type === 'date')
{
let formattedDate = '';
if (value) {
try {
if (value)
{
try
{
const dateObj = new Date(value + 'T00:00:00');
if (!isNaN(dateObj.getTime())) {
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 {
}
else
{
formattedDate = value;
}
} catch (e) {
}
catch (e)
{
console.error('Error formatting date:', value, e);
formattedDate = value;
}
@ -454,28 +529,37 @@ router.get('/responses/:responseId/export/pdf', async (req, res) => {
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') {
}
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 {
}
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.moveDown(1);
}
doc.end();
} catch (err) {
}
catch (err)
{
console.error(`Error generating PDF for response ${responseId}:`, err.message);
if (!res.headersSent) {
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.");
}
else
{
console.error('Headers already sent, could not send JSON error for PDF generation failure.');
res.end();
}
}
@ -485,8 +569,10 @@ router.get('/responses/:responseId/export/pdf', async (req, res) => {
// --- Mantis Summary API Route --- //
// GET /api/mantis-summary/today - Get today's summary specifically
router.get('/mantis-summary/today', async (req, res) => {
try {
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
@ -495,23 +581,30 @@ router.get('/mantis-summary/today', async (req, res) => {
select: { summaryDate: true, summaryText: true, generatedAt: true }
});
if (todaySummary) {
if (todaySummary)
{
res.json(todaySummary);
} else {
}
else
{
res.status(404).json({ message: `No Mantis summary found for today (${today.toISOString().split('T')[0]}).` });
}
} catch (error) {
}
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) => {
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 {
try
{
const [summaries, totalItems] = await prisma.$transaction([
prisma.mantisSummary.findMany({
orderBy: { summaryDate: 'desc' },
@ -523,104 +616,78 @@ router.get('/mantis-summaries', async (req, res) => {
]);
res.json({ summaries, total: totalItems });
} catch (error) {
}
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 {
router.post('/mantis-summaries/generate', async(req, res) =>
{
try
{
// Trigger generation asynchronously, don't wait for it
generateTodaysSummary()
.then(() => {
.then(() =>
{
console.log('Summary generation process finished successfully (async).');
})
.catch(error => {
.catch(error =>
{
console.error('Background summary generation failed:', error);
});
res.status(202).json({ message: 'Summary generation started.' });
} catch (error) {
}
catch (error)
{
handlePrismaError(res, error, 'initiate Mantis summary generation');
}
});
// --- Email Summary API Routes --- //
// GET /api/email-summaries - Get ALL email summaries from the DB, with pagination
router.get('/email-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.emailSummary.findMany({ // Use emailSummary model
orderBy: { summaryDate: 'desc' },
take: limit,
skip: skip,
select: { id: true, summaryDate: true, summaryText: true, generatedAt: true }
}),
prisma.emailSummary.count() // Count emailSummary model
]);
res.json({ summaries, total: totalItems });
} catch (error) {
handlePrismaError(res, error, 'fetch paginated Email summaries');
}
});
// POST /api/email-summaries/generate - Trigger email summary generation
router.post('/email-summaries/generate', async (req, res) => {
try {
// Trigger generation asynchronously, don't wait for it
generateAndStoreEmailSummary() // Use the email summarizer function
.then(() => {
console.log('Email summary generation process finished successfully (async).');
})
.catch(error => {
console.error('Background email summary generation failed:', error);
});
res.status(202).json({ message: 'Email summary generation started.' });
} catch (error) {
handlePrismaError(res, error, 'initiate Email summary generation');
}
});
// --- Settings API --- //
// GET /api/settings/:key - Get a specific setting value
router.get('/settings/:key', async (req, res) => {
router.get('/settings/:key', async(req, res) =>
{
const { key } = req.params;
try {
try
{
const setting = await prisma.setting.findUnique({
where: { key: key },
select: { value: true }
});
if (setting !== null) {
if (setting !== null)
{
res.json({ key, value: setting.value });
} else {
}
else
{
res.json({ key, value: '' }); // Return empty value if not found
}
} catch (err) {
}
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) => {
router.put('/settings/:key', async(req, res) =>
{
const { key } = req.params;
const { value } = req.body;
if (typeof value === 'undefined') {
if (typeof value === 'undefined')
{
return res.status(400).json({ error: 'Setting value is required in the request body' });
}
try {
try
{
const upsertedSetting = await prisma.setting.upsert({
where: { key: key },
update: { value: String(value) },
@ -628,7 +695,9 @@ router.put('/settings/:key', async (req, res) => {
select: { key: true, value: true } // Select to return the updated/created value
});
res.status(200).json(upsertedSetting);
} catch (err) {
}
catch (err)
{
handlePrismaError(res, err, `update setting '${key}'`);
}
});