Moved away from SSR to regular Node API server.
This commit is contained in:
		
							parent
							
								
									9aea69c7be
								
							
						
					
					
						commit
						83d93aefc0
					
				
					 30 changed files with 939 additions and 1024 deletions
				
			
		
							
								
								
									
										705
									
								
								src-server/routes/api.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										705
									
								
								src-server/routes/api.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,705 @@ | |||
| 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; | ||||
							
								
								
									
										459
									
								
								src-server/routes/auth.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										459
									
								
								src-server/routes/auth.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,459 @@ | |||
| // src-ssr/routes/auth.js
 | ||||
| import express from 'express'; | ||||
| import { | ||||
|   generateRegistrationOptions, | ||||
|   verifyRegistrationResponse, | ||||
|   generateAuthenticationOptions, | ||||
|   verifyAuthenticationResponse, | ||||
| } from '@simplewebauthn/server'; | ||||
| import { isoBase64URL } from '@simplewebauthn/server/helpers'; // Ensure this is imported if not already
 | ||||
| import prisma from '../database.js'; | ||||
| import { rpID, rpName, origin, challengeStore } from '../server.js'; // Import RP details and challenge store
 | ||||
| 
 | ||||
| const router = express.Router(); | ||||
| 
 | ||||
| // Helper function to get user authenticators
 | ||||
| async function getUserAuthenticators(userId)  | ||||
| { | ||||
|   return prisma.authenticator.findMany({ | ||||
|     where: { userId }, | ||||
|     select: { | ||||
|       credentialID: true, | ||||
|       credentialPublicKey: true, | ||||
|       counter: true, | ||||
|       transports: true, | ||||
|     }, | ||||
|   }); | ||||
| } | ||||
| 
 | ||||
| // Helper function to get a user by username
 | ||||
| async function getUserByUsername(username)  | ||||
| { | ||||
|   return prisma.user.findUnique({ where: { username } }); | ||||
| } | ||||
| 
 | ||||
| // Helper function to get a user by ID
 | ||||
| async function getUserById(id)  | ||||
| { | ||||
|   return prisma.user.findUnique({ where: { id } }); | ||||
| } | ||||
| 
 | ||||
| // Helper function to get an authenticator by credential ID
 | ||||
| async function getAuthenticatorByCredentialID(credentialID)  | ||||
| { | ||||
|   return prisma.authenticator.findUnique({ where: { credentialID } }); | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| // Generate Registration Options
 | ||||
| router.post('/generate-registration-options', async(req, res) =>  | ||||
| { | ||||
|   const { username } = req.body; | ||||
| 
 | ||||
|   if (!username)  | ||||
|   { | ||||
|     return res.status(400).json({ error: 'Username is required' }); | ||||
|   } | ||||
| 
 | ||||
|   try  | ||||
|   { | ||||
|     let user = await getUserByUsername(username); | ||||
| 
 | ||||
|     // If user doesn't exist, create one
 | ||||
|     if (!user)  | ||||
|     { | ||||
|       user = await prisma.user.create({ | ||||
|         data: { username }, | ||||
|       }); | ||||
|     } | ||||
| 
 | ||||
|     const userAuthenticators = await getUserAuthenticators(user.id); | ||||
| 
 | ||||
|     if(userAuthenticators.length > 0)  | ||||
|     { | ||||
|       //The user is trying to register a new authenticator, so we need to check if the user registering is the same as the one in the session
 | ||||
|       if (!req.session.loggedInUserId || req.session.loggedInUserId !== user.id)  | ||||
|       { | ||||
|         return res.status(403).json({ error: 'Invalid registration attempt.' }); | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     const options = await generateRegistrationOptions({ | ||||
|       rpName, | ||||
|       rpID, | ||||
|       userName: user.username, | ||||
|       // Don't prompt users for additional authenticators if they've already registered some
 | ||||
|       excludeCredentials: userAuthenticators.map(auth => ({ | ||||
|         id: auth.credentialID, // Use isoBase64URL helper
 | ||||
|         type: 'public-key', | ||||
|         // Optional: Specify transports if you know them
 | ||||
|         transports: auth.transports ? auth.transports.split(',') : undefined, | ||||
|       })), | ||||
|       authenticatorSelection: { | ||||
|         // Defaults
 | ||||
|         residentKey: 'required', | ||||
|         userVerification: 'preferred', | ||||
|       }, | ||||
|       // Strong advice: Always require attestation for registration
 | ||||
|       attestationType: 'none', // Use 'none' for simplicity, 'direct' or 'indirect' recommended for production
 | ||||
|     }); | ||||
| 
 | ||||
|     // Store the challenge
 | ||||
|     challengeStore.set(user.id, options.challenge); | ||||
|     req.session.userId = user.id; // Temporarily store userId in session for verification step
 | ||||
| 
 | ||||
|     res.json(options); | ||||
|   } | ||||
|   catch (error)  | ||||
|   { | ||||
|     console.error('Registration options error:', error); | ||||
|     res.status(500).json({ error: 'Failed to generate registration options' }); | ||||
|   } | ||||
| }); | ||||
| 
 | ||||
| // Verify Registration
 | ||||
| router.post('/verify-registration', async(req, res) =>  | ||||
| { | ||||
|   const { registrationResponse } = req.body; | ||||
|   const userId = req.session.userId; // Retrieve userId stored during options generation
 | ||||
| 
 | ||||
|   if (!userId)  | ||||
|   { | ||||
|     return res.status(400).json({ error: 'User session not found. Please start registration again.' }); | ||||
|   } | ||||
| 
 | ||||
|   const expectedChallenge = challengeStore.get(userId); | ||||
| 
 | ||||
|   if (!expectedChallenge)  | ||||
|   { | ||||
|     return res.status(400).json({ error: 'Challenge not found or expired' }); | ||||
|   } | ||||
| 
 | ||||
|   try  | ||||
|   { | ||||
|     const user = await getUserById(userId); | ||||
|     if (!user)  | ||||
|     { | ||||
|       return res.status(404).json({ error: 'User not found' }); | ||||
|     } | ||||
| 
 | ||||
|     const verification = await verifyRegistrationResponse({ | ||||
|       response: registrationResponse, | ||||
|       expectedChallenge: expectedChallenge, | ||||
|       expectedOrigin: origin, | ||||
|       expectedRPID: rpID, | ||||
|       requireUserVerification: false, // Adjust based on your requirements
 | ||||
|     }); | ||||
| 
 | ||||
|     const { verified, registrationInfo } = verification; | ||||
| 
 | ||||
|     console.log(verification); | ||||
| 
 | ||||
|     if (verified && registrationInfo)  | ||||
|     { | ||||
|       const { credential, credentialDeviceType, credentialBackedUp } = registrationInfo; | ||||
| 
 | ||||
|       const credentialID = credential.id; | ||||
|       const credentialPublicKey = credential.publicKey; | ||||
|       const counter = credential.counter; | ||||
|       const transports = credential.transports || []; // Use empty array if transports are not provided
 | ||||
| 
 | ||||
|       // Check if authenticator with this ID already exists
 | ||||
|       const existingAuthenticator = await getAuthenticatorByCredentialID(isoBase64URL.fromBuffer(credentialID)); | ||||
| 
 | ||||
|       if (existingAuthenticator)  | ||||
|       { | ||||
|         return res.status(409).json({ error: 'Authenticator already registered' }); | ||||
|       } | ||||
| 
 | ||||
|       // Save the authenticator
 | ||||
|       await prisma.authenticator.create({ | ||||
|         data: { | ||||
|           credentialID, // Store as Base64URL string
 | ||||
|           credentialPublicKey: Buffer.from(credentialPublicKey), // Store as Bytes
 | ||||
|           counter: BigInt(counter), // Store as BigInt
 | ||||
|           credentialDeviceType, | ||||
|           credentialBackedUp, | ||||
|           transports: transports.join(','), // Store transports as comma-separated string
 | ||||
|           userId: user.id, | ||||
|         }, | ||||
|       }); | ||||
| 
 | ||||
|       // Clear the challenge and temporary userId
 | ||||
|       challengeStore.delete(userId); | ||||
|       delete req.session.userId; | ||||
| 
 | ||||
|       // Log the user in by setting the final session userId
 | ||||
|       req.session.loggedInUserId = user.id; | ||||
| 
 | ||||
|       res.json({ verified: true }); | ||||
|     } | ||||
|     else  | ||||
|     { | ||||
|       res.status(400).json({ error: 'Registration verification failed' }); | ||||
|     } | ||||
|   } | ||||
|   catch (error)  | ||||
|   { | ||||
|     console.error('Registration verification error:', error); | ||||
|     challengeStore.delete(userId); // Clean up challenge on error
 | ||||
|     delete req.session.userId; | ||||
|     res.status(500).json({ error: 'Failed to verify registration', details: error.message }); | ||||
|   } | ||||
| }); | ||||
| 
 | ||||
| // Generate Authentication Options
 | ||||
| router.post('/generate-authentication-options', async(req, res) =>  | ||||
| { | ||||
|   const { username } = req.body; | ||||
| 
 | ||||
|   try  | ||||
|   { | ||||
|     let user; | ||||
|     if (username)  | ||||
|     { | ||||
|       user = await getUserByUsername(username); | ||||
|     } | ||||
|     else if (req.session.loggedInUserId)  | ||||
|     { | ||||
|       // If already logged in, allow re-authentication (e.g., for step-up)
 | ||||
|       user = await getUserById(req.session.loggedInUserId); | ||||
|     } | ||||
| 
 | ||||
|     if (!user)  | ||||
|     { | ||||
|       return res.status(404).json({ error: 'User not found' }); | ||||
|     } | ||||
| 
 | ||||
|     console.log('User found:', user); | ||||
| 
 | ||||
|     const userAuthenticators = await getUserAuthenticators(user.id); | ||||
| 
 | ||||
|     console.log('User authenticators:', userAuthenticators); | ||||
| 
 | ||||
|     const options = await generateAuthenticationOptions({ | ||||
|       rpID, | ||||
|       // Require users to use a previously-registered authenticator
 | ||||
|       allowCredentials: userAuthenticators.map(auth => ({ | ||||
|         id: auth.credentialID, | ||||
|         type: 'public-key', | ||||
|         transports: auth.transports ? auth.transports.split(',') : undefined, | ||||
|       })), | ||||
|       userVerification: 'preferred', | ||||
|     }); | ||||
| 
 | ||||
|     // Store the challenge associated with the user ID for verification
 | ||||
|     challengeStore.set(user.id, options.challenge); | ||||
|     req.session.challengeUserId = user.id; // Store user ID associated with this challenge
 | ||||
| 
 | ||||
|     res.json(options); | ||||
|   } | ||||
|   catch (error)  | ||||
|   { | ||||
|     console.error('Authentication options error:', error); | ||||
|     res.status(500).json({ error: 'Failed to generate authentication options' }); | ||||
|   } | ||||
| }); | ||||
| 
 | ||||
| // Verify Authentication
 | ||||
| router.post('/verify-authentication', async(req, res) =>  | ||||
| { | ||||
|   const { authenticationResponse } = req.body; | ||||
|   const challengeUserId = req.session.challengeUserId; // Get user ID associated with the challenge
 | ||||
| 
 | ||||
|   if (!challengeUserId)  | ||||
|   { | ||||
|     return res.status(400).json({ error: 'Challenge session not found. Please try logging in again.' }); | ||||
|   } | ||||
| 
 | ||||
|   const expectedChallenge = challengeStore.get(challengeUserId); | ||||
| 
 | ||||
|   if (!expectedChallenge)  | ||||
|   { | ||||
|     return res.status(400).json({ error: 'Challenge not found or expired' }); | ||||
|   } | ||||
| 
 | ||||
|   try  | ||||
|   { | ||||
|     const user = await getUserById(challengeUserId); | ||||
|     if (!user)  | ||||
|     { | ||||
|       return res.status(404).json({ error: 'User associated with challenge not found' }); | ||||
|     } | ||||
| 
 | ||||
|     const authenticator = await getAuthenticatorByCredentialID(authenticationResponse.id); | ||||
| 
 | ||||
|     if (!authenticator)  | ||||
|     { | ||||
|       return res.status(404).json({ error: 'Authenticator not found' }); | ||||
|     } | ||||
| 
 | ||||
|     // Ensure the authenticator belongs to the user attempting to log in
 | ||||
|     if (authenticator.userId !== user.id)  | ||||
|     { | ||||
|       return res.status(403).json({ error: 'Authenticator does not belong to this user' }); | ||||
|     } | ||||
| 
 | ||||
|     const verification = await verifyAuthenticationResponse({ | ||||
|       response: authenticationResponse, | ||||
|       expectedChallenge: expectedChallenge, | ||||
|       expectedOrigin: origin, | ||||
|       expectedRPID: rpID, | ||||
|       credential: { | ||||
|         id: authenticator.credentialID, | ||||
|         publicKey: authenticator.credentialPublicKey, | ||||
|         counter: authenticator.counter.toString(), // Convert BigInt to string for comparison
 | ||||
|         transports: authenticator.transports ? authenticator.transports.split(',') : undefined, | ||||
|       }, | ||||
|       requireUserVerification: false, // Enforce user verification
 | ||||
|     }); | ||||
| 
 | ||||
|     const { verified, authenticationInfo } = verification; | ||||
| 
 | ||||
|     if (verified)  | ||||
|     { | ||||
|       // Update the authenticator counter
 | ||||
|       await prisma.authenticator.update({ | ||||
|         where: { credentialID: authenticator.credentialID }, | ||||
|         data: { counter: BigInt(authenticationInfo.newCounter) }, // Update with the new counter
 | ||||
|       }); | ||||
| 
 | ||||
|       // Clear the challenge and associated user ID
 | ||||
|       challengeStore.delete(challengeUserId); | ||||
|       delete req.session.challengeUserId; | ||||
| 
 | ||||
|       // Log the user in
 | ||||
|       req.session.loggedInUserId = user.id; | ||||
| 
 | ||||
|       res.json({ verified: true, user: { id: user.id, username: user.username } }); | ||||
|     } | ||||
|     else  | ||||
|     { | ||||
|       res.status(400).json({ error: 'Authentication verification failed' }); | ||||
|     } | ||||
|   } | ||||
|   catch (error)  | ||||
|   { | ||||
|     console.error('Authentication verification error:', error); | ||||
|     challengeStore.delete(challengeUserId); // Clean up challenge on error
 | ||||
|     delete req.session.challengeUserId; | ||||
|     res.status(500).json({ error: 'Failed to verify authentication', details: error.message }); | ||||
|   } | ||||
| }); | ||||
| 
 | ||||
| // GET Passkeys for Logged-in User
 | ||||
| router.get('/passkeys', async(req, res) =>  | ||||
| { | ||||
|   if (!req.session.loggedInUserId)  | ||||
|   { | ||||
|     return res.status(401).json({ error: 'Not authenticated' }); | ||||
|   } | ||||
| 
 | ||||
|   try  | ||||
|   { | ||||
|     const userId = req.session.loggedInUserId; | ||||
|     const authenticators = await prisma.authenticator.findMany({ | ||||
|       where: { userId }, | ||||
|       select: { | ||||
|         credentialID: true, // Already Base64URL string
 | ||||
|         // Add other fields if needed, e.g., createdAt if you add it to the schema
 | ||||
|         // createdAt: true,
 | ||||
|       }, | ||||
|     }); | ||||
| 
 | ||||
|     // No need to convert credentialID here as it's stored as Base64URL string
 | ||||
|     res.json(authenticators); | ||||
|   } | ||||
|   catch (error)  | ||||
|   { | ||||
|     console.error('Error fetching passkeys:', error); | ||||
|     res.status(500).json({ error: 'Failed to fetch passkeys' }); | ||||
|   } | ||||
| }); | ||||
| 
 | ||||
| // DELETE Passkey
 | ||||
| router.delete('/passkeys/:credentialID', async(req, res) =>  | ||||
| { | ||||
|   if (!req.session.loggedInUserId)  | ||||
|   { | ||||
|     return res.status(401).json({ error: 'Not authenticated' }); | ||||
|   } | ||||
| 
 | ||||
|   const { credentialID } = req.params; // This is already a Base64URL string from the client
 | ||||
| 
 | ||||
|   if (!credentialID)  | ||||
|   { | ||||
|     return res.status(400).json({ error: 'Credential ID is required' }); | ||||
|   } | ||||
| 
 | ||||
|   try  | ||||
|   { | ||||
|     const userId = req.session.loggedInUserId; | ||||
| 
 | ||||
|     // Find the authenticator first to ensure it belongs to the logged-in user
 | ||||
|     const authenticator = await prisma.authenticator.findUnique({ | ||||
|       where: { credentialID: credentialID }, // Use the Base64URL string directly
 | ||||
|     }); | ||||
| 
 | ||||
|     if (!authenticator)  | ||||
|     { | ||||
|       return res.status(404).json({ error: 'Passkey not found' }); | ||||
|     } | ||||
| 
 | ||||
|     // Security check: Ensure the passkey belongs to the user trying to delete it
 | ||||
|     if (authenticator.userId !== userId)  | ||||
|     { | ||||
|       return res.status(403).json({ error: 'Permission denied' }); | ||||
|     } | ||||
| 
 | ||||
|     // Delete the authenticator
 | ||||
|     await prisma.authenticator.delete({ | ||||
|       where: { credentialID: credentialID }, | ||||
|     }); | ||||
| 
 | ||||
|     res.json({ message: 'Passkey deleted successfully' }); | ||||
|   } | ||||
|   catch (error)  | ||||
|   { | ||||
|     console.error('Error deleting passkey:', error); | ||||
|     // Handle potential Prisma errors, e.g., record not found if deleted between check and delete
 | ||||
|     if (error.code === 'P2025')  | ||||
|     { // Prisma code for record not found on delete/update
 | ||||
|       return res.status(404).json({ error: 'Passkey not found' }); | ||||
|     } | ||||
|     res.status(500).json({ error: 'Failed to delete passkey' }); | ||||
|   } | ||||
| }); | ||||
| 
 | ||||
| // Check Authentication Status
 | ||||
| router.get('/status', async(req, res) =>  | ||||
| { | ||||
|   if (req.session.loggedInUserId)  | ||||
|   { | ||||
|     const user = await getUserById(req.session.loggedInUserId); | ||||
|     if (!user)  | ||||
|     { | ||||
|       req.session.destroy(err =>  | ||||
|       {}); | ||||
|       return res.status(401).json({ status: 'unauthenticated' }); | ||||
|     } | ||||
|     return res.json({ status: 'authenticated', user: { id: user.id, username: user.username, email: user.email } }); | ||||
|   } | ||||
|   res.json({ status: 'unauthenticated' }); | ||||
| }); | ||||
| 
 | ||||
| // Logout
 | ||||
| router.post('/logout', (req, res) =>  | ||||
| { | ||||
|   req.session.destroy(err =>  | ||||
|   { | ||||
|     if (err)  | ||||
|     { | ||||
|       console.error('Logout error:', err); | ||||
|       return res.status(500).json({ error: 'Failed to logout' }); | ||||
|     } | ||||
|     res.json({ message: 'Logged out successfully' }); | ||||
|   }); | ||||
| }); | ||||
| 
 | ||||
| export default router; | ||||
							
								
								
									
										164
									
								
								src-server/routes/chat.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										164
									
								
								src-server/routes/chat.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,164 @@ | |||
| import { Router } from 'express'; | ||||
| import prisma from '../database.js'; | ||||
| import { requireAuth } from '../middlewares/authMiddleware.js'; // Import the middleware
 | ||||
| 
 | ||||
| import { askGeminiChat } from '../utils/gemini.js'; | ||||
| 
 | ||||
| const router = Router(); | ||||
| 
 | ||||
| // Apply the authentication middleware to all chat routes
 | ||||
| router.use(requireAuth); | ||||
| 
 | ||||
| // POST /api/chat/threads - Create a new chat thread (optionally with a first message)
 | ||||
| router.post('/threads', async(req, res) =>  | ||||
| { | ||||
|   const { content } = req.body; // Content is now optional
 | ||||
| 
 | ||||
|   // If content is provided, validate it
 | ||||
|   if (content && (typeof content !== 'string' || content.trim().length === 0))  | ||||
|   { | ||||
|     return res.status(400).json({ error: 'Message content cannot be empty if provided.' }); | ||||
|   } | ||||
| 
 | ||||
|   try  | ||||
|   { | ||||
|     const createData = {}; | ||||
|     if (content)  | ||||
|     { | ||||
|       // If content exists, create the thread with the first message
 | ||||
|       createData.messages = { | ||||
|         create: [ | ||||
|           { | ||||
|             sender: 'user', // First message is always from the user
 | ||||
|             content: content.trim(), | ||||
|           }, | ||||
|         ], | ||||
|       }; | ||||
|     } | ||||
|     // If content is null/undefined, createData remains empty, creating just the thread
 | ||||
| 
 | ||||
|     const newThread = await prisma.chatThread.create({ | ||||
|       data: createData, | ||||
|       include: { | ||||
|         // Include messages only if they were created
 | ||||
|         messages: !!content, | ||||
|       }, | ||||
|     }); | ||||
| 
 | ||||
|     if(content) | ||||
|     { | ||||
|       await askGeminiChat(newThread.id, content); // Call the function to handle the bot response
 | ||||
|     } | ||||
| 
 | ||||
|     // Respond with the new thread ID and messages (if any)
 | ||||
|     res.status(201).json({ | ||||
|       threadId: newThread.id, | ||||
|       // Ensure messages array is empty if no content was provided
 | ||||
|       messages: newThread.messages ? newThread.messages.map(msg => ({ ...msg, createdAt: msg.createdAt.toISOString() })) : [] | ||||
|     }); | ||||
|   } | ||||
|   catch (error)  | ||||
|   { | ||||
|     console.error('Error creating chat thread:', error); | ||||
|     res.status(500).json({ error: 'Failed to create chat thread.' }); | ||||
|   } | ||||
| }); | ||||
| 
 | ||||
| // GET /api/chat/threads/:threadId/messages - Get messages for a specific thread
 | ||||
| router.get('/threads/:threadId/messages', async(req, res) =>  | ||||
| { | ||||
|   const { threadId } = req.params; | ||||
| 
 | ||||
|   try  | ||||
|   { | ||||
|     const messages = await prisma.chatMessage.findMany({ | ||||
|       where: { | ||||
|         threadId: threadId, | ||||
|       }, | ||||
|       orderBy: { | ||||
|         createdAt: 'asc', // Get messages in chronological order
 | ||||
|       }, | ||||
|     }); | ||||
| 
 | ||||
|     if (!messages)  | ||||
|     { // Check if thread exists indirectly
 | ||||
|       // If findMany returns empty, the thread might not exist or has no messages.
 | ||||
|       // Check if thread exists explicitly
 | ||||
|       const thread = await prisma.chatThread.findUnique({ where: { id: threadId } }); | ||||
|       if (!thread)  | ||||
|       { | ||||
|         return res.status(404).json({ error: 'Chat thread not found.' }); | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     res.status(200).json(messages.map(msg => ({ ...msg, createdAt: msg.createdAt.toISOString() }))); | ||||
|   } | ||||
|   catch (error)  | ||||
|   { | ||||
|     console.error(`Error fetching messages for thread ${threadId}:`, error); | ||||
|     // Basic error handling, check for specific Prisma errors if needed
 | ||||
|     if (error.code === 'P2023' || error.message.includes('Malformed UUID'))  | ||||
|     { // Example: Invalid UUID format
 | ||||
|       return res.status(400).json({ error: 'Invalid thread ID format.' }); | ||||
|     } | ||||
|     res.status(500).json({ error: 'Failed to fetch messages.' }); | ||||
|   } | ||||
| }); | ||||
| 
 | ||||
| // POST /api/chat/threads/:threadId/messages - Add a message to an existing thread
 | ||||
| router.post('/threads/:threadId/messages', async(req, res) =>  | ||||
| { | ||||
|   const { threadId } = req.params; | ||||
|   const { content, sender = 'user' } = req.body; // Default sender to 'user'
 | ||||
| 
 | ||||
|   if (!content || typeof content !== 'string' || content.trim().length === 0)  | ||||
|   { | ||||
|     return res.status(400).json({ error: 'Message content cannot be empty.' }); | ||||
|   } | ||||
|   if (sender !== 'user' && sender !== 'bot')  | ||||
|   { | ||||
|     return res.status(400).json({ error: 'Invalid sender type.' }); | ||||
|   } | ||||
| 
 | ||||
|   try  | ||||
|   { | ||||
|     // Verify thread exists first
 | ||||
|     const thread = await prisma.chatThread.findUnique({ | ||||
|       where: { id: threadId }, | ||||
|     }); | ||||
| 
 | ||||
|     if (!thread)  | ||||
|     { | ||||
|       return res.status(404).json({ error: 'Chat thread not found.' }); | ||||
|     } | ||||
| 
 | ||||
|     const newMessage = await prisma.chatMessage.create({ | ||||
|       data: { | ||||
|         threadId: threadId, | ||||
|         sender: sender, | ||||
|         content: content.trim(), | ||||
|       }, | ||||
|     }); | ||||
| 
 | ||||
|     // Optionally: Update the thread's updatedAt timestamp
 | ||||
|     await prisma.chatThread.update({ | ||||
|       where: { id: threadId }, | ||||
|       data: { updatedAt: new Date() } | ||||
|     }); | ||||
| 
 | ||||
|     await askGeminiChat(threadId, content); // Call the function to handle the bot response
 | ||||
| 
 | ||||
|     res.status(201).json({ ...newMessage, createdAt: newMessage.createdAt.toISOString() }); | ||||
|   } | ||||
|   catch (error)  | ||||
|   { | ||||
|     console.error(`Error adding message to thread ${threadId}:`, error); | ||||
|     if (error.code === 'P2023' || error.message.includes('Malformed UUID'))  | ||||
|     { // Example: Invalid UUID format
 | ||||
|       return res.status(400).json({ error: 'Invalid thread ID format.' }); | ||||
|     } | ||||
|     res.status(500).json({ error: 'Failed to add message.' }); | ||||
|   } | ||||
| }); | ||||
| 
 | ||||
| export default router; | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue