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
@ -208,5 +217,5 @@ export default defineConfig((/* ctx */) => {
*/ */
extraScripts: [] extraScripts: []
} }
} };
}) });

View file

@ -5,10 +5,3 @@ 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) { {
if (category.name)
{
doc.fontSize(14).font('Roboto-Bold').text(category.name); doc.fontSize(14).font('Roboto-Bold').text(category.name);
doc.moveDown(0.5); 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,12 +529,16 @@ 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);
@ -469,13 +548,18 @@ router.get('/responses/:responseId/export/pdf', async (req, res) => {
} }
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,30 +103,37 @@ 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' });
} }
@ -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); user = await getUserByUsername(username);
} else if (req.session.loggedInUserId) { }
else if (req.session.loggedInUserId)
{
// If already logged in, allow re-authentication (e.g., for step-up) // If already logged in, allow re-authentication (e.g., for step-up)
user = await getUserById(req.session.loggedInUserId); 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,41 +247,50 @@ 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' });
} }
@ -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,10 +412,13 @@ 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')
{ // Prisma code for record not found on delete/update
return res.status(404).json({ error: 'Passkey not found' }); 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' });
@ -369,17 +426,22 @@ router.delete('/passkeys/:credentialID', async (req, res) => {
}); });
// 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: [
@ -52,17 +56,21 @@ router.post('/threads', async (req, res) => {
// 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,20 +80,25 @@ router.get('/threads/:threadId/messages', async (req, res) => {
}, },
}); });
if (!messages) { // Check if thread exists indirectly if (!messages)
{ // Check if thread exists indirectly
// If findMany returns empty, the thread might not exist or has no messages. // If findMany returns empty, the thread might not exist or has no messages.
// Check if thread exists explicitly // Check if thread exists explicitly
const thread = await prisma.chatThread.findUnique({ where: { id: threadId } }); const thread = await prisma.chatThread.findUnique({ 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.' });
} }
} }
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'))
{ // Example: Invalid UUID format
return res.status(400).json({ error: 'Invalid thread ID 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.' });
@ -93,24 +106,29 @@ router.get('/threads/:threadId/messages', async (req, res) => {
}); });
// 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.' });
} }
@ -131,9 +149,12 @@ router.post('/threads/:threadId/messages', async (req, res) => {
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'))
{ // Example: Invalid UUID format
return res.status(400).json({ error: 'Invalid thread ID 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); {
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.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,23 +115,18 @@ 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."; {
summaryText = 'No Mantis tickets updated recently.';
console.log('No recent Mantis tickets found.'); console.log('No recent Mantis tickets found.');
} else {
console.log(`Found ${tickets.length} recent Mantis tickets. Generating summary...`);
let prompt = promptTemplate.replaceAll("$DATE", new Date().toISOString().split('T')[0]);
prompt = prompt.replaceAll("$MANTIS_TICKETS", JSON.stringify(tickets, null, 2));
const response = await ai.models.generateContent({
"model": "gemini-2.5-flash-preview-04-17",
"contents": prompt,
config: {
temperature: 0
} }
}); else
{
console.log(`Found ${tickets.length} recent Mantis tickets. Generating summary...`);
let prompt = promptTemplate.replaceAll('$DATE', new Date().toISOString().split('T')[0]);
prompt = prompt.replaceAll('$MANTIS_TICKETS', JSON.stringify(tickets, null, 2));
summaryText = response.text; summaryText = await askGemini(prompt);
console.log('Mantis summary generated successfully by AI.'); console.log('Mantis summary generated successfully by AI.');
} }
@ -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,26 +1,31 @@
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 = setting.value; 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({ const ai = GOOGLE_API_KEY ? new GoogleGenAI({
apiKey: GOOGLE_API_KEY, apiKey: GOOGLE_API_KEY,
}) : null; }) : null;
if (!ai) { if (!ai)
{
throw new Error('Google API key is not set in the database.'); throw new Error('Google API key is not set in the database.');
} }
try { try
{
const response = await ai.models.generateContent({ const response = await ai.models.generateContent({
model, model,
contents: content, contents: content,
@ -30,7 +35,9 @@ export const askGemini = async (content) => {
}); });
return response.text; return response.text;
} catch (error) { }
catch (error)
{
console.error('Error communicating with Gemini API:', error); console.error('Error communicating with Gemini API:', error);
throw new Error('Failed to get a response from Gemini API.'); throw new Error('Failed to get a response from Gemini API.');
} }
@ -38,7 +45,8 @@ export const askGemini = async (content) => {
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({ let messages = await prisma.chatMessage.findMany({
where: { where: {
threadId: threadId, threadId: threadId,
@ -49,7 +57,8 @@ export const askGeminiChat = async (threadId, content) => {
}); });
// Exclude the latest message if there are any messages // Exclude the latest message if there are any messages
if (messages.length > 0) { if (messages.length > 0)
{
messages = messages.slice(0, -1); messages = messages.slice(0, -1);
} }
@ -58,7 +67,8 @@ export const askGeminiChat = async (threadId, content) => {
select: { value: true } select: { value: true }
}); });
if (!setting) { if (!setting)
{
throw new Error('Google API key is not set in the database.'); throw new Error('Google API key is not set in the database.');
} }
@ -68,16 +78,19 @@ export const askGeminiChat = async (threadId, content) => {
apiKey: GOOGLE_API_KEY, apiKey: GOOGLE_API_KEY,
}) : null; }) : null;
if (!ai) { if (!ai)
{
throw new Error('Google API key is not set in the database.'); throw new Error('Google API key is not set in the database.');
} }
let chat = null; let chat = null;
if (chatCache.has(threadId)) { if (chatCache.has(threadId))
{
chat = chatCache.get(threadId); chat = chatCache.get(threadId);
} }
else { else
{
messages = [ messages = [
{ {
sender: 'user', sender: 'user',
@ -92,7 +105,7 @@ export const askGeminiChat = async (threadId, content) => {
content: 'Okay, noted! I\'ll keep that in mind.' content: 'Okay, noted! I\'ll keep that in mind.'
}, },
...messages, ...messages,
] ];
const createOptions = { const createOptions = {
model, model,
history: messages.map((msg) => ({ history: messages.map((msg) => ({

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

@ -5,7 +5,11 @@
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"> <div
v-for="(message, index) in messages"
:key="index"
class="q-mb-sm q-mx-md"
>
<q-chat-message <q-chat-message
:name="message.sender.toUpperCase()" :name="message.sender.toUpperCase()"
:sent="message.sender === 'user'" :sent="message.sender === 'user'"
@ -13,9 +17,16 @@
:text-color="message.sender === 'user' ? 'white' : 'black'" :text-color="message.sender === 'user' ? 'white' : 'black'"
> >
<!-- Use v-html to render parsed markdown --> <!-- Use v-html to render parsed markdown -->
<div v-if="!message.loading" v-html="parseMarkdown(message.content)" class="message-content"></div> <div
v-if="!message.loading"
v-html="parseMarkdown(message.content)"
class="message-content"
/>
<!-- Optional: Add a spinner for a better loading visual --> <!-- Optional: Add a spinner for a better loading visual -->
<template v-if="message.loading" v-slot:default> <template
v-if="message.loading"
#default
>
<q-spinner-dots size="2em" /> <q-spinner-dots size="2em" />
</template> </template>
</q-chat-message> </q-chat-message>
@ -57,7 +68,7 @@ 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 }
@ -69,8 +80,10 @@ 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) { {
if (scrollAreaRef.value)
{
const scrollTarget = scrollAreaRef.value.getScrollTarget(); const scrollTarget = scrollAreaRef.value.getScrollTarget();
const duration = 300; // Optional: animation duration const duration = 300; // Optional: animation duration
// Use getScrollTarget().scrollHeight for accurate height // Use getScrollTarget().scrollHeight for accurate height
@ -78,21 +91,26 @@ const scrollToBottom = () => {
} }
}; };
const sendMessage = () => { const sendMessage = () =>
{
const trimmedMessage = newMessage.value.trim(); const trimmedMessage = newMessage.value.trim();
if (trimmedMessage) { if (trimmedMessage)
{
emit('send-message', trimmedMessage); emit('send-message', trimmedMessage);
newMessage.value = ''; newMessage.value = '';
// Ensure the scroll happens after the message is potentially added to the list // Ensure the scroll happens after the message is potentially added to the list
nextTick(() => { nextTick(() =>
{
scrollToBottom(); 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,8 +119,10 @@ 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(() => { {
nextTick(() =>
{
scrollToBottom(); scrollToBottom();
}); });
}, { deep: true, immediate: true }); }, { deep: true, immediate: true });

View file

@ -7,12 +7,18 @@
:model-value="true" :model-value="true"
> >
<q-list> <q-list>
<q-item clickable v-ripple @click="toggleLeftDrawer"> <q-item
clickable
v-ripple
@click="toggleLeftDrawer"
>
<q-item-section avatar> <q-item-section avatar>
<q-icon name="menu" /> <q-icon name="menu" />
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<q-item-label class="text-h6">StylePoint</q-item-label> <q-item-label class="text-h6">
StylePoint
</q-item-label>
</q-item-section> </q-item-section>
</q-item> </q-item>
@ -25,7 +31,10 @@
:to="{ name: item.name }" :to="{ name: item.name }"
exact exact
> >
<q-tooltip anchor="center right" self="center left" > <q-tooltip
anchor="center right"
self="center left"
>
<span>{{ item.meta.title }}</span> <span>{{ item.meta.title }}</span>
</q-tooltip> </q-tooltip>
<q-item-section avatar> <q-item-section avatar>
@ -33,7 +42,9 @@
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<q-item-label>{{ item.meta.title }}</q-item-label> <q-item-label>{{ item.meta.title }}</q-item-label>
<q-item-label caption>{{ item.meta.caption }}</q-item-label> <q-item-label caption>
{{ item.meta.caption }}
</q-item-label>
</q-item-section> </q-item-section>
</q-item> </q-item>
@ -44,7 +55,10 @@
v-ripple v-ripple
@click="logout" @click="logout"
> >
<q-tooltip anchor="center right" self="center left" > <q-tooltip
anchor="center right"
self="center left"
>
<span>Logout</span> <span>Logout</span>
</q-tooltip> </q-tooltip>
<q-item-section avatar> <q-item-section avatar>
@ -54,7 +68,6 @@
<q-item-label>Logout</q-item-label> <q-item-label>Logout</q-item-label>
</q-item-section> </q-item-section>
</q-item> </q-item>
</q-list> </q-list>
</q-drawer> </q-drawer>
@ -63,7 +76,11 @@
</q-page-container> </q-page-container>
<!-- Chat FAB --> <!-- Chat FAB -->
<q-page-sticky v-if="isAuthenticated" position="bottom-right" :offset="[18, 18]"> <q-page-sticky
v-if="isAuthenticated"
position="bottom-right"
:offset="[18, 18]"
>
<q-fab <q-fab
v-model="fabOpen" v-model="fabOpen"
icon="chat" icon="chat"
@ -75,27 +92,53 @@
</q-page-sticky> </q-page-sticky>
<!-- Chat Window Dialog --> <!-- Chat Window Dialog -->
<q-dialog v-model="isChatVisible" :maximized="$q.screen.lt.sm" fixed persistent style="width: max(400px, 25%);"> <q-dialog
v-model="isChatVisible"
:maximized="$q.screen.lt.sm"
fixed
persistent
style="width: max(400px, 25%);"
>
<q-card style="width: max(400px, 25%); height: 600px; max-height: 80vh;"> <q-card style="width: max(400px, 25%); height: 600px; max-height: 80vh;">
<q-bar class="bg-primary text-white"> <q-bar class="bg-primary text-white">
<div>Chat</div> <div>Chat</div>
<q-space /> <q-space />
<q-btn dense flat icon="close" @click="toggleChat" /> <q-btn
dense
flat
icon="close"
@click="toggleChat"
/>
</q-bar> </q-bar>
<q-card-section class="q-pa-none" style="height: calc(100% - 50px);"> <!-- Adjust height based on q-bar --> <q-card-section
class="q-pa-none"
style="height: calc(100% - 50px);"
>
<ChatInterface <ChatInterface
:messages="chatMessages" :messages="chatMessages"
@send-message="handleSendMessage" @send-message="handleSendMessage"
/> />
</q-card-section> </q-card-section>
<q-inner-loading :showing="isLoading"> <q-inner-loading :showing="isLoading">
<q-spinner-gears size="50px" color="primary" /> <q-spinner-gears
size="50px"
color="primary"
/>
</q-inner-loading> </q-inner-loading>
<q-banner v-if="chatError" inline-actions class="text-white bg-red"> <q-banner
v-if="chatError"
inline-actions
class="text-white bg-red"
>
{{ chatError }} {{ chatError }}
<template v-slot:action> <template #action>
<q-btn flat color="white" label="Dismiss" @click="clearError" /> <q-btn
flat
color="white"
label="Dismiss"
@click="clearError"
/>
</template> </template>
</q-banner> </q-banner>
</q-card> </q-card>
@ -104,36 +147,38 @@
</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 => { {
return mainLayoutRoutes.filter(route =>
{
const navGroup = route.meta?.navGroup; const navGroup = route.meta?.navGroup;
if (!navGroup) return false; // Only include routes with navGroup defined if (!navGroup) return false; // Only include routes with navGroup defined
@ -147,33 +192,42 @@ const navItems = computed(() => {
// 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 // Optional: Add an extra check here if needed, though hiding the button is primary
if (isAuthenticated.value) { if (isAuthenticated.value)
chatStore.toggleChat() {
} 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 () { function toggleLeftDrawer()
leftDrawerOpen.value = !leftDrawerOpen.value {
leftDrawerOpen.value = !leftDrawerOpen.value;
} }
async function logout() { async function logout()
try { {
try
{
await axios.post('/auth/logout'); await axios.post('/auth/logout');
authStore.logout(); // Use the store action to update state authStore.logout(); // Use the store action to update state
// No need to manually push, router guard should redirect // No need to manually push, router guard should redirect
// router.push({ name: 'login' }); // router.push({ name: 'login' });
} catch (error) { }
catch (error)
{
console.error('Logout failed:', error); console.error('Logout failed:', error);
$q.notify({ $q.notify({

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,51 +1,137 @@
<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"
:key="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 outlined dense v-model="category.name" :label="`Category ${catIndex + 1} Name *`" <q-input
class="col q-mr-sm" lazy-rules outlined
:rules="[val => val && val.length > 0 || 'Category name required']" /> dense
<q-btn flat round dense icon="delete" color="negative" @click="removeCategory(catIndex)" v-model="category.name"
title="Remove Category" /> :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>
<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"
:key="fieldIndex"
class="q-ml-md q-mb-sm field-item"
>
<div class="row items-center q-gutter-sm"> <div class="row items-center q-gutter-sm">
<q-input outlined dense v-model="field.label" label="Field Label *" class="col" lazy-rules <q-input
:rules="[val => val && val.length > 0 || 'Field label required']" /> outlined
<q-select outlined dense v-model="field.type" :options="fieldTypes" label="Field Type *" dense
class="col-auto" style="min-width: 150px;" lazy-rules v-model="field.label"
:rules="[val => !!val || 'Field type required']" /> label="Field Label *"
<q-btn flat round dense icon="delete" color="negative" class="col"
@click="removeField(catIndex, fieldIndex)" title="Remove Field" /> lazy-rules
:rules="[val => val && val.length > 0 || 'Field label required']"
/>
<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> </div>
<q-input v-model="field.description" outlined dense label="Field Description (Optional)" autogrow <q-input
class="q-mt-xs q-mb-xl" hint="This description will appear below the field label on the form." /> 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> </div>
<q-btn color="primary" label="Add Field" @click="addField(catIndex)" class="q-ml-md q-mt-sm" /> <q-btn
color="primary"
label="Add Field"
@click="addField(catIndex)"
class="q-ml-md q-mt-sm"
/>
</div> </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"
type="submit"
color="primary"
:loading="submitting"
/>
<q-btn
label="Cancel"
type="reset"
color="warning"
class="q-ml-sm"
:to="{ name: 'formList' }"
/>
</div> </div>
</q-form> </q-form>
</q-page> </q-page>
@ -71,25 +157,31 @@ const form = ref({
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; submitting.value = true;
try { try
{
const response = await axios.post('/api/forms', form.value); const response = await axios.post('/api/forms', form.value);
$q.notify({ $q.notify({
color: 'positive', color: 'positive',
@ -98,7 +190,9 @@ async function createForm() {
icon: 'check_circle' icon: 'check_circle'
}); });
router.push({ name: 'formList' }); router.push({ name: 'formList' });
} catch (error) { }
catch (error)
{
console.error('Error creating form:', error); console.error('Error creating form:', error);
const message = error.response?.data?.error || 'Failed to create form. Please check the details and try again.'; const message = error.response?.data?.error || 'Failed to create form. Please check the details and try again.';
$q.notify({ $q.notify({
@ -107,7 +201,9 @@ async function createForm() {
message: message, message: message,
icon: 'report_problem' icon: 'report_problem'
}); });
} finally { }
finally
{
submitting.value = false; submitting.value = false;
} }
} }

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) { {
if (!form.value.categories[catIndex].fields)
{
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
@ -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
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> </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
size="50px"
color="primary"
/>
</q-inner-loading> </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,17 +1,35 @@
<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"> <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> <h1 class="text-h3 text-weight-bold text-primary q-mb-sm">
<p class="text-h6 text-grey-8 q-mb-lg">The all-in-one tool designed for StyleTech Developers.</p> 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>
<div class="features q-mt-xl q-pa-md text-center" style="max-width: 800px; width: 100%;"> <div
<h2 class="text-h4 text-weight-medium text-secondary q-mb-lg">Features</h2> class="features q-mt-xl q-pa-md text-center"
<q-list bordered separator class="rounded-borders"> style="max-width: 800px; width: 100%;"
<q-item v-for="(feature, index) in features" :key="index" class="q-pa-md"> >
<h2 class="text-h4 text-weight-medium text-secondary q-mb-lg">
Features
</h2>
<q-list
bordered
separator
class="rounded-borders"
>
<q-item
v-for="(feature, index) in features"
:key="index"
class="q-pa-md"
>
<q-item-section> <q-item-section>
<q-item-label class="text-body1">{{ feature }}</q-item-label> <q-item-label class="text-body1">
{{ feature }}
</q-item-label>
</q-item-section> </q-item-section>
</q-item> </q-item>
</q-list> </q-list>

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.'; errorMessage.value = 'Authentication ceremony was cancelled or timed out.';
} else if (error.response?.status === 404 && error.response?.data?.error?.includes('User not found')) { }
else if (error.response?.status === 404 && error.response?.data?.error?.includes('User not found'))
{
errorMessage.value = 'User not found. Please check your username or register.'; 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('Authenticator not found'))
{
errorMessage.value = 'No registered passkey found for this user or device. Try registering first.'; errorMessage.value = 'No registered passkey found for this user or device. Try registering first.';
} else { }
else
{
errorMessage.value = `Login failed: ${message}`; 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"
@ -60,9 +91,10 @@
</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,7 +169,9 @@ 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({
@ -136,31 +179,38 @@ const generateSummary = async () => {
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,7 +1,9 @@
<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"
@ -27,10 +29,24 @@
<!-- 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
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-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 // Use register error message ref for consistency if login is required first
registerErrorMessage.value = initialAuthError || 'You must be logged in to manage passkeys.'; registerErrorMessage.value = initialAuthError || 'You must be logged in to manage passkeys.';
} else { }
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') { }
else if (error.name === 'NotAllowedError')
{
registerErrorMessage.value = 'Registration ceremony was cancelled or timed out.'; registerErrorMessage.value = 'Registration ceremony was cancelled or timed out.';
} else if (error.response?.status === 409) { }
else if (error.response?.status === 409)
{
registerErrorMessage.value = 'This passkey seems to be registered already.'; registerErrorMessage.value = 'This passkey seems to be registered already.';
} else { }
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 // Only clear if it's still the same identified key
if (identifiedPasskeyId.value === authResp.id) { if (identifiedPasskeyId.value === authResp.id)
{
identifiedPasskeyId.value = null; 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 { }
else
{
// Maybe redirect to a profile page or dashboard if already logged in // Maybe redirect to a profile page or dashboard if already logged in
// setTimeout(() => { router.push('/dashboard'); }, 2000); // 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') { }
else if (error.name === 'NotAllowedError')
{
errorMessage.value = 'Registration ceremony was cancelled or timed out.'; errorMessage.value = 'Registration ceremony was cancelled or timed out.';
} else if (error.response?.status === 409) { }
else if (error.response?.status === 409)
{
errorMessage.value = 'This passkey seems to be registered already.'; errorMessage.value = 'This passkey seems to be registered already.';
} else { }
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>
@ -56,7 +71,6 @@
/> />
</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,19 +26,24 @@ 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
try
{
await authStore.checkAuthStatus(); await authStore.checkAuthStatus();
} catch (e) { }
console.error("Initial auth check failed", e); catch (e)
{
console.error('Initial auth check failed', e);
// Decide how to handle initial check failure (e.g., proceed, redirect to error page) // Decide how to handle initial check failure (e.g., proceed, redirect to error page)
} }
} }
@ -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

@ -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,7 +86,8 @@ 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,
@ -79,23 +97,29 @@ export const useChatStore = defineStore('chat', () => {
})).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,11 +139,13 @@ 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.';
console.error('Attempted to send message without a thread ID.');
return; // Should not happen if UI waits for thread creation return; // Should not happen if UI waits for thread creation
} }
@ -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,7 +180,9 @@ 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) { }
catch (err)
{
console.error('Error sending message:', err); console.error('Error sending message:', err);
error.value = 'Failed to send message.'; error.value = 'Failed to send message.';
// Remove loading indicator on error // Remove loading indicator on error
@ -159,7 +190,9 @@ export const useChatStore = defineStore('chat', () => {
// Optionally add an error message to the chat // Optionally add an error message to the chat
// Ensure the object is correctly formatted // Ensure the object is correctly formatted
messages.value.push({ sender: 'bot', content: "Sorry, I couldn't send that message.", createdAt: new Date() }); messages.value.push({ sender: 'bot', content: "Sorry, I couldn't send that message.", createdAt: new Date() });
} finally { }
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;
}) });