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"
},
"devDependencies": {
"@eslint/js": "^9.25.1",
"@quasar/app-vite": "^2.1.0",
"@stylistic/eslint-plugin": "^4.2.0",
"@types/express-session": "^1.18.1",
"@types/uuid": "^10.0.0",
"@vue/eslint-config-prettier": "^10.2.0",
"autoprefixer": "^10.4.2",
"eslint": "^9.25.1",
"eslint-plugin-vue": "^10.0.0",
"globals": "^16.0.0",
"postcss": "^8.4.14",
"prisma": "^6.6.0"
"prettier": "^3.5.3",
"prisma": "^6.6.0",
"vite-plugin-checker": "^0.9.1"
},
"engines": {
"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
import autoprefixer from 'autoprefixer'
import autoprefixer from 'autoprefixer';
// import rtlcss from 'postcss-rtlcss'
export default {
@ -26,4 +26,4 @@ export default {
// 3. uncomment the following line (and its import statement above):
// rtlcss()
]
}
};

View file

@ -1,9 +1,10 @@
// Configuration for your app
// 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 {
// https://v2.quasar.dev/quasar-cli-vite/prefetch-feature
// preFetch: true,
@ -62,6 +63,14 @@ export default defineConfig((/* ctx */) => {
// vitePlugins: [
// [ '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
@ -114,7 +123,7 @@ export default defineConfig((/* ctx */) => {
// https://v2.quasar.dev/quasar-cli-vite/developing-ssr/configuring-ssr
ssr: {
prodPort: 3000, // The default port that the production server should use
// (gets superseded if process.env.PORT is specified at runtime)
// (gets superseded if process.env.PORT is specified at runtime)
middlewares: [
'render' // keep this as last one
@ -208,5 +217,5 @@ export default defineConfig((/* ctx */) => {
*/
extraScripts: []
}
}
})
};
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,8 +1,13 @@
<template>
<q-page padding>
<q-card flat bordered>
<q-card
flat
bordered
>
<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
label="Generate Today's Summary"
color="primary"
@ -15,8 +20,11 @@
<q-separator />
<q-card-section v-if="generationError">
<q-banner inline-actions class="text-white bg-red">
<template v-slot:avatar>
<q-banner
inline-actions
class="text-white bg-red"
>
<template #avatar>
<q-icon name="error" />
</template>
{{ generationError }}
@ -24,30 +32,53 @@
</q-card-section>
<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>
</q-card-section>
<q-card-section v-if="error && !generationError">
<q-banner inline-actions class="text-white bg-red">
<template v-slot:avatar>
<q-banner
inline-actions
class="text-white bg-red"
>
<template #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-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-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"
>
<div v-html="parseMarkdown(summary.content)" />
</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-card-section
v-if="totalPages > 1"
class="flex flex-center q-mt-md"
>
<q-pagination
v-model="currentPage"
:max="totalPages"
@ -59,10 +90,11 @@
/>
</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 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>
@ -86,18 +118,21 @@ const totalItems = ref(0);
// Create a custom renderer
const renderer = new marked.Renderer();
const linkRenderer = renderer.link;
renderer.link = (href, title, text) => {
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) => {
const fetchSummaries = async(page = 1) =>
{
loading.value = true;
error.value = null;
try {
const response = await axios.get(`/api/mantis-summaries`, {
try
{
const response = await axios.get('/api/mantis-summaries', {
params: {
page: page,
limit: itemsPerPage.value
@ -106,19 +141,25 @@ const fetchSummaries = async (page = 1) => {
summaries.value = response.data.summaries;
totalItems.value = response.data.total;
currentPage.value = page;
} catch (err) {
}
catch (err)
{
console.error('Error fetching Mantis summaries:', err);
error.value = err.response?.data?.error || 'Failed to load summaries. Please try again later.';
} finally {
}
finally
{
loading.value = false;
}
};
const generateSummary = async () => {
const generateSummary = async() =>
{
generating.value = true;
generationError.value = null;
error.value = null; // Clear previous loading errors
try {
try
{
await axios.post('/api/mantis-summaries/generate');
$q.notify({
color: 'positive',
@ -128,39 +169,48 @@ const generateSummary = async () => {
// 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) {
}
catch (err)
{
console.error('Error generating Mantis summary:', err);
generationError.value = err.response?.data?.error || 'Failed to start summary generation.';
$q.notify({
$q.notify({
color: 'negative',
icon: 'error',
message: generationError.value,
});
} finally {
}
finally
{
generating.value = false;
}
};
const formatDate = (dateString) => {
const formatDate = (dateString) =>
{
// Assuming dateString is YYYY-MM-DD
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');
};
const parseMarkdown = (markdownText) => {
const parseMarkdown = (markdownText) =>
{
if (!markdownText) return '';
// Use the custom renderer with marked
return marked(markdownText, { renderer });
};
const totalPages = computed(() => {
const totalPages = computed(() =>
{
return Math.ceil(totalItems.value / itemsPerPage.value);
});
onMounted(() => {
onMounted(() =>
{
fetchSummaries(currentPage.value);
});
</script>

View file

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

View file

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

View file

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

View file

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

View file

@ -31,7 +31,7 @@ const routes = [
icon: 'person_add',
title: 'Register',
caption: 'Create an account'
}
}
},
// Add a new route specifically for managing passkeys when logged in
{
@ -74,18 +74,6 @@ const routes = [
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',
name: 'settings',
@ -107,6 +95,6 @@ const routes = [
path: '/:catchAll(.*)*',
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';
export const useAuthStore = defineStore('auth', {
@ -9,29 +9,39 @@ export const useAuthStore = defineStore('auth', {
error: null, // Optional: track errors
}),
actions: {
async checkAuthStatus() {
async checkAuthStatus()
{
this.loading = true;
this.error = null;
try {
try
{
const res = await axios.get('/auth/check-auth');
if (res.data.isAuthenticated) {
if (res.data.isAuthenticated)
{
this.isAuthenticated = true;
this.user = res.data.user;
} else {
}
else
{
this.isAuthenticated = false;
this.user = null;
}
} catch (error) {
}
catch (error)
{
console.error('Failed to check authentication status:', error);
this.error = 'Could not verify login status.';
this.isAuthenticated = false;
this.user = null;
} finally {
}
finally
{
this.loading = false;
}
},
// Action to manually set user as logged out (e.g., after logout)
logout() {
logout()
{
this.isAuthenticated = false;
this.user = null;
}

View file

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

View file

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