Merge pull request 'linting' (#4) from linting into main

Reviewed-on: styletech-sls-team/stylepoint#4
This commit is contained in:
Cameron Redmore 2025-04-25 08:48:32 +01:00
commit 9aea69c7be
48 changed files with 5207 additions and 1174 deletions

View file

@ -1,6 +1,7 @@
{
"recommendations": [
"prisma.prisma"
"prisma.prisma",
"dbaeumer.vscode-eslint"
],
"unwantedRecommendations": [
"octref.vetur",

15
.vscode/settings.json vendored
View file

@ -1,4 +1,17 @@
{
"editor.bracketPairColorization.enabled": true,
"editor.guides.bracketPairs": true
"editor.guides.bracketPairs": true,
"eslint.validate": [
"javascript"
],
"editor.defaultFormatter": "dbaeumer.vscode-eslint",
"eslint.format.enable": true,
"eslint.codeActionsOnSave.rules": null,
"[jsonc]": {
"editor.defaultFormatter": "vscode.json-language-features"
},
"[vue]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
},
"editor.formatOnSave": true
}

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

@ -8,7 +8,7 @@
"private": true,
"scripts": {
"test": "echo \"No test specified\" && exit 0",
"dev": "prisma db push && quasar dev -m ssr",
"dev": "pnpm prisma migrate dev && quasar dev -m ssr",
"build": "quasar build -m ssr",
"postinstall": "quasar prepare"
},
@ -16,25 +16,40 @@
"@google/genai": "^0.9.0",
"@prisma/client": "^6.6.0",
"@quasar/extras": "^1.16.4",
"@simplewebauthn/browser": "^13.1.0",
"@simplewebauthn/server": "^13.1.1",
"axios": "^1.8.4",
"better-sqlite3": "^11.9.1",
"date-fns": "^4.1.0",
"dotenv": "^16.5.0",
"express-session": "^1.18.1",
"mailparser": "^3.7.2",
"marked": "^15.0.9",
"node-cron": "^3.0.3",
"node-imap": "^0.9.6",
"pdfkit": "^0.17.0",
"pdfmake": "^0.2.18",
"pinia": "^3.0.2",
"quasar": "^2.16.0",
"uuid": "^11.1.0",
"vue": "^3.4.18",
"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",

1334
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,7 @@
onlyBuiltDependencies:
- '@prisma/client'
- '@prisma/engines'
- better-sqlite3
- esbuild
- prisma
- sqlite3

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

@ -0,0 +1,166 @@
CREATE EXTENSION IF NOT EXISTS "citext";
-- CreateEnum
CREATE TYPE "FieldType" AS ENUM ('text', 'number', 'date', 'textarea', 'boolean');
-- CreateTable
CREATE TABLE "Form" (
"id" SERIAL NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Form_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Category" (
"id" SERIAL NOT NULL,
"form_id" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"sort_order" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Field" (
"id" SERIAL NOT NULL,
"category_id" INTEGER NOT NULL,
"label" TEXT NOT NULL,
"type" "FieldType" NOT NULL,
"description" TEXT,
"sort_order" INTEGER NOT NULL,
CONSTRAINT "Field_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Response" (
"id" SERIAL NOT NULL,
"form_id" INTEGER NOT NULL,
"submitted_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Response_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ResponseValue" (
"id" SERIAL NOT NULL,
"response_id" INTEGER NOT NULL,
"field_id" INTEGER NOT NULL,
"value" TEXT,
CONSTRAINT "ResponseValue_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MantisSummary" (
"id" SERIAL NOT NULL,
"summary_date" DATE NOT NULL,
"summary_text" TEXT NOT NULL,
"generated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MantisSummary_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Setting" (
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
CONSTRAINT "Setting_pkey" PRIMARY KEY ("key")
);
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"username" CITEXT NOT NULL,
"email" CITEXT,
"fullName" TEXT,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UserPreference" (
"id" SERIAL NOT NULL,
"user_id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
CONSTRAINT "UserPreference_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Authenticator" (
"id" TEXT NOT NULL,
"credential_id" TEXT NOT NULL,
"credential_public_key" BYTEA NOT NULL,
"counter" BIGINT NOT NULL,
"credential_device_type" TEXT NOT NULL,
"credential_backed_up" BOOLEAN NOT NULL,
"transports" TEXT,
"user_id" TEXT NOT NULL,
CONSTRAINT "Authenticator_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ChatThread" (
"id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ChatThread_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ChatMessage" (
"id" TEXT NOT NULL,
"thread_id" TEXT NOT NULL,
"sender" TEXT NOT NULL,
"content" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ChatMessage_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "MantisSummary_summary_date_key" ON "MantisSummary"("summary_date");
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "UserPreference_user_id_key_key" ON "UserPreference"("user_id", "key");
-- CreateIndex
CREATE UNIQUE INDEX "Authenticator_credential_id_key" ON "Authenticator"("credential_id");
-- AddForeignKey
ALTER TABLE "Category" ADD CONSTRAINT "Category_form_id_fkey" FOREIGN KEY ("form_id") REFERENCES "Form"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Field" ADD CONSTRAINT "Field_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "Category"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Response" ADD CONSTRAINT "Response_form_id_fkey" FOREIGN KEY ("form_id") REFERENCES "Form"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ResponseValue" ADD CONSTRAINT "ResponseValue_response_id_fkey" FOREIGN KEY ("response_id") REFERENCES "Response"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ResponseValue" ADD CONSTRAINT "ResponseValue_field_id_fkey" FOREIGN KEY ("field_id") REFERENCES "Field"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserPreference" ADD CONSTRAINT "UserPreference_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Authenticator" ADD CONSTRAINT "Authenticator_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChatMessage" ADD CONSTRAINT "ChatMessage_thread_id_fkey" FOREIGN KEY ("thread_id") REFERENCES "ChatThread"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View file

@ -1,9 +1,3 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
generator client {
provider = "prisma-client-js"
}
@ -20,8 +14,6 @@ model Form {
createdAt DateTime @default(now()) @map("created_at")
categories Category[]
responses Response[]
@@map("forms") // Map to the 'forms' table
}
model Category {
@ -31,8 +23,6 @@ model Category {
sortOrder Int @default(0) @map("sort_order")
form Form @relation(fields: [formId], references: [id], onDelete: Cascade)
fields Field[]
@@map("categories") // Map to the 'categories' table
}
enum FieldType {
@ -47,13 +37,11 @@ model Field {
id Int @id @default(autoincrement())
categoryId Int @map("category_id")
label String
type FieldType // Using Prisma Enum based on CHECK constraint
type FieldType
description String?
sortOrder Int @map("sort_order")
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
responseValues ResponseValue[]
@@map("fields") // Map to the 'fields' table
}
model Response {
@ -62,8 +50,6 @@ model Response {
submittedAt DateTime @default(now()) @map("submitted_at")
form Form @relation(fields: [formId], references: [id], onDelete: Cascade)
responseValues ResponseValue[]
@@map("responses") // Map to the 'responses' table
}
model ResponseValue {
@ -73,29 +59,65 @@ model ResponseValue {
value String?
response Response @relation(fields: [responseId], references: [id], onDelete: Cascade)
field Field @relation(fields: [fieldId], references: [id], onDelete: Cascade)
@@map("response_values") // Map to the 'response_values' table
}
model MantisSummary {
id Int @id @default(autoincrement())
summaryDate DateTime @unique @db.Date @map("summary_date")
summaryDate DateTime @unique @map("summary_date") @db.Date
summaryText String @map("summary_text")
generatedAt DateTime @default(now()) @map("generated_at")
@@map("mantis_summaries") // Map to the 'mantis_summaries' table
}
model EmailSummary {
id Int @id @default(autoincrement())
summaryDate DateTime @unique @db.Date
summaryText String
generatedAt DateTime @default(now())
}
model Setting {
key String @id
value String
@@map("settings") // Map to the 'settings' table
}
model User {
id String @id @default(uuid())
username String @unique @db.Citext()
authenticators Authenticator[]
preferences UserPreference[]
email String? @unique @db.Citext()
fullName String?
}
model UserPreference {
id Int @id @default(autoincrement())
userId String @map("user_id")
key String
value String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, key])
}
model Authenticator {
id String @id @default(uuid())
credentialID String @unique @map("credential_id")
credentialPublicKey Bytes @map("credential_public_key")
counter BigInt
credentialDeviceType String @map("credential_device_type")
credentialBackedUp Boolean @map("credential_backed_up")
transports String?
userId String @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model ChatThread {
id String @id @default(uuid())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
messages ChatMessage[]
}
model ChatMessage {
id String @id @default(uuid())
threadId String @map("thread_id")
sender String // 'user' or 'bot'
content String
createdAt DateTime @default(now()) @map("created_at")
thread ChatThread @relation(fields: [threadId], references: [id], onDelete: Cascade)
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 859 B

After

Width:  |  Height:  |  Size: 1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Before After
Before After

BIN
public/stylepoint.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 662 KiB

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

View file

@ -5,10 +5,3 @@ 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.

View file

@ -0,0 +1,12 @@
// src-ssr/middlewares/authMiddleware.js
export function requireAuth(req, res, next)
{
if (!req.session || !req.session.loggedInUserId)
{
// User is not authenticated
return res.status(401).json({ error: 'Authentication required' });
}
// User is authenticated, proceed to the next middleware or route handler
next();
}

View file

@ -1,45 +1,57 @@
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) {
// hmm, Vue Router could not find the requested route
else
{
res.redirect(err.url);
}
}
else if (err.code === 404)
{
// 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 +59,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) {
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) {
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,12 +529,16 @@ 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);
@ -469,13 +548,18 @@ router.get('/responses/:responseId/export/pdf', async (req, res) => {
}
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}'`);
}
});

452
src-ssr/routes/auth.js Normal file
View file

@ -0,0 +1,452 @@
// src-ssr/routes/auth.js
import express from 'express';
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers'; // Ensure this is imported if not already
import prisma from '../database.js';
import { rpID, rpName, origin, challengeStore } from '../server.js'; // Import RP details and challenge store
const router = express.Router();
// Helper function to get user authenticators
async function getUserAuthenticators(userId)
{
return prisma.authenticator.findMany({
where: { userId },
select: {
credentialID: true,
credentialPublicKey: true,
counter: true,
transports: true,
},
});
}
// Helper function to get a user by username
async function getUserByUsername(username)
{
return prisma.user.findUnique({ where: { username } });
}
// Helper function to get a user by ID
async function getUserById(id)
{
return prisma.user.findUnique({ where: { id } });
}
// Helper function to get an authenticator by credential ID
async function getAuthenticatorByCredentialID(credentialID)
{
return prisma.authenticator.findUnique({ where: { credentialID } });
}
// Generate Registration Options
router.post('/generate-registration-options', async(req, res) =>
{
const { username } = req.body;
if (!username)
{
return res.status(400).json({ error: 'Username is required' });
}
try
{
let user = await getUserByUsername(username);
// If user doesn't exist, create one
if (!user)
{
user = await prisma.user.create({
data: { username },
});
}
const userAuthenticators = await getUserAuthenticators(user.id);
if(userAuthenticators.length > 0)
{
//The user is trying to register a new authenticator, so we need to check if the user registering is the same as the one in the session
if (!req.session.loggedInUserId || req.session.loggedInUserId !== user.id)
{
return res.status(403).json({ error: 'Invalid registration attempt.' });
}
}
const options = await generateRegistrationOptions({
rpName,
rpID,
userName: user.username,
// Don't prompt users for additional authenticators if they've already registered some
excludeCredentials: userAuthenticators.map(auth => ({
id: auth.credentialID, // Use isoBase64URL helper
type: 'public-key',
// Optional: Specify transports if you know them
transports: auth.transports ? auth.transports.split(',') : undefined,
})),
authenticatorSelection: {
// Defaults
residentKey: 'required',
userVerification: 'preferred',
},
// Strong advice: Always require attestation for registration
attestationType: 'none', // Use 'none' for simplicity, 'direct' or 'indirect' recommended for production
});
// Store the challenge
challengeStore.set(user.id, options.challenge);
req.session.userId = user.id; // Temporarily store userId in session for verification step
res.json(options);
}
catch (error)
{
console.error('Registration options error:', error);
res.status(500).json({ error: 'Failed to generate registration options' });
}
});
// Verify Registration
router.post('/verify-registration', async(req, res) =>
{
const { registrationResponse } = req.body;
const userId = req.session.userId; // Retrieve userId stored during options generation
if (!userId)
{
return res.status(400).json({ error: 'User session not found. Please start registration again.' });
}
const expectedChallenge = challengeStore.get(userId);
if (!expectedChallenge)
{
return res.status(400).json({ error: 'Challenge not found or expired' });
}
try
{
const user = await getUserById(userId);
if (!user)
{
return res.status(404).json({ error: 'User not found' });
}
const verification = await verifyRegistrationResponse({
response: registrationResponse,
expectedChallenge: expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
requireUserVerification: false, // Adjust based on your requirements
});
const { verified, registrationInfo } = verification;
console.log(verification);
if (verified && registrationInfo)
{
const { credential, credentialDeviceType, credentialBackedUp } = registrationInfo;
const credentialID = credential.id;
const credentialPublicKey = credential.publicKey;
const counter = credential.counter;
const transports = credential.transports || []; // Use empty array if transports are not provided
// Check if authenticator with this ID already exists
const existingAuthenticator = await getAuthenticatorByCredentialID(isoBase64URL.fromBuffer(credentialID));
if (existingAuthenticator)
{
return res.status(409).json({ error: 'Authenticator already registered' });
}
// Save the authenticator
await prisma.authenticator.create({
data: {
credentialID, // Store as Base64URL string
credentialPublicKey: Buffer.from(credentialPublicKey), // Store as Bytes
counter: BigInt(counter), // Store as BigInt
credentialDeviceType,
credentialBackedUp,
transports: transports.join(','), // Store transports as comma-separated string
userId: user.id,
},
});
// Clear the challenge and temporary userId
challengeStore.delete(userId);
delete req.session.userId;
// Log the user in by setting the final session userId
req.session.loggedInUserId = user.id;
res.json({ verified: true });
}
else
{
res.status(400).json({ error: 'Registration verification failed' });
}
}
catch (error)
{
console.error('Registration verification error:', error);
challengeStore.delete(userId); // Clean up challenge on error
delete req.session.userId;
res.status(500).json({ error: 'Failed to verify registration', details: error.message });
}
});
// Generate Authentication Options
router.post('/generate-authentication-options', async(req, res) =>
{
const { username } = req.body;
try
{
let user;
if (username)
{
user = await getUserByUsername(username);
}
else if (req.session.loggedInUserId)
{
// If already logged in, allow re-authentication (e.g., for step-up)
user = await getUserById(req.session.loggedInUserId);
}
if (!user)
{
return res.status(404).json({ error: 'User not found' });
}
console.log('User found:', user);
const userAuthenticators = await getUserAuthenticators(user.id);
console.log('User authenticators:', userAuthenticators);
const options = await generateAuthenticationOptions({
rpID,
// Require users to use a previously-registered authenticator
allowCredentials: userAuthenticators.map(auth => ({
id: auth.credentialID,
type: 'public-key',
transports: auth.transports ? auth.transports.split(',') : undefined,
})),
userVerification: 'preferred',
});
// Store the challenge associated with the user ID for verification
challengeStore.set(user.id, options.challenge);
req.session.challengeUserId = user.id; // Store user ID associated with this challenge
res.json(options);
}
catch (error)
{
console.error('Authentication options error:', error);
res.status(500).json({ error: 'Failed to generate authentication options' });
}
});
// Verify Authentication
router.post('/verify-authentication', async(req, res) =>
{
const { authenticationResponse } = req.body;
const challengeUserId = req.session.challengeUserId; // Get user ID associated with the challenge
if (!challengeUserId)
{
return res.status(400).json({ error: 'Challenge session not found. Please try logging in again.' });
}
const expectedChallenge = challengeStore.get(challengeUserId);
if (!expectedChallenge)
{
return res.status(400).json({ error: 'Challenge not found or expired' });
}
try
{
const user = await getUserById(challengeUserId);
if (!user)
{
return res.status(404).json({ error: 'User associated with challenge not found' });
}
const authenticator = await getAuthenticatorByCredentialID(authenticationResponse.id);
if (!authenticator)
{
return res.status(404).json({ error: 'Authenticator not found' });
}
// Ensure the authenticator belongs to the user attempting to log in
if (authenticator.userId !== user.id)
{
return res.status(403).json({ error: 'Authenticator does not belong to this user' });
}
const verification = await verifyAuthenticationResponse({
response: authenticationResponse,
expectedChallenge: expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
credential: {
id: authenticator.credentialID,
publicKey: authenticator.credentialPublicKey,
counter: authenticator.counter.toString(), // Convert BigInt to string for comparison
transports: authenticator.transports ? authenticator.transports.split(',') : undefined,
},
requireUserVerification: false, // Enforce user verification
});
const { verified, authenticationInfo } = verification;
if (verified)
{
// Update the authenticator counter
await prisma.authenticator.update({
where: { credentialID: authenticator.credentialID },
data: { counter: BigInt(authenticationInfo.newCounter) }, // Update with the new counter
});
// Clear the challenge and associated user ID
challengeStore.delete(challengeUserId);
delete req.session.challengeUserId;
// Log the user in
req.session.loggedInUserId = user.id;
res.json({ verified: true, user: { id: user.id, username: user.username } });
}
else
{
res.status(400).json({ error: 'Authentication verification failed' });
}
}
catch (error)
{
console.error('Authentication verification error:', error);
challengeStore.delete(challengeUserId); // Clean up challenge on error
delete req.session.challengeUserId;
res.status(500).json({ error: 'Failed to verify authentication', details: error.message });
}
});
// GET Passkeys for Logged-in User
router.get('/passkeys', async(req, res) =>
{
if (!req.session.loggedInUserId)
{
return res.status(401).json({ error: 'Not authenticated' });
}
try
{
const userId = req.session.loggedInUserId;
const authenticators = await prisma.authenticator.findMany({
where: { userId },
select: {
credentialID: true, // Already Base64URL string
// Add other fields if needed, e.g., createdAt if you add it to the schema
// createdAt: true,
},
});
// No need to convert credentialID here as it's stored as Base64URL string
res.json(authenticators);
}
catch (error)
{
console.error('Error fetching passkeys:', error);
res.status(500).json({ error: 'Failed to fetch passkeys' });
}
});
// DELETE Passkey
router.delete('/passkeys/:credentialID', async(req, res) =>
{
if (!req.session.loggedInUserId)
{
return res.status(401).json({ error: 'Not authenticated' });
}
const { credentialID } = req.params; // This is already a Base64URL string from the client
if (!credentialID)
{
return res.status(400).json({ error: 'Credential ID is required' });
}
try
{
const userId = req.session.loggedInUserId;
// Find the authenticator first to ensure it belongs to the logged-in user
const authenticator = await prisma.authenticator.findUnique({
where: { credentialID: credentialID }, // Use the Base64URL string directly
});
if (!authenticator)
{
return res.status(404).json({ error: 'Passkey not found' });
}
// Security check: Ensure the passkey belongs to the user trying to delete it
if (authenticator.userId !== userId)
{
return res.status(403).json({ error: 'Permission denied' });
}
// Delete the authenticator
await prisma.authenticator.delete({
where: { credentialID: credentialID },
});
res.json({ message: 'Passkey deleted successfully' });
}
catch (error)
{
console.error('Error deleting passkey:', error);
// Handle potential Prisma errors, e.g., record not found if deleted between check and delete
if (error.code === 'P2025')
{ // Prisma code for record not found on delete/update
return res.status(404).json({ error: 'Passkey not found' });
}
res.status(500).json({ error: 'Failed to delete passkey' });
}
});
// Check Authentication Status
router.get('/status', (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)
{
console.error('Logout error:', err);
return res.status(500).json({ error: 'Failed to logout' });
}
res.json({ message: 'Logged out successfully' });
});
});
export default router;

164
src-ssr/routes/chat.js Normal file
View file

@ -0,0 +1,164 @@
import { Router } from 'express';
import prisma from '../database.js';
import { requireAuth } from '../middlewares/authMiddleware.js'; // Import the middleware
import { askGeminiChat } from '../utils/gemini.js';
const router = Router();
// Apply the authentication middleware to all chat routes
router.use(requireAuth);
// POST /api/chat/threads - Create a new chat thread (optionally with a first message)
router.post('/threads', async(req, res) =>
{
const { content } = req.body; // Content is now optional
// If content is provided, validate it
if (content && (typeof content !== 'string' || content.trim().length === 0))
{
return res.status(400).json({ error: 'Message content cannot be empty if provided.' });
}
try
{
const createData = {};
if (content)
{
// If content exists, create the thread with the first message
createData.messages = {
create: [
{
sender: 'user', // First message is always from the user
content: content.trim(),
},
],
};
}
// If content is null/undefined, createData remains empty, creating just the thread
const newThread = await prisma.chatThread.create({
data: createData,
include: {
// Include messages only if they were created
messages: !!content,
},
});
if(content)
{
await askGeminiChat(newThread.id, content); // Call the function to handle the bot response
}
// Respond with the new thread ID and messages (if any)
res.status(201).json({
threadId: newThread.id,
// Ensure messages array is empty if no content was provided
messages: newThread.messages ? newThread.messages.map(msg => ({ ...msg, createdAt: msg.createdAt.toISOString() })) : []
});
}
catch (error)
{
console.error('Error creating chat thread:', error);
res.status(500).json({ error: 'Failed to create chat thread.' });
}
});
// GET /api/chat/threads/:threadId/messages - Get messages for a specific thread
router.get('/threads/:threadId/messages', async(req, res) =>
{
const { threadId } = req.params;
try
{
const messages = await prisma.chatMessage.findMany({
where: {
threadId: threadId,
},
orderBy: {
createdAt: 'asc', // Get messages in chronological order
},
});
if (!messages)
{ // Check if thread exists indirectly
// If findMany returns empty, the thread might not exist or has no messages.
// Check if thread exists explicitly
const thread = await prisma.chatThread.findUnique({ where: { id: threadId } });
if (!thread)
{
return res.status(404).json({ error: 'Chat thread not found.' });
}
}
res.status(200).json(messages.map(msg => ({ ...msg, createdAt: msg.createdAt.toISOString() })));
}
catch (error)
{
console.error(`Error fetching messages for thread ${threadId}:`, error);
// Basic error handling, check for specific Prisma errors if needed
if (error.code === 'P2023' || error.message.includes('Malformed UUID'))
{ // Example: Invalid UUID format
return res.status(400).json({ error: 'Invalid thread ID format.' });
}
res.status(500).json({ error: 'Failed to fetch messages.' });
}
});
// POST /api/chat/threads/:threadId/messages - Add a message to an existing thread
router.post('/threads/:threadId/messages', async(req, res) =>
{
const { threadId } = req.params;
const { content, sender = 'user' } = req.body; // Default sender to 'user'
if (!content || typeof content !== 'string' || content.trim().length === 0)
{
return res.status(400).json({ error: 'Message content cannot be empty.' });
}
if (sender !== 'user' && sender !== 'bot')
{
return res.status(400).json({ error: 'Invalid sender type.' });
}
try
{
// Verify thread exists first
const thread = await prisma.chatThread.findUnique({
where: { id: threadId },
});
if (!thread)
{
return res.status(404).json({ error: 'Chat thread not found.' });
}
const newMessage = await prisma.chatMessage.create({
data: {
threadId: threadId,
sender: sender,
content: content.trim(),
},
});
// Optionally: Update the thread's updatedAt timestamp
await prisma.chatThread.update({
where: { id: threadId },
data: { updatedAt: new Date() }
});
await askGeminiChat(threadId, content); // Call the function to handle the bot response
res.status(201).json({ ...newMessage, createdAt: newMessage.createdAt.toISOString() });
}
catch (error)
{
console.error(`Error adding message to thread ${threadId}:`, error);
if (error.code === 'P2023' || error.message.includes('Malformed UUID'))
{ // Example: Invalid UUID format
return res.status(400).json({ error: 'Invalid thread ID format.' });
}
res.status(500).json({ error: 'Failed to add message.' });
}
});
export default router;

View file

@ -9,21 +9,33 @@
* 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 {
defineSsrCreate,
defineSsrListen,
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';
import authRoutes from './routes/auth.js'; // Added for WebAuthn routes
import chatRoutes from './routes/chat.js'; // Added for Chat routes
import cron from 'node-cron';
import { generateAndStoreMantisSummary } from './services/mantisSummarizer.js';
// Define Relying Party details (Update with your actual details)
export const rpID = process.env.NODE_ENV === 'production' ? 'your-production-domain.com' : 'localhost';
export const rpName = 'StylePoint';
export const origin = process.env.NODE_ENV === 'production' ? `https://${rpID}` : `http://${rpID}:9100`;
// In-memory store for challenges (Replace with a persistent store in production)
export const challengeStore = new Map();
/**
* Create your webserver and return its instance.
* If needed, prepare your webserver to receive
@ -31,33 +43,54 @@ import { generateAndStoreMantisSummary } from './services/mantisSummarizer.js';
*
* 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({
genid: (req) => uuidv4(), // Use UUIDs for session IDs
secret: process.env.SESSION_SECRET || 'a-very-strong-secret-key', // Use an environment variable for the secret
resave: false,
saveUninitialized: true,
cookie: {
secure: process.env.NODE_ENV === 'production', // Use secure cookies in production
httpOnly: true,
maxAge: 1000 * 60 * 60 * 24 // 1 day
}
}));
// 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
@ -65,22 +98,25 @@ 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());
// Add API routes
app.use('/api', apiRoutes);
app.use('/auth', authRoutes); // Added WebAuthn auth routes
app.use('/api/chat', chatRoutes); // Added Chat routes
// 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
@ -95,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.
@ -111,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
@ -136,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);
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,23 +115,18 @@ export async function generateAndStoreMantisSummary() {
const tickets = await getMantisTickets();
let summaryText;
if (tickets.length === 0) {
summaryText = "No Mantis tickets updated recently.";
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
}
});
else
{
console.log(`Found ${tickets.length} recent Mantis tickets. Generating summary...`);
let prompt = promptTemplate.replaceAll('$DATE', new Date().toISOString().split('T')[0]);
prompt = prompt.replaceAll('$MANTIS_TICKETS', JSON.stringify(tickets, null, 2));
summaryText = response.text;
summaryText = await askGemini(prompt);
console.log('Mantis summary generated successfully by AI.');
}
@ -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.');
}

162
src-ssr/utils/gemini.js Normal file
View file

@ -0,0 +1,162 @@
import { GoogleGenAI } from '@google/genai';
import prisma from '../database.js';
import { getSetting } from './settings.js';
const model = 'gemini-2.0-flash';
export async function askGemini(content)
{
const GOOGLE_API_KEY = await getSetting('GEMINI_API_KEY');
if (!GOOGLE_API_KEY)
{
throw new Error('Google API key is not set in the database.');
}
const ai = GOOGLE_API_KEY ? new GoogleGenAI({
apiKey: GOOGLE_API_KEY,
}) : null;
if (!ai)
{
throw new Error('Google API key is not set in the database.');
}
try
{
const response = await ai.models.generateContent({
model,
contents: content,
config: {
temperature: 0.5
}
});
return response.text;
}
catch (error)
{
console.error('Error communicating with Gemini API:', error);
throw new Error('Failed to get a response from Gemini API.');
}
}
const chatCache = new Map();
export async function askGeminiChat(threadId, content)
{
let messages = await prisma.chatMessage.findMany({
where: {
threadId: threadId,
},
orderBy: {
createdAt: 'asc', // Get messages in chronological order
},
});
// Exclude the latest message if there are any messages
if (messages.length > 0)
{
messages = messages.slice(0, -1);
}
const 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;
}

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

@ -0,0 +1,137 @@
<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
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"
/>
<!-- 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 />
<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>
import { ref, watch, nextTick } from 'vue';
import { QScrollArea, QChatMessage, QSpinnerDots } from 'quasar'; // Import QSpinnerDots
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 }
},
});
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 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) =>
{
// Basic check to prevent errors if content is not a string
if (typeof content !== 'string')
{
return '';
}
// Configure marked options if needed (e.g., sanitization)
// marked.setOptions({ sanitize: true }); // Example: Enable sanitization
return marked(content);
};
// Scroll to bottom when messages change or component mounts
watch(() => props.messages, () =>
{
nextTick(() =>
{
scrollToBottom();
});
}, { deep: true, immediate: true });
</script>
<style>
.message-content p {
margin: 0;
padding: 0;
}
</style>

View file

@ -7,98 +7,241 @@
: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
@click="toggleLeftDrawer"
>
<q-tooltip anchor="center right" self="center left" >
<span>Mantis Summaries</span>
</q-tooltip>
<q-item-section avatar>
<q-icon name="summarize" />
<q-icon name="menu" />
</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-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: 'emailSummaries' }"
:to="{ name: item.name }"
exact
>
<q-tooltip anchor="center right" self="center left" >
<span>Email Summaries</span>
<q-tooltip
anchor="center right"
self="center left"
>
<span>{{ item.meta.title }}</span>
</q-tooltip>
<q-item-section avatar>
<q-icon name="email" />
<q-icon :name="item.meta.icon" />
</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-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
to="/settings" exact
v-ripple
@click="logout"
>
<q-tooltip anchor="center right" self="center left" >
<span>Settings</span>
<q-tooltip
anchor="center right"
self="center left"
>
<span>Logout</span>
</q-tooltip>
<q-item-section
avatar
>
<q-icon name="settings" />
<q-item-section avatar>
<q-icon name="logout" />
</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-label>Logout</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"
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"
/>
</template>
</q-banner>
</q-card>
</q-dialog>
</q-layout>
</template>
<script setup>
import { ref } from 'vue'
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 routes from '../router/routes'; // Import routes
const leftDrawerOpen = ref(false)
const $q = useQuasar();
const leftDrawerOpen = ref(false);
const router = useRouter();
const authStore = useAuthStore(); // Use the auth store
const chatStore = useChatStore();
function toggleLeftDrawer () {
leftDrawerOpen.value = !leftDrawerOpen.value
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
// 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
if (navGroup === 'always') return true;
if (navGroup === 'auth' && isAuthenticated.value) return true;
if (navGroup === 'noAuth' && !isAuthenticated.value) return true;
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();
}
};
// 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
};
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);
$q.notify({
color: 'negative',
message: 'Logout failed. Please try again.',
icon: 'report_problem'
});
}
}
</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,51 +1,137 @@
<template>
<q-page padding>
<div class="text-h4 q-mb-md">Create New Form</div>
<div class="text-h4 q-mb-md">
Create New Form
</div>
<q-form @submit.prevent="createForm" class="q-gutter-md">
<q-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" />
<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
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" />
<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
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" />
<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." />
<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" />
<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" />
<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' }" />
<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>
@ -71,25 +157,31 @@ const form = ref({
const fieldTypes = ref(['text', 'number', 'date', 'textarea', 'boolean']);
const submitting = ref(false);
function addCategory() {
function addCategory()
{
form.value.categories.push({ name: `Category ${form.value.categories.length + 1}`, fields: [{ label: '', type: null, description: '' }] });
}
function removeCategory(index) {
function removeCategory(index)
{
form.value.categories.splice(index, 1);
}
function addField(catIndex) {
function addField(catIndex)
{
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 createForm() {
async function createForm()
{
submitting.value = true;
try {
try
{
const response = await axios.post('/api/forms', form.value);
$q.notify({
color: 'positive',
@ -98,7 +190,9 @@ async function createForm() {
icon: 'check_circle'
});
router.push({ name: 'formList' });
} catch (error) {
}
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({
@ -107,7 +201,9 @@ async function createForm() {
message: message,
icon: 'report_problem'
});
} finally {
}
finally
{
submitting.value = false;
}
}

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" />
<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) {
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
@ -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' }" />
<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-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',

54
src/pages/LandingPage.vue Normal file
View file

@ -0,0 +1,54 @@
<template>
<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="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>
import { ref } from 'vue';
import { useQuasar } from 'quasar';
const $q = useQuasar();
const currentYear = ref(new Date().getFullYear());
const features = ref([
'Auatomated Daily Reports',
'Deep Mantis Integration',
'Easy Authentication',
'And more..?'
]);
</script>

130
src/pages/LoginPage.vue Normal file
View file

@ -0,0 +1,130 @@
<template>
<q-page class="flex flex-center">
<q-card style="width: 400px; max-width: 90vw;">
<q-card-section>
<div class="text-h6">
Login
</div>
</q-card-section>
<q-card-section>
<q-input
v-model="username"
label="Username"
outlined
dense
class="q-mb-md"
@keyup.enter="handleLogin"
:hint="errorMessage ? errorMessage : ''"
:rules="[val => !!val || 'Username is required']"
/>
<q-btn
label="Login with Passkey"
color="primary"
class="full-width"
@click="handleLogin"
:loading="loading"
/>
<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-card-actions>
</q-card>
</q-page>
</template>
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { startAuthentication } from '@simplewebauthn/browser';
import axios from 'axios';
import { useAuthStore } from 'stores/auth'; // Import the auth store
const username = ref('');
const loading = ref(false);
const errorMessage = ref('');
const router = useRouter();
const authStore = useAuthStore(); // Use the auth store
async function handleLogin()
{
loading.value = true;
errorMessage.value = '';
try
{
// 1. Get options from server
const optionsRes = await axios.post('/auth/generate-authentication-options', {
username: username.value || undefined, // Send username if provided
});
const options = optionsRes.data;
// 2. Start authentication ceremony in browser
const authResp = await startAuthentication(options);
// 3. Send response to server for verification
const verificationRes = await axios.post('/auth/verify-authentication', {
authenticationResponse: authResp,
});
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
{
errorMessage.value = 'Authentication failed.';
// Optionally update store state on failure
authStore.isAuthenticated = false;
authStore.user = null;
authStore.error = 'Authentication failed.';
}
}
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}`;
}
// Optionally update store state on error
authStore.isAuthenticated = false;
authStore.user = null;
authStore.error = `Login failed: ${message}`;
}
finally
{
loading.value = false;
}
}
</script>

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"
@ -60,9 +91,10 @@
</q-card-section>
<q-card-section v-if="!loading && !error && summaries.length === 0">
<div class="text-center text-grey">No summaries found.</div>
<div class="text-center text-grey">
No summaries found.
</div>
</q-card-section>
</q-card>
</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,7 +169,9 @@ 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({
@ -136,31 +179,38 @@ const generateSummary = async () => {
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

@ -0,0 +1,371 @@
<template>
<q-page padding>
<div class="q-mb-md row justify-between items-center">
<div class="text-h4">
Passkey Management
</div>
<div>
<q-btn
label="Identify Passkey"
color="secondary"
class="q-mx-md q-mt-md"
@click="handleIdentify"
:loading="identifyLoading"
:disable="identifyLoading || !isLoggedIn"
outline
/>
<q-btn
label="Register New Passkey"
color="primary"
class="q-mx-md q-mt-md"
@click="handleRegister"
: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-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>
</q-item>
<q-item
v-for="passkey in passkeys"
:key="passkey.credentialID"
:class="{ 'bg-info text-h6': identifiedPasskeyId === passkey.credentialID }"
>
<q-item-section>
<q-item-label>Passkey ID: {{ passkey.credentialID }} </q-item-label>
<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"
>
<!-- Delete Button -->
<q-btn
flat
dense
round
color="negative"
icon="delete"
@click="handleDelete(passkey.credentialID)"
:loading="deleteLoading === passkey.credentialID"
:disable="!!deleteLoading || !!identifyLoading"
/>
</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>
</q-page>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'; // Import startAuthentication
import axios from 'axios';
import { useAuthStore } from 'stores/auth';
const registerLoading = ref(false);
const registerErrorMessage = ref('');
const registerSuccessMessage = ref('');
const fetchLoading = ref(false);
const fetchErrorMessage = ref('');
const deleteLoading = ref(null);
const deleteErrorMessage = ref('');
const deleteSuccessMessage = ref('');
const identifyLoading = ref(null); // Store the ID of the passkey being identified
const identifyErrorMessage = ref('');
const identifiedPasskeyId = ref(null); // Store the ID of the successfully identified passkey
const authStore = useAuthStore();
const passkeys = ref([]); // To store the list of passkeys
// Computed properties to get state from the store
const isLoggedIn = computed(() => authStore.isAuthenticated);
const username = computed(() => authStore.user?.username);
// Fetch existing passkeys
async function fetchPasskeys()
{
if (!isLoggedIn.value) return;
fetchLoading.value = true;
fetchErrorMessage.value = '';
deleteSuccessMessage.value = ''; // Clear delete messages on refresh
deleteErrorMessage.value = '';
identifyErrorMessage.value = ''; // Clear identify message
identifiedPasskeyId.value = null; // Clear identified key
try
{
const response = await axios.get('/auth/passkeys');
passkeys.value = response.data || [];
}
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
{
fetchLoading.value = false;
}
}
// Check auth status and fetch passkeys on component mount
onMounted(async() =>
{
let initialAuthError = '';
if (!authStore.isAuthenticated)
{
await authStore.checkAuthStatus();
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
{
fetchPasskeys(); // Fetch passkeys if logged in
}
});
async function handleRegister()
{
if (!isLoggedIn.value || !username.value)
{
registerErrorMessage.value = 'User not authenticated.';
return;
}
registerLoading.value = true;
registerErrorMessage.value = '';
registerSuccessMessage.value = '';
deleteSuccessMessage.value = ''; // Clear other messages
deleteErrorMessage.value = '';
identifyErrorMessage.value = '';
identifiedPasskeyId.value = null;
try
{
// 1. Get options from server
const optionsRes = await axios.post('/auth/generate-registration-options', {
username: username.value, // Use username from store
});
const options = optionsRes.data;
// 2. Start registration ceremony in browser
const regResp = await startRegistration(options);
// 3. Send response to server for verification
const verificationRes = await axios.post('/auth/verify-registration', {
registrationResponse: regResp,
});
if (verificationRes.data.verified)
{
registerSuccessMessage.value = 'New passkey registered successfully!';
fetchPasskeys(); // Refresh the list of passkeys
}
else
{
registerErrorMessage.value = 'Passkey verification failed.';
}
}
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')
{
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
{
registerErrorMessage.value = `Registration failed: ${message}`;
}
}
finally
{
registerLoading.value = false;
}
}
// Handle deleting a passkey
async function handleDelete(credentialID)
{
if (!credentialID) return;
// Optional: Add a confirmation dialog here
// if (!confirm('Are you sure you want to delete this passkey?')) {
// return;
// }
deleteLoading.value = credentialID; // Set loading state for the specific button
deleteErrorMessage.value = '';
deleteSuccessMessage.value = '';
registerSuccessMessage.value = ''; // Clear other messages
registerErrorMessage.value = '';
identifyErrorMessage.value = '';
identifiedPasskeyId.value = null;
try
{
await axios.delete(`/auth/passkeys/${credentialID}`);
deleteSuccessMessage.value = 'Passkey deleted successfully.';
fetchPasskeys(); // Refresh the list
}
catch (error)
{
console.error('Error deleting passkey:', error);
deleteErrorMessage.value = error.response?.data?.error || 'Failed to delete passkey.';
}
finally
{
deleteLoading.value = null; // Clear loading state
}
}
// Handle identifying a passkey
async function handleIdentify()
{
if (!isLoggedIn.value)
{
identifyErrorMessage.value = 'You must be logged in.';
return;
}
identifyLoading.value = true;
identifyErrorMessage.value = '';
identifiedPasskeyId.value = null; // Reset identified key
// Clear other messages
registerSuccessMessage.value = '';
registerErrorMessage.value = '';
deleteSuccessMessage.value = '';
deleteErrorMessage.value = '';
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
const options = optionsRes.data;
// Optionally filter options to only allow the specific key if needed, but usually not necessary for identification
// options.allowCredentials = options.allowCredentials?.filter(cred => cred.id === credentialIDToIdentify);
// 2. Start authentication ceremony in the browser
const authResp = await startAuthentication(options);
// 3. If successful, the response contains the ID of the key used
identifiedPasskeyId.value = authResp.id;
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;
}
}, 5000); // Clear highlight after 5 seconds
}
catch (error)
{
console.error('Identification error:', error);
identifiedPasskeyId.value = null;
if (error.name === 'NotAllowedError')
{
identifyErrorMessage.value = 'Identification ceremony was cancelled or timed out.';
}
else
{
identifyErrorMessage.value = error.response?.data?.error || error.message || 'Failed to identify passkey.';
}
}
finally
{
identifyLoading.value = null; // Clear loading state
}
}
</script>

179
src/pages/RegisterPage.vue Normal file
View file

@ -0,0 +1,179 @@
<template>
<q-page class="flex flex-center">
<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>
</q-card-section>
<q-card-section>
<q-input
v-model="username"
label="Username"
outlined
dense
class="q-mb-md"
:rules="[val => !!val || 'Username is required']"
@keyup.enter="handleRegister"
:disable="isLoggedIn"
:hint="isLoggedIn ? 'Registering a new passkey for your current account.' : ''"
:readonly="isLoggedIn"
/>
<q-btn
:label="isLoggedIn ? 'Register New Passkey' : 'Register Passkey'"
color="primary"
class="full-width"
@click="handleRegister"
: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>
</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>
</q-card>
</q-page>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'; // Import computed
import { useRouter } from 'vue-router';
import { startRegistration } from '@simplewebauthn/browser';
import axios from 'axios';
import { useAuthStore } from 'stores/auth'; // Import the auth store
const loading = ref(false);
const errorMessage = ref('');
const successMessage = ref('');
const router = useRouter();
const authStore = useAuthStore(); // Use the auth store
// Computed properties to get state from the store
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)
{
await authStore.checkAuthStatus();
if (authStore.error)
{
errorMessage.value = authStore.error;
}
}
if (!isLoggedIn.value)
{
username.value = ''; // Clear username if not logged in
}
else
{
username.value = authStore.user?.username || ''; // Use username from store if logged in
}
});
async function handleRegister()
{
const currentUsername = isLoggedIn.value ? authStore.user?.username : username.value;
if (!currentUsername)
{
errorMessage.value = 'Username is missing.';
return;
}
loading.value = true;
errorMessage.value = '';
successMessage.value = '';
try
{
// 1. Get options from server
const optionsRes = await axios.post('/auth/generate-registration-options', {
username: currentUsername, // Use username from store
});
const options = optionsRes.data;
// 2. Start registration ceremony in browser
const regResp = await startRegistration(options);
// 3. Send response to server for verification
const verificationRes = await axios.post('/auth/verify-registration', {
registrationResponse: regResp,
});
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)
{
// Redirect to login page only if they weren't logged in
setTimeout(() =>
{
router.push('/login');
}, 2000);
}
else
{
// Maybe redirect to a profile page or dashboard if already logged in
// setTimeout(() => { router.push('/dashboard'); }, 2000);
}
}
else
{
errorMessage.value = 'Registration failed.';
}
}
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')
{
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
{
errorMessage.value = `Registration failed: ${message}`;
}
}
finally
{
loading.value = false;
}
}
</script>

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>
@ -56,7 +71,6 @@
/>
</q-card-actions>
</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,7 @@
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
/*
* If not building with SSR mode, you can
@ -11,10 +12,11 @@ import routes from './routes'
* with the Router instance.
*/
export default defineRouter(function (/* { 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 }),
@ -24,7 +26,46 @@ export default defineRouter(function (/* { store, ssrContext } */) {
// quasar.conf.js -> build -> vueRouterMode
// quasar.conf.js -> build -> publicPath
history: createHistory(process.env.VUE_ROUTER_BASE)
})
});
return Router
})
// Navigation Guard using Pinia store
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)
}
}
const requiresAuth = to.matched.some(record => record.meta.requiresAuth);
const publicPages = ['/login', '/register'];
const isPublicPage = publicPages.includes(to.path);
const isAuthenticated = authStore.isAuthenticated; // Get status from store
if (requiresAuth && !isAuthenticated)
{
next('/login');
}
else if (isPublicPage && isAuthenticated)
{
next('/');
}
else
{
next();
}
});
return Router;
});

View file

@ -3,15 +3,89 @@ const routes = [
path: '/',
component: () => import('layouts/MainLayout.vue'),
children: [
{ path: '', name: 'home', component: () => import('pages/FormListPage.vue') },
{ path: 'forms', name: 'formList', component: () => import('pages/FormListPage.vue') },
{ path: 'forms/new', name: 'formCreate', component: () => import('pages/FormCreatePage.vue') },
{ path: 'forms/:id/edit', name: 'formEdit', component: () => import('pages/FormEditPage.vue'), props: true },
{ path: 'forms/:id/fill', name: 'formFill', component: () => import('pages/FormFillPage.vue'), props: true },
{ path: 'forms/:id/responses', name: 'formResponses', component: () => import('pages/FormResponsesPage.vue'), props: true },
{ path: 'mantis-summaries', name: 'mantisSummaries', component: () => import('pages/MantisSummariesPage.vue') },
{ path: 'email-summaries', name: 'emailSummaries', component: () => import('pages/EmailSummariesPage.vue') },
{ path: 'settings', name: 'settings', component: () => import('pages/SettingsPage.vue') }
{
path: '',
name: 'home',
component: () => import('pages/LandingPage.vue'),
meta: { requiresAuth: false } // Keep home accessible, but don't show in nav
},
{
path: '/login',
name: 'login',
component: () => import('pages/LoginPage.vue'),
meta: {
requiresAuth: false,
navGroup: 'noAuth', // Show only when logged out
icon: 'login',
title: 'Login',
caption: 'Access your account'
}
},
{
path: '/register',
name: 'register',
component: () => import('pages/RegisterPage.vue'),
meta: {
requiresAuth: false,
navGroup: 'noAuth', // Show only when logged out
icon: 'person_add',
title: 'Register',
caption: 'Create an account'
}
},
// Add a new route specifically for managing passkeys when logged in
{
path: '/passkeys',
name: 'passkeys',
component: () => import('pages/PasskeyManagementPage.vue'), // Assuming this page exists or will be created
meta: {
requiresAuth: true,
navGroup: 'auth', // Show only when logged in
icon: 'key',
title: 'Passkeys',
caption: 'Manage your passkeys'
}
},
{
path: 'forms',
name: 'formList',
component: () => import('pages/FormListPage.vue'),
meta: {
requiresAuth: true,
navGroup: 'auth', // Show only when logged in
icon: 'list_alt',
title: 'Forms',
caption: 'View existing forms'
}
},
{ path: 'forms/new', name: 'formCreate', component: () => import('pages/FormCreatePage.vue'), meta: { requiresAuth: true } }, // Not in nav
{ path: 'forms/:id/edit', name: 'formEdit', component: () => import('pages/FormEditPage.vue'), props: true, meta: { requiresAuth: true } }, // Not in nav
{ path: 'forms/:id/fill', name: 'formFill', component: () => import('pages/FormFillPage.vue'), props: true, meta: { requiresAuth: true } }, // Not in nav
{ path: 'forms/:id/responses', name: 'formResponses', component: () => import('pages/FormResponsesPage.vue'), props: true, meta: { requiresAuth: true } }, // Not in nav
{
path: 'mantis-summaries',
name: 'mantisSummaries',
component: () => import('pages/MantisSummariesPage.vue'),
meta: {
requiresAuth: true,
navGroup: 'auth', // Show only when logged in
icon: 'summarize',
title: 'Mantis Summaries',
caption: 'View daily summaries'
}
},
{
path: 'settings',
name: 'settings',
component: () => import('pages/SettingsPage.vue'),
meta: {
requiresAuth: true,
navGroup: 'auth', // Show only when logged in
icon: 'settings',
title: 'Settings',
caption: 'Manage application settings'
}
}
]
},
@ -21,6 +95,6 @@ const routes = [
path: '/:catchAll(.*)*',
component: () => import('pages/ErrorNotFound.vue')
}
]
];
export default routes
export default routes;

49
src/stores/auth.js Normal file
View file

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

256
src/stores/chat.js Normal file
View file

@ -0,0 +1,256 @@
import { defineStore } from 'pinia';
import { ref, computed, watch } from 'vue'; // Import watch
import axios from 'axios';
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 }
const isLoading = ref(false);
const error = ref(null);
const pollingIntervalId = ref(null); // To store the interval ID
// --- Getters ---
const chatMessages = computed(() => messages.value);
const isChatVisible = computed(() => isVisible.value);
const activeThreadId = computed(() => currentThreadId.value);
// --- Actions ---
// New action to create a thread if it doesn't exist
async function createThreadIfNotExists()
{
if (currentThreadId.value) return; // Already have a thread
isLoading.value = true;
error.value = null;
try
{
// Call the endpoint without content to just create the thread
const response = await axios.post('/api/chat/threads', {});
currentThreadId.value = response.data.threadId;
messages.value = []; // Start with an empty message list for the new thread
console.log('Created new chat thread:', currentThreadId.value);
// Start polling now that we have a thread ID
startPolling();
}
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
{
isLoading.value = false;
}
}
function toggleChat()
{
isVisible.value = !isVisible.value;
if (isVisible.value)
{
if (!currentThreadId.value)
{
// If opening and no thread exists, create one
createThreadIfNotExists();
}
else
{
// If opening and thread exists, fetch messages if empty and start polling
if (messages.value.length === 0)
{
fetchMessages();
}
startPolling();
}
}
else
{
// If closing, stop polling
stopPolling();
}
}
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;
}
// 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
{
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...'
})).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;
}
}
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
{
// isLoading.value = false;
}
}
// Function to start polling
function startPolling()
{
if (pollingIntervalId.value) return; // Already polling
if (!currentThreadId.value) return; // No thread to poll for
console.log('Starting chat polling for thread:', currentThreadId.value);
pollingIntervalId.value = setInterval(fetchMessages, 5000); // Poll every 5 seconds
}
// Function to stop polling
function stopPolling()
{
if (pollingIntervalId.value)
{
console.log('Stopping chat polling.');
clearInterval(pollingIntervalId.value);
pollingIntervalId.value = null;
}
}
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
}
const userMessage = {
sender: 'user',
content: content.trim(),
createdAt: new Date(),
};
messages.value.push(userMessage);
const loadingMessage = { sender: 'bot', content: '...', loading: true, createdAt: new Date(Date.now() + 1) }; // Ensure unique key/time
messages.value.push(loadingMessage);
// Stop polling temporarily while sending a message to avoid conflicts
stopPolling();
isLoading.value = true; // Indicate activity
error.value = null;
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);
// Remove loading indicator
messages.value = messages.value.filter(m => !m.loading);
// The POST might return the new message, but we'll rely on the next fetchMessages call
// triggered by startPolling to get the latest state including any potential bot response.
// 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
{
isLoading.value = false;
// Restart polling after sending attempt is complete
startPolling();
}
}
// Call this when the user logs out or the app closes if you want to clear state
function resetChat()
{
stopPolling(); // Ensure polling stops on reset
isVisible.value = false;
currentThreadId.value = null;
messages.value = [];
isLoading.value = false;
error.value = null;
}
// Watch for visibility changes to manage polling (alternative to putting logic in toggleChat)
// watch(isVisible, (newValue) => {
// if (newValue && currentThreadId.value) {
// startPolling();
// } else {
// stopPolling();
// }
// });
// Watch for thread ID changes (e.g., after creation)
// watch(currentThreadId, (newId) => {
// if (newId && isVisible.value) {
// messages.value = []; // Clear old messages if any
// fetchMessages(); // Fetch messages for the new thread
// startPolling(); // Start polling for the new thread
// } else {
// stopPolling(); // Stop polling if thread ID becomes null
// }
// });
return {
// State refs
isVisible,
currentThreadId,
messages,
isLoading,
error,
// Computed getters
chatMessages,
isChatVisible,
activeThreadId,
// Actions
toggleChat,
sendMessage,
fetchMessages, // Expose if needed externally
resetChat,
// Expose polling control if needed externally, though typically managed internally
// startPolling,
// stopPolling,
};
});

21
src/stores/index.js Normal file
View file

@ -0,0 +1,21 @@
import { defineStore } from '#q-app/wrappers';
import { createPinia } from 'pinia';
/*
* If not building with SSR mode, you can
* directly export the Store instantiation;
*
* The function below can be async too; either use
* async/await or return a Promise which resolves
* with the Store instance.
*/
export default defineStore((/* { ssrContext } */) =>
{
const pinia = createPinia();
// You can add Pinia plugins here
// pinia.use(SomePiniaPlugin)
return pinia;
});