diff --git a/.vscode/settings.json b/.vscode/settings.json index 8dec94a..c0c478e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -13,5 +13,8 @@ "[vue]": { "editor.defaultFormatter": "dbaeumer.vscode-eslint" }, - "editor.formatOnSave": true + "editor.formatOnSave": true, + "files.eol": "\n", + "files.trimTrailingWhitespace": true, + "editor.trimAutoWhitespace": true } \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index 9876cce..a03b74f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,91 +1,97 @@ -import stylistic from '@stylistic/eslint-plugin'; -import globals from 'globals'; -import pluginVue from 'eslint-plugin-vue'; -import pluginQuasar from '@quasar/app-vite/eslint'; - -export default -[ - { - /** - * Ignore the following files. - * Please note that pluginQuasar.configs.recommended() already ignores - * the "node_modules" folder for you (and all other Quasar project - * relevant folders and files). - * - * ESLint requires "ignores" key to be the only one in this object - */ - // ignores: [] - }, - - ...pluginQuasar.configs.recommended(), - - /** - * https://eslint.vuejs.org - * - * pluginVue.configs.base - * -> Settings and rules to enable correct ESLint parsing. - * pluginVue.configs[ 'flat/essential'] - * -> base, plus rules to prevent errors or unintended behavior. - * pluginVue.configs["flat/strongly-recommended"] - * -> Above, plus rules to considerably improve code readability and/or dev experience. - * pluginVue.configs["flat/recommended"] - * -> Above, plus rules to enforce subjective community defaults to ensure consistency. - */ - ...pluginVue.configs['flat/essential'], - ...pluginVue.configs['flat/strongly-recommended'], - - { - plugins: { - '@stylistic': stylistic, - }, - languageOptions: - { - ecmaVersion: 'latest', - sourceType: 'module', - - globals: - { - ...globals.browser, - ...globals.node, // SSR, Electron, config files - process: 'readonly', // process.env.* - ga: 'readonly', // Google Analytics - cordova: 'readonly', - Capacitor: 'readonly', - chrome: 'readonly', // BEX related - browser: 'readonly' // BEX related - } - }, - - // add your custom rules here - rules: - { - 'prefer-promise-reject-errors': 'off', - - // allow debugger during development only - 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', - - // enforce Allman brace style - '@stylistic/brace-style': ['warn', 'allman'], - '@stylistic/indent': ['warn', 2], - - //Enforce single quotes - '@stylistic/quotes': ['warn', 'single', { avoidEscape: true }], - '@stylistic/quote-props': ['warn', 'as-needed', { keywords: true, unnecessary: true, numbers: true }], - - //Enforce semicolon - '@stylistic/semi': ['warn', 'always'], - '@stylistic/space-before-function-paren': ['warn', 'never'], - } - }, - - { - files: ['src-pwa/custom-service-worker.js'], - languageOptions: - { - globals: - { - ...globals.serviceworker - } - } - } +import stylistic from '@stylistic/eslint-plugin'; +import globals from 'globals'; +import pluginVue from 'eslint-plugin-vue'; +import pluginQuasar from '@quasar/app-vite/eslint'; + +export default +[ + { + /** + * Ignore the following files. + * Please note that pluginQuasar.configs.recommended() already ignores + * the "node_modules" folder for you (and all other Quasar project + * relevant folders and files). + * + * ESLint requires "ignores" key to be the only one in this object + */ + // ignores: [] + }, + + ...pluginQuasar.configs.recommended(), + + /** + * https://eslint.vuejs.org + * + * pluginVue.configs.base + * -> Settings and rules to enable correct ESLint parsing. + * pluginVue.configs[ 'flat/essential'] + * -> base, plus rules to prevent errors or unintended behavior. + * pluginVue.configs["flat/strongly-recommended"] + * -> Above, plus rules to considerably improve code readability and/or dev experience. + * pluginVue.configs["flat/recommended"] + * -> Above, plus rules to enforce subjective community defaults to ensure consistency. + */ + ...pluginVue.configs['flat/essential'], + ...pluginVue.configs['flat/strongly-recommended'], + + { + plugins: { + '@stylistic': stylistic, + }, + languageOptions: + { + ecmaVersion: 'latest', + sourceType: 'module', + + globals: + { + ...globals.browser, + ...globals.node, // SSR, Electron, config files + process: 'readonly', // process.env.* + ga: 'readonly', // Google Analytics + cordova: 'readonly', + Capacitor: 'readonly', + chrome: 'readonly', // BEX related + browser: 'readonly' // BEX related + } + }, + + // add your custom rules here + rules: + { + 'prefer-promise-reject-errors': 'off', + + // allow debugger during development only + 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', + + // enforce Allman brace style + '@stylistic/brace-style': ['warn', 'allman'], + '@stylistic/indent': ['warn', 2], + + //Enforce single quotes + '@stylistic/quotes': ['warn', 'single', { avoidEscape: true }], + '@stylistic/quote-props': ['warn', 'as-needed', { keywords: true, unnecessary: true, numbers: true }], + + //Enforce semicolon + '@stylistic/semi': ['warn', 'always'], + '@stylistic/space-before-function-paren': ['warn', 'never'], + + //Force LF and not CRLF + '@stylistic/linebreak-style': ['warn', 'unix'], + + //Force no trailing spaces + '@stylistic/no-trailing-spaces': ['warn', { skipBlankLines: false, ignoreComments: false }] + } + }, + + { + files: ['src-pwa/custom-service-worker.js'], + languageOptions: + { + globals: + { + ...globals.serviceworker + } + } + } ]; \ No newline at end of file diff --git a/quasar.config.js b/quasar.config.js index ee0fb1a..3c2c539 100644 --- a/quasar.config.js +++ b/quasar.config.js @@ -3,7 +3,7 @@ import { defineConfig } from '#q-app/wrappers'; -export default defineConfig((/* ctx */) => +export default defineConfig((/* ctx */) => { return { // https://v2.quasar.dev/quasar-cli-vite/prefetch-feature @@ -23,7 +23,7 @@ export default defineConfig((/* ctx */) => // https://github.com/quasarframework/quasar/tree/dev/extras extras: [ // 'ionicons-v4', - // 'mdi-v7', + 'mdi-v7', // 'fontawesome-v6', // 'eva-icons', // 'themify', @@ -59,7 +59,7 @@ export default defineConfig((/* ctx */) => // extendViteConf (viteConf) {}, // viteVuePluginOptions: {}, - + // vitePlugins: [ // [ 'package-name', { ..pluginOptions.. }, { server: true, client: true } ] // ] @@ -77,7 +77,7 @@ export default defineConfig((/* ctx */) => devServer: { // https: true, open: true, // opens browser window automatically - + //Add a proxy from /api to the backend server for dev usage proxy: { '/api': { diff --git a/src-server/database.js b/src-server/database.js index b6e447a..23261de 100644 --- a/src-server/database.js +++ b/src-server/database.js @@ -1,7 +1,7 @@ -import { PrismaClient } from '@prisma/client'; - -// Instantiate Prisma Client -const prisma = new PrismaClient(); - -// Export the Prisma Client instance for use in other modules +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; \ No newline at end of file diff --git a/src-server/middlewares/authMiddleware.js b/src-server/middlewares/authMiddleware.js index 754d825..ac561ef 100644 --- a/src-server/middlewares/authMiddleware.js +++ b/src-server/middlewares/authMiddleware.js @@ -1,12 +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(); -} +// 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(); +} diff --git a/src-server/routes/api.js b/src-server/routes/api.js index 6d112ed..8931d03 100644 --- a/src-server/routes/api.js +++ b/src-server/routes/api.js @@ -1,705 +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}'`); - } -}); - +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; \ No newline at end of file diff --git a/src-server/routes/auth.js b/src-server/routes/auth.js index 2087afb..9dfe52b 100644 --- a/src-server/routes/auth.js +++ b/src-server/routes/auth.js @@ -1,459 +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' }); - }); -}); - +// 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; \ No newline at end of file diff --git a/src-server/routes/chat.js b/src-server/routes/chat.js index cfd649f..3a41cba 100644 --- a/src-server/routes/chat.js +++ b/src-server/routes/chat.js @@ -1,164 +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() })) : [] - }); +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.' }); } - 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() }))); + + 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 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 creating chat thread:', error); + res.status(500).json({ error: 'Failed to create chat thread.' }); } - 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; +}); + +// 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; diff --git a/src-server/server.js b/src-server/server.js index 9180c1c..10716c4 100644 --- a/src-server/server.js +++ b/src-server/server.js @@ -65,15 +65,15 @@ app.use(session({ // Schedule the Mantis summary task // 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); } @@ -96,14 +96,14 @@ app.use('/api/chat', chatRoutes); // place here any middlewares that // absolutely need to run before anything else -if (process.env.PROD) +if (process.env.PROD) { app.use(compression()); } app.use(express.static('public', { index: false })); -app.listen(8000, () => +app.listen(8000, () => { console.log('Server is running on http://localhost:8000'); }); \ No newline at end of file diff --git a/src-server/services/mantisSummarizer.js b/src-server/services/mantisSummarizer.js index 32ad7f4..a1d7381 100644 --- a/src-server/services/mantisSummarizer.js +++ b/src-server/services/mantisSummarizer.js @@ -1,169 +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.'); - } -} +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.'); + } +} diff --git a/src-server/utils/gemini.js b/src-server/utils/gemini.js index 248ca62..1d8e32b 100644 --- a/src-server/utils/gemini.js +++ b/src-server/utils/gemini.js @@ -1,154 +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; + +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; } \ No newline at end of file diff --git a/src-server/utils/settings.js b/src-server/utils/settings.js index 724e44e..aa8be85 100644 --- a/src-server/utils/settings.js +++ b/src-server/utils/settings.js @@ -1,20 +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 } - }); +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 } + }); } \ No newline at end of file diff --git a/src/App.vue b/src/App.vue index 760a1d4..1f003d1 100644 --- a/src/App.vue +++ b/src/App.vue @@ -6,7 +6,7 @@ import { useAuthStore } from './stores/auth'; defineOptions({ - preFetch() + preFetch() { const authStore = useAuthStore(); return authStore.checkAuthStatus(); diff --git a/src/boot/axios.js b/src/boot/axios.js index 0051fae..e0e6099 100644 --- a/src/boot/axios.js +++ b/src/boot/axios.js @@ -1,14 +1,14 @@ -import { boot } from 'quasar/wrappers'; -import axios from 'axios'; - -// Be careful when using SSR for cross-request state pollution -// due to creating a Singleton instance here; -// If any client changes this (global) instance, it might be a -// good idea to move this instance creation inside of the -// "export default () => {}" function below (which runs individually -// for each client) - -axios.defaults.withCredentials = true; // Enable sending cookies with requests - -// Export the API instance so you can import it easily elsewhere, e.g. stores +import { boot } from 'quasar/wrappers'; +import axios from 'axios'; + +// Be careful when using SSR for cross-request state pollution +// due to creating a Singleton instance here; +// If any client changes this (global) instance, it might be a +// good idea to move this instance creation inside of the +// "export default () => {}" function below (which runs individually +// for each client) + +axios.defaults.withCredentials = true; // Enable sending cookies with requests + +// Export the API instance so you can import it easily elsewhere, e.g. stores export default axios; \ No newline at end of file diff --git a/src/components/ChatInterface.vue b/src/components/ChatInterface.vue index f339c5d..1177974 100644 --- a/src/components/ChatInterface.vue +++ b/src/components/ChatInterface.vue @@ -1,137 +1,137 @@ - - - - - \ No newline at end of file diff --git a/src/layouts/MainLayout.vue b/src/layouts/MainLayout.vue index b6cc747..99c8d59 100644 --- a/src/layouts/MainLayout.vue +++ b/src/layouts/MainLayout.vue @@ -1,247 +1,311 @@ - - - - - \ No newline at end of file + + + + + diff --git a/src/pages/FormCreatePage.vue b/src/pages/FormCreatePage.vue index 3c3c6a0..c88b929 100644 --- a/src/pages/FormCreatePage.vue +++ b/src/pages/FormCreatePage.vue @@ -1,220 +1,220 @@ - - - - - + + + + + diff --git a/src/pages/FormEditPage.vue b/src/pages/FormEditPage.vue index 837d0b4..8fbecba 100644 --- a/src/pages/FormEditPage.vue +++ b/src/pages/FormEditPage.vue @@ -1,285 +1,285 @@ - - - - - + + + + + diff --git a/src/pages/FormFillPage.vue b/src/pages/FormFillPage.vue index f0a9759..18af863 100644 --- a/src/pages/FormFillPage.vue +++ b/src/pages/FormFillPage.vue @@ -1,223 +1,223 @@ - - - + + + diff --git a/src/pages/FormListPage.vue b/src/pages/FormListPage.vue index dfab999..aefc2b9 100644 --- a/src/pages/FormListPage.vue +++ b/src/pages/FormListPage.vue @@ -1,187 +1,187 @@ - - - + + + diff --git a/src/pages/FormResponsesPage.vue b/src/pages/FormResponsesPage.vue index 42a37c8..3588d4e 100644 --- a/src/pages/FormResponsesPage.vue +++ b/src/pages/FormResponsesPage.vue @@ -1,250 +1,250 @@ - - - + + + diff --git a/src/pages/LandingPage.vue b/src/pages/LandingPage.vue index 2e85f4c..cbc8f61 100644 --- a/src/pages/LandingPage.vue +++ b/src/pages/LandingPage.vue @@ -1,54 +1,54 @@ -