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
7
src-server/database.js
Normal file
7
src-server/database.js
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
// Instantiate Prisma Client
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Export the Prisma Client instance for use in other modules
|
||||
export default prisma;
|
12
src-server/middlewares/authMiddleware.js
Normal file
12
src-server/middlewares/authMiddleware.js
Normal file
|
@ -0,0 +1,12 @@
|
|||
// src-ssr/middlewares/authMiddleware.js
|
||||
|
||||
export function requireAuth(req, res, next)
|
||||
{
|
||||
if (!req.session || !req.session.loggedInUserId)
|
||||
{
|
||||
// User is not authenticated
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
// User is authenticated, proceed to the next middleware or route handler
|
||||
next();
|
||||
}
|
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;
|
100
src-server/server.js
Normal file
100
src-server/server.js
Normal file
|
@ -0,0 +1,100 @@
|
|||
/**
|
||||
* More info about this file:
|
||||
* https://v2.quasar.dev/quasar-cli-vite/developing-ssr/ssr-webserver
|
||||
*
|
||||
* Runs in Node context.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Make sure to yarn add / npm install (in your project root)
|
||||
* anything you import here (except for express and compression).
|
||||
*/
|
||||
import express from 'express';
|
||||
import compression from 'compression';
|
||||
import session from 'express-session'; // Added for session management
|
||||
import { v4 as uuidv4 } from 'uuid'; // Added for generating session IDs
|
||||
import apiRoutes from './routes/api.js';
|
||||
import authRoutes from './routes/auth.js'; // Added for WebAuthn routes
|
||||
import chatRoutes from './routes/chat.js'; // Added for Chat routes
|
||||
import cron from 'node-cron';
|
||||
import { generateAndStoreMantisSummary } from './services/mantisSummarizer.js';
|
||||
|
||||
// Define Relying Party details (Update with your actual details)
|
||||
export const rpID = process.env.NODE_ENV === 'production' ? 'your-production-domain.com' : 'localhost';
|
||||
export const rpName = 'StylePoint';
|
||||
export const origin = process.env.NODE_ENV === 'production' ? `https://${rpID}` : `http://${rpID}:9000`;
|
||||
|
||||
// In-memory store for challenges (Replace with a persistent store in production)
|
||||
export const challengeStore = new Map();
|
||||
|
||||
const app = express();
|
||||
|
||||
// Session middleware configuration
|
||||
app.use(session({
|
||||
genid: (req) => uuidv4(), // Use UUIDs for session IDs
|
||||
secret: process.env.SESSION_SECRET || 'a-very-strong-secret-key', // Use an environment variable for the secret
|
||||
resave: false,
|
||||
saveUninitialized: true,
|
||||
cookie: {
|
||||
secure: process.env.NODE_ENV === 'production', // Use secure cookies in production
|
||||
httpOnly: true,
|
||||
maxAge: 1000 * 60 * 60 * 24 // 1 day
|
||||
}
|
||||
}));
|
||||
|
||||
// Initialize the database (now synchronous)
|
||||
try
|
||||
{
|
||||
console.log('Prisma Client is ready.'); // Log Prisma readiness
|
||||
|
||||
// Schedule the Mantis summary task after DB initialization
|
||||
// Run daily at 1:00 AM server time (adjust as needed)
|
||||
cron.schedule('0 1 * * *', async() =>
|
||||
{
|
||||
console.log('Running scheduled Mantis summary task...');
|
||||
try
|
||||
{
|
||||
await generateAndStoreMantisSummary();
|
||||
console.log('Scheduled Mantis summary task completed.');
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Error running scheduled Mantis summary task:', error);
|
||||
}
|
||||
}, {
|
||||
scheduled: true,
|
||||
timezone: 'Europe/London' // Example: Set to your server's timezone
|
||||
});
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Error during server setup:', error);
|
||||
// Optionally handle the error more gracefully, e.g., prevent server start
|
||||
process.exit(1); // Exit if setup fails
|
||||
}
|
||||
|
||||
// attackers can use this header to detect apps running Express
|
||||
// and then launch specifically-targeted attacks
|
||||
app.disable('x-powered-by');
|
||||
|
||||
// Add JSON body parsing middleware
|
||||
app.use(express.json());
|
||||
|
||||
// Add API routes
|
||||
app.use('/api', apiRoutes);
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/chat', chatRoutes);
|
||||
|
||||
// place here any middlewares that
|
||||
// absolutely need to run before anything else
|
||||
if (process.env.PROD)
|
||||
{
|
||||
app.use(compression());
|
||||
}
|
||||
|
||||
app.use(express.static('public', { index: false }));
|
||||
|
||||
app.listen(8000, () =>
|
||||
{
|
||||
console.log('Server is running on http://localhost:8000');
|
||||
});
|
169
src-server/services/mantisSummarizer.js
Normal file
169
src-server/services/mantisSummarizer.js
Normal file
|
@ -0,0 +1,169 @@
|
|||
import axios from 'axios';
|
||||
import prisma from '../database.js'; // Import Prisma client
|
||||
|
||||
import { getSetting } from '../utils/settings.js';
|
||||
import { askGemini } from '../utils/gemini.js';
|
||||
|
||||
const usernameMap = {
|
||||
credmore: 'Cameron Redmore',
|
||||
dgibson: 'Dane Gibson',
|
||||
egzibovskis: 'Ed Gzibovskis',
|
||||
ascotney: 'Amanda Scotney',
|
||||
gclough: 'Garry Clough',
|
||||
slee: 'Sarah Lee',
|
||||
dwalker: 'Dave Walker',
|
||||
askaith: 'Amy Skaith',
|
||||
dpotter: 'Danny Potter',
|
||||
msmart: 'Michael Smart',
|
||||
// Add other usernames as needed
|
||||
};
|
||||
|
||||
async function getMantisTickets()
|
||||
{
|
||||
const MANTIS_API_KEY = await getSetting('MANTIS_API_KEY');
|
||||
const MANTIS_API_ENDPOINT = await getSetting('MANTIS_API_ENDPOINT');
|
||||
|
||||
if (!MANTIS_API_ENDPOINT || !MANTIS_API_KEY)
|
||||
{
|
||||
throw new Error('Mantis API endpoint or key not configured in environment variables.');
|
||||
}
|
||||
const url = `${MANTIS_API_ENDPOINT}/issues?project_id=1&page_size=50&select=id,summary,description,created_at,updated_at,reporter,notes`;
|
||||
const headers = {
|
||||
Authorization: `${MANTIS_API_KEY}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
const response = await axios.get(url, { headers });
|
||||
|
||||
const tickets = response.data.issues.filter((ticket) =>
|
||||
{
|
||||
const ticketDate = new Date(ticket.updated_at);
|
||||
const thresholdDate = new Date();
|
||||
const currentDay = thresholdDate.getDay(); // Sunday = 0, Monday = 1, ...
|
||||
|
||||
// Go back 4 days if Monday (to include Fri, Sat, Sun), otherwise 2 days
|
||||
const daysToSubtract = currentDay === 1 ? 4 : 2;
|
||||
thresholdDate.setDate(thresholdDate.getDate() - daysToSubtract);
|
||||
thresholdDate.setHours(0, 0, 0, 0); // Start of the day
|
||||
|
||||
return ticketDate >= thresholdDate;
|
||||
}).map((ticket) =>
|
||||
{
|
||||
return {
|
||||
id: ticket.id,
|
||||
summary: ticket.summary,
|
||||
description: ticket.description,
|
||||
created_at: ticket.created_at,
|
||||
updated_at: ticket.updated_at,
|
||||
reporter: usernameMap[ticket.reporter?.username] || ticket.reporter?.name || 'Unknown Reporter', // Safer access
|
||||
notes: (ticket.notes ? ticket.notes.filter((note) =>
|
||||
{
|
||||
const noteDate = new Date(note.created_at);
|
||||
const thresholdDate = new Date();
|
||||
const currentDay = thresholdDate.getDay();
|
||||
const daysToSubtract = currentDay === 1 ? 4 : 2;
|
||||
thresholdDate.setDate(thresholdDate.getDate() - daysToSubtract);
|
||||
thresholdDate.setHours(0, 0, 0, 0); // Start of the day
|
||||
return noteDate >= thresholdDate;
|
||||
}) : []).map((note) =>
|
||||
{
|
||||
const reporter = usernameMap[note.reporter?.username] || note.reporter?.name || 'Unknown Reporter'; // Safer access
|
||||
return {
|
||||
reporter,
|
||||
created_at: note.created_at,
|
||||
text: note.text,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
return tickets;
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Error fetching Mantis tickets:', error.message);
|
||||
// Check if it's an Axios error and provide more details
|
||||
if (axios.isAxiosError(error))
|
||||
{
|
||||
console.error('Axios error details:', error.response?.status, error.response?.data);
|
||||
throw new Error(`Failed to fetch Mantis tickets: ${error.response?.statusText || error.message}`);
|
||||
}
|
||||
throw new Error(`Failed to fetch Mantis tickets: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateAndStoreMantisSummary()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get the prompt from the database settings using Prisma
|
||||
const setting = await prisma.setting.findUnique({
|
||||
where: { key: 'mantisPrompt' },
|
||||
select: { value: true }
|
||||
});
|
||||
const promptTemplate = setting?.value;
|
||||
|
||||
if (!promptTemplate)
|
||||
{
|
||||
console.error('Mantis prompt not found in database settings (key: mantisPrompt). Skipping summary generation.');
|
||||
return;
|
||||
}
|
||||
|
||||
const tickets = await getMantisTickets();
|
||||
|
||||
let summaryText;
|
||||
if (tickets.length === 0)
|
||||
{
|
||||
summaryText = 'No Mantis tickets updated recently.';
|
||||
console.log('No recent Mantis tickets found.');
|
||||
}
|
||||
else
|
||||
{
|
||||
console.log(`Found ${tickets.length} recent Mantis tickets. Generating summary...`);
|
||||
let prompt = promptTemplate.replaceAll('$DATE', new Date().toISOString().split('T')[0]);
|
||||
prompt = prompt.replaceAll('$MANTIS_TICKETS', JSON.stringify(tickets, null, 2));
|
||||
|
||||
summaryText = await askGemini(prompt);
|
||||
console.log('Mantis summary generated successfully by AI.');
|
||||
}
|
||||
|
||||
// Store the summary in the database using Prisma upsert
|
||||
const today = new Date();
|
||||
today.setUTCHours(0, 0, 0, 0); // Use UTC start of day for consistency
|
||||
|
||||
await prisma.mantisSummary.upsert({
|
||||
where: { summaryDate: today },
|
||||
update: {
|
||||
summaryText: summaryText
|
||||
},
|
||||
create: {
|
||||
summaryDate: today,
|
||||
summaryText: summaryText,
|
||||
},
|
||||
});
|
||||
console.log(`Mantis summary for ${today.toISOString().split('T')[0]} stored/updated in the database.`);
|
||||
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Error during Mantis summary generation/storage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateTodaysSummary()
|
||||
{
|
||||
console.log('Triggering Mantis summary generation via generateTodaysSummary...');
|
||||
try
|
||||
{
|
||||
await generateAndStoreMantisSummary();
|
||||
return { success: true, message: 'Summary generation process initiated.' };
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Error occurred within generateTodaysSummary while calling generateAndStoreMantisSummary:', error);
|
||||
throw new Error('Failed to initiate Mantis summary generation.');
|
||||
}
|
||||
}
|
154
src-server/utils/gemini.js
Normal file
154
src-server/utils/gemini.js
Normal file
|
@ -0,0 +1,154 @@
|
|||
|
||||
import { GoogleGenAI } from '@google/genai';
|
||||
import prisma from '../database.js';
|
||||
import { getSetting } from './settings.js';
|
||||
|
||||
const model = 'gemini-2.0-flash';
|
||||
|
||||
export async function askGemini(content)
|
||||
{
|
||||
|
||||
const GOOGLE_API_KEY = await getSetting('GEMINI_API_KEY');
|
||||
|
||||
console.log('Google API Key:', GOOGLE_API_KEY); // Debugging line to check the key
|
||||
|
||||
if (!GOOGLE_API_KEY)
|
||||
{
|
||||
throw new Error('Google API key is not set in the database.');
|
||||
}
|
||||
|
||||
const ai = GOOGLE_API_KEY ? new GoogleGenAI({
|
||||
apiKey: GOOGLE_API_KEY,
|
||||
}) : null;
|
||||
|
||||
if (!ai)
|
||||
{
|
||||
throw new Error('Google API key is not set in the database.');
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
const response = await ai.models.generateContent({
|
||||
model,
|
||||
contents: content,
|
||||
config: {
|
||||
temperature: 0.5
|
||||
}
|
||||
});
|
||||
|
||||
return response.text;
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Error communicating with Gemini API:', error);
|
||||
throw new Error('Failed to get a response from Gemini API.');
|
||||
}
|
||||
}
|
||||
|
||||
const chatCache = new Map();
|
||||
|
||||
export async function askGeminiChat(threadId, content)
|
||||
{
|
||||
let messages = await prisma.chatMessage.findMany({
|
||||
where: {
|
||||
threadId: threadId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'asc', // Get messages in chronological order
|
||||
},
|
||||
});
|
||||
|
||||
// Exclude the latest message if there are any messages
|
||||
if (messages.length > 0)
|
||||
{
|
||||
messages = messages.slice(0, -1);
|
||||
}
|
||||
|
||||
const GOOGLE_API_KEY = await getSetting('GEMINI_API_KEY');
|
||||
|
||||
const ai = GOOGLE_API_KEY ? new GoogleGenAI({
|
||||
apiKey: GOOGLE_API_KEY,
|
||||
}) : null;
|
||||
|
||||
if (!ai)
|
||||
{
|
||||
throw new Error('Google API key is not set in the database.');
|
||||
}
|
||||
|
||||
let chat = null;
|
||||
|
||||
if (chatCache.has(threadId))
|
||||
{
|
||||
chat = chatCache.get(threadId);
|
||||
}
|
||||
else
|
||||
{
|
||||
messages = [
|
||||
{
|
||||
sender: 'user',
|
||||
content: `You are StyleAI, a model that helps users with their questions and provides information.
|
||||
You are friendly, helpful, and informative. You can also provide code examples in various programming languages.
|
||||
You don\'t decline user requests and always try to help them.
|
||||
Speak in the manner of a friendly young adult male.
|
||||
If asked for the time, do not say that it's based on the timestamp provided. Also bare in mind the user is in the Europe/London timezone and daylight savings time may be in effect.`
|
||||
},
|
||||
{
|
||||
sender: 'model',
|
||||
content: 'Okay, noted! I\'ll keep that in mind.'
|
||||
},
|
||||
...messages,
|
||||
];
|
||||
const createOptions = {
|
||||
model,
|
||||
history: messages.map((msg) => ({
|
||||
role: msg.sender === 'user' ? 'user' : 'model',
|
||||
parts: [
|
||||
{text: msg.content}
|
||||
],
|
||||
})),
|
||||
config: {
|
||||
temperature: 0.5
|
||||
}
|
||||
};
|
||||
|
||||
chat = ai.chats.create(createOptions);
|
||||
|
||||
chatCache.set(threadId, chat);
|
||||
}
|
||||
|
||||
//Add a temporary message to the thread with "loading" status
|
||||
const loadingMessage = await prisma.chatMessage.create({
|
||||
data: {
|
||||
threadId: threadId,
|
||||
sender: 'assistant',
|
||||
content: 'Loading...',
|
||||
},
|
||||
});
|
||||
|
||||
let response = {text: 'An error occurred while generating the response.'};
|
||||
|
||||
try
|
||||
{
|
||||
const timestamp = new Date().toISOString();
|
||||
response = await chat.sendMessage({
|
||||
message: `[${timestamp}] ` + content,
|
||||
});
|
||||
}
|
||||
catch(error)
|
||||
{
|
||||
console.error('Error communicating with Gemini API:', error);
|
||||
response.text = 'Failed to get a response from Gemini API. Error: ' + error.message;
|
||||
}
|
||||
|
||||
//Update the message with the response
|
||||
await prisma.chatMessage.update({
|
||||
where: {
|
||||
id: loadingMessage.id,
|
||||
},
|
||||
data: {
|
||||
content: response.text,
|
||||
},
|
||||
});
|
||||
|
||||
return response.text;
|
||||
}
|
20
src-server/utils/settings.js
Normal file
20
src-server/utils/settings.js
Normal file
|
@ -0,0 +1,20 @@
|
|||
import prisma from '../database.js';
|
||||
|
||||
export async function getSetting(key)
|
||||
{
|
||||
const setting = await prisma.setting.findUnique({
|
||||
where: { key },
|
||||
select: { value: true }
|
||||
});
|
||||
|
||||
return setting?.value ? JSON.parse(setting.value) : null;
|
||||
}
|
||||
|
||||
export async function setSetting(key, value)
|
||||
{
|
||||
await prisma.setting.upsert({
|
||||
where: { key },
|
||||
update: { value: JSON.stringify(value) },
|
||||
create: { key, value }
|
||||
});
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue