Added linting and enforced code styling.

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

View file

@ -4,11 +4,4 @@ import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
// Export the Prisma Client instance for use in other modules
export default prisma;
// --- Old better-sqlite3 code removed ---
// No need for initializeDatabase, getDb, closeDatabase, etc.
// Prisma Client manages the connection pool.
// --- Settings Functions removed ---
// Settings can now be accessed via prisma.setting.findUnique, prisma.setting.upsert, etc.
export default prisma;

View file

@ -1,7 +1,9 @@
// src-ssr/middlewares/authMiddleware.js
export function requireAuth(req, res, next) {
if (!req.session || !req.session.loggedInUserId) {
export function requireAuth(req, res, next)
{
if (!req.session || !req.session.loggedInUserId)
{
// User is not authenticated
return res.status(401).json({ error: 'Authentication required' });
}

View file

@ -1,45 +1,59 @@
import { defineSsrMiddleware } from '#q-app/wrappers'
import { defineSsrMiddleware } from '#q-app/wrappers';
// This middleware should execute as last one
// since it captures everything and tries to
// render the page with Vue
export default defineSsrMiddleware(({ app, resolve, render, serve }) => {
export default defineSsrMiddleware(({ app, resolve, render, serve }) =>
{
// we capture any other Express route and hand it
// over to Vue and Vue Router to render our page
app.get(resolve.urlPath('*'), (req, res) => {
res.setHeader('Content-Type', 'text/html')
app.get(resolve.urlPath('*'), (req, res) =>
{
res.setHeader('Content-Type', 'text/html');
render(/* the ssrContext: */ { req, res })
.then(html => {
.then(html =>
{
// now let's send the rendered html to the client
res.send(html)
res.send(html);
})
.catch(err => {
.catch(err =>
{
// oops, we had an error while rendering the page
// we were told to redirect to another URL
if (err.url) {
if (err.code) {
res.redirect(err.code, err.url)
} else {
res.redirect(err.url)
if (err.url)
{
if (err.code)
{
res.redirect(err.code, err.url);
}
} else if (err.code === 404) {
else
{
res.redirect(err.url);
}
}
else if (err.code === 404)
{
// hmm, Vue Router could not find the requested route
// Should reach here only if no "catch-all" route
// is defined in /src/routes
res.status(404).send('404 | Page Not Found')
} else if (process.env.DEV) {
res.status(404).send('404 | Page Not Found');
}
else if (process.env.DEV)
{
// well, we treat any other code as error;
// if we're in dev mode, then we can use Quasar CLI
// to display a nice error page that contains the stack
// and other useful information
// serve.error is available on dev only
serve.error({ err, req, res })
} else {
serve.error({ err, req, res });
}
else
{
// we're in production, so we should have another method
// to display something to the client when we encounter an error
// (for security reasons, it's not ok to display the same wealth
@ -47,12 +61,13 @@ export default defineSsrMiddleware(({ app, resolve, render, serve }) => {
// Render Error Page on production or
// create a route (/src/routes) for an error page and redirect to it
res.status(500).send('500 | Internal Server Error')
res.status(500).send('500 | Internal Server Error');
if (process.env.DEBUGGING) {
console.error(err.stack)
if (process.env.DEBUGGING)
{
console.error(err.stack);
}
}
})
})
})
});
});
});

View file

@ -1,20 +1,21 @@
import { Router } from 'express';
import prisma from '../database.js'; // Import Prisma client
import prisma from '../database.js';
import PDFDocument from 'pdfkit';
import { join } from 'path';
import { generateTodaysSummary } from '../services/mantisSummarizer.js'; // Keep mantisSummarizer import
import { generateAndStoreEmailSummary } from '../services/emailSummarizer.js'; // Import email summarizer function
import { FieldType } from '@prisma/client'; // Import generated FieldType enum
import { generateTodaysSummary } from '../services/mantisSummarizer.js';
import { FieldType } from '@prisma/client';
const router = Router();
const __dirname = new URL('.', import.meta.url).pathname.replace(/\/$/, '');
// Helper function for consistent error handling
const handlePrismaError = (res, err, context) => {
const handlePrismaError = (res, err, context) =>
{
console.error(`Error ${context}:`, err.message);
// Basic error handling, can be expanded (e.g., check for Prisma-specific error codes)
if (err.code === 'P2025') { // Prisma code for record not found
if (err.code === 'P2025')
{ // Prisma code for record not found
return res.status(404).json({ error: `${context}: Record not found` });
}
res.status(500).json({ error: `Failed to ${context}: ${err.message}` });
@ -23,8 +24,10 @@ const handlePrismaError = (res, err, context) => {
// --- Forms API --- //
// GET /api/forms - List all forms
router.get('/forms', async (req, res) => {
try {
router.get('/forms', async(req, res) =>
{
try
{
const forms = await prisma.form.findMany({
orderBy: {
createdAt: 'desc',
@ -37,20 +40,25 @@ router.get('/forms', async (req, res) => {
}
});
res.json(forms);
} catch (err) {
}
catch (err)
{
handlePrismaError(res, err, 'fetch forms');
}
});
// POST /api/forms - Create a new form
router.post('/forms', async (req, res) => {
router.post('/forms', async(req, res) =>
{
const { title, description, categories } = req.body;
if (!title) {
if (!title)
{
return res.status(400).json({ error: 'Form title is required' });
}
try {
try
{
const newForm = await prisma.form.create({
data: {
title,
@ -60,12 +68,15 @@ router.post('/forms', async (req, res) => {
name: category.name,
sortOrder: catIndex,
fields: {
create: category.fields?.map((field, fieldIndex) => {
create: category.fields?.map((field, fieldIndex) =>
{
// Validate field type against Prisma Enum
if (!Object.values(FieldType).includes(field.type)) {
if (!Object.values(FieldType).includes(field.type))
{
throw new Error(`Invalid field type: ${field.type}`);
}
if (!field.label) {
if (!field.label)
{
throw new Error('Field label is required');
}
return {
@ -86,21 +97,26 @@ router.post('/forms', async (req, res) => {
}
});
res.status(201).json(newForm);
} catch (err) {
}
catch (err)
{
handlePrismaError(res, err, 'create form');
}
});
// GET /api/forms/:id - Get a specific form with its structure
router.get('/forms/:id', async (req, res) => {
router.get('/forms/:id', async(req, res) =>
{
const { id } = req.params;
const formId = parseInt(id, 10);
if (isNaN(formId)) {
if (isNaN(formId))
{
return res.status(400).json({ error: 'Invalid form ID' });
}
try {
try
{
const form = await prisma.form.findUnique({
where: { id: formId },
include: {
@ -115,54 +131,68 @@ router.get('/forms/:id', async (req, res) => {
},
});
if (!form) {
if (!form)
{
return res.status(404).json({ error: 'Form not found' });
}
res.json(form);
} catch (err) {
}
catch (err)
{
handlePrismaError(res, err, `fetch form ${formId}`);
}
});
// DELETE /api/forms/:id - Delete a specific form and all related data
router.delete('/forms/:id', async (req, res) => {
router.delete('/forms/:id', async(req, res) =>
{
const { id } = req.params;
const formId = parseInt(id, 10);
if (isNaN(formId)) {
if (isNaN(formId))
{
return res.status(400).json({ error: 'Invalid form ID' });
}
try {
try
{
// Prisma automatically handles cascading deletes based on schema relations (onDelete: Cascade)
const deletedForm = await prisma.form.delete({
where: { id: formId },
});
res.status(200).json({ message: `Form ${formId} and all related data deleted successfully.` });
} catch (err) {
}
catch (err)
{
handlePrismaError(res, err, `delete form ${formId}`);
}
});
// PUT /api/forms/:id - Update an existing form
router.put('/forms/:id', async (req, res) => {
router.put('/forms/:id', async(req, res) =>
{
const { id } = req.params;
const formId = parseInt(id, 10);
const { title, description, categories } = req.body;
if (isNaN(formId)) {
if (isNaN(formId))
{
return res.status(400).json({ error: 'Invalid form ID' });
}
if (!title) {
if (!title)
{
return res.status(400).json({ error: 'Form title is required' });
}
try {
try
{
// Use a transaction to ensure atomicity: delete old structure, update form, create new structure
const result = await prisma.$transaction(async (tx) => {
const result = await prisma.$transaction(async(tx) =>
{
// 1. Check if form exists (optional, delete/update will fail if not found anyway)
const existingForm = await tx.form.findUnique({ where: { id: formId } });
if (!existingForm) {
if (!existingForm)
{
throw { code: 'P2025' }; // Simulate Prisma not found error
}
@ -180,11 +210,14 @@ router.put('/forms/:id', async (req, res) => {
name: category.name,
sortOrder: catIndex,
fields: {
create: category.fields?.map((field, fieldIndex) => {
if (!Object.values(FieldType).includes(field.type)) {
create: category.fields?.map((field, fieldIndex) =>
{
if (!Object.values(FieldType).includes(field.type))
{
throw new Error(`Invalid field type: ${field.type}`);
}
if (!field.label) {
if (!field.label)
{
throw new Error('Field label is required');
}
return {
@ -208,7 +241,9 @@ router.put('/forms/:id', async (req, res) => {
});
res.status(200).json(result);
} catch (err) {
}
catch (err)
{
handlePrismaError(res, err, `update form ${formId}`);
}
});
@ -217,24 +252,30 @@ router.put('/forms/:id', async (req, res) => {
// --- Responses API --- //
// POST /api/forms/:id/responses - Submit a response for a form
router.post('/forms/:id/responses', async (req, res) => {
router.post('/forms/:id/responses', async(req, res) =>
{
const { id } = req.params;
const formId = parseInt(id, 10);
const { values } = req.body; // values is expected to be { fieldId: value, ... }
if (isNaN(formId)) {
if (isNaN(formId))
{
return res.status(400).json({ error: 'Invalid form ID' });
}
if (!values || typeof values !== 'object' || Object.keys(values).length === 0) {
if (!values || typeof values !== 'object' || Object.keys(values).length === 0)
{
return res.status(400).json({ error: 'Response values are required' });
}
try {
try
{
// Use transaction to ensure response and values are created together
const result = await prisma.$transaction(async (tx) => {
const result = await prisma.$transaction(async(tx) =>
{
// 1. Verify form exists
const form = await tx.form.findUnique({ where: { id: formId }, select: { id: true } });
if (!form) {
if (!form)
{
throw { code: 'P2025' }; // Simulate Prisma not found error
}
@ -253,23 +294,27 @@ router.post('/forms/:id/responses', async (req, res) => {
// Optional: Verify all field IDs belong to the form (more robust)
const validFields = await tx.field.findMany({
where: {
id: { in: fieldIds },
id: { 'in': fieldIds },
category: { formId: formId }
},
select: { id: true }
});
const validFieldIds = new Set(validFields.map(f => f.id));
for (const fieldIdStr in values) {
for (const fieldIdStr in values)
{
const fieldId = parseInt(fieldIdStr, 10);
if (validFieldIds.has(fieldId)) {
if (validFieldIds.has(fieldId))
{
const value = values[fieldIdStr];
responseValuesData.push({
responseId: newResponse.id,
fieldId: fieldId,
value: (value === null || typeof value === 'undefined') ? null : String(value),
});
} else {
}
else
{
console.warn(`Attempted to submit value for field ${fieldId} not belonging to form ${formId}`);
// Decide whether to throw an error or just skip invalid fields
// throw new Error(`Field ${fieldId} does not belong to form ${formId}`);
@ -277,7 +322,8 @@ router.post('/forms/:id/responses', async (req, res) => {
}
// 4. Create all response values
if (responseValuesData.length > 0) {
if (responseValuesData.length > 0)
{
await tx.responseValue.createMany({
data: responseValuesData,
});
@ -287,24 +333,30 @@ router.post('/forms/:id/responses', async (req, res) => {
});
res.status(201).json(result);
} catch (err) {
}
catch (err)
{
handlePrismaError(res, err, `submit response for form ${formId}`);
}
});
// GET /api/forms/:id/responses - Get all responses for a form
router.get('/forms/:id/responses', async (req, res) => {
router.get('/forms/:id/responses', async(req, res) =>
{
const { id } = req.params;
const formId = parseInt(id, 10);
if (isNaN(formId)) {
if (isNaN(formId))
{
return res.status(400).json({ error: 'Invalid form ID' });
}
try {
try
{
// 1. Check if form exists
const formExists = await prisma.form.findUnique({ where: { id: formId }, select: { id: true } });
if (!formExists) {
if (!formExists)
{
return res.status(404).json({ error: 'Form not found' });
}
@ -328,13 +380,15 @@ router.get('/forms/:id/responses', async (req, res) => {
id: response.id,
submittedAt: response.submittedAt,
values: response.responseValues
.sort((a, b) => {
.sort((a, b) =>
{
// Sort by category order, then field order
const catSort = a.field.category.sortOrder - b.field.category.sortOrder;
if (catSort !== 0) return catSort;
return a.field.sortOrder - b.field.sortOrder;
})
.reduce((acc, rv) => {
.reduce((acc, rv) =>
{
acc[rv.fieldId] = {
label: rv.field.label,
type: rv.field.type,
@ -345,22 +399,27 @@ router.get('/forms/:id/responses', async (req, res) => {
}));
res.json(groupedResponses);
} catch (err) {
}
catch (err)
{
handlePrismaError(res, err, `fetch responses for form ${formId}`);
}
});
// GET /responses/:responseId/export/pdf - Export response as PDF
router.get('/responses/:responseId/export/pdf', async (req, res) => {
router.get('/responses/:responseId/export/pdf', async(req, res) =>
{
const { responseId: responseIdStr } = req.params;
const responseId = parseInt(responseIdStr, 10);
if (isNaN(responseId)) {
if (isNaN(responseId))
{
return res.status(400).json({ error: 'Invalid response ID' });
}
try {
try
{
// 1. Fetch the response, form title, form structure, and values in one go
const responseData = await prisma.response.findUnique({
where: { id: responseId },
@ -385,13 +444,15 @@ router.get('/responses/:responseId/export/pdf', async (req, res) => {
}
});
if (!responseData) {
if (!responseData)
{
return res.status(404).json({ error: 'Response not found' });
}
const formTitle = responseData.form.title;
const categories = responseData.form.categories;
const responseValues = responseData.responseValues.reduce((acc, rv) => {
const responseValues = responseData.responseValues.reduce((acc, rv) =>
{
acc[rv.fieldId] = (rv.value === null || typeof rv.value === 'undefined') ? '' : String(rv.value);
return acc;
}, {});
@ -414,39 +475,53 @@ router.get('/responses/:responseId/export/pdf', async (req, res) => {
doc.fontSize(18).font('Roboto-Bold').text(formTitle, { align: 'center' });
doc.moveDown();
for (const category of categories) {
if (category.name) {
doc.fontSize(14).font('Roboto-Bold').text(category.name);
doc.moveDown(0.5);
for (const category of categories)
{
if (category.name)
{
doc.fontSize(14).font('Roboto-Bold').text(category.name);
doc.moveDown(0.5);
}
for (const field of category.fields) {
for (const field of category.fields)
{
const value = responseValues[field.id] || '';
doc.fontSize(12).font('Roboto-SemiBold').text(field.label + ':', { continued: false });
if (field.description) {
doc.fontSize(9).font('Roboto-Italics').text(field.description);
if (field.description)
{
doc.fontSize(9).font('Roboto-Italics').text(field.description);
}
doc.moveDown(0.2);
doc.fontSize(11).font('Roboto-Regular');
if (field.type === 'textarea') {
if (field.type === 'textarea')
{
const textHeight = doc.heightOfString(value, { width: 500 });
doc.rect(doc.x, doc.y, 500, Math.max(textHeight + 10, 30)).stroke();
doc.text(value, doc.x + 5, doc.y + 5, { width: 490 });
doc.y += Math.max(textHeight + 10, 30) + 10;
} else if (field.type === 'date') {
}
else if (field.type === 'date')
{
let formattedDate = '';
if (value) {
try {
if (value)
{
try
{
const dateObj = new Date(value + 'T00:00:00');
if (!isNaN(dateObj.getTime())) {
if (!isNaN(dateObj.getTime()))
{
const day = String(dateObj.getDate()).padStart(2, '0');
const month = String(dateObj.getMonth() + 1).padStart(2, '0');
const year = dateObj.getFullYear();
formattedDate = `${day}/${month}/${year}`;
} else {
}
else
{
formattedDate = value;
}
} catch (e) {
}
catch (e)
{
console.error('Error formatting date:', value, e);
formattedDate = value;
}
@ -454,28 +529,37 @@ router.get('/responses/:responseId/export/pdf', async (req, res) => {
doc.text(formattedDate || ' ');
doc.lineCap('butt').moveTo(doc.x, doc.y).lineTo(doc.x + 500, doc.y).stroke();
doc.moveDown(1.5);
} else if (field.type === 'boolean') {
}
else if (field.type === 'boolean')
{
const displayValue = value === 'true' ? 'Yes' : (value === 'false' ? 'No' : ' ');
doc.text(displayValue);
doc.lineCap('butt').moveTo(doc.x, doc.y).lineTo(doc.x + 500, doc.y).stroke();
doc.moveDown(1.5);
} else {
}
else
{
doc.text(value || ' ');
doc.lineCap('butt').moveTo(doc.x, doc.y).lineTo(doc.x + 500, doc.y).stroke();
doc.moveDown(1.5);
}
}
doc.moveDown(1);
doc.moveDown(1);
}
doc.end();
} catch (err) {
}
catch (err)
{
console.error(`Error generating PDF for response ${responseId}:`, err.message);
if (!res.headersSent) {
if (!res.headersSent)
{
// Use the helper function
handlePrismaError(res, err, `generate PDF for response ${responseId}`);
} else {
console.error("Headers already sent, could not send JSON error for PDF generation failure.");
}
else
{
console.error('Headers already sent, could not send JSON error for PDF generation failure.');
res.end();
}
}
@ -485,8 +569,10 @@ router.get('/responses/:responseId/export/pdf', async (req, res) => {
// --- Mantis Summary API Route --- //
// GET /api/mantis-summary/today - Get today's summary specifically
router.get('/mantis-summary/today', async (req, res) => {
try {
router.get('/mantis-summary/today', async(req, res) =>
{
try
{
const today = new Date();
today.setHours(0, 0, 0, 0); // Set to start of day UTC for comparison
@ -495,23 +581,30 @@ router.get('/mantis-summary/today', async (req, res) => {
select: { summaryDate: true, summaryText: true, generatedAt: true }
});
if (todaySummary) {
if (todaySummary)
{
res.json(todaySummary);
} else {
}
else
{
res.status(404).json({ message: `No Mantis summary found for today (${today.toISOString().split('T')[0]}).` });
}
} catch (error) {
}
catch (error)
{
handlePrismaError(res, error, 'fetch today\'s Mantis summary');
}
});
// GET /api/mantis-summaries - Get ALL summaries from the DB, with pagination
router.get('/mantis-summaries', async (req, res) => {
router.get('/mantis-summaries', async(req, res) =>
{
const page = parseInt(req.query.page, 10) || 1;
const limit = parseInt(req.query.limit, 10) || 10;
const skip = (page - 1) * limit;
try {
try
{
const [summaries, totalItems] = await prisma.$transaction([
prisma.mantisSummary.findMany({
orderBy: { summaryDate: 'desc' },
@ -523,104 +616,78 @@ router.get('/mantis-summaries', async (req, res) => {
]);
res.json({ summaries, total: totalItems });
} catch (error) {
}
catch (error)
{
handlePrismaError(res, error, 'fetch paginated Mantis summaries');
}
});
// POST /api/mantis-summaries/generate - Trigger summary generation
router.post('/mantis-summaries/generate', async (req, res) => {
try {
router.post('/mantis-summaries/generate', async(req, res) =>
{
try
{
// Trigger generation asynchronously, don't wait for it
generateTodaysSummary()
.then(() => {
.then(() =>
{
console.log('Summary generation process finished successfully (async).');
})
.catch(error => {
.catch(error =>
{
console.error('Background summary generation failed:', error);
});
res.status(202).json({ message: 'Summary generation started.' });
} catch (error) {
}
catch (error)
{
handlePrismaError(res, error, 'initiate Mantis summary generation');
}
});
// --- Email Summary API Routes --- //
// GET /api/email-summaries - Get ALL email summaries from the DB, with pagination
router.get('/email-summaries', async (req, res) => {
const page = parseInt(req.query.page, 10) || 1;
const limit = parseInt(req.query.limit, 10) || 10;
const skip = (page - 1) * limit;
try {
const [summaries, totalItems] = await prisma.$transaction([
prisma.emailSummary.findMany({ // Use emailSummary model
orderBy: { summaryDate: 'desc' },
take: limit,
skip: skip,
select: { id: true, summaryDate: true, summaryText: true, generatedAt: true }
}),
prisma.emailSummary.count() // Count emailSummary model
]);
res.json({ summaries, total: totalItems });
} catch (error) {
handlePrismaError(res, error, 'fetch paginated Email summaries');
}
});
// POST /api/email-summaries/generate - Trigger email summary generation
router.post('/email-summaries/generate', async (req, res) => {
try {
// Trigger generation asynchronously, don't wait for it
generateAndStoreEmailSummary() // Use the email summarizer function
.then(() => {
console.log('Email summary generation process finished successfully (async).');
})
.catch(error => {
console.error('Background email summary generation failed:', error);
});
res.status(202).json({ message: 'Email summary generation started.' });
} catch (error) {
handlePrismaError(res, error, 'initiate Email summary generation');
}
});
// --- Settings API --- //
// GET /api/settings/:key - Get a specific setting value
router.get('/settings/:key', async (req, res) => {
router.get('/settings/:key', async(req, res) =>
{
const { key } = req.params;
try {
try
{
const setting = await prisma.setting.findUnique({
where: { key: key },
select: { value: true }
});
if (setting !== null) {
if (setting !== null)
{
res.json({ key, value: setting.value });
} else {
}
else
{
res.json({ key, value: '' }); // Return empty value if not found
}
} catch (err) {
}
catch (err)
{
handlePrismaError(res, err, `fetch setting '${key}'`);
}
});
// PUT /api/settings/:key - Update or create a specific setting
router.put('/settings/:key', async (req, res) => {
router.put('/settings/:key', async(req, res) =>
{
const { key } = req.params;
const { value } = req.body;
if (typeof value === 'undefined') {
if (typeof value === 'undefined')
{
return res.status(400).json({ error: 'Setting value is required in the request body' });
}
try {
try
{
const upsertedSetting = await prisma.setting.upsert({
where: { key: key },
update: { value: String(value) },
@ -628,7 +695,9 @@ router.put('/settings/:key', async (req, res) => {
select: { key: true, value: true } // Select to return the updated/created value
});
res.status(200).json(upsertedSetting);
} catch (err) {
}
catch (err)
{
handlePrismaError(res, err, `update setting '${key}'`);
}
});

View file

@ -13,7 +13,8 @@ import { rpID, rpName, origin, challengeStore } from '../server.js'; // Import R
const router = express.Router();
// Helper function to get user authenticators
async function getUserAuthenticators(userId) {
async function getUserAuthenticators(userId)
{
return prisma.authenticator.findMany({
where: { userId },
select: {
@ -26,34 +27,41 @@ async function getUserAuthenticators(userId) {
}
// Helper function to get a user by username
async function getUserByUsername(username) {
return prisma.user.findUnique({ where: { 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 } });
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 } });
async function getAuthenticatorByCredentialID(credentialID)
{
return prisma.authenticator.findUnique({ where: { credentialID } });
}
// Generate Registration Options
router.post('/generate-registration-options', async (req, res) => {
router.post('/generate-registration-options', async(req, res) =>
{
const { username } = req.body;
if (!username) {
if (!username)
{
return res.status(400).json({ error: 'Username is required' });
}
try {
try
{
let user = await getUserByUsername(username);
// If user doesn't exist, create one
if (!user) {
if (!user)
{
user = await prisma.user.create({
data: { username },
});
@ -61,9 +69,11 @@ router.post('/generate-registration-options', async (req, res) => {
const userAuthenticators = await getUserAuthenticators(user.id);
if(userAuthenticators.length > 0) {
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) {
if (!req.session.loggedInUserId || req.session.loggedInUserId !== user.id)
{
return res.status(403).json({ error: 'Invalid registration attempt.' });
}
}
@ -93,31 +103,38 @@ router.post('/generate-registration-options', async (req, res) => {
req.session.userId = user.id; // Temporarily store userId in session for verification step
res.json(options);
} catch (error) {
}
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) => {
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.' });
if (!userId)
{
return res.status(400).json({ error: 'User session not found. Please start registration again.' });
}
const expectedChallenge = challengeStore.get(userId);
if (!expectedChallenge) {
if (!expectedChallenge)
{
return res.status(400).json({ error: 'Challenge not found or expired' });
}
try {
try
{
const user = await getUserById(userId);
if (!user) {
return res.status(404).json({ error: 'User not found' });
if (!user)
{
return res.status(404).json({ error: 'User not found' });
}
const verification = await verifyRegistrationResponse({
@ -132,7 +149,8 @@ router.post('/verify-registration', async (req, res) => {
console.log(verification);
if (verified && registrationInfo) {
if (verified && registrationInfo)
{
const { credential, credentialDeviceType, credentialBackedUp } = registrationInfo;
const credentialID = credential.id;
@ -143,7 +161,8 @@ router.post('/verify-registration', async (req, res) => {
// Check if authenticator with this ID already exists
const existingAuthenticator = await getAuthenticatorByCredentialID(isoBase64URL.fromBuffer(credentialID));
if (existingAuthenticator) {
if (existingAuthenticator)
{
return res.status(409).json({ error: 'Authenticator already registered' });
}
@ -168,10 +187,14 @@ router.post('/verify-registration', async (req, res) => {
req.session.loggedInUserId = user.id;
res.json({ verified: true });
} else {
}
else
{
res.status(400).json({ error: 'Registration verification failed' });
}
} catch (error) {
}
catch (error)
{
console.error('Registration verification error:', error);
challengeStore.delete(userId); // Clean up challenge on error
delete req.session.userId;
@ -180,19 +203,25 @@ router.post('/verify-registration', async (req, res) => {
});
// Generate Authentication Options
router.post('/generate-authentication-options', async (req, res) => {
router.post('/generate-authentication-options', async(req, res) =>
{
const { username } = req.body;
try {
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 (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) {
if (!user)
{
return res.status(404).json({ error: 'User not found' });
}
@ -218,42 +247,51 @@ router.post('/generate-authentication-options', async (req, res) => {
req.session.challengeUserId = user.id; // Store user ID associated with this challenge
res.json(options);
} catch (error) {
}
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) => {
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.' });
if (!challengeUserId)
{
return res.status(400).json({ error: 'Challenge session not found. Please try logging in again.' });
}
const expectedChallenge = challengeStore.get(challengeUserId);
if (!expectedChallenge) {
if (!expectedChallenge)
{
return res.status(400).json({ error: 'Challenge not found or expired' });
}
try {
try
{
const user = await getUserById(challengeUserId);
if (!user) {
return res.status(404).json({ error: 'User associated with challenge not found' });
if (!user)
{
return res.status(404).json({ error: 'User associated with challenge not found' });
}
const authenticator = await getAuthenticatorByCredentialID(authenticationResponse.id);
if (!authenticator) {
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' });
if (authenticator.userId !== user.id)
{
return res.status(403).json({ error: 'Authenticator does not belong to this user' });
}
const verification = await verifyAuthenticationResponse({
@ -272,7 +310,8 @@ router.post('/verify-authentication', async (req, res) => {
const { verified, authenticationInfo } = verification;
if (verified) {
if (verified)
{
// Update the authenticator counter
await prisma.authenticator.update({
where: { credentialID: authenticator.credentialID },
@ -287,10 +326,14 @@ router.post('/verify-authentication', async (req, res) => {
req.session.loggedInUserId = user.id;
res.json({ verified: true, user: { id: user.id, username: user.username } });
} else {
}
else
{
res.status(400).json({ error: 'Authentication verification failed' });
}
} catch (error) {
}
catch (error)
{
console.error('Authentication verification error:', error);
challengeStore.delete(challengeUserId); // Clean up challenge on error
delete req.session.challengeUserId;
@ -299,12 +342,15 @@ router.post('/verify-authentication', async (req, res) => {
});
// GET Passkeys for Logged-in User
router.get('/passkeys', async (req, res) => {
if (!req.session.loggedInUserId) {
router.get('/passkeys', async(req, res) =>
{
if (!req.session.loggedInUserId)
{
return res.status(401).json({ error: 'Not authenticated' });
}
try {
try
{
const userId = req.session.loggedInUserId;
const authenticators = await prisma.authenticator.findMany({
where: { userId },
@ -317,25 +363,31 @@ router.get('/passkeys', async (req, res) => {
// No need to convert credentialID here as it's stored as Base64URL string
res.json(authenticators);
} catch (error) {
}
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) {
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) {
if (!credentialID)
{
return res.status(400).json({ error: 'Credential ID is required' });
}
try {
try
{
const userId = req.session.loggedInUserId;
// Find the authenticator first to ensure it belongs to the logged-in user
@ -343,12 +395,14 @@ router.delete('/passkeys/:credentialID', async (req, res) => {
where: { credentialID: credentialID }, // Use the Base64URL string directly
});
if (!authenticator) {
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) {
if (authenticator.userId !== userId)
{
return res.status(403).json({ error: 'Permission denied' });
}
@ -358,28 +412,36 @@ router.delete('/passkeys/:credentialID', async (req, res) => {
});
res.json({ message: 'Passkey deleted successfully' });
} catch (error) {
}
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' });
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', (req, res) => {
if (req.session.loggedInUserId) {
router.get('/status', (req, res) =>
{
if (req.session.loggedInUserId)
{
return res.json({ status: 'authenticated' });
}
res.json({ status: 'unauthenticated' });
});
// Logout
router.post('/logout', (req, res) => {
req.session.destroy(err => {
if (err) {
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' });
}

View file

@ -10,17 +10,21 @@ const router = Router();
router.use(requireAuth);
// POST /api/chat/threads - Create a new chat thread (optionally with a first message)
router.post('/threads', async (req, res) => {
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)) {
if (content && (typeof content !== 'string' || content.trim().length === 0))
{
return res.status(400).json({ error: 'Message content cannot be empty if provided.' });
}
try {
try
{
const createData = {};
if (content) {
if (content)
{
// If content exists, create the thread with the first message
createData.messages = {
create: [
@ -48,21 +52,25 @@ router.post('/threads', async (req, res) => {
// 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() })) : []
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) {
}
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) => {
router.get('/threads/:threadId/messages', async(req, res) =>
{
const { threadId } = req.params;
try {
try
{
const messages = await prisma.chatMessage.findMany({
where: {
threadId: threadId,
@ -72,45 +80,55 @@ router.get('/threads/:threadId/messages', async (req, res) => {
},
});
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.' });
}
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) {
}
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.' });
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) => {
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) {
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.' });
if (sender !== 'user' && sender !== 'bot')
{
return res.status(400).json({ error: 'Invalid sender type.' });
}
try {
try
{
// Verify thread exists first
const thread = await prisma.chatThread.findUnique({
where: { id: threadId },
});
if (!thread) {
if (!thread)
{
return res.status(404).json({ error: 'Chat thread not found.' });
}
@ -124,17 +142,20 @@ router.post('/threads/:threadId/messages', async (req, res) => {
// Optionally: Update the thread's updatedAt timestamp
await prisma.chatThread.update({
where: { id: threadId },
data: { updatedAt: new Date() }
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) {
}
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.' });
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.' });
}

View file

@ -9,8 +9,8 @@
* 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 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 {
@ -19,7 +19,7 @@ import {
defineSsrClose,
defineSsrServeStaticContent,
defineSsrRenderPreloadTag
} from '#q-app/wrappers'
} from '#q-app/wrappers';
import prisma from './database.js'; // Import the prisma client instance
import apiRoutes from './routes/api.js';
@ -43,8 +43,9 @@ export const challengeStore = new Map();
*
* Can be async: defineSsrCreate(async ({ ... }) => { ... })
*/
export const create = defineSsrCreate((/* { ... } */) => {
const app = express()
export const create = defineSsrCreate((/* { ... } */) =>
{
const app = express();
// Session middleware configuration
app.use(session({
@ -60,29 +61,36 @@ export const create = defineSsrCreate((/* { ... } */) => {
}));
// Initialize the database (now synchronous)
try {
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 () => {
cron.schedule('0 1 * * *', async() =>
{
console.log('Running scheduled Mantis summary task...');
try {
try
{
await generateAndStoreMantisSummary();
console.log('Scheduled Mantis summary task completed.');
} catch (error) {
}
catch (error)
{
console.error('Error running scheduled Mantis summary task:', error);
}
}, {
scheduled: true,
timezone: "Europe/London" // Example: Set to your server's timezone
timezone: 'Europe/London' // Example: Set to your server's timezone
});
console.log('Mantis summary cron job scheduled.');
// Optional: Run once immediately on server start if needed
generateAndStoreMantisSummary().catch(err => console.error('Initial Mantis summary failed:', err));
} catch (error) {
}
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
@ -90,7 +98,7 @@ export const create = defineSsrCreate((/* { ... } */) => {
// attackers can use this header to detect apps running Express
// and then launch specifically-targeted attacks
app.disable('x-powered-by')
app.disable('x-powered-by');
// Add JSON body parsing middleware
app.use(express.json());
@ -102,12 +110,13 @@ export const create = defineSsrCreate((/* { ... } */) => {
// place here any middlewares that
// absolutely need to run before anything else
if (process.env.PROD) {
app.use(compression())
if (process.env.PROD)
{
app.use(compression());
}
return app
})
return app;
});
/**
* You need to make the server listen to the indicated port
@ -122,14 +131,17 @@ export const create = defineSsrCreate((/* { ... } */) => {
*
* Can be async: defineSsrListen(async ({ app, devHttpsApp, port }) => { ... })
*/
export const listen = defineSsrListen(({ app, devHttpsApp, port }) => {
const server = devHttpsApp || app
return server.listen(port, () => {
if (process.env.PROD) {
console.log('Server listening at port ' + port)
export const listen = defineSsrListen(({ app, devHttpsApp, port }) =>
{
const server = devHttpsApp || app;
return server.listen(port, () =>
{
if (process.env.PROD)
{
console.log('Server listening at port ' + port);
}
})
})
});
});
/**
* Should close the server and free up any resources.
@ -138,21 +150,25 @@ export const listen = defineSsrListen(({ app, devHttpsApp, port }) => {
*
* Can be async: defineSsrClose(async ({ ... }) => { ... })
*/
export const close = defineSsrClose(async ({ listenResult }) => {
export const close = defineSsrClose(async({ listenResult }) =>
{
// Close the database connection when the server shuts down
try {
try
{
await prisma.$disconnect();
console.log('Prisma Client disconnected.');
} catch (e) {
}
catch (e)
{
console.error('Error disconnecting Prisma Client:', e);
}
return listenResult.close()
})
return listenResult.close();
});
const maxAge = process.env.DEV
? 0
: 1000 * 60 * 60 * 24 * 30
: 1000 * 60 * 60 * 24 * 30;
/**
* Should return a function that will be used to configure the webserver
@ -163,53 +179,63 @@ const maxAge = process.env.DEV
* Can be async: defineSsrServeStaticContent(async ({ app, resolve }) => {
* Can return an async function: return async ({ urlPath = '/', pathToServe = '.', opts = {} }) => {
*/
export const serveStaticContent = defineSsrServeStaticContent(({ app, resolve }) => {
return ({ urlPath = '/', pathToServe = '.', opts = {} }) => {
const serveFn = express.static(resolve.public(pathToServe), { maxAge, ...opts })
app.use(resolve.urlPath(urlPath), serveFn)
}
})
export const serveStaticContent = defineSsrServeStaticContent(({ app, resolve }) =>
{
return ({ urlPath = '/', pathToServe = '.', opts = {} }) =>
{
const serveFn = express.static(resolve.public(pathToServe), { maxAge, ...opts });
app.use(resolve.urlPath(urlPath), serveFn);
};
});
const jsRE = /\.js$/
const cssRE = /\.css$/
const woffRE = /\.woff$/
const woff2RE = /\.woff2$/
const gifRE = /\.gif$/
const jpgRE = /\.jpe?g$/
const pngRE = /\.png$/
const jsRE = /\.js$/;
const cssRE = /\.css$/;
const woffRE = /\.woff$/;
const woff2RE = /\.woff2$/;
const gifRE = /\.gif$/;
const jpgRE = /\.jpe?g$/;
const pngRE = /\.png$/;
/**
* Should return a String with HTML output
* (if any) for preloading indicated file
*/
export const renderPreloadTag = defineSsrRenderPreloadTag((file/* , { ssrContext } */) => {
if (jsRE.test(file) === true) {
return `<link rel="modulepreload" href="${file}" crossorigin>`
export const renderPreloadTag = defineSsrRenderPreloadTag((file/* , { ssrContext } */) =>
{
if (jsRE.test(file) === true)
{
return `<link rel="modulepreload" href="${file}" crossorigin>`;
}
if (cssRE.test(file) === true) {
return `<link rel="stylesheet" href="${file}" crossorigin>`
if (cssRE.test(file) === true)
{
return `<link rel="stylesheet" href="${file}" crossorigin>`;
}
if (woffRE.test(file) === true) {
return `<link rel="preload" href="${file}" as="font" type="font/woff" crossorigin>`
if (woffRE.test(file) === true)
{
return `<link rel="preload" href="${file}" as="font" type="font/woff" crossorigin>`;
}
if (woff2RE.test(file) === true) {
return `<link rel="preload" href="${file}" as="font" type="font/woff2" crossorigin>`
if (woff2RE.test(file) === true)
{
return `<link rel="preload" href="${file}" as="font" type="font/woff2" crossorigin>`;
}
if (gifRE.test(file) === true) {
return `<link rel="preload" href="${file}" as="image" type="image/gif" crossorigin>`
if (gifRE.test(file) === true)
{
return `<link rel="preload" href="${file}" as="image" type="image/gif" crossorigin>`;
}
if (jpgRE.test(file) === true) {
return `<link rel="preload" href="${file}" as="image" type="image/jpeg" crossorigin>`
if (jpgRE.test(file) === true)
{
return `<link rel="preload" href="${file}" as="image" type="image/jpeg" crossorigin>`;
}
if (pngRE.test(file) === true) {
return `<link rel="preload" href="${file}" as="image" type="image/png" crossorigin>`
if (pngRE.test(file) === true)
{
return `<link rel="preload" href="${file}" as="image" type="image/png" crossorigin>`;
}
return ''
})
return '';
});

View file

@ -1,199 +0,0 @@
import Imap from 'node-imap';
import { simpleParser } from 'mailparser';
import { GoogleGenAI } from '@google/genai';
import prisma from '../database.js';
// --- Environment Variables ---
const { GOOGLE_API_KEY } = process.env; // Added
// --- AI Setup ---
const ai = GOOGLE_API_KEY ? new GoogleGenAI({
apiKey: GOOGLE_API_KEY,
}) : null; // Added
export async function fetchAndFormatEmails() {
return new Promise((resolve, reject) => {
const imapConfig = {
user: process.env.OUTLOOK_EMAIL_ADDRESS,
password: process.env.OUTLOOK_APP_PASSWORD,
host: 'outlook.office365.com',
port: 993,
tls: true,
tlsOptions: { rejectUnauthorized: false } // Adjust as needed for your environment
};
const imap = new Imap(imapConfig);
const emailsJson = [];
function openInbox(cb) {
// Note: IMAP uses '/' as hierarchy separator, adjust if your server uses something else
imap.openBox('SLSNotifications/Reports/Backups', false, cb);
}
imap.once('ready', () => {
openInbox((err, box) => {
if (err) {
imap.end();
return reject(new Error(`Error opening mailbox: ${err.message}`));
}
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const searchCriteria = [['SINCE', yesterday.toISOString().split('T')[0]]]; // Search since midnight yesterday
const fetchOptions = { bodies: ['HEADER.FIELDS (SUBJECT DATE)', 'TEXT'], struct: true };
imap.search(searchCriteria, (searchErr, results) => {
if (searchErr) {
imap.end();
return reject(new Error(`Error searching emails: ${searchErr.message}`));
}
if (results.length === 0) {
console.log('No emails found from the last 24 hours.');
imap.end();
return resolve([]);
}
const f = imap.fetch(results, fetchOptions);
let processedCount = 0;
f.on('message', (msg, seqno) => {
let header = '';
let body = '';
msg.on('body', (stream, info) => {
let buffer = '';
stream.on('data', (chunk) => {
buffer += chunk.toString('utf8');
});
stream.once('end', () => {
if (info.which === 'TEXT') {
body = buffer;
} else {
// Assuming HEADER.FIELDS (SUBJECT DATE) comes as one chunk
header = buffer;
}
});
});
msg.once('attributes', (attrs) => {
// Attributes might contain date if not fetched via header
});
msg.once('end', async () => {
try {
// Use mailparser to handle potential encoding issues and structure
const mail = await simpleParser(`Subject: ${header.match(/Subject: (.*)/i)?.[1] || ''}\nDate: ${header.match(/Date: (.*)/i)?.[1] || ''}\n\n${body}`);
emailsJson.push({
title: mail.subject || 'No Subject',
time: mail.date ? mail.date.toISOString() : 'No Date',
body: mail.text || mail.html || 'No Body Content' // Prefer text, fallback to html, then empty
});
} catch (parseErr) {
console.error(`Error parsing email seqno ${seqno}:`, parseErr);
// Decide if you want to reject or just skip this email
}
processedCount++;
if (processedCount === results.length) {
// This check might be slightly inaccurate if errors occur,
// but it's a common pattern. Consider refining with promises.
}
});
});
f.once('error', (fetchErr) => {
console.error('Fetch error: ' + fetchErr);
// Don't reject here immediately, might still get some emails
});
f.once('end', () => {
console.log('Done fetching all messages!');
imap.end();
});
});
});
});
imap.once('error', (err) => {
reject(new Error(`IMAP Connection Error: ${err.message}`));
});
imap.once('end', () => {
console.log('IMAP Connection ended.');
resolve(emailsJson); // Resolve with the collected emails
});
imap.connect();
});
}
// --- Email Summary Logic (New Function) ---
export async function generateAndStoreEmailSummary() {
console.log('Attempting to generate and store Email summary...');
if (!ai) {
console.error('Google AI API key not configured. Skipping email summary generation.');
return;
}
try {
// Get the prompt from the database settings using Prisma
const setting = await prisma.setting.findUnique({
where: { key: 'emailPrompt' }, // Use 'emailPrompt' as the key
select: { value: true }
});
const promptTemplate = setting?.value;
if (!promptTemplate) {
console.error('Email prompt not found in database settings (key: emailPrompt). Skipping summary generation.');
return;
}
const emails = await fetchAndFormatEmails();
let summaryText;
if (emails.length === 0) {
summaryText = "No relevant emails found in the last 24 hours.";
console.log('No recent emails found for summary.');
} else {
console.log(`Found ${emails.length} recent emails. Generating summary...`);
// Replace placeholder in the prompt template
// Ensure your prompt template uses $EMAIL_DATA
let prompt = promptTemplate.replaceAll("$EMAIL_DATA", JSON.stringify(emails, null, 2));
// Call the AI model (adjust model name and config as needed)
const response = await ai.models.generateContent({
"model": "gemini-2.5-preview-04-17",
"contents": prompt,
config: {
temperature: 0 // Adjust temperature as needed
}
});
summaryText = response.text;
console.log('Email 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.emailSummary.upsert({
where: { summaryDate: today },
update: {
summaryText: summaryText,
// generatedAt is updated automatically by @default(now())
},
create: {
summaryDate: today,
summaryText: summaryText,
},
});
console.log(`Email summary for ${today.toISOString().split('T')[0]} stored/updated in the database.`);
} catch (error) {
console.error("Error during Email summary generation/storage:", error);
// Re-throw or handle as appropriate for your application
throw error;
}
}

View file

@ -1,48 +1,45 @@
import axios from 'axios';
import { GoogleGenAI } from '@google/genai';
import prisma from '../database.js'; // Import Prisma client
// --- Environment Variables ---
const {
MANTIS_API_KEY,
MANTIS_API_ENDPOINT,
GOOGLE_API_KEY
} = process.env;
// --- Mantis Summarizer Setup ---
const ai = GOOGLE_API_KEY ? new GoogleGenAI({
apiKey: GOOGLE_API_KEY,
}) : null;
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',
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() {
if (!MANTIS_API_ENDPOINT || !MANTIS_API_KEY) {
throw new Error("Mantis API endpoint or key not configured in environment variables.");
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',
Authorization: `${MANTIS_API_KEY}`,
Accept: 'application/json',
'Content-Type': 'application/json',
};
try {
try
{
const response = await axios.get(url, { headers });
const tickets = response.data.issues.filter((ticket) => {
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, ...
@ -53,7 +50,8 @@ async function getMantisTickets() {
thresholdDate.setHours(0, 0, 0, 0); // Start of the day
return ticketDate >= thresholdDate;
}).map((ticket) => {
}).map((ticket) =>
{
return {
id: ticket.id,
summary: ticket.summary,
@ -61,7 +59,8 @@ async function getMantisTickets() {
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) => {
notes: (ticket.notes ? ticket.notes.filter((note) =>
{
const noteDate = new Date(note.created_at);
const thresholdDate = new Date();
const currentDay = thresholdDate.getDay();
@ -69,7 +68,8 @@ async function getMantisTickets() {
thresholdDate.setDate(thresholdDate.getDate() - daysToSubtract);
thresholdDate.setHours(0, 0, 0, 0); // Start of the day
return noteDate >= thresholdDate;
}) : []).map((note) => {
}) : []).map((note) =>
{
const reporter = usernameMap[note.reporter?.username] || note.reporter?.name || 'Unknown Reporter'; // Safer access
return {
reporter,
@ -81,27 +81,24 @@ async function getMantisTickets() {
});
return tickets;
} catch (error) {
console.error("Error fetching Mantis tickets:", error.message);
}
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}`);
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}`);
}
}
// --- Mantis Summary Logic (Exported) --- //
export async function generateAndStoreMantisSummary() {
console.log('Attempting to generate and store Mantis summary...');
if (!ai) {
console.error('Google AI API key not configured. Skipping summary generation.');
return;
}
try {
export async function generateAndStoreMantisSummary()
{
try
{
// Get the prompt from the database settings using Prisma
const setting = await prisma.setting.findUnique({
where: { key: 'mantisPrompt' },
@ -109,7 +106,8 @@ export async function generateAndStoreMantisSummary() {
});
const promptTemplate = setting?.value;
if (!promptTemplate) {
if (!promptTemplate)
{
console.error('Mantis prompt not found in database settings (key: mantisPrompt). Skipping summary generation.');
return;
}
@ -117,24 +115,19 @@ export async function generateAndStoreMantisSummary() {
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));
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));
const response = await ai.models.generateContent({
"model": "gemini-2.5-flash-preview-04-17",
"contents": prompt,
config: {
temperature: 0
}
});
summaryText = response.text;
console.log('Mantis summary generated successfully by AI.');
summaryText = await askGemini(prompt);
console.log('Mantis summary generated successfully by AI.');
}
// Store the summary in the database using Prisma upsert
@ -144,8 +137,7 @@ export async function generateAndStoreMantisSummary() {
await prisma.mantisSummary.upsert({
where: { summaryDate: today },
update: {
summaryText: summaryText,
// generatedAt is updated automatically by @default(now())
summaryText: summaryText
},
create: {
summaryDate: today,
@ -154,17 +146,23 @@ export async function generateAndStoreMantisSummary() {
});
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);
}
catch (error)
{
console.error('Error during Mantis summary generation/storage:', error);
}
}
export async function generateTodaysSummary() {
export async function generateTodaysSummary()
{
console.log('Triggering Mantis summary generation via generateTodaysSummary...');
try {
try
{
await generateAndStoreMantisSummary();
return { success: true, message: 'Summary generation process initiated.' };
} catch (error) {
}
catch (error)
{
console.error('Error occurred within generateTodaysSummary while calling generateAndStoreMantisSummary:', error);
throw new Error('Failed to initiate Mantis summary generation.');
}

View file

@ -1,149 +1,162 @@
import { GoogleGenAI } from '@google/genai';
import prisma from '../database.js';
import { getSetting } from './settings.js';
const model = 'gemini-2.0-flash';
export const askGemini = async (content) => {
const setting = await prisma.setting.findUnique({
where: { key: 'GEMINI_API_KEY' },
select: { value: true }
export async function askGemini(content)
{
const GOOGLE_API_KEY = await getSetting('GEMINI_API_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
}
});
const GOOGLE_API_KEY = setting.value;
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.');
}
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 const askGeminiChat = async (threadId, content) => {
let messages = await prisma.chatMessage.findMany({
where: {
threadId: threadId,
},
orderBy: {
createdAt: 'asc', // Get messages in chronological order
},
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 setting = await prisma.setting.findUnique({
where: { key: 'GEMINI_API_KEY' },
select: { value: true }
});
if (!setting)
{
throw new Error('Google API key is not set in the database.');
}
const GOOGLE_API_KEY = setting.value;
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;
}
// Exclude the latest message if there are any messages
if (messages.length > 0) {
messages = messages.slice(0, -1);
}
//Update the message with the response
await prisma.chatMessage.update({
where: {
id: loadingMessage.id,
},
data: {
content: response.text,
},
});
const setting = await prisma.setting.findUnique({
where: { key: 'GEMINI_API_KEY' },
select: { value: true }
});
if (!setting) {
throw new Error('Google API key is not set in the database.');
}
const GOOGLE_API_KEY = setting.value;
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;
return response.text;
}

20
src-ssr/utils/settings.js Normal file
View 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 }
});
}