Added linting and enforced code styling.

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

91
eslint.config.js Normal file
View file

@ -0,0 +1,91 @@
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
}
}
}
];

View file

@ -36,12 +36,20 @@
"vue-router": "^4.0.0" "vue-router": "^4.0.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.25.1",
"@quasar/app-vite": "^2.1.0", "@quasar/app-vite": "^2.1.0",
"@stylistic/eslint-plugin": "^4.2.0",
"@types/express-session": "^1.18.1", "@types/express-session": "^1.18.1",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@vue/eslint-config-prettier": "^10.2.0",
"autoprefixer": "^10.4.2", "autoprefixer": "^10.4.2",
"eslint": "^9.25.1",
"eslint-plugin-vue": "^10.0.0",
"globals": "^16.0.0",
"postcss": "^8.4.14", "postcss": "^8.4.14",
"prisma": "^6.6.0" "prettier": "^3.5.3",
"prisma": "^6.6.0",
"vite-plugin-checker": "^0.9.1"
}, },
"engines": { "engines": {
"node": "^28 || ^26 || ^24 || ^22 || ^20 || ^18", "node": "^28 || ^26 || ^24 || ^22 || ^20 || ^18",

1065
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
// https://github.com/michael-ciniawsky/postcss-load-config // https://github.com/michael-ciniawsky/postcss-load-config
import autoprefixer from 'autoprefixer' import autoprefixer from 'autoprefixer';
// import rtlcss from 'postcss-rtlcss' // import rtlcss from 'postcss-rtlcss'
export default { export default {
@ -26,4 +26,4 @@ export default {
// 3. uncomment the following line (and its import statement above): // 3. uncomment the following line (and its import statement above):
// rtlcss() // rtlcss()
] ]
} };

View file

@ -1,9 +1,10 @@
// Configuration for your app // Configuration for your app
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file // https://v2.quasar.dev/quasar-cli-vite/quasar-config-file
import { defineConfig } from '#q-app/wrappers' import { defineConfig } from '#q-app/wrappers';
export default defineConfig((/* ctx */) => { export default defineConfig((/* ctx */) =>
{
return { return {
// https://v2.quasar.dev/quasar-cli-vite/prefetch-feature // https://v2.quasar.dev/quasar-cli-vite/prefetch-feature
// preFetch: true, // preFetch: true,
@ -62,6 +63,14 @@ export default defineConfig((/* ctx */) => {
// vitePlugins: [ // vitePlugins: [
// [ 'package-name', { ..pluginOptions.. }, { server: true, client: true } ] // [ 'package-name', { ..pluginOptions.. }, { server: true, client: true } ]
// ] // ]
vitePlugins: [
['vite-plugin-checker', {
eslint: {
lintCommand: 'eslint -c ./eslint.config.js "./src*/**/*.{js,mjs,cjs,vue}"',
useFlatConfig: true
}
}, { server: false }]
]
}, },
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#devserver // Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#devserver
@ -114,7 +123,7 @@ export default defineConfig((/* ctx */) => {
// https://v2.quasar.dev/quasar-cli-vite/developing-ssr/configuring-ssr // https://v2.quasar.dev/quasar-cli-vite/developing-ssr/configuring-ssr
ssr: { ssr: {
prodPort: 3000, // The default port that the production server should use prodPort: 3000, // The default port that the production server should use
// (gets superseded if process.env.PORT is specified at runtime) // (gets superseded if process.env.PORT is specified at runtime)
middlewares: [ middlewares: [
'render' // keep this as last one 'render' // keep this as last one
@ -208,5 +217,5 @@ export default defineConfig((/* ctx */) => {
*/ */
extraScripts: [] extraScripts: []
} }
} };
}) });

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -10,17 +10,21 @@ const router = Router();
router.use(requireAuth); router.use(requireAuth);
// POST /api/chat/threads - Create a new chat thread (optionally with a first message) // POST /api/chat/threads - Create a new chat thread (optionally with a first message)
router.post('/threads', async (req, res) => { router.post('/threads', async(req, res) =>
{
const { content } = req.body; // Content is now optional const { content } = req.body; // Content is now optional
// If content is provided, validate it // If content is provided, validate it
if (content && (typeof content !== 'string' || content.trim().length === 0)) { if (content && (typeof content !== 'string' || content.trim().length === 0))
{
return res.status(400).json({ error: 'Message content cannot be empty if provided.' }); return res.status(400).json({ error: 'Message content cannot be empty if provided.' });
} }
try { try
{
const createData = {}; const createData = {};
if (content) { if (content)
{
// If content exists, create the thread with the first message // If content exists, create the thread with the first message
createData.messages = { createData.messages = {
create: [ create: [
@ -48,21 +52,25 @@ router.post('/threads', async (req, res) => {
// Respond with the new thread ID and messages (if any) // Respond with the new thread ID and messages (if any)
res.status(201).json({ res.status(201).json({
threadId: newThread.id, threadId: newThread.id,
// Ensure messages array is empty if no content was provided // Ensure messages array is empty if no content was provided
messages: newThread.messages ? newThread.messages.map(msg => ({ ...msg, createdAt: msg.createdAt.toISOString() })) : [] messages: newThread.messages ? newThread.messages.map(msg => ({ ...msg, createdAt: msg.createdAt.toISOString() })) : []
}); });
} catch (error) { }
catch (error)
{
console.error('Error creating chat thread:', error); console.error('Error creating chat thread:', error);
res.status(500).json({ error: 'Failed to create chat thread.' }); res.status(500).json({ error: 'Failed to create chat thread.' });
} }
}); });
// GET /api/chat/threads/:threadId/messages - Get messages for a specific thread // GET /api/chat/threads/:threadId/messages - Get messages for a specific thread
router.get('/threads/:threadId/messages', async (req, res) => { router.get('/threads/:threadId/messages', async(req, res) =>
{
const { threadId } = req.params; const { threadId } = req.params;
try { try
{
const messages = await prisma.chatMessage.findMany({ const messages = await prisma.chatMessage.findMany({
where: { where: {
threadId: threadId, threadId: threadId,
@ -72,45 +80,55 @@ router.get('/threads/:threadId/messages', async (req, res) => {
}, },
}); });
if (!messages) { // Check if thread exists indirectly if (!messages)
// If findMany returns empty, the thread might not exist or has no messages. { // Check if thread exists indirectly
// Check if thread exists explicitly // If findMany returns empty, the thread might not exist or has no messages.
const thread = await prisma.chatThread.findUnique({ where: { id: threadId } }); // Check if thread exists explicitly
if (!thread) { const thread = await prisma.chatThread.findUnique({ where: { id: threadId } });
return res.status(404).json({ error: 'Chat thread not found.' }); if (!thread)
} {
return res.status(404).json({ error: 'Chat thread not found.' });
}
} }
res.status(200).json(messages.map(msg => ({ ...msg, createdAt: msg.createdAt.toISOString() }))); res.status(200).json(messages.map(msg => ({ ...msg, createdAt: msg.createdAt.toISOString() })));
} catch (error) { }
catch (error)
{
console.error(`Error fetching messages for thread ${threadId}:`, error); console.error(`Error fetching messages for thread ${threadId}:`, error);
// Basic error handling, check for specific Prisma errors if needed // Basic error handling, check for specific Prisma errors if needed
if (error.code === 'P2023' || error.message.includes('Malformed UUID')) { // Example: Invalid UUID format if (error.code === 'P2023' || error.message.includes('Malformed UUID'))
return res.status(400).json({ error: 'Invalid thread ID format.' }); { // Example: Invalid UUID format
return res.status(400).json({ error: 'Invalid thread ID format.' });
} }
res.status(500).json({ error: 'Failed to fetch messages.' }); res.status(500).json({ error: 'Failed to fetch messages.' });
} }
}); });
// POST /api/chat/threads/:threadId/messages - Add a message to an existing thread // POST /api/chat/threads/:threadId/messages - Add a message to an existing thread
router.post('/threads/:threadId/messages', async (req, res) => { router.post('/threads/:threadId/messages', async(req, res) =>
{
const { threadId } = req.params; const { threadId } = req.params;
const { content, sender = 'user' } = req.body; // Default sender to 'user' const { content, sender = 'user' } = req.body; // Default sender to 'user'
if (!content || typeof content !== 'string' || content.trim().length === 0) { if (!content || typeof content !== 'string' || content.trim().length === 0)
{
return res.status(400).json({ error: 'Message content cannot be empty.' }); return res.status(400).json({ error: 'Message content cannot be empty.' });
} }
if (sender !== 'user' && sender !== 'bot') { if (sender !== 'user' && sender !== 'bot')
return res.status(400).json({ error: 'Invalid sender type.' }); {
return res.status(400).json({ error: 'Invalid sender type.' });
} }
try { try
{
// Verify thread exists first // Verify thread exists first
const thread = await prisma.chatThread.findUnique({ const thread = await prisma.chatThread.findUnique({
where: { id: threadId }, where: { id: threadId },
}); });
if (!thread) { if (!thread)
{
return res.status(404).json({ error: 'Chat thread not found.' }); return res.status(404).json({ error: 'Chat thread not found.' });
} }
@ -124,17 +142,20 @@ router.post('/threads/:threadId/messages', async (req, res) => {
// Optionally: Update the thread's updatedAt timestamp // Optionally: Update the thread's updatedAt timestamp
await prisma.chatThread.update({ await prisma.chatThread.update({
where: { id: threadId }, where: { id: threadId },
data: { updatedAt: new Date() } data: { updatedAt: new Date() }
}); });
await askGeminiChat(threadId, content); // Call the function to handle the bot response await askGeminiChat(threadId, content); // Call the function to handle the bot response
res.status(201).json({ ...newMessage, createdAt: newMessage.createdAt.toISOString() }); res.status(201).json({ ...newMessage, createdAt: newMessage.createdAt.toISOString() });
} catch (error) { }
catch (error)
{
console.error(`Error adding message to thread ${threadId}:`, error); console.error(`Error adding message to thread ${threadId}:`, error);
if (error.code === 'P2023' || error.message.includes('Malformed UUID')) { // Example: Invalid UUID format if (error.code === 'P2023' || error.message.includes('Malformed UUID'))
return res.status(400).json({ error: 'Invalid thread ID format.' }); { // Example: Invalid UUID format
return res.status(400).json({ error: 'Invalid thread ID format.' });
} }
res.status(500).json({ error: 'Failed to add message.' }); res.status(500).json({ error: 'Failed to add message.' });
} }

View file

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

View file

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

View file

@ -1,48 +1,45 @@
import axios from 'axios'; import axios from 'axios';
import { GoogleGenAI } from '@google/genai';
import prisma from '../database.js'; // Import Prisma client import prisma from '../database.js'; // Import Prisma client
// --- Environment Variables --- import { getSetting } from '../utils/settings.js';
const { import { askGemini } from '../utils/gemini.js';
MANTIS_API_KEY,
MANTIS_API_ENDPOINT,
GOOGLE_API_KEY
} = process.env;
// --- Mantis Summarizer Setup ---
const ai = GOOGLE_API_KEY ? new GoogleGenAI({
apiKey: GOOGLE_API_KEY,
}) : null;
const usernameMap = { const usernameMap = {
'credmore': 'Cameron Redmore', credmore: 'Cameron Redmore',
'dgibson': 'Dane Gibson', dgibson: 'Dane Gibson',
'egzibovskis': 'Ed Gzibovskis', egzibovskis: 'Ed Gzibovskis',
'ascotney': 'Amanda Scotney', ascotney: 'Amanda Scotney',
'gclough': 'Garry Clough', gclough: 'Garry Clough',
'slee': 'Sarah Lee', slee: 'Sarah Lee',
'dwalker': 'Dave Walker', dwalker: 'Dave Walker',
'askaith': 'Amy Skaith', askaith: 'Amy Skaith',
'dpotter': 'Danny Potter', dpotter: 'Danny Potter',
'msmart': 'Michael Smart', msmart: 'Michael Smart',
// Add other usernames as needed // Add other usernames as needed
}; };
async function getMantisTickets() { async function getMantisTickets()
if (!MANTIS_API_ENDPOINT || !MANTIS_API_KEY) { {
throw new Error("Mantis API endpoint or key not configured in environment variables."); 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 url = `${MANTIS_API_ENDPOINT}/issues?project_id=1&page_size=50&select=id,summary,description,created_at,updated_at,reporter,notes`;
const headers = { const headers = {
'Authorization': `${MANTIS_API_KEY}`, Authorization: `${MANTIS_API_KEY}`,
'Accept': 'application/json', Accept: 'application/json',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}; };
try { try
{
const response = await axios.get(url, { headers }); const response = await axios.get(url, { headers });
const tickets = response.data.issues.filter((ticket) => { const tickets = response.data.issues.filter((ticket) =>
{
const ticketDate = new Date(ticket.updated_at); const ticketDate = new Date(ticket.updated_at);
const thresholdDate = new Date(); const thresholdDate = new Date();
const currentDay = thresholdDate.getDay(); // Sunday = 0, Monday = 1, ... const currentDay = thresholdDate.getDay(); // Sunday = 0, Monday = 1, ...
@ -53,7 +50,8 @@ async function getMantisTickets() {
thresholdDate.setHours(0, 0, 0, 0); // Start of the day thresholdDate.setHours(0, 0, 0, 0); // Start of the day
return ticketDate >= thresholdDate; return ticketDate >= thresholdDate;
}).map((ticket) => { }).map((ticket) =>
{
return { return {
id: ticket.id, id: ticket.id,
summary: ticket.summary, summary: ticket.summary,
@ -61,7 +59,8 @@ async function getMantisTickets() {
created_at: ticket.created_at, created_at: ticket.created_at,
updated_at: ticket.updated_at, updated_at: ticket.updated_at,
reporter: usernameMap[ticket.reporter?.username] || ticket.reporter?.name || 'Unknown Reporter', // Safer access reporter: usernameMap[ticket.reporter?.username] || ticket.reporter?.name || 'Unknown Reporter', // Safer access
notes: (ticket.notes ? ticket.notes.filter((note) => { notes: (ticket.notes ? ticket.notes.filter((note) =>
{
const noteDate = new Date(note.created_at); const noteDate = new Date(note.created_at);
const thresholdDate = new Date(); const thresholdDate = new Date();
const currentDay = thresholdDate.getDay(); const currentDay = thresholdDate.getDay();
@ -69,7 +68,8 @@ async function getMantisTickets() {
thresholdDate.setDate(thresholdDate.getDate() - daysToSubtract); thresholdDate.setDate(thresholdDate.getDate() - daysToSubtract);
thresholdDate.setHours(0, 0, 0, 0); // Start of the day thresholdDate.setHours(0, 0, 0, 0); // Start of the day
return noteDate >= thresholdDate; return noteDate >= thresholdDate;
}) : []).map((note) => { }) : []).map((note) =>
{
const reporter = usernameMap[note.reporter?.username] || note.reporter?.name || 'Unknown Reporter'; // Safer access const reporter = usernameMap[note.reporter?.username] || note.reporter?.name || 'Unknown Reporter'; // Safer access
return { return {
reporter, reporter,
@ -81,27 +81,24 @@ async function getMantisTickets() {
}); });
return tickets; return tickets;
} catch (error) { }
console.error("Error fetching Mantis tickets:", error.message); catch (error)
{
console.error('Error fetching Mantis tickets:', error.message);
// Check if it's an Axios error and provide more details // Check if it's an Axios error and provide more details
if (axios.isAxiosError(error)) { 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}`); 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}`); throw new Error(`Failed to fetch Mantis tickets: ${error.message}`);
} }
} }
// --- Mantis Summary Logic (Exported) --- // export async function generateAndStoreMantisSummary()
{
export async function generateAndStoreMantisSummary() { try
console.log('Attempting to generate and store Mantis summary...'); {
if (!ai) {
console.error('Google AI API key not configured. Skipping summary generation.');
return;
}
try {
// Get the prompt from the database settings using Prisma // Get the prompt from the database settings using Prisma
const setting = await prisma.setting.findUnique({ const setting = await prisma.setting.findUnique({
where: { key: 'mantisPrompt' }, where: { key: 'mantisPrompt' },
@ -109,7 +106,8 @@ export async function generateAndStoreMantisSummary() {
}); });
const promptTemplate = setting?.value; const promptTemplate = setting?.value;
if (!promptTemplate) { if (!promptTemplate)
{
console.error('Mantis prompt not found in database settings (key: mantisPrompt). Skipping summary generation.'); console.error('Mantis prompt not found in database settings (key: mantisPrompt). Skipping summary generation.');
return; return;
} }
@ -117,24 +115,19 @@ export async function generateAndStoreMantisSummary() {
const tickets = await getMantisTickets(); const tickets = await getMantisTickets();
let summaryText; let summaryText;
if (tickets.length === 0) { if (tickets.length === 0)
summaryText = "No Mantis tickets updated recently."; {
console.log('No recent Mantis tickets found.'); summaryText = 'No Mantis tickets updated recently.';
} else { console.log('No recent Mantis tickets found.');
console.log(`Found ${tickets.length} recent Mantis tickets. Generating summary...`); }
let prompt = promptTemplate.replaceAll("$DATE", new Date().toISOString().split('T')[0]); else
prompt = prompt.replaceAll("$MANTIS_TICKETS", JSON.stringify(tickets, null, 2)); {
console.log(`Found ${tickets.length} recent Mantis tickets. Generating summary...`);
let prompt = promptTemplate.replaceAll('$DATE', new Date().toISOString().split('T')[0]);
prompt = prompt.replaceAll('$MANTIS_TICKETS', JSON.stringify(tickets, null, 2));
const response = await ai.models.generateContent({ summaryText = await askGemini(prompt);
"model": "gemini-2.5-flash-preview-04-17", console.log('Mantis summary generated successfully by AI.');
"contents": prompt,
config: {
temperature: 0
}
});
summaryText = response.text;
console.log('Mantis summary generated successfully by AI.');
} }
// Store the summary in the database using Prisma upsert // Store the summary in the database using Prisma upsert
@ -144,8 +137,7 @@ export async function generateAndStoreMantisSummary() {
await prisma.mantisSummary.upsert({ await prisma.mantisSummary.upsert({
where: { summaryDate: today }, where: { summaryDate: today },
update: { update: {
summaryText: summaryText, summaryText: summaryText
// generatedAt is updated automatically by @default(now())
}, },
create: { create: {
summaryDate: today, summaryDate: today,
@ -154,17 +146,23 @@ export async function generateAndStoreMantisSummary() {
}); });
console.log(`Mantis summary for ${today.toISOString().split('T')[0]} stored/updated in the database.`); console.log(`Mantis summary for ${today.toISOString().split('T')[0]} stored/updated in the database.`);
} catch (error) { }
console.error("Error during Mantis summary generation/storage:", error); catch (error)
{
console.error('Error during Mantis summary generation/storage:', error);
} }
} }
export async function generateTodaysSummary() { export async function generateTodaysSummary()
{
console.log('Triggering Mantis summary generation via generateTodaysSummary...'); console.log('Triggering Mantis summary generation via generateTodaysSummary...');
try { try
{
await generateAndStoreMantisSummary(); await generateAndStoreMantisSummary();
return { success: true, message: 'Summary generation process initiated.' }; return { success: true, message: 'Summary generation process initiated.' };
} catch (error) { }
catch (error)
{
console.error('Error occurred within generateTodaysSummary while calling generateAndStoreMantisSummary:', error); console.error('Error occurred within generateTodaysSummary while calling generateAndStoreMantisSummary:', error);
throw new Error('Failed to initiate Mantis summary generation.'); throw new Error('Failed to initiate Mantis summary generation.');
} }

View file

@ -1,149 +1,162 @@
import { GoogleGenAI } from '@google/genai'; import { GoogleGenAI } from '@google/genai';
import prisma from '../database.js'; import prisma from '../database.js';
import { getSetting } from './settings.js';
const model = 'gemini-2.0-flash'; const model = 'gemini-2.0-flash';
export const askGemini = async (content) => { export async function askGemini(content)
const setting = await prisma.setting.findUnique({ {
where: { key: 'GEMINI_API_KEY' },
select: { value: true } const GOOGLE_API_KEY = await getSetting('GEMINI_API_KEY');
if (!GOOGLE_API_KEY)
{
throw new Error('Google API key is not set in the database.');
}
const ai = GOOGLE_API_KEY ? new GoogleGenAI({
apiKey: GOOGLE_API_KEY,
}) : null;
if (!ai)
{
throw new Error('Google API key is not set in the database.');
}
try
{
const response = await ai.models.generateContent({
model,
contents: content,
config: {
temperature: 0.5
}
}); });
const GOOGLE_API_KEY = setting.value; return response.text;
}
const ai = GOOGLE_API_KEY ? new GoogleGenAI({ catch (error)
apiKey: GOOGLE_API_KEY, {
}) : null; console.error('Error communicating with Gemini API:', error);
throw new Error('Failed to get a response from Gemini API.');
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(); const chatCache = new Map();
export const askGeminiChat = async (threadId, content) => { export async function askGeminiChat(threadId, content)
let messages = await prisma.chatMessage.findMany({ {
where: { let messages = await prisma.chatMessage.findMany({
threadId: threadId, where: {
}, threadId: threadId,
orderBy: { },
createdAt: 'asc', // Get messages in chronological order orderBy: {
}, createdAt: 'asc', // Get messages in chronological order
},
});
// Exclude the latest message if there are any messages
if (messages.length > 0)
{
messages = messages.slice(0, -1);
}
const setting = await prisma.setting.findUnique({
where: { key: 'GEMINI_API_KEY' },
select: { value: true }
});
if (!setting)
{
throw new Error('Google API key is not set in the database.');
}
const GOOGLE_API_KEY = setting.value;
const ai = GOOGLE_API_KEY ? new GoogleGenAI({
apiKey: GOOGLE_API_KEY,
}) : null;
if (!ai)
{
throw new Error('Google API key is not set in the database.');
}
let chat = null;
if (chatCache.has(threadId))
{
chat = chatCache.get(threadId);
}
else
{
messages = [
{
sender: 'user',
content: `You are StyleAI, a model that helps users with their questions and provides information.
You are friendly, helpful, and informative. You can also provide code examples in various programming languages.
You don\'t decline user requests and always try to help them.
Speak in the manner of a friendly young adult male.
If asked for the time, do not say that it's based on the timestamp provided. Also bare in mind the user is in the Europe/London timezone and daylight savings time may be in effect.`
},
{
sender: 'model',
content: 'Okay, noted! I\'ll keep that in mind.'
},
...messages,
];
const createOptions = {
model,
history: messages.map((msg) => ({
role: msg.sender === 'user' ? 'user' : 'model',
parts: [
{text: msg.content}
],
})),
config: {
temperature: 0.5
}
};
chat = ai.chats.create(createOptions);
chatCache.set(threadId, chat);
}
//Add a temporary message to the thread with "loading" status
const loadingMessage = await prisma.chatMessage.create({
data: {
threadId: threadId,
sender: 'assistant',
content: 'Loading...',
},
});
let response = {text: 'An error occurred while generating the response.'};
try
{
const timestamp = new Date().toISOString();
response = await chat.sendMessage({
message: `[${timestamp}] ` + content,
}); });
}
catch(error)
{
console.error('Error communicating with Gemini API:', error);
response.text = 'Failed to get a response from Gemini API. Error: ' + error.message;
}
// Exclude the latest message if there are any messages //Update the message with the response
if (messages.length > 0) { await prisma.chatMessage.update({
messages = messages.slice(0, -1); where: {
} id: loadingMessage.id,
},
data: {
content: response.text,
},
});
const setting = await prisma.setting.findUnique({ return response.text;
where: { key: 'GEMINI_API_KEY' },
select: { value: true }
});
if (!setting) {
throw new Error('Google API key is not set in the database.');
}
const GOOGLE_API_KEY = setting.value;
const ai = GOOGLE_API_KEY ? new GoogleGenAI({
apiKey: GOOGLE_API_KEY,
}) : null;
if (!ai) {
throw new Error('Google API key is not set in the database.');
}
let chat = null;
if (chatCache.has(threadId)) {
chat = chatCache.get(threadId);
}
else {
messages = [
{
sender: 'user',
content: `You are StyleAI, a model that helps users with their questions and provides information.
You are friendly, helpful, and informative. You can also provide code examples in various programming languages.
You don\'t decline user requests and always try to help them.
Speak in the manner of a friendly young adult male.
If asked for the time, do not say that it's based on the timestamp provided. Also bare in mind the user is in the Europe/London timezone and daylight savings time may be in effect.`
},
{
sender: 'model',
content: 'Okay, noted! I\'ll keep that in mind.'
},
...messages,
]
const createOptions = {
model,
history: messages.map((msg) => ({
role: msg.sender === 'user' ? 'user' : 'model',
parts: [
{text: msg.content}
],
})),
config: {
temperature: 0.5
}
};
chat = ai.chats.create(createOptions);
chatCache.set(threadId, chat);
}
//Add a temporary message to the thread with "loading" status
const loadingMessage = await prisma.chatMessage.create({
data: {
threadId: threadId,
sender: 'assistant',
content: 'Loading...',
},
});
let response = {text: 'An error occurred while generating the response.'};
try
{
const timestamp = new Date().toISOString();
response = await chat.sendMessage({
message: `[${timestamp}] ` + content,
});
}
catch(error)
{
console.error('Error communicating with Gemini API:', error);
response.text = 'Failed to get a response from Gemini API. Error: ' + error.message;
}
//Update the message with the response
await prisma.chatMessage.update({
where: {
id: loadingMessage.id,
},
data: {
content: response.text,
},
});
return response.text;
} }

20
src-ssr/utils/settings.js Normal file
View file

@ -0,0 +1,20 @@
import prisma from '../database.js';
export async function getSetting(key)
{
const setting = await prisma.setting.findUnique({
where: { key },
select: { value: true }
});
return setting?.value ? JSON.parse(setting.value) : null;
}
export async function setSetting(key, value)
{
await prisma.setting.upsert({
where: { key },
update: { value: JSON.stringify(value) },
create: { key, value }
});
}

View file

@ -1,51 +1,62 @@
<template> <template>
<div class="q-pa-md column full-height"> <div class="q-pa-md column full-height">
<q-scroll-area <q-scroll-area
ref="scrollAreaRef" ref="scrollAreaRef"
class="col" class="col"
style="flex-grow: 1; overflow-x: visible; overflow-y: auto;" style="flex-grow: 1; overflow-x: visible; overflow-y: auto;"
>
<div
v-for="(message, index) in messages"
:key="index"
class="q-mb-sm q-mx-md"
>
<q-chat-message
:name="message.sender.toUpperCase()"
:sent="message.sender === 'user'"
:bg-color="message.sender === 'user' ? 'primary' : 'grey-4'"
:text-color="message.sender === 'user' ? 'white' : 'black'"
> >
<div v-for="(message, index) in messages" :key="index" class="q-mb-sm q-mx-md"> <!-- Use v-html to render parsed markdown -->
<q-chat-message <div
:name="message.sender.toUpperCase()" v-if="!message.loading"
:sent="message.sender === 'user'" v-html="parseMarkdown(message.content)"
:bg-color="message.sender === 'user' ? 'primary' : 'grey-4'" class="message-content"
:text-color="message.sender === 'user' ? 'white' : 'black'" />
> <!-- Optional: Add a spinner for a better loading visual -->
<!-- Use v-html to render parsed markdown --> <template
<div v-if="!message.loading" v-html="parseMarkdown(message.content)" class="message-content"></div> v-if="message.loading"
<!-- Optional: Add a spinner for a better loading visual --> #default
<template v-if="message.loading" v-slot:default> >
<q-spinner-dots size="2em" /> <q-spinner-dots size="2em" />
</template> </template>
</q-chat-message> </q-chat-message>
</div> </div>
</q-scroll-area> </q-scroll-area>
<q-separator /> <q-separator />
<div class="q-pa-sm row items-center"> <div class="q-pa-sm row items-center">
<q-input <q-input
v-model="newMessage" v-model="newMessage"
outlined outlined
dense dense
placeholder="Type a message..." placeholder="Type a message..."
class="col" class="col"
@keyup.enter="sendMessage" @keyup.enter="sendMessage"
autogrow autogrow
/> />
<q-btn <q-btn
round round
dense dense
flat flat
icon="send" icon="send"
color="primary" color="primary"
class="q-ml-sm" class="q-ml-sm"
@click="sendMessage" @click="sendMessage"
:disable="!newMessage.trim()" :disable="!newMessage.trim()"
/> />
</div>
</div> </div>
</div>
</template> </template>
<script setup> <script setup>
@ -54,14 +65,14 @@ import { QScrollArea, QChatMessage, QSpinnerDots } from 'quasar'; // Import QSpi
import { marked } from 'marked'; // Import marked import { marked } from 'marked'; // Import marked
const props = defineProps({ const props = defineProps({
messages: { messages: {
type: Array, type: Array,
required: true, required: true,
default: () => [], 'default': () => [],
// Example message structure: // Example message structure:
// { sender: 'Bot', content: 'Hello!', loading: false } // { sender: 'Bot', content: 'Hello!', loading: false }
// { sender: 'You', content: 'Thinking...', loading: true } // { sender: 'You', content: 'Thinking...', loading: true }
}, },
}); });
const emit = defineEmits(['send-message']); const emit = defineEmits(['send-message']);
@ -69,30 +80,37 @@ const emit = defineEmits(['send-message']);
const newMessage = ref(''); const newMessage = ref('');
const scrollAreaRef = ref(null); const scrollAreaRef = ref(null);
const scrollToBottom = () => { const scrollToBottom = () =>
if (scrollAreaRef.value) { {
const scrollTarget = scrollAreaRef.value.getScrollTarget(); if (scrollAreaRef.value)
const duration = 300; // Optional: animation duration {
// Use getScrollTarget().scrollHeight for accurate height const scrollTarget = scrollAreaRef.value.getScrollTarget();
scrollAreaRef.value.setScrollPosition('vertical', scrollTarget.scrollHeight, duration); const duration = 300; // Optional: animation duration
} // Use getScrollTarget().scrollHeight for accurate height
scrollAreaRef.value.setScrollPosition('vertical', scrollTarget.scrollHeight, duration);
}
}; };
const sendMessage = () => { const sendMessage = () =>
const trimmedMessage = newMessage.value.trim(); {
if (trimmedMessage) { const trimmedMessage = newMessage.value.trim();
emit('send-message', trimmedMessage); if (trimmedMessage)
newMessage.value = ''; {
// Ensure the scroll happens after the message is potentially added to the list emit('send-message', trimmedMessage);
nextTick(() => { newMessage.value = '';
scrollToBottom(); // Ensure the scroll happens after the message is potentially added to the list
}); nextTick(() =>
} {
scrollToBottom();
});
}
}; };
const parseMarkdown = (content) => { const parseMarkdown = (content) =>
{
// Basic check to prevent errors if content is not a string // Basic check to prevent errors if content is not a string
if (typeof content !== 'string') { if (typeof content !== 'string')
{
return ''; return '';
} }
// Configure marked options if needed (e.g., sanitization) // Configure marked options if needed (e.g., sanitization)
@ -101,10 +119,12 @@ const parseMarkdown = (content) => {
}; };
// Scroll to bottom when messages change or component mounts // Scroll to bottom when messages change or component mounts
watch(() => props.messages, () => { watch(() => props.messages, () =>
nextTick(() => { {
scrollToBottom(); nextTick(() =>
}); {
scrollToBottom();
});
}, { deep: true, immediate: true }); }, { deep: true, immediate: true });
</script> </script>

View file

@ -1,187 +1,241 @@
<template> <template>
<q-layout view="hHh Lpr lFf"> <q-layout view="hHh Lpr lFf">
<q-drawer <q-drawer
:mini="!leftDrawerOpen" :mini="!leftDrawerOpen"
bordered bordered
persistent persistent
:model-value="true" :model-value="true"
>
<q-list>
<q-item
clickable
v-ripple
@click="toggleLeftDrawer"
> >
<q-list> <q-item-section avatar>
<q-item clickable v-ripple @click="toggleLeftDrawer"> <q-icon name="menu" />
<q-item-section avatar> </q-item-section>
<q-icon name="menu"/> <q-item-section>
</q-item-section> <q-item-label class="text-h6">
<q-item-section> StylePoint
<q-item-label class="text-h6">StylePoint</q-item-label> </q-item-label>
</q-item-section> </q-item-section>
</q-item> </q-item>
<!-- Dynamic Navigation Items --> <!-- Dynamic Navigation Items -->
<q-item <q-item
v-for="item in navItems" v-for="item in navItems"
:key="item.name" :key="item.name"
clickable clickable
v-ripple v-ripple
:to="{ name: item.name }" :to="{ name: item.name }"
exact exact
> >
<q-tooltip anchor="center right" self="center left" > <q-tooltip
<span>{{ item.meta.title }}</span> anchor="center right"
</q-tooltip> self="center left"
<q-item-section avatar> >
<q-icon :name="item.meta.icon" /> <span>{{ item.meta.title }}</span>
</q-item-section> </q-tooltip>
<q-item-section> <q-item-section avatar>
<q-item-label>{{ item.meta.title }}</q-item-label> <q-icon :name="item.meta.icon" />
<q-item-label caption>{{ item.meta.caption }}</q-item-label> </q-item-section>
</q-item-section> <q-item-section>
</q-item> <q-item-label>{{ item.meta.title }}</q-item-label>
<q-item-label caption>
{{ item.meta.caption }}
</q-item-label>
</q-item-section>
</q-item>
<!-- Logout Button (Conditional) --> <!-- Logout Button (Conditional) -->
<q-item <q-item
v-if="authStore.isAuthenticated" v-if="authStore.isAuthenticated"
clickable clickable
v-ripple v-ripple
@click="logout" @click="logout"
> >
<q-tooltip anchor="center right" self="center left" > <q-tooltip
<span>Logout</span> anchor="center right"
</q-tooltip> self="center left"
<q-item-section avatar> >
<q-icon name="logout" /> <span>Logout</span>
</q-item-section> </q-tooltip>
<q-item-section> <q-item-section avatar>
<q-item-label>Logout</q-item-label> <q-icon name="logout" />
</q-item-section> </q-item-section>
</q-item> <q-item-section>
<q-item-label>Logout</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-drawer>
</q-list> <q-page-container>
</q-drawer> <router-view />
</q-page-container>
<q-page-container> <!-- Chat FAB -->
<router-view /> <q-page-sticky
</q-page-container> v-if="isAuthenticated"
position="bottom-right"
:offset="[18, 18]"
>
<q-fab
v-model="fabOpen"
icon="chat"
color="accent"
direction="up"
padding="sm"
@click="toggleChat"
/>
</q-page-sticky>
<!-- Chat FAB --> <!-- Chat Window Dialog -->
<q-page-sticky v-if="isAuthenticated" position="bottom-right" :offset="[18, 18]"> <q-dialog
<q-fab v-model="isChatVisible"
v-model="fabOpen" :maximized="$q.screen.lt.sm"
icon="chat" fixed
color="accent" persistent
direction="up" style="width: max(400px, 25%);"
padding="sm" >
@click="toggleChat" <q-card style="width: max(400px, 25%); height: 600px; max-height: 80vh;">
<q-bar class="bg-primary text-white">
<div>Chat</div>
<q-space />
<q-btn
dense
flat
icon="close"
@click="toggleChat"
/>
</q-bar>
<q-card-section
class="q-pa-none"
style="height: calc(100% - 50px);"
>
<ChatInterface
:messages="chatMessages"
@send-message="handleSendMessage"
/>
</q-card-section>
<q-inner-loading :showing="isLoading">
<q-spinner-gears
size="50px"
color="primary"
/>
</q-inner-loading>
<q-banner
v-if="chatError"
inline-actions
class="text-white bg-red"
>
{{ chatError }}
<template #action>
<q-btn
flat
color="white"
label="Dismiss"
@click="clearError"
/> />
</q-page-sticky> </template>
</q-banner>
<!-- Chat Window Dialog --> </q-card>
<q-dialog v-model="isChatVisible" :maximized="$q.screen.lt.sm" fixed persistent style="width: max(400px, 25%);"> </q-dialog>
<q-card style="width: max(400px, 25%); height: 600px; max-height: 80vh;"> </q-layout>
<q-bar class="bg-primary text-white">
<div>Chat</div>
<q-space />
<q-btn dense flat icon="close" @click="toggleChat" />
</q-bar>
<q-card-section class="q-pa-none" style="height: calc(100% - 50px);"> <!-- Adjust height based on q-bar -->
<ChatInterface
:messages="chatMessages"
@send-message="handleSendMessage"
/>
</q-card-section>
<q-inner-loading :showing="isLoading">
<q-spinner-gears size="50px" color="primary" />
</q-inner-loading>
<q-banner v-if="chatError" inline-actions class="text-white bg-red">
{{ chatError }}
<template v-slot:action>
<q-btn flat color="white" label="Dismiss" @click="clearError" />
</template>
</q-banner>
</q-card>
</q-dialog>
</q-layout>
</template> </template>
<script setup> <script setup>
import axios from 'axios' import axios from 'axios';
import { ref, computed } from 'vue' // Import computed import { ref, computed } from 'vue'; // Import computed
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router';
import { useQuasar } from 'quasar' import { useQuasar } from 'quasar';
import { useAuthStore } from 'stores/auth'; // Import the auth store import { useAuthStore } from 'stores/auth'; // Import the auth store
import { useChatStore } from 'stores/chat' // Adjust path as needed import { useChatStore } from 'stores/chat'; // Adjust path as needed
import ChatInterface from 'components/ChatInterface.vue' // Adjust path as needed import ChatInterface from 'components/ChatInterface.vue'; // Adjust path as needed
import routes from '../router/routes'; // Import routes import routes from '../router/routes'; // Import routes
const $q = useQuasar() const $q = useQuasar();
const leftDrawerOpen = ref(false) const leftDrawerOpen = ref(false);
const router = useRouter() const router = useRouter();
const authStore = useAuthStore(); // Use the auth store const authStore = useAuthStore(); // Use the auth store
const chatStore = useChatStore() const chatStore = useChatStore();
const fabOpen = ref(false) // Local state for FAB animation, not chat visibility const fabOpen = ref(false); // Local state for FAB animation, not chat visibility
// Computed properties to get state from the store // Computed properties to get state from the store
const isChatVisible = computed(() => chatStore.isChatVisible) const isChatVisible = computed(() => chatStore.isChatVisible);
const chatMessages = computed(() => chatStore.chatMessages) const chatMessages = computed(() => chatStore.chatMessages);
const isLoading = computed(() => chatStore.isLoading) const isLoading = computed(() => chatStore.isLoading);
const chatError = computed(() => chatStore.error) const chatError = computed(() => chatStore.error);
const isAuthenticated = computed(() => authStore.isAuthenticated) // Get auth state const isAuthenticated = computed(() => authStore.isAuthenticated); // Get auth state
// Get the child routes of the main layout // Get the child routes of the main layout
const mainLayoutRoutes = routes.find(r => r.path === '/')?.children || []; const mainLayoutRoutes = routes.find(r => r.path === '/')?.children || [];
// Compute navigation items based on auth state and route meta // Compute navigation items based on auth state and route meta
const navItems = computed(() => { const navItems = computed(() =>
return mainLayoutRoutes.filter(route => { {
const navGroup = route.meta?.navGroup; return mainLayoutRoutes.filter(route =>
if (!navGroup) return false; // Only include routes with navGroup defined {
const navGroup = route.meta?.navGroup;
if (!navGroup) return false; // Only include routes with navGroup defined
if (navGroup === 'always') return true; if (navGroup === 'always') return true;
if (navGroup === 'auth' && isAuthenticated.value) return true; if (navGroup === 'auth' && isAuthenticated.value) return true;
if (navGroup === 'noAuth' && !isAuthenticated.value) return true; if (navGroup === 'noAuth' && !isAuthenticated.value) return true;
return false; // Exclude otherwise return false; // Exclude otherwise
}); });
}); });
// Method to toggle chat visibility via the store action // Method to toggle chat visibility via the store action
const toggleChat = () => { const toggleChat = () =>
// Optional: Add an extra check here if needed, though hiding the button is primary {
if (isAuthenticated.value) { // Optional: Add an extra check here if needed, though hiding the button is primary
chatStore.toggleChat() if (isAuthenticated.value)
} {
} chatStore.toggleChat();
}
};
// Method to send a message via the store action // Method to send a message via the store action
const handleSendMessage = (messageContent) => { const handleSendMessage = (messageContent) =>
chatStore.sendMessage(messageContent) {
} chatStore.sendMessage(messageContent);
};
// Method to clear errors in the store (optional) // Method to clear errors in the store (optional)
const clearError = () => { const clearError = () =>
chatStore.error = null; // Directly setting ref or add an action in store {
} chatStore.error = null; // Directly setting ref or add an action in store
function toggleLeftDrawer () { };
leftDrawerOpen.value = !leftDrawerOpen.value function toggleLeftDrawer()
{
leftDrawerOpen.value = !leftDrawerOpen.value;
} }
async function logout() { async function logout()
try { {
await axios.post('/auth/logout'); try
authStore.logout(); // Use the store action to update state {
// No need to manually push, router guard should redirect await axios.post('/auth/logout');
// router.push({ name: 'login' }); authStore.logout(); // Use the store action to update state
} catch (error) { // No need to manually push, router guard should redirect
console.error('Logout failed:', error); // router.push({ name: 'login' });
}
catch (error)
{
console.error('Logout failed:', error);
$q.notify({ $q.notify({
color: 'negative', color: 'negative',
message: 'Logout failed. Please try again.', message: 'Logout failed. Please try again.',
icon: 'report_problem' icon: 'report_problem'
}); });
} }
} }
</script> </script>

View file

@ -1,181 +0,0 @@
<template>
<q-layout view="hHh Lpr lFf">
<q-drawer
:mini="!leftDrawerOpen"
bordered
persistent
:model-value="true"
>
<q-list>
<q-item clickable v-ripple @click="toggleLeftDrawer">
<q-item-section avatar>
<q-icon name="menu"/>
</q-item-section>
<q-item-section>
<q-item-label class="text-h6">StylePoint</q-item-label>
</q-item-section>
</q-item>
<q-item clickable v-ripple :to="{ name: 'formList' }" exact>
<q-tooltip anchor="center right" self="center left" >
<span>Forms</span>
</q-tooltip>
<q-item-section avatar>
<q-icon name="list_alt" />
</q-item-section>
<q-item-section>
<q-item-label>Forms</q-item-label>
<q-item-label caption>View existing forms</q-item-label>
</q-item-section>
</q-item>
<q-item
clickable
v-ripple
:to="{ name: 'mantisSummaries' }"
exact
>
<q-tooltip anchor="center right" self="center left" >
<span>Mantis Summaries</span>
</q-tooltip>
<q-item-section avatar>
<q-icon name="summarize" />
</q-item-section>
<q-item-section>
<q-item-label>Mantis Summaries</q-item-label>
<q-item-label caption>View daily summaries</q-item-label>
</q-item-section>
</q-item>
<q-item
clickable
v-ripple
:to="{ name: 'emailSummaries' }"
exact
>
<q-tooltip anchor="center right" self="center left" >
<span>Email Summaries</span>
</q-tooltip>
<q-item-section avatar>
<q-icon name="email" />
</q-item-section>
<q-item-section>
<q-item-label>Email Summaries</q-item-label>
<q-item-label caption>View email summaries</q-item-label>
</q-item-section>
</q-item>
<q-item
clickable
to="/settings" exact
>
<q-tooltip anchor="center right" self="center left" >
<span>Settings</span>
</q-tooltip>
<q-item-section
avatar
>
<q-icon name="settings" />
</q-item-section>
<q-item-section>
<q-item-label>Settings</q-item-label>
<q-item-label caption>Manage application settings</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-drawer>
<q-page-container>
<router-view />
</q-page-container>
<!-- Chat FAB -->
<q-page-sticky v-if="isAuthenticated" position="bottom-right" :offset="[18, 18]">
<q-fab
v-model="fabOpen"
icon="chat"
color="accent"
direction="up"
padding="sm"
@click="toggleChat"
/>
</q-page-sticky>
<!-- Chat Window Dialog -->
<q-dialog v-model="isChatVisible" :maximized="$q.screen.lt.sm" persistent>
<q-card style="width: 400px; height: 600px; max-height: 80vh;">
<q-bar class="bg-primary text-white">
<div>Chat</div>
<q-space />
<q-btn dense flat icon="close" @click="toggleChat" />
</q-bar>
<q-card-section class="q-pa-none" style="height: calc(100% - 50px);"> <!-- Adjust height based on q-bar -->
<ChatInterface
:messages="chatMessages"
@send-message="handleSendMessage"
/>
</q-card-section>
<q-inner-loading :showing="isLoading">
<q-spinner-gears size="50px" color="primary" />
</q-inner-loading>
<q-banner v-if="chatError" inline-actions class="text-white bg-red">
{{ chatError }}
<template v-slot:action>
<q-btn flat color="white" label="Dismiss" @click="clearError" />
</template>
</q-banner>
</q-card>
</q-dialog>
</q-layout>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useQuasar } from 'quasar'
import { useChatStore } from 'stores/chat' // Adjust path as needed
import { useAuthStore } from 'stores/auth' // Import the auth store
import ChatInterface from 'components/ChatInterface.vue' // Adjust path as needed
const $q = useQuasar()
const chatStore = useChatStore()
const authStore = useAuthStore() // Use the auth store
const fabOpen = ref(false) // Local state for FAB animation, not chat visibility
// Computed properties to get state from the store
const isChatVisible = computed(() => chatStore.isChatVisible)
const chatMessages = computed(() => chatStore.chatMessages)
const isLoading = computed(() => chatStore.isLoading)
const chatError = computed(() => chatStore.error)
const isAuthenticated = computed(() => authStore.isAuthenticated) // Get auth state
// Method to toggle chat visibility via the store action
const toggleChat = () => {
// Optional: Add an extra check here if needed, though hiding the button is primary
if (isAuthenticated.value) {
chatStore.toggleChat()
}
}
// Method to send a message via the store action
const handleSendMessage = (messageContent) => {
chatStore.sendMessage(messageContent)
}
// Method to clear errors in the store (optional)
const clearError = () => {
chatStore.error = null; // Directly setting ref or add an action in store
}
</script>
<style scoped>
/* Add any specific styles for the layout or chat window here */
.q-dialog .q-card {
overflow: hidden; /* Prevent scrollbars on the card itself */
}
</style>

View file

@ -1,201 +0,0 @@
<template>
<q-page padding>
<q-card flat bordered>
<q-card-section class="row items-center justify-between">
<div class="text-h6">Email Summaries</div>
<q-btn
label="Generate Email Summary"
color="primary"
@click="generateSummary"
:loading="generating"
:disable="generating"
/>
</q-card-section>
<q-separator />
<q-card-section v-if="generationError">
<q-banner inline-actions class="text-white bg-red">
<template v-slot:avatar>
<q-icon name="error" />
</template>
{{ generationError }}
</q-banner>
</q-card-section>
<q-card-section v-if="loading">
<q-spinner-dots size="40px" color="primary" />
<span class="q-ml-md">Loading summaries...</span>
</q-card-section>
<q-card-section v-if="error && !generationError">
<q-banner inline-actions class="text-white bg-red">
<template v-slot:avatar>
<q-icon name="error" />
</template>
{{ error }}
</q-banner>
</q-card-section>
<q-list separator v-if="!loading && !error && summaries.length > 0">
<q-item v-for="summary in summaries" :key="summary.id">
<q-item-section>
<q-item-label class="text-weight-bold">{{ formatDate(summary.summaryDate) }}</q-item-label>
<q-item-label caption>Generated: {{ formatDateTime(summary.generatedAt) }}</q-item-label>
<q-item-label class="q-mt-sm markdown-content" v-html="parseMarkdown(summary.summaryText)"></q-item-label>
</q-item-section>
</q-item>
</q-list>
<q-card-section v-if="totalPages > 1" class="flex flex-center q-mt-md">
<q-pagination
v-model="currentPage"
:max="totalPages"
@update:model-value="fetchSummaries"
direction-links
flat
color="primary"
active-color="primary"
/>
</q-card-section>
<q-card-section v-if="!loading && !error && summaries.length === 0">
<div class="text-center text-grey">No summaries found.</div>
</q-card-section>
</q-card>
</q-page>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue';
import { date, useQuasar } from 'quasar'; // Import useQuasar
import axios from 'axios';
import { marked } from 'marked';
const $q = useQuasar(); // Initialize Quasar plugin usage
const summaries = ref([]);
const loading = ref(true);
const error = ref(null);
const generating = ref(false); // State for generation button
const generationError = ref(null); // State for generation error
const currentPage = ref(1);
const itemsPerPage = ref(10); // Or your desired page size
const totalItems = ref(0);
// Create a custom renderer
const renderer = new marked.Renderer();
const linkRenderer = renderer.link;
renderer.link = (href, title, text) => {
const html = linkRenderer.call(renderer, href, title, text);
// Add target="_blank" to the link
return html.replace(/^<a /, '<a target="_blank" rel="noopener noreferrer" ');
};
const fetchSummaries = async (page = 1) => {
loading.value = true;
error.value = null;
try {
// *** CHANGED API ENDPOINT ***
const response = await axios.get(`/api/email-summaries`, {
params: {
page: page,
limit: itemsPerPage.value
}
});
summaries.value = response.data.summaries;
totalItems.value = response.data.total;
currentPage.value = page;
} catch (err) {
console.error('Error fetching Email summaries:', err);
error.value = err.response?.data?.error || 'Failed to load summaries. Please try again later.';
} finally {
loading.value = false;
}
};
const generateSummary = async () => {
generating.value = true;
generationError.value = null;
error.value = null; // Clear previous loading errors
try {
// *** CHANGED API ENDPOINT ***
await axios.post('/api/email-summaries/generate');
$q.notify({
color: 'positive',
icon: 'check_circle',
// *** CHANGED MESSAGE ***
message: 'Email summary generation started successfully. It may take a few moments to appear.',
});
// Optionally, refresh the list after a short delay or immediately
// Consider that generation might be async on the backend
setTimeout(() => fetchSummaries(1), 3000); // Refresh after 3 seconds
} catch (err) {
console.error('Error generating Email summary:', err);
// *** CHANGED MESSAGE ***
generationError.value = err.response?.data?.error || 'Failed to start email summary generation.';
$q.notify({
color: 'negative',
icon: 'error',
message: generationError.value,
});
} finally {
generating.value = false;
}
};
const formatDate = (dateString) => {
// Assuming dateString is YYYY-MM-DD
return date.formatDate(dateString + 'T00:00:00', 'DD MMMM YYYY');
};
const formatDateTime = (dateTimeString) => {
return date.formatDate(dateTimeString, 'DD MMMM YYYY HH:mm');
};
const parseMarkdown = (markdownText) => {
if (!markdownText) return '';
// Use the custom renderer with marked
return marked(markdownText, { renderer });
};
const totalPages = computed(() => {
return Math.ceil(totalItems.value / itemsPerPage.value);
});
onMounted(() => {
fetchSummaries(currentPage.value);
});
</script>
<style scoped>
.markdown-content :deep(table) {
border-collapse: collapse;
width: 100%;
margin-top: 1em;
margin-bottom: 1em;
}
.markdown-content :deep(th),
.markdown-content :deep(td) {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}
.markdown-content :deep(th) {
background-color: rgba(0, 0, 0, 0.25);
}
.markdown-content :deep(a) {
color: var(--q-primary);
text-decoration: none;
}
.markdown-content :deep(a:hover) {
text-decoration: underline;
}
/* Add any specific styles if needed */
</style>

View file

@ -5,7 +5,10 @@
404 404
</div> </div>
<div class="text-h2" style="opacity:.4"> <div
class="text-h2"
style="opacity:.4"
>
Oops. Nothing here... Oops. Nothing here...
</div> </div>

View file

@ -1,54 +1,140 @@
<template> <template>
<q-page padding> <q-page padding>
<div class="text-h4 q-mb-md">Create New Form</div> <div class="text-h4 q-mb-md">
Create New Form
</div>
<q-form @submit.prevent="createForm" class="q-gutter-md"> <q-form
<q-input outlined v-model="form.title" label="Form Title *" lazy-rules @submit.prevent="createForm"
:rules="[val => val && val.length > 0 || 'Please enter a title']" /> class="q-gutter-md"
>
<q-input
outlined
v-model="form.title"
label="Form Title *"
lazy-rules
:rules="[val => val && val.length > 0 || 'Please enter a title']"
/>
<q-input outlined v-model="form.description" label="Form Description" type="textarea" autogrow /> <q-input
outlined
v-model="form.description"
label="Form Description"
type="textarea"
autogrow
/>
<q-separator class="q-my-lg" /> <q-separator class="q-my-lg" />
<div class="text-h6 q-mb-sm">Categories & Fields</div> <div class="text-h6 q-mb-sm">
Categories & Fields
</div>
<div v-for="(category, catIndex) in form.categories" :key="catIndex" <div
class="q-mb-lg q-pa-md bordered rounded-borders"> v-for="(category, catIndex) in form.categories"
<div class="row items-center q-mb-sm"> :key="catIndex"
<q-input outlined dense v-model="category.name" :label="`Category ${catIndex + 1} Name *`" class="q-mb-lg q-pa-md bordered rounded-borders"
class="col q-mr-sm" lazy-rules >
:rules="[val => val && val.length > 0 || 'Category name required']" /> <div class="row items-center q-mb-sm">
<q-btn flat round dense icon="delete" color="negative" @click="removeCategory(catIndex)" <q-input
title="Remove Category" /> outlined
</div> dense
v-model="category.name"
:label="`Category ${catIndex + 1} Name *`"
class="col q-mr-sm"
lazy-rules
:rules="[val => val && val.length > 0 || 'Category name required']"
/>
<q-btn
flat
round
dense
icon="delete"
color="negative"
@click="removeCategory(catIndex)"
title="Remove Category"
/>
</div>
<div v-for="(field, fieldIndex) in category.fields" :key="fieldIndex" <div
class="q-ml-md q-mb-sm field-item"> v-for="(field, fieldIndex) in category.fields"
<div class="row items-center q-gutter-sm"> :key="fieldIndex"
<q-input outlined dense v-model="field.label" label="Field Label *" class="col" lazy-rules class="q-ml-md q-mb-sm field-item"
:rules="[val => val && val.length > 0 || 'Field label required']" /> >
<q-select outlined dense v-model="field.type" :options="fieldTypes" label="Field Type *" <div class="row items-center q-gutter-sm">
class="col-auto" style="min-width: 150px;" lazy-rules <q-input
:rules="[val => !!val || 'Field type required']" /> outlined
<q-btn flat round dense icon="delete" color="negative" dense
@click="removeField(catIndex, fieldIndex)" title="Remove Field" /> v-model="field.label"
</div> label="Field Label *"
<q-input v-model="field.description" outlined dense label="Field Description (Optional)" autogrow class="col"
class="q-mt-xs q-mb-xl" hint="This description will appear below the field label on the form." /> lazy-rules
</div> :rules="[val => val && val.length > 0 || 'Field label required']"
<q-btn color="primary" label="Add Field" @click="addField(catIndex)" class="q-ml-md q-mt-sm" /> />
</div> <q-select
outlined
dense
v-model="field.type"
:options="fieldTypes"
label="Field Type *"
class="col-auto"
style="min-width: 150px;"
lazy-rules
:rules="[val => !!val || 'Field type required']"
/>
<q-btn
flat
round
dense
icon="delete"
color="negative"
@click="removeField(catIndex, fieldIndex)"
title="Remove Field"
/>
</div>
<q-input
v-model="field.description"
outlined
dense
label="Field Description (Optional)"
autogrow
class="q-mt-xs q-mb-xl"
hint="This description will appear below the field label on the form."
/>
</div>
<q-btn
color="primary"
label="Add Field"
@click="addField(catIndex)"
class="q-ml-md q-mt-sm"
/>
</div>
<q-btn color="secondary" label="Add Category" @click="addCategory" /> <q-btn
color="secondary"
label="Add Category"
@click="addCategory"
/>
<q-separator class="q-my-lg" /> <q-separator class="q-my-lg" />
<div> <div>
<q-btn label="Create Form" type="submit" color="primary" :loading="submitting" /> <q-btn
<q-btn label="Cancel" type="reset" color="warning" class="q-ml-sm" :to="{ name: 'formList' }" /> label="Create Form"
</div> type="submit"
</q-form> color="primary"
</q-page> :loading="submitting"
/>
<q-btn
label="Cancel"
type="reset"
color="warning"
class="q-ml-sm"
:to="{ name: 'formList' }"
/>
</div>
</q-form>
</q-page>
</template> </template>
<script setup> <script setup>
@ -61,55 +147,65 @@ const $q = useQuasar();
const router = useRouter(); const router = useRouter();
const form = ref({ const form = ref({
title: '', title: '',
description: '', description: '',
categories: [ categories: [
{ name: 'Category 1', fields: [{ label: '', type: null, description: '' }] } { name: 'Category 1', fields: [{ label: '', type: null, description: '' }] }
] ]
}); });
const fieldTypes = ref(['text', 'number', 'date', 'textarea', 'boolean']); const fieldTypes = ref(['text', 'number', 'date', 'textarea', 'boolean']);
const submitting = ref(false); const submitting = ref(false);
function addCategory() { function addCategory()
form.value.categories.push({ name: `Category ${form.value.categories.length + 1}`, fields: [{ label: '', type: null, description: '' }] }); {
form.value.categories.push({ name: `Category ${form.value.categories.length + 1}`, fields: [{ label: '', type: null, description: '' }] });
} }
function removeCategory(index) { function removeCategory(index)
form.value.categories.splice(index, 1); {
form.value.categories.splice(index, 1);
} }
function addField(catIndex) { function addField(catIndex)
form.value.categories[catIndex].fields.push({ label: '', type: 'text', description: '' }); {
form.value.categories[catIndex].fields.push({ label: '', type: 'text', description: '' });
} }
function removeField(catIndex, fieldIndex) { function removeField(catIndex, fieldIndex)
form.value.categories[catIndex].fields.splice(fieldIndex, 1); {
form.value.categories[catIndex].fields.splice(fieldIndex, 1);
} }
async function createForm() { async function createForm()
submitting.value = true; {
try { submitting.value = true;
const response = await axios.post('/api/forms', form.value); try
$q.notify({ {
color: 'positive', const response = await axios.post('/api/forms', form.value);
position: 'top', $q.notify({
message: `Form "${form.value.title}" created successfully!`, color: 'positive',
icon: 'check_circle' position: 'top',
}); message: `Form "${form.value.title}" created successfully!`,
router.push({ name: 'formList' }); icon: 'check_circle'
} catch (error) { });
console.error('Error creating form:', error); router.push({ name: 'formList' });
const message = error.response?.data?.error || 'Failed to create form. Please check the details and try again.'; }
$q.notify({ catch (error)
color: 'negative', {
position: 'top', console.error('Error creating form:', error);
message: message, const message = error.response?.data?.error || 'Failed to create form. Please check the details and try again.';
icon: 'report_problem' $q.notify({
}); color: 'negative',
} finally { position: 'top',
submitting.value = false; message: message,
} icon: 'report_problem'
});
}
finally
{
submitting.value = false;
}
} }
</script> </script>

View file

@ -1,8 +1,14 @@
<template> <template>
<q-page padding> <q-page padding>
<div class="text-h4 q-mb-md">Edit Form</div> <div class="text-h4 q-mb-md">
Edit Form
</div>
<q-form v-if="!loading && form" @submit.prevent="updateForm" class="q-gutter-md"> <q-form
v-if="!loading && form"
@submit.prevent="updateForm"
class="q-gutter-md"
>
<q-input <q-input
outlined outlined
v-model="form.title" v-model="form.title"
@ -21,25 +27,45 @@
<q-separator class="q-my-lg" /> <q-separator class="q-my-lg" />
<div class="text-h6 q-mb-sm">Categories & Fields</div> <div class="text-h6 q-mb-sm">
Categories & Fields
</div>
<div v-for="(category, catIndex) in form.categories" :key="category.id || catIndex" class="q-mb-lg q-pa-md bordered rounded-borders"> <div
v-for="(category, catIndex) in form.categories"
:key="category.id || catIndex"
class="q-mb-lg q-pa-md bordered rounded-borders"
>
<div class="row items-center q-mb-sm"> <div class="row items-center q-mb-sm">
<q-input <q-input
outlined dense outlined
dense
v-model="category.name" v-model="category.name"
:label="`Category ${catIndex + 1} Name *`" :label="`Category ${catIndex + 1} Name *`"
class="col q-mr-sm" class="col q-mr-sm"
lazy-rules lazy-rules
:rules="[ val => val && val.length > 0 || 'Category name required']" :rules="[ val => val && val.length > 0 || 'Category name required']"
/> />
<q-btn flat round dense icon="delete" color="negative" @click="removeCategory(catIndex)" title="Remove Category" /> <q-btn
flat
round
dense
icon="delete"
color="negative"
@click="removeCategory(catIndex)"
title="Remove Category"
/>
</div> </div>
<div v-for="(field, fieldIndex) in category.fields" :key="field.id || fieldIndex" class="q-ml-md q-mb-sm"> <div
v-for="(field, fieldIndex) in category.fields"
:key="field.id || fieldIndex"
class="q-ml-md q-mb-sm"
>
<div class="row items-center q-gutter-sm"> <div class="row items-center q-gutter-sm">
<q-input <q-input
outlined dense outlined
dense
v-model="field.label" v-model="field.label"
label="Field Label *" label="Field Label *"
class="col" class="col"
@ -47,7 +73,8 @@
:rules="[ val => val && val.length > 0 || 'Field label required']" :rules="[ val => val && val.length > 0 || 'Field label required']"
/> />
<q-select <q-select
outlined dense outlined
dense
v-model="field.type" v-model="field.type"
:options="fieldTypes" :options="fieldTypes"
label="Field Type *" label="Field Type *"
@ -56,7 +83,15 @@
lazy-rules lazy-rules
:rules="[ val => !!val || 'Field type required']" :rules="[ val => !!val || 'Field type required']"
/> />
<q-btn flat round dense icon="delete" color="negative" @click="removeField(catIndex, fieldIndex)" title="Remove Field" /> <q-btn
flat
round
dense
icon="delete"
color="negative"
@click="removeField(catIndex, fieldIndex)"
title="Remove Field"
/>
</div> </div>
<q-input <q-input
v-model="field.description" v-model="field.description"
@ -68,23 +103,53 @@
hint="This description will appear below the field label on the form." hint="This description will appear below the field label on the form."
/> />
</div> </div>
<q-btn outline color="primary" label="Add Field" @click="addField(catIndex)" class="q-ml-md q-mt-sm" /> <q-btn
outline
color="primary"
label="Add Field"
@click="addField(catIndex)"
class="q-ml-md q-mt-sm"
/>
</div> </div>
<q-btn outline color="secondary" label="Add Category" @click="addCategory" /> <q-btn
outline
color="secondary"
label="Add Category"
@click="addCategory"
/>
<q-separator class="q-my-lg" /> <q-separator class="q-my-lg" />
<div> <div>
<q-btn outline label="Update Form" type="submit" color="primary" :loading="submitting"/> <q-btn
<q-btn outline label="Cancel" type="reset" color="warning" class="q-ml-sm" :to="{ name: 'formList' }" /> outline
label="Update Form"
type="submit"
color="primary"
:loading="submitting"
/>
<q-btn
outline
label="Cancel"
type="reset"
color="warning"
class="q-ml-sm"
:to="{ name: 'formList' }"
/>
</div> </div>
</q-form> </q-form>
<div v-else-if="loading"> <div v-else-if="loading">
<q-spinner-dots color="primary" size="40px" /> <q-spinner-dots
color="primary"
size="40px"
/>
Loading form details... Loading form details...
</div> </div>
<div v-else class="text-negative"> <div
v-else
class="text-negative"
>
Failed to load form details. Failed to load form details.
</div> </div>
</q-page> </q-page>
@ -112,17 +177,22 @@ const loading = ref(true);
const fieldTypes = ref(['text', 'number', 'date', 'textarea', 'boolean']); const fieldTypes = ref(['text', 'number', 'date', 'textarea', 'boolean']);
const submitting = ref(false); const submitting = ref(false);
async function fetchForm() { async function fetchForm()
{
loading.value = true; loading.value = true;
try { try
{
const response = await axios.get(`/api/forms/${props.id}`); const response = await axios.get(`/api/forms/${props.id}`);
// Ensure categories and fields exist, even if empty // Ensure categories and fields exist, even if empty
response.data.categories = response.data.categories || []; response.data.categories = response.data.categories || [];
response.data.categories.forEach(cat => { response.data.categories.forEach(cat =>
{
cat.fields = cat.fields || []; cat.fields = cat.fields || [];
}); });
form.value = response.data; form.value = response.data;
} catch (error) { }
catch (error)
{
console.error('Error fetching form details:', error); console.error('Error fetching form details:', error);
$q.notify({ $q.notify({
color: 'negative', color: 'negative',
@ -131,38 +201,48 @@ async function fetchForm() {
icon: 'report_problem' icon: 'report_problem'
}); });
form.value = null; // Indicate failure form.value = null; // Indicate failure
} finally { }
finally
{
loading.value = false; loading.value = false;
} }
} }
onMounted(fetchForm); onMounted(fetchForm);
function addCategory() { function addCategory()
if (!form.value.categories) { {
if (!form.value.categories)
{
form.value.categories = []; form.value.categories = [];
} }
form.value.categories.push({ name: `Category ${form.value.categories.length + 1}`, fields: [{ label: '', type: 'text', description: '' }] }); form.value.categories.push({ name: `Category ${form.value.categories.length + 1}`, fields: [{ label: '', type: 'text', description: '' }] });
} }
function removeCategory(index) { function removeCategory(index)
{
form.value.categories.splice(index, 1); form.value.categories.splice(index, 1);
} }
function addField(catIndex) { function addField(catIndex)
if (!form.value.categories[catIndex].fields) { {
form.value.categories[catIndex].fields = []; if (!form.value.categories[catIndex].fields)
} {
form.value.categories[catIndex].fields = [];
}
form.value.categories[catIndex].fields.push({ label: '', type: 'text', description: '' }); form.value.categories[catIndex].fields.push({ label: '', type: 'text', description: '' });
} }
function removeField(catIndex, fieldIndex) { function removeField(catIndex, fieldIndex)
{
form.value.categories[catIndex].fields.splice(fieldIndex, 1); form.value.categories[catIndex].fields.splice(fieldIndex, 1);
} }
async function updateForm() { async function updateForm()
{
submitting.value = true; submitting.value = true;
try { try
{
// Prepare payload, potentially removing temporary IDs if any were added client-side // Prepare payload, potentially removing temporary IDs if any were added client-side
const payload = JSON.parse(JSON.stringify(form.value)); const payload = JSON.parse(JSON.stringify(form.value));
// The backend PUT expects title, description, categories (with name, fields (with label, type, description)) // The backend PUT expects title, description, categories (with name, fields (with label, type, description))
@ -176,7 +256,9 @@ async function updateForm() {
icon: 'check_circle' icon: 'check_circle'
}); });
router.push({ name: 'formList' }); // Or maybe back to the form details/responses page router.push({ name: 'formList' }); // Or maybe back to the form details/responses page
} catch (error) { }
catch (error)
{
console.error('Error updating form:', error); console.error('Error updating form:', error);
const message = error.response?.data?.error || 'Failed to update form. Please check the details and try again.'; const message = error.response?.data?.error || 'Failed to update form. Please check the details and try again.';
$q.notify({ $q.notify({
@ -185,7 +267,9 @@ async function updateForm() {
message: message, message: message,
icon: 'report_problem' icon: 'report_problem'
}); });
} finally { }
finally
{
submitting.value = false; submitting.value = false;
} }
} }

View file

@ -1,20 +1,47 @@
<template> <template>
<q-page padding> <q-page padding>
<q-inner-loading :showing="loading"> <q-inner-loading :showing="loading">
<q-spinner-gears size="50px" color="primary" /> <q-spinner-gears
size="50px"
color="primary"
/>
</q-inner-loading> </q-inner-loading>
<div v-if="!loading && form"> <div v-if="!loading && form">
<div class="text-h4 q-mb-xs">{{ form.title }}</div> <div class="text-h4 q-mb-xs">
<div class="text-subtitle1 text-grey q-mb-lg">{{ form.description }}</div> {{ form.title }}
</div>
<div class="text-subtitle1 text-grey q-mb-lg">
{{ form.description }}
</div>
<q-form @submit.prevent="submitResponse" class="q-gutter-md"> <q-form
@submit.prevent="submitResponse"
<div v-for="category in form.categories" :key="category.id" class="q-mb-lg"> class="q-gutter-md"
<div class="text-h6 q-mb-sm">{{ category.name }}</div> >
<div v-for="field in category.fields" :key="field.id" class="q-mb-md"> <div
<q-item-label class="q-mb-xs">{{ field.label }}</q-item-label> v-for="category in form.categories"
<q-item-label caption v-if="field.description" class="q-mb-xs text-grey-7">{{ field.description }}</q-item-label> :key="category.id"
class="q-mb-lg"
>
<div class="text-h6 q-mb-sm">
{{ category.name }}
</div>
<div
v-for="field in category.fields"
:key="field.id"
class="q-mb-md"
>
<q-item-label class="q-mb-xs">
{{ field.label }}
</q-item-label>
<q-item-label
caption
v-if="field.description"
class="q-mb-xs text-grey-7"
>
{{ field.description }}
</q-item-label>
<q-input <q-input
v-if="field.type === 'text'" v-if="field.type === 'text'"
outlined outlined
@ -28,7 +55,7 @@
v-model.number="responses[field.id]" v-model.number="responses[field.id]"
:label="field.label" :label="field.label"
/> />
<q-input <q-input
v-else-if="field.type === 'date'" v-else-if="field.type === 'date'"
outlined outlined
type="date" type="date"
@ -58,19 +85,39 @@
<q-separator class="q-my-lg" /> <q-separator class="q-my-lg" />
<div> <div>
<q-btn outline label="Submit Response" type="submit" color="primary" :loading="submitting"/> <q-btn
<q-btn outline label="Cancel" type="reset" color="default" class="q-ml-sm" :to="{ name: 'formList' }" /> outline
</div> label="Submit Response"
type="submit"
color="primary"
:loading="submitting"
/>
<q-btn
outline
label="Cancel"
type="reset"
color="default"
class="q-ml-sm"
:to="{ name: 'formList' }"
/>
</div>
</q-form> </q-form>
</div> </div>
<q-banner v-else-if="!loading && !form" class="bg-negative text-white"> <q-banner
<template v-slot:avatar> v-else-if="!loading && !form"
class="bg-negative text-white"
>
<template #avatar>
<q-icon name="error" /> <q-icon name="error" />
</template> </template>
Form not found or could not be loaded. Form not found or could not be loaded.
<template v-slot:action> <template #action>
<q-btn flat color="white" label="Back to Forms" :to="{ name: 'formList' }" /> <q-btn
flat
color="white"
label="Back to Forms"
:to="{ name: 'formList' }"
/>
</template> </template>
</q-banner> </q-banner>
</q-page> </q-page>
@ -97,19 +144,25 @@ const responses = reactive({}); // Use reactive for dynamic properties
const loading = ref(true); const loading = ref(true);
const submitting = ref(false); const submitting = ref(false);
async function fetchFormDetails() { async function fetchFormDetails()
{
loading.value = true; loading.value = true;
form.value = null; // Reset form data form.value = null; // Reset form data
try { try
{
const response = await axios.get(`/api/forms/${props.id}`); const response = await axios.get(`/api/forms/${props.id}`);
form.value = response.data; form.value = response.data;
// Initialize responses object based on fields // Initialize responses object based on fields
form.value.categories.forEach(cat => { form.value.categories.forEach(cat =>
cat.fields.forEach(field => { {
cat.fields.forEach(field =>
{
responses[field.id] = null; // Initialize all fields to null or default responses[field.id] = null; // Initialize all fields to null or default
}); });
}); });
} catch (error) { }
catch (error)
{
console.error(`Error fetching form ${props.id}:`, error); console.error(`Error fetching form ${props.id}:`, error);
$q.notify({ $q.notify({
color: 'negative', color: 'negative',
@ -117,14 +170,18 @@ async function fetchFormDetails() {
message: 'Failed to load form details.', message: 'Failed to load form details.',
icon: 'report_problem' icon: 'report_problem'
}); });
} finally { }
finally
{
loading.value = false; loading.value = false;
} }
} }
async function submitResponse() { async function submitResponse()
{
submitting.value = true; submitting.value = true;
try { try
{
// Basic check if any response is provided (optional) // Basic check if any response is provided (optional)
// const hasResponse = Object.values(responses).some(val => val !== null && val !== ''); // const hasResponse = Object.values(responses).some(val => val !== null && val !== '');
// if (!hasResponse) { // if (!hasResponse) {
@ -144,7 +201,9 @@ async function submitResponse() {
// Or clear the form: // Or clear the form:
// Object.keys(responses).forEach(key => { responses[key] = null; }); // Object.keys(responses).forEach(key => { responses[key] = null; });
} catch (error) { }
catch (error)
{
console.error('Error submitting response:', error); console.error('Error submitting response:', error);
const message = error.response?.data?.error || 'Failed to submit response.'; const message = error.response?.data?.error || 'Failed to submit response.';
$q.notify({ $q.notify({
@ -153,7 +212,9 @@ async function submitResponse() {
message: message, message: message,
icon: 'report_problem' icon: 'report_problem'
}); });
} finally { }
finally
{
submitting.value = false; submitting.value = false;
} }
} }

View file

@ -1,38 +1,96 @@
<template> <template>
<q-page padding> <q-page padding>
<div class="q-mb-md row justify-between items-center"> <div class="q-mb-md row justify-between items-center">
<div class="text-h4">Forms</div> <div class="text-h4">
<q-btn outline label="Create New Form" color="primary" :to="{ name: 'formCreate' }" /> Forms
</div>
<q-btn
outline
label="Create New Form"
color="primary"
:to="{ name: 'formCreate' }"
/>
</div> </div>
<q-list bordered separator v-if="forms.length > 0"> <q-list
<q-item v-for="form in forms" :key="form.id"> bordered
separator
v-if="forms.length > 0"
>
<q-item
v-for="form in forms"
:key="form.id"
>
<q-item-section> <q-item-section>
<q-item-label>{{ form.title }}</q-item-label> <q-item-label>{{ form.title }}</q-item-label>
<q-item-label caption>{{ form.description || 'No description' }}</q-item-label> <q-item-label caption>
<q-item-label caption>Created: {{ formatDate(form.createdAt) }}</q-item-label> {{ form.description || 'No description' }}
</q-item-label>
<q-item-label caption>
Created: {{ formatDate(form.createdAt) }}
</q-item-label>
</q-item-section> </q-item-section>
<q-item-section side> <q-item-section side>
<div class="q-gutter-sm"> <div class="q-gutter-sm">
<q-btn flat round dense icon="edit_note" color="info" :to="{ name: 'formFill', params: { id: form.id } }" title="Fill Form" /> <q-btn
<q-btn flat round dense icon="visibility" color="secondary" :to="{ name: 'formResponses', params: { id: form.id } }" title="View Responses" /> flat
<q-btn flat round dense icon="edit" color="warning" :to="{ name: 'formEdit', params: { id: form.id } }" title="Edit Form" /> round
<q-btn flat round dense icon="delete" color="negative" @click.stop="confirmDeleteForm(form.id)" title="Delete Form" /> dense
icon="edit_note"
color="info"
:to="{ name: 'formFill', params: { id: form.id } }"
title="Fill Form"
/>
<q-btn
flat
round
dense
icon="visibility"
color="secondary"
:to="{ name: 'formResponses', params: { id: form.id } }"
title="View Responses"
/>
<q-btn
flat
round
dense
icon="edit"
color="warning"
:to="{ name: 'formEdit', params: { id: form.id } }"
title="Edit Form"
/>
<q-btn
flat
round
dense
icon="delete"
color="negative"
@click.stop="confirmDeleteForm(form.id)"
title="Delete Form"
/>
</div> </div>
</q-item-section> </q-item-section>
</q-item> </q-item>
</q-list> </q-list>
<q-banner v-else class="bg-info text-white"> <q-banner
<template v-slot:avatar> v-else
<q-icon name="info" color="white" /> class="bg-info text-white"
>
<template #avatar>
<q-icon
name="info"
color="white"
/>
</template> </template>
No forms created yet. Click the button above to create your first form. No forms created yet. Click the button above to create your first form.
</q-banner> </q-banner>
<q-inner-loading :showing="loading"> <q-inner-loading :showing="loading">
<q-spinner-gears size="50px" color="primary" /> <q-spinner-gears
</q-inner-loading> size="50px"
color="primary"
/>
</q-inner-loading>
</q-page> </q-page>
</template> </template>
@ -45,12 +103,16 @@ const $q = useQuasar();
const forms = ref([]); const forms = ref([]);
const loading = ref(false); const loading = ref(false);
async function fetchForms() { async function fetchForms()
{
loading.value = true; loading.value = true;
try { try
{
const response = await axios.get('/api/forms'); const response = await axios.get('/api/forms');
forms.value = response.data; forms.value = response.data;
} catch (error) { }
catch (error)
{
console.error('Error fetching forms:', error); console.error('Error fetching forms:', error);
$q.notify({ $q.notify({
color: 'negative', color: 'negative',
@ -58,13 +120,16 @@ async function fetchForms() {
message: 'Failed to load forms. Please try again later.', message: 'Failed to load forms. Please try again later.',
icon: 'report_problem' icon: 'report_problem'
}); });
} finally { }
finally
{
loading.value = false; loading.value = false;
} }
} }
// Add function to handle delete confirmation // Add function to handle delete confirmation
function confirmDeleteForm(id) { function confirmDeleteForm(id)
{
$q.dialog({ $q.dialog({
title: 'Confirm Delete', title: 'Confirm Delete',
message: 'Are you sure you want to delete this form and all its responses? This action cannot be undone.', message: 'Are you sure you want to delete this form and all its responses? This action cannot be undone.',
@ -79,14 +144,17 @@ function confirmDeleteForm(id) {
label: 'Cancel', label: 'Cancel',
flat: true flat: true
} }
}).onOk(() => { }).onOk(() =>
{
deleteForm(id); deleteForm(id);
}); });
} }
// Add function to call the delete API // Add function to call the delete API
async function deleteForm(id) { async function deleteForm(id)
try { {
try
{
await axios.delete(`/api/forms/${id}`); await axios.delete(`/api/forms/${id}`);
forms.value = forms.value.filter(form => form.id !== id); forms.value = forms.value.filter(form => form.id !== id);
$q.notify({ $q.notify({
@ -95,7 +163,9 @@ async function deleteForm(id) {
message: 'Form deleted successfully.', message: 'Form deleted successfully.',
icon: 'check_circle' icon: 'check_circle'
}); });
} catch (error) { }
catch (error)
{
console.error(`Error deleting form ${id}:`, error); console.error(`Error deleting form ${id}:`, error);
const errorMessage = error.response?.data?.error || 'Failed to delete form. Please try again.'; const errorMessage = error.response?.data?.error || 'Failed to delete form. Please try again.';
$q.notify({ $q.notify({
@ -108,7 +178,8 @@ async function deleteForm(id) {
} }
// Add function to format date // Add function to format date
function formatDate(date) { function formatDate(date)
{
return new Date(date).toLocaleString(); return new Date(date).toLocaleString();
} }

View file

@ -1,12 +1,17 @@
<template> <template>
<q-page padding> <q-page padding>
<q-inner-loading :showing="loading"> <q-inner-loading :showing="loading">
<q-spinner-gears size="50px" color="primary" /> <q-spinner-gears
size="50px"
color="primary"
/>
</q-inner-loading> </q-inner-loading>
<div v-if="!loading && formTitle"> <div v-if="!loading && formTitle">
<div class="row justify-between items-center q-mb-md"> <div class="row justify-between items-center q-mb-md">
<div class="text-h4">Responses for: {{ formTitle }}</div> <div class="text-h4">
Responses for: {{ formTitle }}
</div>
</div> </div>
<!-- Add Search Input --> <!-- Add Search Input -->
@ -19,7 +24,7 @@
placeholder="Search responses..." placeholder="Search responses..."
class="q-mb-md" class="q-mb-md"
> >
<template v-slot:append> <template #append>
<q-icon name="search" /> <q-icon name="search" />
</template> </template>
</q-input> </q-input>
@ -29,22 +34,25 @@
:rows="formattedResponses" :rows="formattedResponses"
:columns="columns" :columns="columns"
row-key="id" row-key="id"
flat bordered flat
bordered
separator="cell" separator="cell"
wrap-cells wrap-cells
:filter="filterText" :filter="filterText"
> >
<template v-slot:body-cell-submittedAt="props"> <template #body-cell-submittedAt="props">
<q-td :props="props"> <q-td :props="props">
{{ new Date(props.value).toLocaleString() }} {{ new Date(props.value).toLocaleString() }}
</q-td> </q-td>
</template> </template>
<!-- Slot for Actions column --> <!-- Slot for Actions column -->
<template v-slot:body-cell-actions="props"> <template #body-cell-actions="props">
<q-td :props="props"> <q-td :props="props">
<q-btn <q-btn
flat dense round flat
dense
round
icon="download" icon="download"
color="primary" color="primary"
@click="downloadResponsePdf(props.row.id)" @click="downloadResponsePdf(props.row.id)"
@ -54,24 +62,36 @@
</q-btn> </q-btn>
</q-td> </q-td>
</template> </template>
</q-table> </q-table>
<q-banner v-else class=""> <q-banner
<template v-slot:avatar> v-else
<q-icon name="info" color="info" /> class=""
>
<template #avatar>
<q-icon
name="info"
color="info"
/>
</template> </template>
No responses have been submitted for this form yet. No responses have been submitted for this form yet.
</q-banner> </q-banner>
</div> </div>
<q-banner v-else-if="!loading && !formTitle" class="bg-negative text-white"> <q-banner
<template v-slot:avatar> v-else-if="!loading && !formTitle"
class="bg-negative text-white"
>
<template #avatar>
<q-icon name="error" /> <q-icon name="error" />
</template> </template>
Form not found or could not load responses. Form not found or could not load responses.
<template v-slot:action> <template #action>
<q-btn flat color="white" label="Back to Forms" :to="{ name: 'formList' }" /> <q-btn
flat
color="white"
label="Back to Forms"
:to="{ name: 'formList' }"
/>
</template> </template>
</q-banner> </q-banner>
</q-page> </q-page>
@ -81,9 +101,8 @@
import { ref, onMounted, computed } from 'vue'; import { ref, onMounted, computed } from 'vue';
import axios from 'axios'; import axios from 'axios';
import { useQuasar } from 'quasar'; import { useQuasar } from 'quasar';
import { useRoute } from 'vue-router';
const props = defineProps({ const componentProps = defineProps({
id: { id: {
type: [String, Number], type: [String, Number],
required: true required: true
@ -93,34 +112,37 @@ const props = defineProps({
const $q = useQuasar(); const $q = useQuasar();
const formTitle = ref(''); const formTitle = ref('');
const responses = ref([]); const responses = ref([]);
const columns = ref([]); // Columns will be generated dynamically const columns = ref([]);
const loading = ref(true); const loading = ref(true);
const filterText = ref(''); // Add ref for filter text const filterText = ref('');
// Fetch both form details (for title and field labels/order) and responses // Fetch both form details (for title and field labels/order) and responses
async function fetchData() { async function fetchData()
{
loading.value = true; loading.value = true;
formTitle.value = ''; formTitle.value = '';
responses.value = []; responses.value = [];
columns.value = []; columns.value = [];
try { try
{
// Fetch form details first to get the structure // Fetch form details first to get the structure
const formDetailsResponse = await axios.get(`/api/forms/${props.id}`); const formDetailsResponse = await axios.get(`/api/forms/${componentProps.id}`);
const form = formDetailsResponse.data; const form = formDetailsResponse.data;
formTitle.value = form.title; formTitle.value = form.title;
// Generate columns based on form fields in correct order // Generate columns based on form fields in correct order
const generatedColumns = [{ name: 'submittedAt', label: 'Submitted At', field: 'submittedAt', align: 'left', sortable: true }]; const generatedColumns = [{ name: 'submittedAt', label: 'Submitted At', field: 'submittedAt', align: 'left', sortable: true }];
form.categories.forEach(cat => { form.categories.forEach(cat =>
cat.fields.forEach(field => { {
cat.fields.forEach(field =>
{
generatedColumns.push({ generatedColumns.push({
name: `field_${field.id}`, // Unique name for column name: `field_${field.id}`,
label: field.label, label: field.label,
field: row => row.values[field.id]?.value ?? '', // Access nested value safely field: row => row.values[field.id]?.value ?? '',
align: 'left', align: 'left',
sortable: true, sortable: true,
// Add formatting based on field.type if needed
}); });
}); });
}); });
@ -135,25 +157,31 @@ async function fetchData() {
}); });
// Fetch responses // Fetch responses
const responsesResponse = await axios.get(`/api/forms/${props.id}/responses`); const responsesResponse = await axios.get(`/api/forms/${componentProps.id}/responses`);
responses.value = responsesResponse.data; // API already groups them responses.value = responsesResponse.data;
} catch (error) { }
console.error(`Error fetching data for form ${props.id}:`, error); catch (error)
{
console.error(`Error fetching data for form ${componentProps.id}:`, error);
$q.notify({ $q.notify({
color: 'negative', color: 'negative',
position: 'top', position: 'top',
message: 'Failed to load form responses.', message: 'Failed to load form responses.',
icon: 'report_problem' icon: 'report_problem'
}); });
} finally { }
finally
{
loading.value = false; loading.value = false;
} }
} }
// Computed property to match the structure expected by QTable rows // Computed property to match the structure expected by QTable rows
const formattedResponses = computed(() => { const formattedResponses = computed(() =>
return responses.value.map(response => { {
return responses.value.map(response =>
{
const row = { const row = {
id: response.id, id: response.id,
submittedAt: response.submittedAt, submittedAt: response.submittedAt,
@ -165,8 +193,10 @@ const formattedResponses = computed(() => {
}); });
// Function to download a single response as PDF // Function to download a single response as PDF
async function downloadResponsePdf(responseId) { async function downloadResponsePdf(responseId)
try { {
try
{
const response = await axios.get(`/api/responses/${responseId}/export/pdf`, { const response = await axios.get(`/api/responses/${responseId}/export/pdf`, {
responseType: 'blob', // Important for handling file downloads responseType: 'blob', // Important for handling file downloads
}); });
@ -179,9 +209,11 @@ async function downloadResponsePdf(responseId) {
// Try to get filename from content-disposition header // Try to get filename from content-disposition header
const contentDisposition = response.headers['content-disposition']; const contentDisposition = response.headers['content-disposition'];
let filename = `response-${responseId}.pdf`; // Default filename let filename = `response-${responseId}.pdf`; // Default filename
if (contentDisposition) { if (contentDisposition)
{
const filenameMatch = contentDisposition.match(/filename="?(.+)"?/i); const filenameMatch = contentDisposition.match(/filename="?(.+)"?/i);
if (filenameMatch && filenameMatch.length > 1) { if (filenameMatch && filenameMatch.length > 1)
{
filename = filenameMatch[1]; filename = filenameMatch[1];
} }
} }
@ -201,7 +233,9 @@ async function downloadResponsePdf(responseId) {
icon: 'check_circle' icon: 'check_circle'
}); });
} catch (error) { }
catch (error)
{
console.error(`Error downloading PDF for response ${responseId}:`, error); console.error(`Error downloading PDF for response ${responseId}:`, error);
$q.notify({ $q.notify({
color: 'negative', color: 'negative',

View file

@ -1,22 +1,40 @@
<template> <template>
<q-page class="landing-page column items-center q-pa-md"> <q-page class="landing-page column items-center q-pa-md">
<div class="hero text-center q-pa-xl full-width">
<h1 class="text-h3 text-weight-bold text-primary q-mb-sm">
Welcome to StylePoint
</h1>
<p class="text-h6 text-grey-8 q-mb-lg">
The all-in-one tool designed for StyleTech Developers.
</p>
</div>
<div class="hero text-center q-pa-xl full-width"> <div
<h1 class="text-h3 text-weight-bold text-primary q-mb-sm">Welcome to StylePoint</h1> class="features q-mt-xl q-pa-md text-center"
<p class="text-h6 text-grey-8 q-mb-lg">The all-in-one tool designed for StyleTech Developers.</p> style="max-width: 800px; width: 100%;"
</div> >
<h2 class="text-h4 text-weight-medium text-secondary q-mb-lg">
<div class="features q-mt-xl q-pa-md text-center" style="max-width: 800px; width: 100%;"> Features
<h2 class="text-h4 text-weight-medium text-secondary q-mb-lg">Features</h2> </h2>
<q-list bordered separator class="rounded-borders"> <q-list
<q-item v-for="(feature, index) in features" :key="index" class="q-pa-md"> bordered
<q-item-section> separator
<q-item-label class="text-body1">{{ feature }}</q-item-label> class="rounded-borders"
</q-item-section> >
</q-item> <q-item
</q-list> v-for="(feature, index) in features"
</div> :key="index"
</q-page> class="q-pa-md"
>
<q-item-section>
<q-item-label class="text-body1">
{{ feature }}
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</div>
</q-page>
</template> </template>
<script setup> <script setup>
@ -27,10 +45,10 @@ const $q = useQuasar();
const currentYear = ref(new Date().getFullYear()); const currentYear = ref(new Date().getFullYear());
const features = ref([ const features = ref([
'Auatomated Daily Reports', 'Auatomated Daily Reports',
'Deep Mantis Integration', 'Deep Mantis Integration',
'Easy Authentication', 'Easy Authentication',
'And more..?' 'And more..?'
]); ]);
</script> </script>

View file

@ -2,7 +2,9 @@
<q-page class="flex flex-center"> <q-page class="flex flex-center">
<q-card style="width: 400px; max-width: 90vw;"> <q-card style="width: 400px; max-width: 90vw;">
<q-card-section> <q-card-section>
<div class="text-h6">Login</div> <div class="text-h6">
Login
</div>
</q-card-section> </q-card-section>
<q-card-section> <q-card-section>
@ -23,11 +25,20 @@
@click="handleLogin" @click="handleLogin"
:loading="loading" :loading="loading"
/> />
<div v-if="errorMessage" class="text-negative q-mt-md">{{ errorMessage }}</div> <div
v-if="errorMessage"
class="text-negative q-mt-md"
>
{{ errorMessage }}
</div>
</q-card-section> </q-card-section>
<q-card-actions align="center"> <q-card-actions align="center">
<q-btn flat label="Don't have an account? Register" to="/register" /> <q-btn
flat
label="Don't have an account? Register"
to="/register"
/>
</q-card-actions> </q-card-actions>
</q-card> </q-card>
</q-page> </q-page>
@ -46,11 +57,13 @@ const errorMessage = ref('');
const router = useRouter(); const router = useRouter();
const authStore = useAuthStore(); // Use the auth store const authStore = useAuthStore(); // Use the auth store
async function handleLogin() { async function handleLogin()
{
loading.value = true; loading.value = true;
errorMessage.value = ''; errorMessage.value = '';
try { try
{
// 1. Get options from server // 1. Get options from server
const optionsRes = await axios.post('/auth/generate-authentication-options', { const optionsRes = await axios.post('/auth/generate-authentication-options', {
username: username.value || undefined, // Send username if provided username: username.value || undefined, // Send username if provided
@ -65,38 +78,52 @@ async function handleLogin() {
authenticationResponse: authResp, authenticationResponse: authResp,
}); });
if (verificationRes.data.verified) { if (verificationRes.data.verified)
{
// Update the auth store on successful login // Update the auth store on successful login
authStore.isAuthenticated = true; authStore.isAuthenticated = true;
authStore.user = verificationRes.data.user; authStore.user = verificationRes.data.user;
authStore.error = null; // Clear any previous errors authStore.error = null; // Clear any previous errors
console.log('Login successful:', verificationRes.data.user); console.log('Login successful:', verificationRes.data.user);
router.push('/'); // Redirect to home page router.push('/'); // Redirect to home page
} else { }
else
{
errorMessage.value = 'Authentication failed.'; errorMessage.value = 'Authentication failed.';
// Optionally update store state on failure // Optionally update store state on failure
authStore.isAuthenticated = false; authStore.isAuthenticated = false;
authStore.user = null; authStore.user = null;
authStore.error = 'Authentication failed.'; authStore.error = 'Authentication failed.';
} }
} catch (error) { }
catch (error)
{
console.error('Login error:', error); console.error('Login error:', error);
const message = error.response?.data?.error || error.message || 'An unknown error occurred during login.'; const message = error.response?.data?.error || error.message || 'An unknown error occurred during login.';
// Handle specific simplewebauthn errors if needed // Handle specific simplewebauthn errors if needed
if (error.name === 'NotAllowedError') { if (error.name === 'NotAllowedError')
errorMessage.value = 'Authentication ceremony was cancelled or timed out.'; {
} else if (error.response?.status === 404 && error.response?.data?.error?.includes('User not found')) { errorMessage.value = 'Authentication ceremony was cancelled or timed out.';
errorMessage.value = 'User not found. Please check your username or register.'; }
} else if (error.response?.status === 404 && error.response?.data?.error?.includes('Authenticator not found')) { else if (error.response?.status === 404 && error.response?.data?.error?.includes('User not found'))
errorMessage.value = 'No registered passkey found for this user or device. Try registering first.'; {
} else { errorMessage.value = 'User not found. Please check your username or register.';
errorMessage.value = `Login failed: ${message}`; }
else if (error.response?.status === 404 && error.response?.data?.error?.includes('Authenticator not found'))
{
errorMessage.value = 'No registered passkey found for this user or device. Try registering first.';
}
else
{
errorMessage.value = `Login failed: ${message}`;
} }
// Optionally update store state on error // Optionally update store state on error
authStore.isAuthenticated = false; authStore.isAuthenticated = false;
authStore.user = null; authStore.user = null;
authStore.error = `Login failed: ${message}`; authStore.error = `Login failed: ${message}`;
} finally { }
finally
{
loading.value = false; loading.value = false;
} }
} }

View file

@ -1,8 +1,13 @@
<template> <template>
<q-page padding> <q-page padding>
<q-card flat bordered> <q-card
flat
bordered
>
<q-card-section class="row items-center justify-between"> <q-card-section class="row items-center justify-between">
<div class="text-h6">Mantis Summaries</div> <div class="text-h6">
Mantis Summaries
</div>
<q-btn <q-btn
label="Generate Today's Summary" label="Generate Today's Summary"
color="primary" color="primary"
@ -15,8 +20,11 @@
<q-separator /> <q-separator />
<q-card-section v-if="generationError"> <q-card-section v-if="generationError">
<q-banner inline-actions class="text-white bg-red"> <q-banner
<template v-slot:avatar> inline-actions
class="text-white bg-red"
>
<template #avatar>
<q-icon name="error" /> <q-icon name="error" />
</template> </template>
{{ generationError }} {{ generationError }}
@ -24,30 +32,53 @@
</q-card-section> </q-card-section>
<q-card-section v-if="loading"> <q-card-section v-if="loading">
<q-spinner-dots size="40px" color="primary" /> <q-spinner-dots
size="40px"
color="primary"
/>
<span class="q-ml-md">Loading summaries...</span> <span class="q-ml-md">Loading summaries...</span>
</q-card-section> </q-card-section>
<q-card-section v-if="error && !generationError"> <q-card-section v-if="error && !generationError">
<q-banner inline-actions class="text-white bg-red"> <q-banner
<template v-slot:avatar> inline-actions
class="text-white bg-red"
>
<template #avatar>
<q-icon name="error" /> <q-icon name="error" />
</template> </template>
{{ error }} {{ error }}
</q-banner> </q-banner>
</q-card-section> </q-card-section>
<q-list separator v-if="!loading && !error && summaries.length > 0"> <q-list
<q-item v-for="summary in summaries" :key="summary.id"> separator
v-if="!loading && !error && summaries.length > 0"
>
<q-item
v-for="summary in summaries"
:key="summary.id"
>
<q-item-section> <q-item-section>
<q-item-label class="text-weight-bold">{{ formatDate(summary.summaryDate) }}</q-item-label> <q-item-label class="text-weight-bold">
<q-item-label caption>Generated: {{ formatDateTime(summary.generatedAt) }}</q-item-label> {{ formatDate(summary.summaryDate) }}
<q-item-label class="q-mt-sm markdown-content" v-html="parseMarkdown(summary.summaryText)"></q-item-label> </q-item-label>
<q-item-label caption>
Generated: {{ formatDateTime(summary.generatedAt) }}
</q-item-label>
<q-item-label
class="q-mt-sm markdown-content"
>
<div v-html="parseMarkdown(summary.content)" />
</q-item-label>
</q-item-section> </q-item-section>
</q-item> </q-item>
</q-list> </q-list>
<q-card-section v-if="totalPages > 1" class="flex flex-center q-mt-md"> <q-card-section
v-if="totalPages > 1"
class="flex flex-center q-mt-md"
>
<q-pagination <q-pagination
v-model="currentPage" v-model="currentPage"
:max="totalPages" :max="totalPages"
@ -59,10 +90,11 @@
/> />
</q-card-section> </q-card-section>
<q-card-section v-if="!loading && !error && summaries.length === 0"> <q-card-section v-if="!loading && !error && summaries.length === 0">
<div class="text-center text-grey">No summaries found.</div> <div class="text-center text-grey">
No summaries found.
</div>
</q-card-section> </q-card-section>
</q-card> </q-card>
</q-page> </q-page>
</template> </template>
@ -86,18 +118,21 @@ const totalItems = ref(0);
// Create a custom renderer // Create a custom renderer
const renderer = new marked.Renderer(); const renderer = new marked.Renderer();
const linkRenderer = renderer.link; const linkRenderer = renderer.link;
renderer.link = (href, title, text) => { renderer.link = (href, title, text) =>
{
const html = linkRenderer.call(renderer, href, title, text); const html = linkRenderer.call(renderer, href, title, text);
// Add target="_blank" to the link // Add target="_blank" to the link
return html.replace(/^<a /, '<a target="_blank" rel="noopener noreferrer" '); return html.replace(/^<a /, '<a target="_blank" rel="noopener noreferrer" ');
}; };
const fetchSummaries = async (page = 1) => { const fetchSummaries = async(page = 1) =>
{
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try
const response = await axios.get(`/api/mantis-summaries`, { {
const response = await axios.get('/api/mantis-summaries', {
params: { params: {
page: page, page: page,
limit: itemsPerPage.value limit: itemsPerPage.value
@ -106,19 +141,25 @@ const fetchSummaries = async (page = 1) => {
summaries.value = response.data.summaries; summaries.value = response.data.summaries;
totalItems.value = response.data.total; totalItems.value = response.data.total;
currentPage.value = page; currentPage.value = page;
} catch (err) { }
catch (err)
{
console.error('Error fetching Mantis summaries:', err); console.error('Error fetching Mantis summaries:', err);
error.value = err.response?.data?.error || 'Failed to load summaries. Please try again later.'; error.value = err.response?.data?.error || 'Failed to load summaries. Please try again later.';
} finally { }
finally
{
loading.value = false; loading.value = false;
} }
}; };
const generateSummary = async () => { const generateSummary = async() =>
{
generating.value = true; generating.value = true;
generationError.value = null; generationError.value = null;
error.value = null; // Clear previous loading errors error.value = null; // Clear previous loading errors
try { try
{
await axios.post('/api/mantis-summaries/generate'); await axios.post('/api/mantis-summaries/generate');
$q.notify({ $q.notify({
color: 'positive', color: 'positive',
@ -128,39 +169,48 @@ const generateSummary = async () => {
// Optionally, refresh the list after a short delay or immediately // Optionally, refresh the list after a short delay or immediately
// Consider that generation might be async on the backend // Consider that generation might be async on the backend
setTimeout(() => fetchSummaries(1), 3000); // Refresh after 3 seconds setTimeout(() => fetchSummaries(1), 3000); // Refresh after 3 seconds
} catch (err) { }
catch (err)
{
console.error('Error generating Mantis summary:', err); console.error('Error generating Mantis summary:', err);
generationError.value = err.response?.data?.error || 'Failed to start summary generation.'; generationError.value = err.response?.data?.error || 'Failed to start summary generation.';
$q.notify({ $q.notify({
color: 'negative', color: 'negative',
icon: 'error', icon: 'error',
message: generationError.value, message: generationError.value,
}); });
} finally { }
finally
{
generating.value = false; generating.value = false;
} }
}; };
const formatDate = (dateString) => { const formatDate = (dateString) =>
{
// Assuming dateString is YYYY-MM-DD // Assuming dateString is YYYY-MM-DD
return date.formatDate(dateString + 'T00:00:00', 'DD MMMM YYYY'); return date.formatDate(dateString + 'T00:00:00', 'DD MMMM YYYY');
}; };
const formatDateTime = (dateTimeString) => { const formatDateTime = (dateTimeString) =>
{
return date.formatDate(dateTimeString, 'DD MMMM YYYY HH:mm'); return date.formatDate(dateTimeString, 'DD MMMM YYYY HH:mm');
}; };
const parseMarkdown = (markdownText) => { const parseMarkdown = (markdownText) =>
{
if (!markdownText) return ''; if (!markdownText) return '';
// Use the custom renderer with marked // Use the custom renderer with marked
return marked(markdownText, { renderer }); return marked(markdownText, { renderer });
}; };
const totalPages = computed(() => { const totalPages = computed(() =>
{
return Math.ceil(totalItems.value / itemsPerPage.value); return Math.ceil(totalItems.value / itemsPerPage.value);
}); });
onMounted(() => { onMounted(() =>
{
fetchSummaries(currentPage.value); fetchSummaries(currentPage.value);
}); });
</script> </script>

View file

@ -1,9 +1,11 @@
<template> <template>
<q-page padding> <q-page padding>
<div class="q-mb-md row justify-between items-center"> <div class="q-mb-md row justify-between items-center">
<div class="text-h4">Passkey Management</div> <div class="text-h4">
Passkey Management
</div>
<div> <div>
<q-btn <q-btn
label="Identify Passkey" label="Identify Passkey"
color="secondary" color="secondary"
class="q-mx-md q-mt-md" class="q-mx-md q-mt-md"
@ -11,8 +13,8 @@
:loading="identifyLoading" :loading="identifyLoading"
:disable="identifyLoading || !isLoggedIn" :disable="identifyLoading || !isLoggedIn"
outline outline
/> />
<q-btn <q-btn
label="Register New Passkey" label="Register New Passkey"
color="primary" color="primary"
class="q-mx-md q-mt-md" class="q-mx-md q-mt-md"
@ -20,17 +22,31 @@
:loading="registerLoading" :loading="registerLoading"
:disable="registerLoading || !isLoggedIn" :disable="registerLoading || !isLoggedIn"
outline outline
/> />
</div> </div>
</div> </div>
<!-- Passkey List Section --> <!-- Passkey List Section -->
<q-card-section> <q-card-section>
<h5>Your Registered Passkeys</h5> <h5>Your Registered Passkeys</h5>
<q-list bordered separator v-if="passkeys.length > 0 && !fetchLoading"> <q-list
bordered
separator
v-if="passkeys.length > 0 && !fetchLoading"
>
<q-item v-if="registerSuccessMessage || registerErrorMessage"> <q-item v-if="registerSuccessMessage || registerErrorMessage">
<div v-if="registerSuccessMessage" class="text-positive q-mt-md">{{ registerSuccessMessage }}</div> <div
<div v-if="registerErrorMessage" class="text-negative q-mt-md">{{ registerErrorMessage }}</div> v-if="registerSuccessMessage"
class="text-positive q-mt-md"
>
{{ registerSuccessMessage }}
</div>
<div
v-if="registerErrorMessage"
class="text-negative q-mt-md"
>
{{ registerErrorMessage }}
</div>
</q-item> </q-item>
<q-item <q-item
v-for="passkey in passkeys" v-for="passkey in passkeys"
@ -39,14 +55,19 @@
> >
<q-item-section> <q-item-section>
<q-item-label>Passkey ID: {{ passkey.credentialID }} </q-item-label> <q-item-label>Passkey ID: {{ passkey.credentialID }} </q-item-label>
<q-item-label caption v-if="identifiedPasskeyId === passkey.credentialID"> <q-item-label
caption
v-if="identifiedPasskeyId === passkey.credentialID"
>
Verified just now! Verified just now!
</q-item-label> </q-item-label>
<!-- <q-item-label caption>Registered: {{ new Date(passkey.createdAt).toLocaleDateString() }}</q-item-label> --> <!-- <q-item-label caption>Registered: {{ new Date(passkey.createdAt).toLocaleDateString() }}</q-item-label> -->
</q-item-section> </q-item-section>
<q-item-section side class="row no-wrap items-center"> <q-item-section
side
class="row no-wrap items-center"
>
<!-- Delete Button --> <!-- Delete Button -->
<q-btn <q-btn
flat flat
@ -61,16 +82,44 @@
</q-item-section> </q-item-section>
</q-item> </q-item>
</q-list> </q-list>
<div v-else-if="fetchLoading" class="q-mt-md">Loading passkeys...</div> <div
<div v-else class="q-mt-md">You have no passkeys registered yet.</div> v-else-if="fetchLoading"
class="q-mt-md"
<div v-if="fetchErrorMessage" class="text-negative q-mt-md">{{ fetchErrorMessage }}</div> >
<div v-if="deleteSuccessMessage" class="text-positive q-mt-md">{{ deleteSuccessMessage }}</div> Loading passkeys...
<div v-if="deleteErrorMessage" class="text-negative q-mt-md">{{ deleteErrorMessage }}</div> </div>
<div v-if="identifyErrorMessage" class="text-negative q-mt-md">{{ identifyErrorMessage }}</div> <div
v-else
</q-card-section> class="q-mt-md"
>
You have no passkeys registered yet.
</div>
<div
v-if="fetchErrorMessage"
class="text-negative q-mt-md"
>
{{ fetchErrorMessage }}
</div>
<div
v-if="deleteSuccessMessage"
class="text-positive q-mt-md"
>
{{ deleteSuccessMessage }}
</div>
<div
v-if="deleteErrorMessage"
class="text-negative q-mt-md"
>
{{ deleteErrorMessage }}
</div>
<div
v-if="identifyErrorMessage"
class="text-negative q-mt-md"
>
{{ identifyErrorMessage }}
</div>
</q-card-section>
</q-page> </q-page>
</template> </template>
@ -100,7 +149,8 @@ const isLoggedIn = computed(() => authStore.isAuthenticated);
const username = computed(() => authStore.user?.username); const username = computed(() => authStore.user?.username);
// Fetch existing passkeys // Fetch existing passkeys
async function fetchPasskeys() { async function fetchPasskeys()
{
if (!isLoggedIn.value) return; if (!isLoggedIn.value) return;
fetchLoading.value = true; fetchLoading.value = true;
fetchErrorMessage.value = ''; fetchErrorMessage.value = '';
@ -108,37 +158,50 @@ async function fetchPasskeys() {
deleteErrorMessage.value = ''; deleteErrorMessage.value = '';
identifyErrorMessage.value = ''; // Clear identify message identifyErrorMessage.value = ''; // Clear identify message
identifiedPasskeyId.value = null; // Clear identified key identifiedPasskeyId.value = null; // Clear identified key
try { try
{
const response = await axios.get('/auth/passkeys'); const response = await axios.get('/auth/passkeys');
passkeys.value = response.data || []; passkeys.value = response.data || [];
} catch (error) { }
catch (error)
{
console.error('Error fetching passkeys:', error); console.error('Error fetching passkeys:', error);
fetchErrorMessage.value = error.response?.data?.error || 'Failed to load passkeys.'; fetchErrorMessage.value = error.response?.data?.error || 'Failed to load passkeys.';
passkeys.value = []; // Clear passkeys on error passkeys.value = []; // Clear passkeys on error
} finally { }
finally
{
fetchLoading.value = false; fetchLoading.value = false;
} }
} }
// Check auth status and fetch passkeys on component mount // Check auth status and fetch passkeys on component mount
onMounted(async () => { onMounted(async() =>
{
let initialAuthError = ''; let initialAuthError = '';
if (!authStore.isAuthenticated) { if (!authStore.isAuthenticated)
{
await authStore.checkAuthStatus(); await authStore.checkAuthStatus();
if (authStore.error) { if (authStore.error)
initialAuthError = `Authentication error: ${authStore.error}`; {
initialAuthError = `Authentication error: ${authStore.error}`;
} }
} }
if (!isLoggedIn.value) { if (!isLoggedIn.value)
// Use register error message ref for consistency if login is required first {
registerErrorMessage.value = initialAuthError || 'You must be logged in to manage passkeys.'; // Use register error message ref for consistency if login is required first
} else { registerErrorMessage.value = initialAuthError || 'You must be logged in to manage passkeys.';
}
else
{
fetchPasskeys(); // Fetch passkeys if logged in fetchPasskeys(); // Fetch passkeys if logged in
} }
}); });
async function handleRegister() { async function handleRegister()
if (!isLoggedIn.value || !username.value) { {
if (!isLoggedIn.value || !username.value)
{
registerErrorMessage.value = 'User not authenticated.'; registerErrorMessage.value = 'User not authenticated.';
return; return;
} }
@ -150,7 +213,8 @@ async function handleRegister() {
identifyErrorMessage.value = ''; identifyErrorMessage.value = '';
identifiedPasskeyId.value = null; identifiedPasskeyId.value = null;
try { try
{
// 1. Get options from server // 1. Get options from server
const optionsRes = await axios.post('/auth/generate-registration-options', { const optionsRes = await axios.post('/auth/generate-registration-options', {
username: username.value, // Use username from store username: username.value, // Use username from store
@ -165,33 +229,48 @@ async function handleRegister() {
registrationResponse: regResp, registrationResponse: regResp,
}); });
if (verificationRes.data.verified) { if (verificationRes.data.verified)
{
registerSuccessMessage.value = 'New passkey registered successfully!'; registerSuccessMessage.value = 'New passkey registered successfully!';
fetchPasskeys(); // Refresh the list of passkeys fetchPasskeys(); // Refresh the list of passkeys
} else { }
else
{
registerErrorMessage.value = 'Passkey verification failed.'; registerErrorMessage.value = 'Passkey verification failed.';
} }
} catch (error) { }
catch (error)
{
console.error('Registration error:', error); console.error('Registration error:', error);
const message = error.response?.data?.error || error.message || 'An unknown error occurred during registration.'; const message = error.response?.data?.error || error.message || 'An unknown error occurred during registration.';
// Handle specific simplewebauthn errors // Handle specific simplewebauthn errors
if (error.name === 'InvalidStateError') { if (error.name === 'InvalidStateError')
{
registerErrorMessage.value = 'Authenticator may already be registered.'; registerErrorMessage.value = 'Authenticator may already be registered.';
} else if (error.name === 'NotAllowedError') { }
registerErrorMessage.value = 'Registration ceremony was cancelled or timed out.'; else if (error.name === 'NotAllowedError')
} else if (error.response?.status === 409) { {
registerErrorMessage.value = 'This passkey seems to be registered already.'; registerErrorMessage.value = 'Registration ceremony was cancelled or timed out.';
} else { }
else if (error.response?.status === 409)
{
registerErrorMessage.value = 'This passkey seems to be registered already.';
}
else
{
registerErrorMessage.value = `Registration failed: ${message}`; registerErrorMessage.value = `Registration failed: ${message}`;
} }
} finally { }
finally
{
registerLoading.value = false; registerLoading.value = false;
} }
} }
// Handle deleting a passkey // Handle deleting a passkey
async function handleDelete(credentialID) { async function handleDelete(credentialID)
{
if (!credentialID) return; if (!credentialID) return;
// Optional: Add a confirmation dialog here // Optional: Add a confirmation dialog here
@ -207,21 +286,28 @@ async function handleDelete(credentialID) {
identifyErrorMessage.value = ''; identifyErrorMessage.value = '';
identifiedPasskeyId.value = null; identifiedPasskeyId.value = null;
try { try
{
await axios.delete(`/auth/passkeys/${credentialID}`); await axios.delete(`/auth/passkeys/${credentialID}`);
deleteSuccessMessage.value = 'Passkey deleted successfully.'; deleteSuccessMessage.value = 'Passkey deleted successfully.';
fetchPasskeys(); // Refresh the list fetchPasskeys(); // Refresh the list
} catch (error) { }
catch (error)
{
console.error('Error deleting passkey:', error); console.error('Error deleting passkey:', error);
deleteErrorMessage.value = error.response?.data?.error || 'Failed to delete passkey.'; deleteErrorMessage.value = error.response?.data?.error || 'Failed to delete passkey.';
} finally { }
finally
{
deleteLoading.value = null; // Clear loading state deleteLoading.value = null; // Clear loading state
} }
} }
// Handle identifying a passkey // Handle identifying a passkey
async function handleIdentify() { async function handleIdentify()
if (!isLoggedIn.value) { {
if (!isLoggedIn.value)
{
identifyErrorMessage.value = 'You must be logged in.'; identifyErrorMessage.value = 'You must be logged in.';
return; return;
} }
@ -235,7 +321,8 @@ async function handleIdentify() {
deleteSuccessMessage.value = ''; deleteSuccessMessage.value = '';
deleteErrorMessage.value = ''; deleteErrorMessage.value = '';
try { try
{
// 1. Get authentication options from the server // 1. Get authentication options from the server
// We don't need to send username as the server should use the session // We don't need to send username as the server should use the session
const optionsRes = await axios.post('/auth/generate-authentication-options', {}); // Send empty body const optionsRes = await axios.post('/auth/generate-authentication-options', {}); // Send empty body
@ -252,22 +339,31 @@ async function handleIdentify() {
console.log('Identified Passkey ID:', identifiedPasskeyId.value); console.log('Identified Passkey ID:', identifiedPasskeyId.value);
// Optional: Add a small delay before clearing the highlight // Optional: Add a small delay before clearing the highlight
setTimeout(() => { setTimeout(() =>
// Only clear if it's still the same identified key {
if (identifiedPasskeyId.value === authResp.id) { // Only clear if it's still the same identified key
identifiedPasskeyId.value = null; if (identifiedPasskeyId.value === authResp.id)
} {
identifiedPasskeyId.value = null;
}
}, 5000); // Clear highlight after 5 seconds }, 5000); // Clear highlight after 5 seconds
} catch (error) { }
catch (error)
{
console.error('Identification error:', error); console.error('Identification error:', error);
identifiedPasskeyId.value = null; identifiedPasskeyId.value = null;
if (error.name === 'NotAllowedError') { if (error.name === 'NotAllowedError')
{
identifyErrorMessage.value = 'Identification ceremony was cancelled or timed out.'; identifyErrorMessage.value = 'Identification ceremony was cancelled or timed out.';
} else { }
else
{
identifyErrorMessage.value = error.response?.data?.error || error.message || 'Failed to identify passkey.'; identifyErrorMessage.value = error.response?.data?.error || error.message || 'Failed to identify passkey.';
} }
} finally { }
finally
{
identifyLoading.value = null; // Clear loading state identifyLoading.value = null; // Clear loading state
} }
} }

View file

@ -3,7 +3,9 @@
<q-card style="width: 400px; max-width: 90vw;"> <q-card style="width: 400px; max-width: 90vw;">
<q-card-section> <q-card-section>
<!-- Update title based on login status from store --> <!-- Update title based on login status from store -->
<div class="text-h6">{{ isLoggedIn ? 'Register New Passkey' : 'Register Passkey' }}</div> <div class="text-h6">
{{ isLoggedIn ? 'Register New Passkey' : 'Register Passkey' }}
</div>
</q-card-section> </q-card-section>
<q-card-section> <q-card-section>
@ -27,13 +29,28 @@
:loading="loading" :loading="loading"
:disable="loading || (!username && !isLoggedIn)" :disable="loading || (!username && !isLoggedIn)"
/> />
<div v-if="successMessage" class="text-positive q-mt-md">{{ successMessage }}</div> <div
<div v-if="errorMessage" class="text-negative q-mt-md">{{ errorMessage }}</div> v-if="successMessage"
class="text-positive q-mt-md"
>
{{ successMessage }}
</div>
<div
v-if="errorMessage"
class="text-negative q-mt-md"
>
{{ errorMessage }}
</div>
</q-card-section> </q-card-section>
<q-card-actions align="center"> <q-card-actions align="center">
<!-- Hide login link if already logged in based on store state --> <!-- Hide login link if already logged in based on store state -->
<q-btn v-if="!isLoggedIn" flat label="Already have an account? Login" to="/login" /> <q-btn
v-if="!isLoggedIn"
flat
label="Already have an account? Login"
to="/login"
/>
</q-card-actions> </q-card-actions>
</q-card> </q-card>
</q-page> </q-page>
@ -58,24 +75,32 @@ const isLoggedIn = computed(() => authStore.isAuthenticated);
const username = ref(''); // Local ref for username input const username = ref(''); // Local ref for username input
// Check auth status on component mount using the store action // Check auth status on component mount using the store action
onMounted(async () => { onMounted(async() =>
if (!authStore.isAuthenticated) { {
if (!authStore.isAuthenticated)
{
await authStore.checkAuthStatus(); await authStore.checkAuthStatus();
if (authStore.error) { if (authStore.error)
errorMessage.value = authStore.error; {
errorMessage.value = authStore.error;
} }
} }
if (!isLoggedIn.value) { if (!isLoggedIn.value)
{
username.value = ''; // Clear username if not logged in username.value = ''; // Clear username if not logged in
} else { }
else
{
username.value = authStore.user?.username || ''; // Use username from store if logged in username.value = authStore.user?.username || ''; // Use username from store if logged in
} }
}); });
async function handleRegister() { async function handleRegister()
{
const currentUsername = isLoggedIn.value ? authStore.user?.username : username.value; const currentUsername = isLoggedIn.value ? authStore.user?.username : username.value;
if (!currentUsername) { if (!currentUsername)
{
errorMessage.value = 'Username is missing.'; errorMessage.value = 'Username is missing.';
return; return;
} }
@ -83,7 +108,8 @@ async function handleRegister() {
errorMessage.value = ''; errorMessage.value = '';
successMessage.value = ''; successMessage.value = '';
try { try
{
// 1. Get options from server // 1. Get options from server
const optionsRes = await axios.post('/auth/generate-registration-options', { const optionsRes = await axios.post('/auth/generate-registration-options', {
username: currentUsername, // Use username from store username: currentUsername, // Use username from store
@ -98,37 +124,55 @@ async function handleRegister() {
registrationResponse: regResp, registrationResponse: regResp,
}); });
if (verificationRes.data.verified) { if (verificationRes.data.verified)
{
// Adjust success message based on login state // Adjust success message based on login state
successMessage.value = isLoggedIn.value successMessage.value = isLoggedIn.value
? 'New passkey registered successfully!' ? 'New passkey registered successfully!'
: 'Registration successful! Redirecting to login...'; : 'Registration successful! Redirecting to login...';
if (!isLoggedIn.value) { if (!isLoggedIn.value)
{
// Redirect to login page only if they weren't logged in // Redirect to login page only if they weren't logged in
setTimeout(() => { setTimeout(() =>
{
router.push('/login'); router.push('/login');
}, 2000); }, 2000);
} else { }
// Maybe redirect to a profile page or dashboard if already logged in else
// setTimeout(() => { router.push('/dashboard'); }, 2000); {
// Maybe redirect to a profile page or dashboard if already logged in
// setTimeout(() => { router.push('/dashboard'); }, 2000);
} }
} else { }
else
{
errorMessage.value = 'Registration failed.'; errorMessage.value = 'Registration failed.';
} }
} catch (error) { }
catch (error)
{
console.error('Registration error:', error); console.error('Registration error:', error);
const message = error.response?.data?.error || error.message || 'An unknown error occurred during registration.'; const message = error.response?.data?.error || error.message || 'An unknown error occurred during registration.';
// Handle specific simplewebauthn errors // Handle specific simplewebauthn errors
if (error.name === 'InvalidStateError') { if (error.name === 'InvalidStateError')
{
errorMessage.value = 'Authenticator already registered. Try logging in instead.'; errorMessage.value = 'Authenticator already registered. Try logging in instead.';
} else if (error.name === 'NotAllowedError') { }
errorMessage.value = 'Registration ceremony was cancelled or timed out.'; else if (error.name === 'NotAllowedError')
} else if (error.response?.status === 409) { {
errorMessage.value = 'This passkey seems to be registered already.'; errorMessage.value = 'Registration ceremony was cancelled or timed out.';
} else { }
else if (error.response?.status === 409)
{
errorMessage.value = 'This passkey seems to be registered already.';
}
else
{
errorMessage.value = `Registration failed: ${message}`; errorMessage.value = `Registration failed: ${message}`;
} }
} finally { }
finally
{
loading.value = false; loading.value = false;
} }
} }

View file

@ -1,11 +1,21 @@
<template> <template>
<q-page padding> <q-page padding>
<div class="q-gutter-md" style="max-width: 800px; margin: auto;"> <div
<h5 class="q-mt-none q-mb-md">Settings</h5> class="q-gutter-md"
style="max-width: 800px; margin: auto;"
>
<h5 class="q-mt-none q-mb-md">
Settings
</h5>
<q-card flat bordered> <q-card
flat
bordered
>
<q-card-section> <q-card-section>
<div class="text-h6">Mantis Summary Prompt</div> <div class="text-h6">
Mantis Summary Prompt
</div>
<div class="text-caption text-grey q-mb-sm"> <div class="text-caption text-grey q-mb-sm">
Edit the prompt used to generate Mantis summaries. Use $DATE and $MANTIS_TICKETS as placeholders. Edit the prompt used to generate Mantis summaries. Use $DATE and $MANTIS_TICKETS as placeholders.
</div> </div>
@ -30,9 +40,14 @@
</q-card-actions> </q-card-actions>
</q-card> </q-card>
<q-card flat bordered> <q-card
flat
bordered
>
<q-card-section> <q-card-section>
<div class="text-h6">Email Summary Prompt</div> <div class="text-h6">
Email Summary Prompt
</div>
<div class="text-caption text-grey q-mb-sm"> <div class="text-caption text-grey q-mb-sm">
Edit the prompt used to generate Email summaries. Use $EMAIL_DATA as a placeholder for the JSON email array. Edit the prompt used to generate Email summaries. Use $EMAIL_DATA as a placeholder for the JSON email array.
</div> </div>
@ -55,8 +70,7 @@
:disable="!emailPrompt || loadingEmailPrompt" :disable="!emailPrompt || loadingEmailPrompt"
/> />
</q-card-actions> </q-card-actions>
</q-card> </q-card>
</div> </div>
</q-page> </q-page>
</template> </template>
@ -72,40 +86,52 @@ const mantisPrompt = ref('');
const loadingPrompt = ref(false); const loadingPrompt = ref(false);
const savingPrompt = ref(false); const savingPrompt = ref(false);
const fetchMantisPrompt = async () => { const fetchMantisPrompt = async() =>
{
loadingPrompt.value = true; loadingPrompt.value = true;
try { try
{
const response = await axios.get('/api/settings/mantisPrompt'); const response = await axios.get('/api/settings/mantisPrompt');
mantisPrompt.value = response.data.value || ''; // Handle case where setting might not exist yet mantisPrompt.value = response.data.value || ''; // Handle case where setting might not exist yet
} catch (error) { }
catch (error)
{
console.error('Error fetching Mantis prompt:', error); console.error('Error fetching Mantis prompt:', error);
$q.notify({ $q.notify({
color: 'negative', color: 'negative',
message: 'Failed to load Mantis prompt setting.', message: 'Failed to load Mantis prompt setting.',
icon: 'report_problem' icon: 'report_problem'
}); });
} finally { }
finally
{
loadingPrompt.value = false; loadingPrompt.value = false;
} }
}; };
const saveMantisPrompt = async () => { const saveMantisPrompt = async() =>
{
savingPrompt.value = true; savingPrompt.value = true;
try { try
{
await axios.put('/api/settings/mantisPrompt', { value: mantisPrompt.value }); await axios.put('/api/settings/mantisPrompt', { value: mantisPrompt.value });
$q.notify({ $q.notify({
color: 'positive', color: 'positive',
message: 'Mantis prompt updated successfully.', message: 'Mantis prompt updated successfully.',
icon: 'check_circle' icon: 'check_circle'
}); });
} catch (error) { }
catch (error)
{
console.error('Error saving Mantis prompt:', error); console.error('Error saving Mantis prompt:', error);
$q.notify({ $q.notify({
color: 'negative', color: 'negative',
message: 'Failed to save Mantis prompt setting.', message: 'Failed to save Mantis prompt setting.',
icon: 'report_problem' icon: 'report_problem'
}); });
} finally { }
finally
{
savingPrompt.value = false; savingPrompt.value = false;
} }
}; };
@ -114,45 +140,58 @@ const emailPrompt = ref('');
const loadingEmailPrompt = ref(false); const loadingEmailPrompt = ref(false);
const savingEmailPrompt = ref(false); const savingEmailPrompt = ref(false);
const fetchEmailPrompt = async () => { const fetchEmailPrompt = async() =>
{
loadingEmailPrompt.value = true; loadingEmailPrompt.value = true;
try { try
{
const response = await axios.get('/api/settings/emailPrompt'); const response = await axios.get('/api/settings/emailPrompt');
emailPrompt.value = response.data.value || ''; // Handle case where setting might not exist yet emailPrompt.value = response.data.value || ''; // Handle case where setting might not exist yet
} catch (error) { }
catch (error)
{
console.error('Error fetching Email prompt:', error); console.error('Error fetching Email prompt:', error);
$q.notify({ $q.notify({
color: 'negative', color: 'negative',
message: 'Failed to load Email prompt setting.', message: 'Failed to load Email prompt setting.',
icon: 'report_problem' icon: 'report_problem'
}); });
} finally { }
finally
{
loadingEmailPrompt.value = false; loadingEmailPrompt.value = false;
} }
}; };
const saveEmailPrompt = async () => { const saveEmailPrompt = async() =>
{
savingEmailPrompt.value = true; savingEmailPrompt.value = true;
try { try
{
await axios.put('/api/settings/emailPrompt', { value: emailPrompt.value }); await axios.put('/api/settings/emailPrompt', { value: emailPrompt.value });
$q.notify({ $q.notify({
color: 'positive', color: 'positive',
message: 'Email prompt updated successfully.', message: 'Email prompt updated successfully.',
icon: 'check_circle' icon: 'check_circle'
}); });
} catch (error) { }
catch (error)
{
console.error('Error saving Email prompt:', error); console.error('Error saving Email prompt:', error);
$q.notify({ $q.notify({
color: 'negative', color: 'negative',
message: 'Failed to save Email prompt setting.', message: 'Failed to save Email prompt setting.',
icon: 'report_problem' icon: 'report_problem'
}); });
} finally { }
finally
{
savingEmailPrompt.value = false; savingEmailPrompt.value = false;
} }
}; };
onMounted(() => { onMounted(() =>
{
fetchMantisPrompt(); fetchMantisPrompt();
fetchEmailPrompt(); fetchEmailPrompt();
}); });

View file

@ -1,6 +1,6 @@
import { defineRouter } from '#q-app/wrappers' import { defineRouter } from '#q-app/wrappers';
import { createRouter, createMemoryHistory, createWebHistory, createWebHashHistory } from 'vue-router' import { createRouter, createMemoryHistory, createWebHistory, createWebHashHistory } from 'vue-router';
import routes from './routes' import routes from './routes';
import { useAuthStore } from 'stores/auth'; // Import the auth store import { useAuthStore } from 'stores/auth'; // Import the auth store
/* /*
@ -12,10 +12,11 @@ import { useAuthStore } from 'stores/auth'; // Import the auth store
* with the Router instance. * with the Router instance.
*/ */
export default defineRouter(function ({ store /* { store, ssrContext } */ }) { export default defineRouter(function({ store /* { store, ssrContext } */ })
{
const createHistory = process.env.SERVER const createHistory = process.env.SERVER
? createMemoryHistory ? createMemoryHistory
: (process.env.VUE_ROUTER_MODE === 'history' ? createWebHistory : createWebHashHistory) : (process.env.VUE_ROUTER_MODE === 'history' ? createWebHistory : createWebHashHistory);
const Router = createRouter({ const Router = createRouter({
scrollBehavior: () => ({ left: 0, top: 0 }), scrollBehavior: () => ({ left: 0, top: 0 }),
@ -25,21 +26,26 @@ export default defineRouter(function ({ store /* { store, ssrContext } */ }) {
// quasar.conf.js -> build -> vueRouterMode // quasar.conf.js -> build -> vueRouterMode
// quasar.conf.js -> build -> publicPath // quasar.conf.js -> build -> publicPath
history: createHistory(process.env.VUE_ROUTER_BASE) history: createHistory(process.env.VUE_ROUTER_BASE)
}) });
// Navigation Guard using Pinia store // Navigation Guard using Pinia store
Router.beforeEach(async (to, from, next) => { Router.beforeEach(async(to, from, next) =>
{
const authStore = useAuthStore(store); // Get store instance const authStore = useAuthStore(store); // Get store instance
// Ensure auth status is checked, especially on first load or refresh // Ensure auth status is checked, especially on first load or refresh
// This check might be better placed in App.vue or a boot file // This check might be better placed in App.vue or a boot file
if (!authStore.user && !authStore.loading) { // Check only if user is not loaded and not already loading if (!authStore.user && !authStore.loading)
try { { // Check only if user is not loaded and not already loading
await authStore.checkAuthStatus(); try
} catch (e) { {
console.error("Initial auth check failed", e); await authStore.checkAuthStatus();
// Decide how to handle initial check failure (e.g., proceed, redirect to error page) }
} catch (e)
{
console.error('Initial auth check failed', e);
// Decide how to handle initial check failure (e.g., proceed, redirect to error page)
}
} }
const requiresAuth = to.matched.some(record => record.meta.requiresAuth); const requiresAuth = to.matched.some(record => record.meta.requiresAuth);
@ -47,25 +53,19 @@ export default defineRouter(function ({ store /* { store, ssrContext } */ }) {
const isPublicPage = publicPages.includes(to.path); const isPublicPage = publicPages.includes(to.path);
const isAuthenticated = authStore.isAuthenticated; // Get status from store const isAuthenticated = authStore.isAuthenticated; // Get status from store
console.log('Store Auth status:', isAuthenticated); if (requiresAuth && !isAuthenticated)
console.log('Navigating to:', to.path); {
console.log('Requires auth:', requiresAuth);
console.log('Is public page:', isPublicPage);
if (requiresAuth && !isAuthenticated) {
// If route requires auth and user is not authenticated, redirect to login
console.log('Redirecting to login (requires auth, not authenticated)');
next('/login'); next('/login');
} else if (isPublicPage && isAuthenticated) { }
// If user is authenticated and tries to access login/register, redirect to home else if (isPublicPage && isAuthenticated)
console.log('Redirecting to home (public page, authenticated)'); {
next('/'); next('/');
} else { }
// Otherwise, allow navigation else
console.log('Allowing navigation'); {
next(); next();
} }
}); });
return Router return Router;
}) });

View file

@ -31,7 +31,7 @@ const routes = [
icon: 'person_add', icon: 'person_add',
title: 'Register', title: 'Register',
caption: 'Create an account' caption: 'Create an account'
} }
}, },
// Add a new route specifically for managing passkeys when logged in // Add a new route specifically for managing passkeys when logged in
{ {
@ -74,18 +74,6 @@ const routes = [
caption: 'View daily summaries' caption: 'View daily summaries'
} }
}, },
{
path: 'email-summaries',
name: 'emailSummaries',
component: () => import('pages/EmailSummariesPage.vue'),
meta: {
requiresAuth: true,
navGroup: 'auth', // Show only when logged in
icon: 'email',
title: 'Email Summaries',
caption: 'View email summaries'
}
},
{ {
path: 'settings', path: 'settings',
name: 'settings', name: 'settings',
@ -107,6 +95,6 @@ const routes = [
path: '/:catchAll(.*)*', path: '/:catchAll(.*)*',
component: () => import('pages/ErrorNotFound.vue') component: () => import('pages/ErrorNotFound.vue')
} }
] ];
export default routes export default routes;

View file

@ -1,4 +1,4 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia';
import axios from 'axios'; import axios from 'axios';
export const useAuthStore = defineStore('auth', { export const useAuthStore = defineStore('auth', {
@ -9,29 +9,39 @@ export const useAuthStore = defineStore('auth', {
error: null, // Optional: track errors error: null, // Optional: track errors
}), }),
actions: { actions: {
async checkAuthStatus() { async checkAuthStatus()
{
this.loading = true; this.loading = true;
this.error = null; this.error = null;
try { try
{
const res = await axios.get('/auth/check-auth'); const res = await axios.get('/auth/check-auth');
if (res.data.isAuthenticated) { if (res.data.isAuthenticated)
{
this.isAuthenticated = true; this.isAuthenticated = true;
this.user = res.data.user; this.user = res.data.user;
} else { }
else
{
this.isAuthenticated = false; this.isAuthenticated = false;
this.user = null; this.user = null;
} }
} catch (error) { }
catch (error)
{
console.error('Failed to check authentication status:', error); console.error('Failed to check authentication status:', error);
this.error = 'Could not verify login status.'; this.error = 'Could not verify login status.';
this.isAuthenticated = false; this.isAuthenticated = false;
this.user = null; this.user = null;
} finally { }
finally
{
this.loading = false; this.loading = false;
} }
}, },
// Action to manually set user as logged out (e.g., after logout) // Action to manually set user as logged out (e.g., after logout)
logout() { logout()
{
this.isAuthenticated = false; this.isAuthenticated = false;
this.user = null; this.user = null;
} }

View file

@ -2,7 +2,8 @@ import { defineStore } from 'pinia';
import { ref, computed, watch } from 'vue'; // Import watch import { ref, computed, watch } from 'vue'; // Import watch
import axios from 'axios'; import axios from 'axios';
export const useChatStore = defineStore('chat', () => { export const useChatStore = defineStore('chat', () =>
{
const isVisible = ref(false); const isVisible = ref(false);
const currentThreadId = ref(null); const currentThreadId = ref(null);
const messages = ref([]); // Array of { sender: 'user' | 'bot', content: string, createdAt?: Date, loading?: boolean } const messages = ref([]); // Array of { sender: 'user' | 'bot', content: string, createdAt?: Date, loading?: boolean }
@ -18,12 +19,14 @@ export const useChatStore = defineStore('chat', () => {
// --- Actions --- // --- Actions ---
// New action to create a thread if it doesn't exist // New action to create a thread if it doesn't exist
async function createThreadIfNotExists() { async function createThreadIfNotExists()
{
if (currentThreadId.value) return; // Already have a thread if (currentThreadId.value) return; // Already have a thread
isLoading.value = true; isLoading.value = true;
error.value = null; error.value = null;
try { try
{
// Call the endpoint without content to just create the thread // Call the endpoint without content to just create the thread
const response = await axios.post('/api/chat/threads', {}); const response = await axios.post('/api/chat/threads', {});
currentThreadId.value = response.data.threadId; currentThreadId.value = response.data.threadId;
@ -31,37 +34,51 @@ export const useChatStore = defineStore('chat', () => {
console.log('Created new chat thread:', currentThreadId.value); console.log('Created new chat thread:', currentThreadId.value);
// Start polling now that we have a thread ID // Start polling now that we have a thread ID
startPolling(); startPolling();
} catch (err) { }
catch (err)
{
console.error('Error creating chat thread:', err); console.error('Error creating chat thread:', err);
error.value = 'Failed to start chat.'; error.value = 'Failed to start chat.';
// Don't set isVisible to false, let the user see the error // Don't set isVisible to false, let the user see the error
} finally { }
finally
{
isLoading.value = false; isLoading.value = false;
} }
} }
function toggleChat() { function toggleChat()
{
isVisible.value = !isVisible.value; isVisible.value = !isVisible.value;
if (isVisible.value) { if (isVisible.value)
if (!currentThreadId.value) { {
if (!currentThreadId.value)
{
// If opening and no thread exists, create one // If opening and no thread exists, create one
createThreadIfNotExists(); createThreadIfNotExists();
} else { }
else
{
// If opening and thread exists, fetch messages if empty and start polling // If opening and thread exists, fetch messages if empty and start polling
if (messages.value.length === 0) { if (messages.value.length === 0)
fetchMessages(); {
fetchMessages();
} }
startPolling(); startPolling();
} }
} else { }
else
{
// If closing, stop polling // If closing, stop polling
stopPolling(); stopPolling();
} }
} }
async function fetchMessages() { async function fetchMessages()
if (!currentThreadId.value) { {
if (!currentThreadId.value)
{
console.log('No active thread to fetch messages for.'); console.log('No active thread to fetch messages for.');
// Don't try to fetch if no thread ID yet. createThreadIfNotExists handles the initial state. // Don't try to fetch if no thread ID yet. createThreadIfNotExists handles the initial state.
return; return;
@ -69,33 +86,40 @@ export const useChatStore = defineStore('chat', () => {
// Avoid setting isLoading if polling, maybe use a different flag? For now, keep it simple. // Avoid setting isLoading if polling, maybe use a different flag? For now, keep it simple.
// isLoading.value = true; // Might cause flickering during polling // isLoading.value = true; // Might cause flickering during polling
error.value = null; // Clear previous errors on fetch attempt error.value = null; // Clear previous errors on fetch attempt
try { try
{
const response = await axios.get(`/api/chat/threads/${currentThreadId.value}/messages`); const response = await axios.get(`/api/chat/threads/${currentThreadId.value}/messages`);
const newMessages = response.data.map(msg => ({ const newMessages = response.data.map(msg => ({
sender: msg.sender, sender: msg.sender,
content: msg.content, content: msg.content,
createdAt: new Date(msg.createdAt), createdAt: new Date(msg.createdAt),
loading: msg.content === 'Loading...' loading: msg.content === 'Loading...'
})).sort((a, b) => a.createdAt - b.createdAt); })).sort((a, b) => a.createdAt - b.createdAt);
// Only update if messages have actually changed to prevent unnecessary re-renders // Only update if messages have actually changed to prevent unnecessary re-renders
if (JSON.stringify(messages.value) !== JSON.stringify(newMessages)) { if (JSON.stringify(messages.value) !== JSON.stringify(newMessages))
messages.value = newMessages; {
messages.value = newMessages;
} }
} catch (err) { }
catch (err)
{
console.error('Error fetching messages:', err); console.error('Error fetching messages:', err);
error.value = 'Failed to load messages.'; error.value = 'Failed to load messages.';
// Don't clear messages on polling error, keep the last known state // Don't clear messages on polling error, keep the last known state
// messages.value = []; // messages.value = [];
stopPolling(); // Stop polling if there's an error fetching stopPolling(); // Stop polling if there's an error fetching
} finally { }
finally
{
// isLoading.value = false; // isLoading.value = false;
} }
} }
// Function to start polling // Function to start polling
function startPolling() { function startPolling()
{
if (pollingIntervalId.value) return; // Already polling if (pollingIntervalId.value) return; // Already polling
if (!currentThreadId.value) return; // No thread to poll for if (!currentThreadId.value) return; // No thread to poll for
@ -104,8 +128,10 @@ export const useChatStore = defineStore('chat', () => {
} }
// Function to stop polling // Function to stop polling
function stopPolling() { function stopPolling()
if (pollingIntervalId.value) { {
if (pollingIntervalId.value)
{
console.log('Stopping chat polling.'); console.log('Stopping chat polling.');
clearInterval(pollingIntervalId.value); clearInterval(pollingIntervalId.value);
pollingIntervalId.value = null; pollingIntervalId.value = null;
@ -113,12 +139,14 @@ export const useChatStore = defineStore('chat', () => {
} }
async function sendMessage(content) { async function sendMessage(content)
{
if (!content.trim()) return; if (!content.trim()) return;
if (!currentThreadId.value) { if (!currentThreadId.value)
error.value = "Cannot send message: No active chat thread."; {
console.error("Attempted to send message without a thread ID."); error.value = 'Cannot send message: No active chat thread.';
return; // Should not happen if UI waits for thread creation console.error('Attempted to send message without a thread ID.');
return; // Should not happen if UI waits for thread creation
} }
const userMessage = { const userMessage = {
@ -137,7 +165,8 @@ export const useChatStore = defineStore('chat', () => {
isLoading.value = true; // Indicate activity isLoading.value = true; // Indicate activity
error.value = null; error.value = null;
try { try
{
const payload = { content: userMessage.content }; const payload = { content: userMessage.content };
// Always post to the existing thread once it's created // Always post to the existing thread once it's created
const response = await axios.post(`/api/chat/threads/${currentThreadId.value}/messages`, payload); const response = await axios.post(`/api/chat/threads/${currentThreadId.value}/messages`, payload);
@ -151,15 +180,19 @@ export const useChatStore = defineStore('chat', () => {
// Immediately fetch messages after sending to get the updated list // Immediately fetch messages after sending to get the updated list
await fetchMessages(); await fetchMessages();
} catch (err) { }
console.error('Error sending message:', err); catch (err)
error.value = 'Failed to send message.'; {
// Remove loading indicator on error console.error('Error sending message:', err);
messages.value = messages.value.filter(m => !m.loading); error.value = 'Failed to send message.';
// Optionally add an error message to the chat // Remove loading indicator on error
// Ensure the object is correctly formatted messages.value = messages.value.filter(m => !m.loading);
messages.value.push({ sender: 'bot', content: "Sorry, I couldn't send that message.", createdAt: new Date() }); // Optionally add an error message to the chat
} finally { // Ensure the object is correctly formatted
messages.value.push({ sender: 'bot', content: "Sorry, I couldn't send that message.", createdAt: new Date() });
}
finally
{
isLoading.value = false; isLoading.value = false;
// Restart polling after sending attempt is complete // Restart polling after sending attempt is complete
startPolling(); startPolling();
@ -167,7 +200,8 @@ export const useChatStore = defineStore('chat', () => {
} }
// Call this when the user logs out or the app closes if you want to clear state // Call this when the user logs out or the app closes if you want to clear state
function resetChat() { function resetChat()
{
stopPolling(); // Ensure polling stops on reset stopPolling(); // Ensure polling stops on reset
isVisible.value = false; isVisible.value = false;
currentThreadId.value = null; currentThreadId.value = null;

View file

@ -1,5 +1,5 @@
import { defineStore } from '#q-app/wrappers' import { defineStore } from '#q-app/wrappers';
import { createPinia } from 'pinia' import { createPinia } from 'pinia';
/* /*
* If not building with SSR mode, you can * If not building with SSR mode, you can
@ -10,11 +10,12 @@ import { createPinia } from 'pinia'
* with the Store instance. * with the Store instance.
*/ */
export default defineStore((/* { ssrContext } */) => { export default defineStore((/* { ssrContext } */) =>
const pinia = createPinia() {
const pinia = createPinia();
// You can add Pinia plugins here // You can add Pinia plugins here
// pinia.use(SomePiniaPlugin) // pinia.use(SomePiniaPlugin)
return pinia return pinia;
}) });