Merge pull request 'linting' (#4) from linting into main
Reviewed-on: styletech-sls-team/stylepoint#4
							
								
								
									
										3
									
								
								.vscode/extensions.json
									
										
									
									
										vendored
									
									
								
							
							
						
						| 
						 | 
					@ -1,6 +1,7 @@
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
  "recommendations": [
 | 
					  "recommendations": [
 | 
				
			||||||
    "prisma.prisma"
 | 
					    "prisma.prisma",
 | 
				
			||||||
 | 
					    "dbaeumer.vscode-eslint"
 | 
				
			||||||
  ],
 | 
					  ],
 | 
				
			||||||
  "unwantedRecommendations": [
 | 
					  "unwantedRecommendations": [
 | 
				
			||||||
    "octref.vetur",
 | 
					    "octref.vetur",
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										15
									
								
								.vscode/settings.json
									
										
									
									
										vendored
									
									
								
							
							
						
						| 
						 | 
					@ -1,4 +1,17 @@
 | 
				
			||||||
{
 | 
					{
 | 
				
			||||||
  "editor.bracketPairColorization.enabled": true,
 | 
					  "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
									
								
							
							
						
						| 
						 | 
					@ -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
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					];
 | 
				
			||||||
							
								
								
									
										19
									
								
								package.json
									
										
									
									
									
								
							
							
						
						| 
						 | 
					@ -8,7 +8,7 @@
 | 
				
			||||||
  "private": true,
 | 
					  "private": true,
 | 
				
			||||||
  "scripts": {
 | 
					  "scripts": {
 | 
				
			||||||
    "test": "echo \"No test specified\" && exit 0",
 | 
					    "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",
 | 
					    "build": "quasar build -m ssr",
 | 
				
			||||||
    "postinstall": "quasar prepare"
 | 
					    "postinstall": "quasar prepare"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
| 
						 | 
					@ -16,25 +16,40 @@
 | 
				
			||||||
    "@google/genai": "^0.9.0",
 | 
					    "@google/genai": "^0.9.0",
 | 
				
			||||||
    "@prisma/client": "^6.6.0",
 | 
					    "@prisma/client": "^6.6.0",
 | 
				
			||||||
    "@quasar/extras": "^1.16.4",
 | 
					    "@quasar/extras": "^1.16.4",
 | 
				
			||||||
 | 
					    "@simplewebauthn/browser": "^13.1.0",
 | 
				
			||||||
 | 
					    "@simplewebauthn/server": "^13.1.1",
 | 
				
			||||||
    "axios": "^1.8.4",
 | 
					    "axios": "^1.8.4",
 | 
				
			||||||
    "better-sqlite3": "^11.9.1",
 | 
					    "better-sqlite3": "^11.9.1",
 | 
				
			||||||
    "date-fns": "^4.1.0",
 | 
					    "date-fns": "^4.1.0",
 | 
				
			||||||
    "dotenv": "^16.5.0",
 | 
					    "dotenv": "^16.5.0",
 | 
				
			||||||
 | 
					    "express-session": "^1.18.1",
 | 
				
			||||||
    "mailparser": "^3.7.2",
 | 
					    "mailparser": "^3.7.2",
 | 
				
			||||||
    "marked": "^15.0.9",
 | 
					    "marked": "^15.0.9",
 | 
				
			||||||
    "node-cron": "^3.0.3",
 | 
					    "node-cron": "^3.0.3",
 | 
				
			||||||
    "node-imap": "^0.9.6",
 | 
					    "node-imap": "^0.9.6",
 | 
				
			||||||
    "pdfkit": "^0.17.0",
 | 
					    "pdfkit": "^0.17.0",
 | 
				
			||||||
    "pdfmake": "^0.2.18",
 | 
					    "pdfmake": "^0.2.18",
 | 
				
			||||||
 | 
					    "pinia": "^3.0.2",
 | 
				
			||||||
    "quasar": "^2.16.0",
 | 
					    "quasar": "^2.16.0",
 | 
				
			||||||
 | 
					    "uuid": "^11.1.0",
 | 
				
			||||||
    "vue": "^3.4.18",
 | 
					    "vue": "^3.4.18",
 | 
				
			||||||
    "vue-router": "^4.0.0"
 | 
					    "vue-router": "^4.0.0"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "devDependencies": {
 | 
					  "devDependencies": {
 | 
				
			||||||
 | 
					    "@eslint/js": "^9.25.1",
 | 
				
			||||||
    "@quasar/app-vite": "^2.1.0",
 | 
					    "@quasar/app-vite": "^2.1.0",
 | 
				
			||||||
 | 
					    "@stylistic/eslint-plugin": "^4.2.0",
 | 
				
			||||||
 | 
					    "@types/express-session": "^1.18.1",
 | 
				
			||||||
 | 
					    "@types/uuid": "^10.0.0",
 | 
				
			||||||
 | 
					    "@vue/eslint-config-prettier": "^10.2.0",
 | 
				
			||||||
    "autoprefixer": "^10.4.2",
 | 
					    "autoprefixer": "^10.4.2",
 | 
				
			||||||
 | 
					    "eslint": "^9.25.1",
 | 
				
			||||||
 | 
					    "eslint-plugin-vue": "^10.0.0",
 | 
				
			||||||
 | 
					    "globals": "^16.0.0",
 | 
				
			||||||
    "postcss": "^8.4.14",
 | 
					    "postcss": "^8.4.14",
 | 
				
			||||||
    "prisma": "^6.6.0"
 | 
					    "prettier": "^3.5.3",
 | 
				
			||||||
 | 
					    "prisma": "^6.6.0",
 | 
				
			||||||
 | 
					    "vite-plugin-checker": "^0.9.1"
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "engines": {
 | 
					  "engines": {
 | 
				
			||||||
    "node": "^28 || ^26 || ^24 || ^22 || ^20 || ^18",
 | 
					    "node": "^28 || ^26 || ^24 || ^22 || ^20 || ^18",
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										1334
									
								
								pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							
							
						
						| 
						 | 
					@ -1,5 +1,7 @@
 | 
				
			||||||
onlyBuiltDependencies:
 | 
					onlyBuiltDependencies:
 | 
				
			||||||
  - '@prisma/client'
 | 
					  - '@prisma/client'
 | 
				
			||||||
 | 
					  - '@prisma/engines'
 | 
				
			||||||
  - better-sqlite3
 | 
					  - better-sqlite3
 | 
				
			||||||
  - esbuild
 | 
					  - esbuild
 | 
				
			||||||
 | 
					  - prisma
 | 
				
			||||||
  - sqlite3
 | 
					  - sqlite3
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,6 +1,6 @@
 | 
				
			||||||
// https://github.com/michael-ciniawsky/postcss-load-config
 | 
					// https://github.com/michael-ciniawsky/postcss-load-config
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import autoprefixer from 'autoprefixer'
 | 
					import autoprefixer from 'autoprefixer';
 | 
				
			||||||
// import rtlcss from 'postcss-rtlcss'
 | 
					// import rtlcss from 'postcss-rtlcss'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default {
 | 
					export default {
 | 
				
			||||||
| 
						 | 
					@ -26,4 +26,4 @@ export default {
 | 
				
			||||||
    // 3. uncomment the following line (and its import statement above):
 | 
					    // 3. uncomment the following line (and its import statement above):
 | 
				
			||||||
    // rtlcss()
 | 
					    // rtlcss()
 | 
				
			||||||
  ]
 | 
					  ]
 | 
				
			||||||
}
 | 
					};
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										166
									
								
								prisma/migrations/20250425072554_init/migration.sql
									
										
									
									
									
										Normal 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;
 | 
				
			||||||
							
								
								
									
										3
									
								
								prisma/migrations/migration_lock.toml
									
										
									
									
									
										Normal 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"
 | 
				
			||||||
| 
						 | 
					@ -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 {
 | 
					generator client {
 | 
				
			||||||
  provider = "prisma-client-js"
 | 
					  provider = "prisma-client-js"
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -20,8 +14,6 @@ model Form {
 | 
				
			||||||
  createdAt   DateTime   @default(now()) @map("created_at")
 | 
					  createdAt   DateTime   @default(now()) @map("created_at")
 | 
				
			||||||
  categories  Category[]
 | 
					  categories  Category[]
 | 
				
			||||||
  responses   Response[]
 | 
					  responses   Response[]
 | 
				
			||||||
 | 
					 | 
				
			||||||
  @@map("forms") // Map to the 'forms' table
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
model Category {
 | 
					model Category {
 | 
				
			||||||
| 
						 | 
					@ -31,8 +23,6 @@ model Category {
 | 
				
			||||||
  sortOrder Int     @default(0) @map("sort_order")
 | 
					  sortOrder Int     @default(0) @map("sort_order")
 | 
				
			||||||
  form      Form    @relation(fields: [formId], references: [id], onDelete: Cascade)
 | 
					  form      Form    @relation(fields: [formId], references: [id], onDelete: Cascade)
 | 
				
			||||||
  fields    Field[]
 | 
					  fields    Field[]
 | 
				
			||||||
 | 
					 | 
				
			||||||
  @@map("categories") // Map to the 'categories' table
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
enum FieldType {
 | 
					enum FieldType {
 | 
				
			||||||
| 
						 | 
					@ -47,13 +37,11 @@ model Field {
 | 
				
			||||||
  id             Int             @id @default(autoincrement())
 | 
					  id             Int             @id @default(autoincrement())
 | 
				
			||||||
  categoryId     Int             @map("category_id")
 | 
					  categoryId     Int             @map("category_id")
 | 
				
			||||||
  label          String
 | 
					  label          String
 | 
				
			||||||
  type         FieldType // Using Prisma Enum based on CHECK constraint
 | 
					  type           FieldType
 | 
				
			||||||
  description    String?
 | 
					  description    String?
 | 
				
			||||||
  sortOrder      Int             @map("sort_order")
 | 
					  sortOrder      Int             @map("sort_order")
 | 
				
			||||||
  category       Category        @relation(fields: [categoryId], references: [id], onDelete: Cascade)
 | 
					  category       Category        @relation(fields: [categoryId], references: [id], onDelete: Cascade)
 | 
				
			||||||
  responseValues ResponseValue[]
 | 
					  responseValues ResponseValue[]
 | 
				
			||||||
 | 
					 | 
				
			||||||
  @@map("fields") // Map to the 'fields' table
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
model Response {
 | 
					model Response {
 | 
				
			||||||
| 
						 | 
					@ -62,8 +50,6 @@ model Response {
 | 
				
			||||||
  submittedAt    DateTime        @default(now()) @map("submitted_at")
 | 
					  submittedAt    DateTime        @default(now()) @map("submitted_at")
 | 
				
			||||||
  form           Form            @relation(fields: [formId], references: [id], onDelete: Cascade)
 | 
					  form           Form            @relation(fields: [formId], references: [id], onDelete: Cascade)
 | 
				
			||||||
  responseValues ResponseValue[]
 | 
					  responseValues ResponseValue[]
 | 
				
			||||||
 | 
					 | 
				
			||||||
  @@map("responses") // Map to the 'responses' table
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
model ResponseValue {
 | 
					model ResponseValue {
 | 
				
			||||||
| 
						 | 
					@ -73,29 +59,65 @@ model ResponseValue {
 | 
				
			||||||
  value      String?
 | 
					  value      String?
 | 
				
			||||||
  response   Response @relation(fields: [responseId], references: [id], onDelete: Cascade)
 | 
					  response   Response @relation(fields: [responseId], references: [id], onDelete: Cascade)
 | 
				
			||||||
  field      Field    @relation(fields: [fieldId], references: [id], onDelete: Cascade)
 | 
					  field      Field    @relation(fields: [fieldId], references: [id], onDelete: Cascade)
 | 
				
			||||||
 | 
					 | 
				
			||||||
  @@map("response_values") // Map to the 'response_values' table
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
model MantisSummary {
 | 
					model MantisSummary {
 | 
				
			||||||
  id          Int      @id @default(autoincrement())
 | 
					  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")
 | 
					  summaryText String   @map("summary_text")
 | 
				
			||||||
  generatedAt DateTime @default(now()) @map("generated_at")
 | 
					  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 {
 | 
					model Setting {
 | 
				
			||||||
  key   String @id
 | 
					  key   String @id
 | 
				
			||||||
  value String
 | 
					  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)
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
		 Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 128 KiB  | 
| 
		 Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 8.2 KiB  | 
| 
		 Before Width: | Height: | Size: 859 B After Width: | Height: | Size: 1 KiB  | 
| 
		 Before Width: | Height: | Size: 2 KiB After Width: | Height: | Size: 1.9 KiB  | 
| 
		 Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 5.8 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								public/stylepoint.png
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 662 KiB  | 
| 
						 | 
					@ -1,9 +1,10 @@
 | 
				
			||||||
// Configuration for your app
 | 
					// Configuration for your app
 | 
				
			||||||
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file
 | 
					// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { defineConfig } from '#q-app/wrappers'
 | 
					import { defineConfig } from '#q-app/wrappers';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default defineConfig((/* ctx */) => {
 | 
					export default defineConfig((/* ctx */) => 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
  return {
 | 
					  return {
 | 
				
			||||||
    // https://v2.quasar.dev/quasar-cli-vite/prefetch-feature
 | 
					    // https://v2.quasar.dev/quasar-cli-vite/prefetch-feature
 | 
				
			||||||
    // preFetch: true,
 | 
					    // preFetch: true,
 | 
				
			||||||
| 
						 | 
					@ -62,6 +63,14 @@ export default defineConfig((/* ctx */) => {
 | 
				
			||||||
      // vitePlugins: [
 | 
					      // vitePlugins: [
 | 
				
			||||||
      //   [ 'package-name', { ..pluginOptions.. }, { server: true, client: true } ]
 | 
					      //   [ 'package-name', { ..pluginOptions.. }, { server: true, client: true } ]
 | 
				
			||||||
      // ]
 | 
					      // ]
 | 
				
			||||||
 | 
					      vitePlugins: [
 | 
				
			||||||
 | 
					        ['vite-plugin-checker', {
 | 
				
			||||||
 | 
					          eslint: {
 | 
				
			||||||
 | 
					            lintCommand: 'eslint -c ./eslint.config.js "./src*/**/*.{js,mjs,cjs,vue}"',
 | 
				
			||||||
 | 
					            useFlatConfig: true
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        }, { server: false }]
 | 
				
			||||||
 | 
					      ]
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#devserver
 | 
					    // Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#devserver
 | 
				
			||||||
| 
						 | 
					@ -208,5 +217,5 @@ export default defineConfig((/* ctx */) => {
 | 
				
			||||||
       */
 | 
					       */
 | 
				
			||||||
      extraScripts: []
 | 
					      extraScripts: []
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  };
 | 
				
			||||||
})
 | 
					});
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -5,10 +5,3 @@ const prisma = new PrismaClient();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Export the Prisma Client instance for use in other modules
 | 
					// Export the Prisma Client instance for use in other modules
 | 
				
			||||||
export default prisma;
 | 
					export default prisma;
 | 
				
			||||||
 | 
					 | 
				
			||||||
// --- Old better-sqlite3 code removed ---
 | 
					 | 
				
			||||||
// No need for initializeDatabase, getDb, closeDatabase, etc.
 | 
					 | 
				
			||||||
// Prisma Client manages the connection pool.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// --- Settings Functions removed ---
 | 
					 | 
				
			||||||
// Settings can now be accessed via prisma.setting.findUnique, prisma.setting.upsert, etc.
 | 
					 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										12
									
								
								src-ssr/middlewares/authMiddleware.js
									
										
									
									
									
										Normal 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();
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -1,45 +1,57 @@
 | 
				
			||||||
import { defineSsrMiddleware } from '#q-app/wrappers'
 | 
					import { defineSsrMiddleware } from '#q-app/wrappers';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// This middleware should execute as last one
 | 
					// This middleware should execute as last one
 | 
				
			||||||
// since it captures everything and tries to
 | 
					// since it captures everything and tries to
 | 
				
			||||||
// render the page with Vue
 | 
					// render the page with Vue
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default defineSsrMiddleware(({ app, resolve, render, serve }) => {
 | 
					export default defineSsrMiddleware(({ app, resolve, render, serve }) => 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
  // we capture any other Express route and hand it
 | 
					  // we capture any other Express route and hand it
 | 
				
			||||||
  // over to Vue and Vue Router to render our page
 | 
					  // over to Vue and Vue Router to render our page
 | 
				
			||||||
  app.get(resolve.urlPath('*'), (req, res) => {
 | 
					  app.get(resolve.urlPath('*'), (req, res) => 
 | 
				
			||||||
    res.setHeader('Content-Type', 'text/html')
 | 
					  {
 | 
				
			||||||
 | 
					    res.setHeader('Content-Type', 'text/html');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    render(/* the ssrContext: */ { req, res })
 | 
					    render(/* the ssrContext: */ { req, res })
 | 
				
			||||||
      .then(html => {
 | 
					      .then(html => 
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
        // now let's send the rendered html to the client
 | 
					        // now let's send the rendered html to the client
 | 
				
			||||||
        res.send(html)
 | 
					        res.send(html);
 | 
				
			||||||
      })
 | 
					      })
 | 
				
			||||||
      .catch(err => {
 | 
					      .catch(err => 
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
        // oops, we had an error while rendering the page
 | 
					        // oops, we had an error while rendering the page
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        // we were told to redirect to another URL
 | 
					        // we were told to redirect to another URL
 | 
				
			||||||
        if (err.url) {
 | 
					        if (err.url) 
 | 
				
			||||||
          if (err.code) {
 | 
					        {
 | 
				
			||||||
            res.redirect(err.code, err.url)
 | 
					          if (err.code) 
 | 
				
			||||||
          } else {
 | 
					          {
 | 
				
			||||||
            res.redirect(err.url)
 | 
					            res.redirect(err.code, err.url);
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
        } else if (err.code === 404) {
 | 
					          else 
 | 
				
			||||||
          // hmm, Vue Router could not find the requested route
 | 
					          {
 | 
				
			||||||
 | 
					            res.redirect(err.url);
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        else if (err.code === 404) 
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
          // Should reach here only if no "catch-all" route
 | 
					          // Should reach here only if no "catch-all" route
 | 
				
			||||||
          // is defined in /src/routes
 | 
					          // is defined in /src/routes
 | 
				
			||||||
          res.status(404).send('404 | Page Not Found')
 | 
					          res.status(404).send('404 | Page Not Found');
 | 
				
			||||||
        } else if (process.env.DEV) {
 | 
					        }
 | 
				
			||||||
 | 
					        else if (process.env.DEV) 
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
          // well, we treat any other code as error;
 | 
					          // well, we treat any other code as error;
 | 
				
			||||||
          // if we're in dev mode, then we can use Quasar CLI
 | 
					          // if we're in dev mode, then we can use Quasar CLI
 | 
				
			||||||
          // to display a nice error page that contains the stack
 | 
					          // to display a nice error page that contains the stack
 | 
				
			||||||
          // and other useful information
 | 
					          // and other useful information
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          // serve.error is available on dev only
 | 
					          // serve.error is available on dev only
 | 
				
			||||||
          serve.error({ err, req, res })
 | 
					          serve.error({ err, req, res });
 | 
				
			||||||
        } else {
 | 
					        }
 | 
				
			||||||
 | 
					        else 
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
          // we're in production, so we should have another method
 | 
					          // we're in production, so we should have another method
 | 
				
			||||||
          // to display something to the client when we encounter an error
 | 
					          // to display something to the client when we encounter an error
 | 
				
			||||||
          // (for security reasons, it's not ok to display the same wealth
 | 
					          // (for security reasons, it's not ok to display the same wealth
 | 
				
			||||||
| 
						 | 
					@ -47,12 +59,13 @@ export default defineSsrMiddleware(({ app, resolve, render, serve }) => {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          // Render Error Page on production or
 | 
					          // Render Error Page on production or
 | 
				
			||||||
          // create a route (/src/routes) for an error page and redirect to it
 | 
					          // create a route (/src/routes) for an error page and redirect to it
 | 
				
			||||||
          res.status(500).send('500 | Internal Server Error')
 | 
					          res.status(500).send('500 | Internal Server Error');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          if (process.env.DEBUGGING) {
 | 
					          if (process.env.DEBUGGING) 
 | 
				
			||||||
            console.error(err.stack)
 | 
					          {
 | 
				
			||||||
 | 
					            console.error(err.stack);
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
      })
 | 
					      });
 | 
				
			||||||
  })
 | 
					  });
 | 
				
			||||||
})
 | 
					});
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,20 +1,21 @@
 | 
				
			||||||
import { Router } from 'express';
 | 
					import { Router } from 'express';
 | 
				
			||||||
import prisma from '../database.js'; // Import Prisma client
 | 
					import prisma from '../database.js';
 | 
				
			||||||
import PDFDocument from 'pdfkit';
 | 
					import PDFDocument from 'pdfkit';
 | 
				
			||||||
import { join } from 'path';
 | 
					import { join } from 'path';
 | 
				
			||||||
import { generateTodaysSummary } from '../services/mantisSummarizer.js'; // Keep mantisSummarizer import
 | 
					import { generateTodaysSummary } from '../services/mantisSummarizer.js';
 | 
				
			||||||
import { generateAndStoreEmailSummary } from '../services/emailSummarizer.js'; // Import email summarizer function
 | 
					import { FieldType } from '@prisma/client';
 | 
				
			||||||
import { FieldType } from '@prisma/client'; // Import generated FieldType enum
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const router = Router();
 | 
					const router = Router();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const __dirname = new URL('.', import.meta.url).pathname.replace(/\/$/, '');
 | 
					const __dirname = new URL('.', import.meta.url).pathname.replace(/\/$/, '');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Helper function for consistent error handling
 | 
					// Helper function for consistent error handling
 | 
				
			||||||
const handlePrismaError = (res, err, context) => {
 | 
					const handlePrismaError = (res, err, context) => 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
  console.error(`Error ${context}:`, err.message);
 | 
					  console.error(`Error ${context}:`, err.message);
 | 
				
			||||||
  // Basic error handling, can be expanded (e.g., check for Prisma-specific error codes)
 | 
					  // Basic error handling, can be expanded (e.g., check for Prisma-specific error codes)
 | 
				
			||||||
  if (err.code === 'P2025') { // Prisma code for record not found
 | 
					  if (err.code === 'P2025') 
 | 
				
			||||||
 | 
					  { // Prisma code for record not found
 | 
				
			||||||
    return res.status(404).json({ error: `${context}: Record not found` });
 | 
					    return res.status(404).json({ error: `${context}: Record not found` });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  res.status(500).json({ error: `Failed to ${context}: ${err.message}` });
 | 
					  res.status(500).json({ error: `Failed to ${context}: ${err.message}` });
 | 
				
			||||||
| 
						 | 
					@ -23,8 +24,10 @@ const handlePrismaError = (res, err, context) => {
 | 
				
			||||||
// --- Forms API --- //
 | 
					// --- Forms API --- //
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// GET /api/forms - List all forms
 | 
					// GET /api/forms - List all forms
 | 
				
			||||||
router.get('/forms', async (req, res) => {
 | 
					router.get('/forms', async(req, res) => 
 | 
				
			||||||
  try {
 | 
					{
 | 
				
			||||||
 | 
					  try 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    const forms = await prisma.form.findMany({
 | 
					    const forms = await prisma.form.findMany({
 | 
				
			||||||
      orderBy: {
 | 
					      orderBy: {
 | 
				
			||||||
        createdAt: 'desc',
 | 
					        createdAt: 'desc',
 | 
				
			||||||
| 
						 | 
					@ -37,20 +40,25 @@ router.get('/forms', async (req, res) => {
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
    res.json(forms);
 | 
					    res.json(forms);
 | 
				
			||||||
  } catch (err) {
 | 
					  }
 | 
				
			||||||
 | 
					  catch (err) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    handlePrismaError(res, err, 'fetch forms');
 | 
					    handlePrismaError(res, err, 'fetch forms');
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// POST /api/forms - Create a new form
 | 
					// POST /api/forms - Create a new form
 | 
				
			||||||
router.post('/forms', async (req, res) => {
 | 
					router.post('/forms', async(req, res) => 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
  const { title, description, categories } = req.body;
 | 
					  const { title, description, categories } = req.body;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (!title) {
 | 
					  if (!title) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    return res.status(400).json({ error: 'Form title is required' });
 | 
					    return res.status(400).json({ error: 'Form title is required' });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  try {
 | 
					  try 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    const newForm = await prisma.form.create({
 | 
					    const newForm = await prisma.form.create({
 | 
				
			||||||
      data: {
 | 
					      data: {
 | 
				
			||||||
        title,
 | 
					        title,
 | 
				
			||||||
| 
						 | 
					@ -60,12 +68,15 @@ router.post('/forms', async (req, res) => {
 | 
				
			||||||
            name: category.name,
 | 
					            name: category.name,
 | 
				
			||||||
            sortOrder: catIndex,
 | 
					            sortOrder: catIndex,
 | 
				
			||||||
            fields: {
 | 
					            fields: {
 | 
				
			||||||
              create: category.fields?.map((field, fieldIndex) => {
 | 
					              create: category.fields?.map((field, fieldIndex) => 
 | 
				
			||||||
 | 
					              {
 | 
				
			||||||
                // Validate field type against Prisma Enum
 | 
					                // Validate field type against Prisma Enum
 | 
				
			||||||
                if (!Object.values(FieldType).includes(field.type)) {
 | 
					                if (!Object.values(FieldType).includes(field.type)) 
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
                  throw new Error(`Invalid field type: ${field.type}`);
 | 
					                  throw new Error(`Invalid field type: ${field.type}`);
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
                if (!field.label) {
 | 
					                if (!field.label) 
 | 
				
			||||||
 | 
					                {
 | 
				
			||||||
                  throw new Error('Field label is required');
 | 
					                  throw new Error('Field label is required');
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
                return {
 | 
					                return {
 | 
				
			||||||
| 
						 | 
					@ -86,21 +97,26 @@ router.post('/forms', async (req, res) => {
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
    res.status(201).json(newForm);
 | 
					    res.status(201).json(newForm);
 | 
				
			||||||
  } catch (err) {
 | 
					  }
 | 
				
			||||||
 | 
					  catch (err) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    handlePrismaError(res, err, 'create form');
 | 
					    handlePrismaError(res, err, 'create form');
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// GET /api/forms/:id - Get a specific form with its structure
 | 
					// GET /api/forms/:id - Get a specific form with its structure
 | 
				
			||||||
router.get('/forms/:id', async (req, res) => {
 | 
					router.get('/forms/:id', async(req, res) => 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
  const { id } = req.params;
 | 
					  const { id } = req.params;
 | 
				
			||||||
  const formId = parseInt(id, 10);
 | 
					  const formId = parseInt(id, 10);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (isNaN(formId)) {
 | 
					  if (isNaN(formId)) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    return res.status(400).json({ error: 'Invalid form ID' });
 | 
					    return res.status(400).json({ error: 'Invalid form ID' });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  try {
 | 
					  try 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    const form = await prisma.form.findUnique({
 | 
					    const form = await prisma.form.findUnique({
 | 
				
			||||||
      where: { id: formId },
 | 
					      where: { id: formId },
 | 
				
			||||||
      include: {
 | 
					      include: {
 | 
				
			||||||
| 
						 | 
					@ -115,54 +131,68 @@ router.get('/forms/:id', async (req, res) => {
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (!form) {
 | 
					    if (!form) 
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
      return res.status(404).json({ error: 'Form not found' });
 | 
					      return res.status(404).json({ error: 'Form not found' });
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    res.json(form);
 | 
					    res.json(form);
 | 
				
			||||||
  } catch (err) {
 | 
					  }
 | 
				
			||||||
 | 
					  catch (err) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    handlePrismaError(res, err, `fetch form ${formId}`);
 | 
					    handlePrismaError(res, err, `fetch form ${formId}`);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// DELETE /api/forms/:id - Delete a specific form and all related data
 | 
					// DELETE /api/forms/:id - Delete a specific form and all related data
 | 
				
			||||||
router.delete('/forms/:id', async (req, res) => {
 | 
					router.delete('/forms/:id', async(req, res) => 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
  const { id } = req.params;
 | 
					  const { id } = req.params;
 | 
				
			||||||
  const formId = parseInt(id, 10);
 | 
					  const formId = parseInt(id, 10);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (isNaN(formId)) {
 | 
					  if (isNaN(formId)) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    return res.status(400).json({ error: 'Invalid form ID' });
 | 
					    return res.status(400).json({ error: 'Invalid form ID' });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  try {
 | 
					  try 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    // Prisma automatically handles cascading deletes based on schema relations (onDelete: Cascade)
 | 
					    // Prisma automatically handles cascading deletes based on schema relations (onDelete: Cascade)
 | 
				
			||||||
    const deletedForm = await prisma.form.delete({
 | 
					    const deletedForm = await prisma.form.delete({
 | 
				
			||||||
      where: { id: formId },
 | 
					      where: { id: formId },
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
    res.status(200).json({ message: `Form ${formId} and all related data deleted successfully.` });
 | 
					    res.status(200).json({ message: `Form ${formId} and all related data deleted successfully.` });
 | 
				
			||||||
  } catch (err) {
 | 
					  }
 | 
				
			||||||
 | 
					  catch (err) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    handlePrismaError(res, err, `delete form ${formId}`);
 | 
					    handlePrismaError(res, err, `delete form ${formId}`);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// PUT /api/forms/:id - Update an existing form
 | 
					// PUT /api/forms/:id - Update an existing form
 | 
				
			||||||
router.put('/forms/:id', async (req, res) => {
 | 
					router.put('/forms/:id', async(req, res) => 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
  const { id } = req.params;
 | 
					  const { id } = req.params;
 | 
				
			||||||
  const formId = parseInt(id, 10);
 | 
					  const formId = parseInt(id, 10);
 | 
				
			||||||
  const { title, description, categories } = req.body;
 | 
					  const { title, description, categories } = req.body;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (isNaN(formId)) {
 | 
					  if (isNaN(formId)) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    return res.status(400).json({ error: 'Invalid form ID' });
 | 
					    return res.status(400).json({ error: 'Invalid form ID' });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  if (!title) {
 | 
					  if (!title) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    return res.status(400).json({ error: 'Form title is required' });
 | 
					    return res.status(400).json({ error: 'Form title is required' });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  try {
 | 
					  try 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    // Use a transaction to ensure atomicity: delete old structure, update form, create new structure
 | 
					    // Use a transaction to ensure atomicity: delete old structure, update form, create new structure
 | 
				
			||||||
    const result = await prisma.$transaction(async (tx) => {
 | 
					    const result = await prisma.$transaction(async(tx) => 
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
      // 1. Check if form exists (optional, delete/update will fail if not found anyway)
 | 
					      // 1. Check if form exists (optional, delete/update will fail if not found anyway)
 | 
				
			||||||
      const existingForm = await tx.form.findUnique({ where: { id: formId } });
 | 
					      const existingForm = await tx.form.findUnique({ where: { id: formId } });
 | 
				
			||||||
      if (!existingForm) {
 | 
					      if (!existingForm) 
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
        throw { code: 'P2025' }; // Simulate Prisma not found error
 | 
					        throw { code: 'P2025' }; // Simulate Prisma not found error
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -180,11 +210,14 @@ router.put('/forms/:id', async (req, res) => {
 | 
				
			||||||
              name: category.name,
 | 
					              name: category.name,
 | 
				
			||||||
              sortOrder: catIndex,
 | 
					              sortOrder: catIndex,
 | 
				
			||||||
              fields: {
 | 
					              fields: {
 | 
				
			||||||
                create: category.fields?.map((field, fieldIndex) => {
 | 
					                create: category.fields?.map((field, fieldIndex) => 
 | 
				
			||||||
                  if (!Object.values(FieldType).includes(field.type)) {
 | 
					                {
 | 
				
			||||||
 | 
					                  if (!Object.values(FieldType).includes(field.type)) 
 | 
				
			||||||
 | 
					                  {
 | 
				
			||||||
                    throw new Error(`Invalid field type: ${field.type}`);
 | 
					                    throw new Error(`Invalid field type: ${field.type}`);
 | 
				
			||||||
                  }
 | 
					                  }
 | 
				
			||||||
                  if (!field.label) {
 | 
					                  if (!field.label) 
 | 
				
			||||||
 | 
					                  {
 | 
				
			||||||
                    throw new Error('Field label is required');
 | 
					                    throw new Error('Field label is required');
 | 
				
			||||||
                  }
 | 
					                  }
 | 
				
			||||||
                  return {
 | 
					                  return {
 | 
				
			||||||
| 
						 | 
					@ -208,7 +241,9 @@ router.put('/forms/:id', async (req, res) => {
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    res.status(200).json(result);
 | 
					    res.status(200).json(result);
 | 
				
			||||||
  } catch (err) {
 | 
					  }
 | 
				
			||||||
 | 
					  catch (err) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    handlePrismaError(res, err, `update form ${formId}`);
 | 
					    handlePrismaError(res, err, `update form ${formId}`);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
| 
						 | 
					@ -217,24 +252,30 @@ router.put('/forms/:id', async (req, res) => {
 | 
				
			||||||
// --- Responses API --- //
 | 
					// --- Responses API --- //
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// POST /api/forms/:id/responses - Submit a response for a form
 | 
					// POST /api/forms/:id/responses - Submit a response for a form
 | 
				
			||||||
router.post('/forms/:id/responses', async (req, res) => {
 | 
					router.post('/forms/:id/responses', async(req, res) => 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
  const { id } = req.params;
 | 
					  const { id } = req.params;
 | 
				
			||||||
  const formId = parseInt(id, 10);
 | 
					  const formId = parseInt(id, 10);
 | 
				
			||||||
  const { values } = req.body; // values is expected to be { fieldId: value, ... }
 | 
					  const { values } = req.body; // values is expected to be { fieldId: value, ... }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (isNaN(formId)) {
 | 
					  if (isNaN(formId)) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    return res.status(400).json({ error: 'Invalid form ID' });
 | 
					    return res.status(400).json({ error: 'Invalid form ID' });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  if (!values || typeof values !== 'object' || Object.keys(values).length === 0) {
 | 
					  if (!values || typeof values !== 'object' || Object.keys(values).length === 0) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    return res.status(400).json({ error: 'Response values are required' });
 | 
					    return res.status(400).json({ error: 'Response values are required' });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  try {
 | 
					  try 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    // Use transaction to ensure response and values are created together
 | 
					    // Use transaction to ensure response and values are created together
 | 
				
			||||||
    const result = await prisma.$transaction(async (tx) => {
 | 
					    const result = await prisma.$transaction(async(tx) => 
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
      // 1. Verify form exists
 | 
					      // 1. Verify form exists
 | 
				
			||||||
      const form = await tx.form.findUnique({ where: { id: formId }, select: { id: true } });
 | 
					      const form = await tx.form.findUnique({ where: { id: formId }, select: { id: true } });
 | 
				
			||||||
      if (!form) {
 | 
					      if (!form) 
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
        throw { code: 'P2025' }; // Simulate Prisma not found error
 | 
					        throw { code: 'P2025' }; // Simulate Prisma not found error
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -253,23 +294,27 @@ router.post('/forms/:id/responses', async (req, res) => {
 | 
				
			||||||
      // Optional: Verify all field IDs belong to the form (more robust)
 | 
					      // Optional: Verify all field IDs belong to the form (more robust)
 | 
				
			||||||
      const validFields = await tx.field.findMany({
 | 
					      const validFields = await tx.field.findMany({
 | 
				
			||||||
        where: {
 | 
					        where: {
 | 
				
			||||||
          id: { in: fieldIds },
 | 
					          id: { 'in': fieldIds },
 | 
				
			||||||
          category: { formId: formId }
 | 
					          category: { formId: formId }
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        select: { id: true }
 | 
					        select: { id: true }
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
      const validFieldIds = new Set(validFields.map(f => f.id));
 | 
					      const validFieldIds = new Set(validFields.map(f => f.id));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      for (const fieldIdStr in values) {
 | 
					      for (const fieldIdStr in values) 
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
        const fieldId = parseInt(fieldIdStr, 10);
 | 
					        const fieldId = parseInt(fieldIdStr, 10);
 | 
				
			||||||
        if (validFieldIds.has(fieldId)) {
 | 
					        if (validFieldIds.has(fieldId)) 
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
          const value = values[fieldIdStr];
 | 
					          const value = values[fieldIdStr];
 | 
				
			||||||
          responseValuesData.push({
 | 
					          responseValuesData.push({
 | 
				
			||||||
            responseId: newResponse.id,
 | 
					            responseId: newResponse.id,
 | 
				
			||||||
            fieldId: fieldId,
 | 
					            fieldId: fieldId,
 | 
				
			||||||
            value: (value === null || typeof value === 'undefined') ? null : String(value),
 | 
					            value: (value === null || typeof value === 'undefined') ? null : String(value),
 | 
				
			||||||
          });
 | 
					          });
 | 
				
			||||||
        } else {
 | 
					        }
 | 
				
			||||||
 | 
					        else 
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
          console.warn(`Attempted to submit value for field ${fieldId} not belonging to form ${formId}`);
 | 
					          console.warn(`Attempted to submit value for field ${fieldId} not belonging to form ${formId}`);
 | 
				
			||||||
          // Decide whether to throw an error or just skip invalid fields
 | 
					          // Decide whether to throw an error or just skip invalid fields
 | 
				
			||||||
          // throw new Error(`Field ${fieldId} does not belong to form ${formId}`);
 | 
					          // throw new Error(`Field ${fieldId} does not belong to form ${formId}`);
 | 
				
			||||||
| 
						 | 
					@ -277,7 +322,8 @@ router.post('/forms/:id/responses', async (req, res) => {
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      // 4. Create all response values
 | 
					      // 4. Create all response values
 | 
				
			||||||
      if (responseValuesData.length > 0) {
 | 
					      if (responseValuesData.length > 0) 
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
        await tx.responseValue.createMany({
 | 
					        await tx.responseValue.createMany({
 | 
				
			||||||
          data: responseValuesData,
 | 
					          data: responseValuesData,
 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
| 
						 | 
					@ -287,24 +333,30 @@ router.post('/forms/:id/responses', async (req, res) => {
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    res.status(201).json(result);
 | 
					    res.status(201).json(result);
 | 
				
			||||||
  } catch (err) {
 | 
					  }
 | 
				
			||||||
 | 
					  catch (err) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    handlePrismaError(res, err, `submit response for form ${formId}`);
 | 
					    handlePrismaError(res, err, `submit response for form ${formId}`);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// GET /api/forms/:id/responses - Get all responses for a form
 | 
					// GET /api/forms/:id/responses - Get all responses for a form
 | 
				
			||||||
router.get('/forms/:id/responses', async (req, res) => {
 | 
					router.get('/forms/:id/responses', async(req, res) => 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
  const { id } = req.params;
 | 
					  const { id } = req.params;
 | 
				
			||||||
  const formId = parseInt(id, 10);
 | 
					  const formId = parseInt(id, 10);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (isNaN(formId)) {
 | 
					  if (isNaN(formId)) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    return res.status(400).json({ error: 'Invalid form ID' });
 | 
					    return res.status(400).json({ error: 'Invalid form ID' });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  try {
 | 
					  try 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    // 1. Check if form exists
 | 
					    // 1. Check if form exists
 | 
				
			||||||
    const formExists = await prisma.form.findUnique({ where: { id: formId }, select: { id: true } });
 | 
					    const formExists = await prisma.form.findUnique({ where: { id: formId }, select: { id: true } });
 | 
				
			||||||
    if (!formExists) {
 | 
					    if (!formExists) 
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
      return res.status(404).json({ error: 'Form not found' });
 | 
					      return res.status(404).json({ error: 'Form not found' });
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -328,13 +380,15 @@ router.get('/forms/:id/responses', async (req, res) => {
 | 
				
			||||||
      id: response.id,
 | 
					      id: response.id,
 | 
				
			||||||
      submittedAt: response.submittedAt,
 | 
					      submittedAt: response.submittedAt,
 | 
				
			||||||
      values: response.responseValues
 | 
					      values: response.responseValues
 | 
				
			||||||
        .sort((a, b) => {
 | 
					        .sort((a, b) => 
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
          // Sort by category order, then field order
 | 
					          // Sort by category order, then field order
 | 
				
			||||||
          const catSort = a.field.category.sortOrder - b.field.category.sortOrder;
 | 
					          const catSort = a.field.category.sortOrder - b.field.category.sortOrder;
 | 
				
			||||||
          if (catSort !== 0) return catSort;
 | 
					          if (catSort !== 0) return catSort;
 | 
				
			||||||
          return a.field.sortOrder - b.field.sortOrder;
 | 
					          return a.field.sortOrder - b.field.sortOrder;
 | 
				
			||||||
        })
 | 
					        })
 | 
				
			||||||
        .reduce((acc, rv) => {
 | 
					        .reduce((acc, rv) => 
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
          acc[rv.fieldId] = {
 | 
					          acc[rv.fieldId] = {
 | 
				
			||||||
            label: rv.field.label,
 | 
					            label: rv.field.label,
 | 
				
			||||||
            type: rv.field.type,
 | 
					            type: rv.field.type,
 | 
				
			||||||
| 
						 | 
					@ -345,22 +399,27 @@ router.get('/forms/:id/responses', async (req, res) => {
 | 
				
			||||||
    }));
 | 
					    }));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    res.json(groupedResponses);
 | 
					    res.json(groupedResponses);
 | 
				
			||||||
  } catch (err) {
 | 
					  }
 | 
				
			||||||
 | 
					  catch (err) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    handlePrismaError(res, err, `fetch responses for form ${formId}`);
 | 
					    handlePrismaError(res, err, `fetch responses for form ${formId}`);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// GET /responses/:responseId/export/pdf - Export response as PDF
 | 
					// GET /responses/:responseId/export/pdf - Export response as PDF
 | 
				
			||||||
router.get('/responses/:responseId/export/pdf', async (req, res) => {
 | 
					router.get('/responses/:responseId/export/pdf', async(req, res) => 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
  const { responseId: responseIdStr } = req.params;
 | 
					  const { responseId: responseIdStr } = req.params;
 | 
				
			||||||
  const responseId = parseInt(responseIdStr, 10);
 | 
					  const responseId = parseInt(responseIdStr, 10);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (isNaN(responseId)) {
 | 
					  if (isNaN(responseId)) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    return res.status(400).json({ error: 'Invalid response ID' });
 | 
					    return res.status(400).json({ error: 'Invalid response ID' });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  try {
 | 
					  try 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    // 1. Fetch the response, form title, form structure, and values in one go
 | 
					    // 1. Fetch the response, form title, form structure, and values in one go
 | 
				
			||||||
    const responseData = await prisma.response.findUnique({
 | 
					    const responseData = await prisma.response.findUnique({
 | 
				
			||||||
      where: { id: responseId },
 | 
					      where: { id: responseId },
 | 
				
			||||||
| 
						 | 
					@ -385,13 +444,15 @@ router.get('/responses/:responseId/export/pdf', async (req, res) => {
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (!responseData) {
 | 
					    if (!responseData) 
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
      return res.status(404).json({ error: 'Response not found' });
 | 
					      return res.status(404).json({ error: 'Response not found' });
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const formTitle = responseData.form.title;
 | 
					    const formTitle = responseData.form.title;
 | 
				
			||||||
    const categories = responseData.form.categories;
 | 
					    const categories = responseData.form.categories;
 | 
				
			||||||
    const responseValues = responseData.responseValues.reduce((acc, rv) => {
 | 
					    const responseValues = responseData.responseValues.reduce((acc, rv) => 
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
      acc[rv.fieldId] = (rv.value === null || typeof rv.value === 'undefined') ? '' : String(rv.value);
 | 
					      acc[rv.fieldId] = (rv.value === null || typeof rv.value === 'undefined') ? '' : String(rv.value);
 | 
				
			||||||
      return acc;
 | 
					      return acc;
 | 
				
			||||||
    }, {});
 | 
					    }, {});
 | 
				
			||||||
| 
						 | 
					@ -414,39 +475,53 @@ router.get('/responses/:responseId/export/pdf', async (req, res) => {
 | 
				
			||||||
    doc.fontSize(18).font('Roboto-Bold').text(formTitle, { align: 'center' });
 | 
					    doc.fontSize(18).font('Roboto-Bold').text(formTitle, { align: 'center' });
 | 
				
			||||||
    doc.moveDown();
 | 
					    doc.moveDown();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    for (const category of categories) {
 | 
					    for (const category of categories) 
 | 
				
			||||||
      if (category.name) {
 | 
					    {
 | 
				
			||||||
 | 
					      if (category.name) 
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
        doc.fontSize(14).font('Roboto-Bold').text(category.name);
 | 
					        doc.fontSize(14).font('Roboto-Bold').text(category.name);
 | 
				
			||||||
        doc.moveDown(0.5);
 | 
					        doc.moveDown(0.5);
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      for (const field of category.fields) {
 | 
					      for (const field of category.fields) 
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
        const value = responseValues[field.id] || '';
 | 
					        const value = responseValues[field.id] || '';
 | 
				
			||||||
        doc.fontSize(12).font('Roboto-SemiBold').text(field.label + ':', { continued: false });
 | 
					        doc.fontSize(12).font('Roboto-SemiBold').text(field.label + ':', { continued: false });
 | 
				
			||||||
        if (field.description) {
 | 
					        if (field.description) 
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
          doc.fontSize(9).font('Roboto-Italics').text(field.description);
 | 
					          doc.fontSize(9).font('Roboto-Italics').text(field.description);
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        doc.moveDown(0.2);
 | 
					        doc.moveDown(0.2);
 | 
				
			||||||
        doc.fontSize(11).font('Roboto-Regular');
 | 
					        doc.fontSize(11).font('Roboto-Regular');
 | 
				
			||||||
        if (field.type === 'textarea') {
 | 
					        if (field.type === 'textarea') 
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
          const textHeight = doc.heightOfString(value, { width: 500 });
 | 
					          const textHeight = doc.heightOfString(value, { width: 500 });
 | 
				
			||||||
          doc.rect(doc.x, doc.y, 500, Math.max(textHeight + 10, 30)).stroke();
 | 
					          doc.rect(doc.x, doc.y, 500, Math.max(textHeight + 10, 30)).stroke();
 | 
				
			||||||
          doc.text(value, doc.x + 5, doc.y + 5, { width: 490 });
 | 
					          doc.text(value, doc.x + 5, doc.y + 5, { width: 490 });
 | 
				
			||||||
          doc.y += Math.max(textHeight + 10, 30) + 10;
 | 
					          doc.y += Math.max(textHeight + 10, 30) + 10;
 | 
				
			||||||
        } else if (field.type === 'date') {
 | 
					        }
 | 
				
			||||||
 | 
					        else if (field.type === 'date') 
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
          let formattedDate = '';
 | 
					          let formattedDate = '';
 | 
				
			||||||
          if (value) {
 | 
					          if (value) 
 | 
				
			||||||
            try {
 | 
					          {
 | 
				
			||||||
 | 
					            try 
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
              const dateObj = new Date(value + 'T00:00:00');
 | 
					              const dateObj = new Date(value + 'T00:00:00');
 | 
				
			||||||
              if (!isNaN(dateObj.getTime())) {
 | 
					              if (!isNaN(dateObj.getTime())) 
 | 
				
			||||||
 | 
					              {
 | 
				
			||||||
                const day = String(dateObj.getDate()).padStart(2, '0');
 | 
					                const day = String(dateObj.getDate()).padStart(2, '0');
 | 
				
			||||||
                const month = String(dateObj.getMonth() + 1).padStart(2, '0');
 | 
					                const month = String(dateObj.getMonth() + 1).padStart(2, '0');
 | 
				
			||||||
                const year = dateObj.getFullYear();
 | 
					                const year = dateObj.getFullYear();
 | 
				
			||||||
                formattedDate = `${day}/${month}/${year}`;
 | 
					                formattedDate = `${day}/${month}/${year}`;
 | 
				
			||||||
              } else {
 | 
					              }
 | 
				
			||||||
 | 
					              else 
 | 
				
			||||||
 | 
					              {
 | 
				
			||||||
                formattedDate = value;
 | 
					                formattedDate = value;
 | 
				
			||||||
              }
 | 
					              }
 | 
				
			||||||
            } catch (e) {
 | 
					            }
 | 
				
			||||||
 | 
					            catch (e) 
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
              console.error('Error formatting date:', value, e);
 | 
					              console.error('Error formatting date:', value, e);
 | 
				
			||||||
              formattedDate = value;
 | 
					              formattedDate = value;
 | 
				
			||||||
            }
 | 
					            }
 | 
				
			||||||
| 
						 | 
					@ -454,12 +529,16 @@ router.get('/responses/:responseId/export/pdf', async (req, res) => {
 | 
				
			||||||
          doc.text(formattedDate || ' ');
 | 
					          doc.text(formattedDate || ' ');
 | 
				
			||||||
          doc.lineCap('butt').moveTo(doc.x, doc.y).lineTo(doc.x + 500, doc.y).stroke();
 | 
					          doc.lineCap('butt').moveTo(doc.x, doc.y).lineTo(doc.x + 500, doc.y).stroke();
 | 
				
			||||||
          doc.moveDown(1.5);
 | 
					          doc.moveDown(1.5);
 | 
				
			||||||
        } else if (field.type === 'boolean') {
 | 
					        }
 | 
				
			||||||
 | 
					        else if (field.type === 'boolean') 
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
          const displayValue = value === 'true' ? 'Yes' : (value === 'false' ? 'No' : ' ');
 | 
					          const displayValue = value === 'true' ? 'Yes' : (value === 'false' ? 'No' : ' ');
 | 
				
			||||||
          doc.text(displayValue);
 | 
					          doc.text(displayValue);
 | 
				
			||||||
          doc.lineCap('butt').moveTo(doc.x, doc.y).lineTo(doc.x + 500, doc.y).stroke();
 | 
					          doc.lineCap('butt').moveTo(doc.x, doc.y).lineTo(doc.x + 500, doc.y).stroke();
 | 
				
			||||||
          doc.moveDown(1.5);
 | 
					          doc.moveDown(1.5);
 | 
				
			||||||
        } else {
 | 
					        }
 | 
				
			||||||
 | 
					        else 
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
          doc.text(value || ' ');
 | 
					          doc.text(value || ' ');
 | 
				
			||||||
          doc.lineCap('butt').moveTo(doc.x, doc.y).lineTo(doc.x + 500, doc.y).stroke();
 | 
					          doc.lineCap('butt').moveTo(doc.x, doc.y).lineTo(doc.x + 500, doc.y).stroke();
 | 
				
			||||||
          doc.moveDown(1.5);
 | 
					          doc.moveDown(1.5);
 | 
				
			||||||
| 
						 | 
					@ -469,13 +548,18 @@ router.get('/responses/:responseId/export/pdf', async (req, res) => {
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    doc.end();
 | 
					    doc.end();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  } catch (err) {
 | 
					  }
 | 
				
			||||||
 | 
					  catch (err) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    console.error(`Error generating PDF for response ${responseId}:`, err.message);
 | 
					    console.error(`Error generating PDF for response ${responseId}:`, err.message);
 | 
				
			||||||
    if (!res.headersSent) {
 | 
					    if (!res.headersSent) 
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
      // Use the helper function
 | 
					      // Use the helper function
 | 
				
			||||||
      handlePrismaError(res, err, `generate PDF for response ${responseId}`);
 | 
					      handlePrismaError(res, err, `generate PDF for response ${responseId}`);
 | 
				
			||||||
    } else {
 | 
					    }
 | 
				
			||||||
      console.error("Headers already sent, could not send JSON error for PDF generation failure.");
 | 
					    else 
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					      console.error('Headers already sent, could not send JSON error for PDF generation failure.');
 | 
				
			||||||
      res.end();
 | 
					      res.end();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
| 
						 | 
					@ -485,8 +569,10 @@ router.get('/responses/:responseId/export/pdf', async (req, res) => {
 | 
				
			||||||
// --- Mantis Summary API Route --- //
 | 
					// --- Mantis Summary API Route --- //
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// GET /api/mantis-summary/today - Get today's summary specifically
 | 
					// GET /api/mantis-summary/today - Get today's summary specifically
 | 
				
			||||||
router.get('/mantis-summary/today', async (req, res) => {
 | 
					router.get('/mantis-summary/today', async(req, res) => 
 | 
				
			||||||
  try {
 | 
					{
 | 
				
			||||||
 | 
					  try 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    const today = new Date();
 | 
					    const today = new Date();
 | 
				
			||||||
    today.setHours(0, 0, 0, 0); // Set to start of day UTC for comparison
 | 
					    today.setHours(0, 0, 0, 0); // Set to start of day UTC for comparison
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -495,23 +581,30 @@ router.get('/mantis-summary/today', async (req, res) => {
 | 
				
			||||||
      select: { summaryDate: true, summaryText: true, generatedAt: true }
 | 
					      select: { summaryDate: true, summaryText: true, generatedAt: true }
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (todaySummary) {
 | 
					    if (todaySummary) 
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
      res.json(todaySummary);
 | 
					      res.json(todaySummary);
 | 
				
			||||||
    } else {
 | 
					    }
 | 
				
			||||||
 | 
					    else 
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
      res.status(404).json({ message: `No Mantis summary found for today (${today.toISOString().split('T')[0]}).` });
 | 
					      res.status(404).json({ message: `No Mantis summary found for today (${today.toISOString().split('T')[0]}).` });
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  } catch (error) {
 | 
					  }
 | 
				
			||||||
 | 
					  catch (error) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    handlePrismaError(res, error, 'fetch today\'s Mantis summary');
 | 
					    handlePrismaError(res, error, 'fetch today\'s Mantis summary');
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// GET /api/mantis-summaries - Get ALL summaries from the DB, with pagination
 | 
					// GET /api/mantis-summaries - Get ALL summaries from the DB, with pagination
 | 
				
			||||||
router.get('/mantis-summaries', async (req, res) => {
 | 
					router.get('/mantis-summaries', async(req, res) => 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
  const page = parseInt(req.query.page, 10) || 1;
 | 
					  const page = parseInt(req.query.page, 10) || 1;
 | 
				
			||||||
  const limit = parseInt(req.query.limit, 10) || 10;
 | 
					  const limit = parseInt(req.query.limit, 10) || 10;
 | 
				
			||||||
  const skip = (page - 1) * limit;
 | 
					  const skip = (page - 1) * limit;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  try {
 | 
					  try 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    const [summaries, totalItems] = await prisma.$transaction([
 | 
					    const [summaries, totalItems] = await prisma.$transaction([
 | 
				
			||||||
      prisma.mantisSummary.findMany({
 | 
					      prisma.mantisSummary.findMany({
 | 
				
			||||||
        orderBy: { summaryDate: 'desc' },
 | 
					        orderBy: { summaryDate: 'desc' },
 | 
				
			||||||
| 
						 | 
					@ -523,104 +616,78 @@ router.get('/mantis-summaries', async (req, res) => {
 | 
				
			||||||
    ]);
 | 
					    ]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    res.json({ summaries, total: totalItems });
 | 
					    res.json({ summaries, total: totalItems });
 | 
				
			||||||
  } catch (error) {
 | 
					  }
 | 
				
			||||||
 | 
					  catch (error) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    handlePrismaError(res, error, 'fetch paginated Mantis summaries');
 | 
					    handlePrismaError(res, error, 'fetch paginated Mantis summaries');
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// POST /api/mantis-summaries/generate - Trigger summary generation
 | 
					// POST /api/mantis-summaries/generate - Trigger summary generation
 | 
				
			||||||
router.post('/mantis-summaries/generate', async (req, res) => {
 | 
					router.post('/mantis-summaries/generate', async(req, res) => 
 | 
				
			||||||
  try {
 | 
					{
 | 
				
			||||||
 | 
					  try 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    // Trigger generation asynchronously, don't wait for it
 | 
					    // Trigger generation asynchronously, don't wait for it
 | 
				
			||||||
    generateTodaysSummary()
 | 
					    generateTodaysSummary()
 | 
				
			||||||
      .then(() => {
 | 
					      .then(() => 
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
        console.log('Summary generation process finished successfully (async).');
 | 
					        console.log('Summary generation process finished successfully (async).');
 | 
				
			||||||
      })
 | 
					      })
 | 
				
			||||||
      .catch(error => {
 | 
					      .catch(error => 
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
        console.error('Background summary generation failed:', error);
 | 
					        console.error('Background summary generation failed:', error);
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    res.status(202).json({ message: 'Summary generation started.' });
 | 
					    res.status(202).json({ message: 'Summary generation started.' });
 | 
				
			||||||
  } catch (error) {
 | 
					  }
 | 
				
			||||||
 | 
					  catch (error) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    handlePrismaError(res, error, 'initiate Mantis summary generation');
 | 
					    handlePrismaError(res, error, 'initiate Mantis summary generation');
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// --- Email Summary API Routes --- //
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// GET /api/email-summaries - Get ALL email summaries from the DB, with pagination
 | 
					 | 
				
			||||||
router.get('/email-summaries', async (req, res) => {
 | 
					 | 
				
			||||||
  const page = parseInt(req.query.page, 10) || 1;
 | 
					 | 
				
			||||||
  const limit = parseInt(req.query.limit, 10) || 10;
 | 
					 | 
				
			||||||
  const skip = (page - 1) * limit;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  try {
 | 
					 | 
				
			||||||
    const [summaries, totalItems] = await prisma.$transaction([
 | 
					 | 
				
			||||||
      prisma.emailSummary.findMany({ // Use emailSummary model
 | 
					 | 
				
			||||||
        orderBy: { summaryDate: 'desc' },
 | 
					 | 
				
			||||||
        take: limit,
 | 
					 | 
				
			||||||
        skip: skip,
 | 
					 | 
				
			||||||
        select: { id: true, summaryDate: true, summaryText: true, generatedAt: true }
 | 
					 | 
				
			||||||
      }),
 | 
					 | 
				
			||||||
      prisma.emailSummary.count() // Count emailSummary model
 | 
					 | 
				
			||||||
    ]);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    res.json({ summaries, total: totalItems });
 | 
					 | 
				
			||||||
  } catch (error) {
 | 
					 | 
				
			||||||
    handlePrismaError(res, error, 'fetch paginated Email summaries');
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// POST /api/email-summaries/generate - Trigger email summary generation
 | 
					 | 
				
			||||||
router.post('/email-summaries/generate', async (req, res) => {
 | 
					 | 
				
			||||||
  try {
 | 
					 | 
				
			||||||
    // Trigger generation asynchronously, don't wait for it
 | 
					 | 
				
			||||||
    generateAndStoreEmailSummary() // Use the email summarizer function
 | 
					 | 
				
			||||||
      .then(() => {
 | 
					 | 
				
			||||||
        console.log('Email summary generation process finished successfully (async).');
 | 
					 | 
				
			||||||
      })
 | 
					 | 
				
			||||||
      .catch(error => {
 | 
					 | 
				
			||||||
        console.error('Background email summary generation failed:', error);
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    res.status(202).json({ message: 'Email summary generation started.' });
 | 
					 | 
				
			||||||
  } catch (error) {
 | 
					 | 
				
			||||||
    handlePrismaError(res, error, 'initiate Email summary generation');
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// --- Settings API --- //
 | 
					// --- Settings API --- //
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// GET /api/settings/:key - Get a specific setting value
 | 
					// GET /api/settings/:key - Get a specific setting value
 | 
				
			||||||
router.get('/settings/:key', async (req, res) => {
 | 
					router.get('/settings/:key', async(req, res) => 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
  const { key } = req.params;
 | 
					  const { key } = req.params;
 | 
				
			||||||
  try {
 | 
					  try 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    const setting = await prisma.setting.findUnique({
 | 
					    const setting = await prisma.setting.findUnique({
 | 
				
			||||||
      where: { key: key },
 | 
					      where: { key: key },
 | 
				
			||||||
      select: { value: true }
 | 
					      select: { value: true }
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (setting !== null) {
 | 
					    if (setting !== null) 
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
      res.json({ key, value: setting.value });
 | 
					      res.json({ key, value: setting.value });
 | 
				
			||||||
    } else {
 | 
					    }
 | 
				
			||||||
 | 
					    else 
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
      res.json({ key, value: '' }); // Return empty value if not found
 | 
					      res.json({ key, value: '' }); // Return empty value if not found
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  } catch (err) {
 | 
					  }
 | 
				
			||||||
 | 
					  catch (err) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    handlePrismaError(res, err, `fetch setting '${key}'`);
 | 
					    handlePrismaError(res, err, `fetch setting '${key}'`);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// PUT /api/settings/:key - Update or create a specific setting
 | 
					// PUT /api/settings/:key - Update or create a specific setting
 | 
				
			||||||
router.put('/settings/:key', async (req, res) => {
 | 
					router.put('/settings/:key', async(req, res) => 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
  const { key } = req.params;
 | 
					  const { key } = req.params;
 | 
				
			||||||
  const { value } = req.body;
 | 
					  const { value } = req.body;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (typeof value === 'undefined') {
 | 
					  if (typeof value === 'undefined') 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    return res.status(400).json({ error: 'Setting value is required in the request body' });
 | 
					    return res.status(400).json({ error: 'Setting value is required in the request body' });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  try {
 | 
					  try 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    const upsertedSetting = await prisma.setting.upsert({
 | 
					    const upsertedSetting = await prisma.setting.upsert({
 | 
				
			||||||
      where: { key: key },
 | 
					      where: { key: key },
 | 
				
			||||||
      update: { value: String(value) },
 | 
					      update: { value: String(value) },
 | 
				
			||||||
| 
						 | 
					@ -628,7 +695,9 @@ router.put('/settings/:key', async (req, res) => {
 | 
				
			||||||
      select: { key: true, value: true } // Select to return the updated/created value
 | 
					      select: { key: true, value: true } // Select to return the updated/created value
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
    res.status(200).json(upsertedSetting);
 | 
					    res.status(200).json(upsertedSetting);
 | 
				
			||||||
  } catch (err) {
 | 
					  }
 | 
				
			||||||
 | 
					  catch (err) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    handlePrismaError(res, err, `update setting '${key}'`);
 | 
					    handlePrismaError(res, err, `update setting '${key}'`);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										452
									
								
								src-ssr/routes/auth.js
									
										
									
									
									
										Normal 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
									
								
							
							
						
						| 
						 | 
					@ -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;
 | 
				
			||||||
| 
						 | 
					@ -9,21 +9,33 @@
 | 
				
			||||||
 * Make sure to yarn add / npm install (in your project root)
 | 
					 * Make sure to yarn add / npm install (in your project root)
 | 
				
			||||||
 * anything you import here (except for express and compression).
 | 
					 * anything you import here (except for express and compression).
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
import express from 'express'
 | 
					import express from 'express';
 | 
				
			||||||
import compression from 'compression'
 | 
					import compression from 'compression';
 | 
				
			||||||
 | 
					import session from 'express-session'; // Added for session management
 | 
				
			||||||
 | 
					import { v4 as uuidv4 } from 'uuid'; // Added for generating session IDs
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  defineSsrCreate,
 | 
					  defineSsrCreate,
 | 
				
			||||||
  defineSsrListen,
 | 
					  defineSsrListen,
 | 
				
			||||||
  defineSsrClose,
 | 
					  defineSsrClose,
 | 
				
			||||||
  defineSsrServeStaticContent,
 | 
					  defineSsrServeStaticContent,
 | 
				
			||||||
  defineSsrRenderPreloadTag
 | 
					  defineSsrRenderPreloadTag
 | 
				
			||||||
} from '#q-app/wrappers'
 | 
					} from '#q-app/wrappers';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import prisma from './database.js'; // Import the prisma client instance
 | 
					import prisma from './database.js'; // Import the prisma client instance
 | 
				
			||||||
import apiRoutes from './routes/api.js';
 | 
					import apiRoutes from './routes/api.js';
 | 
				
			||||||
 | 
					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 cron from 'node-cron';
 | 
				
			||||||
import { generateAndStoreMantisSummary } from './services/mantisSummarizer.js';
 | 
					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.
 | 
					 * Create your webserver and return its instance.
 | 
				
			||||||
 * If needed, prepare your webserver to receive
 | 
					 * If needed, prepare your webserver to receive
 | 
				
			||||||
| 
						 | 
					@ -31,33 +43,54 @@ import { generateAndStoreMantisSummary } from './services/mantisSummarizer.js';
 | 
				
			||||||
 *
 | 
					 *
 | 
				
			||||||
 * Can be async: defineSsrCreate(async ({ ... }) => { ... })
 | 
					 * Can be async: defineSsrCreate(async ({ ... }) => { ... })
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
export const create = defineSsrCreate((/* { ... } */) => {
 | 
					export const create = defineSsrCreate((/* { ... } */) => 
 | 
				
			||||||
  const app = express()
 | 
					{
 | 
				
			||||||
 | 
					  const app = express();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Session middleware configuration
 | 
				
			||||||
 | 
					  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)
 | 
					  // Initialize the database (now synchronous)
 | 
				
			||||||
  try {
 | 
					  try 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    console.log('Prisma Client is ready.'); // Log Prisma readiness
 | 
					    console.log('Prisma Client is ready.'); // Log Prisma readiness
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Schedule the Mantis summary task after DB initialization
 | 
					    // Schedule the Mantis summary task after DB initialization
 | 
				
			||||||
    // Run daily at 1:00 AM server time (adjust as needed)
 | 
					    // Run daily at 1:00 AM server time (adjust as needed)
 | 
				
			||||||
    cron.schedule('0 1 * * *', async () => {
 | 
					    cron.schedule('0 1 * * *', async() => 
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
      console.log('Running scheduled Mantis summary task...');
 | 
					      console.log('Running scheduled Mantis summary task...');
 | 
				
			||||||
      try {
 | 
					      try 
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
        await generateAndStoreMantisSummary();
 | 
					        await generateAndStoreMantisSummary();
 | 
				
			||||||
        console.log('Scheduled Mantis summary task completed.');
 | 
					        console.log('Scheduled Mantis summary task completed.');
 | 
				
			||||||
      } catch (error) {
 | 
					      }
 | 
				
			||||||
 | 
					      catch (error) 
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
        console.error('Error running scheduled Mantis summary task:', error);
 | 
					        console.error('Error running scheduled Mantis summary task:', error);
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    }, {
 | 
					    }, {
 | 
				
			||||||
      scheduled: true,
 | 
					      scheduled: true,
 | 
				
			||||||
      timezone: "Europe/London" // Example: Set to your server's timezone
 | 
					      timezone: 'Europe/London' // Example: Set to your server's timezone
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
    console.log('Mantis summary cron job scheduled.');
 | 
					    console.log('Mantis summary cron job scheduled.');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Optional: Run once immediately on server start if needed
 | 
					    // Optional: Run once immediately on server start if needed
 | 
				
			||||||
    generateAndStoreMantisSummary().catch(err => console.error('Initial Mantis summary failed:', err));
 | 
					    generateAndStoreMantisSummary().catch(err => console.error('Initial Mantis summary failed:', err));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  } catch (error) {
 | 
					  }
 | 
				
			||||||
 | 
					  catch (error) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    console.error('Error during server setup:', error);
 | 
					    console.error('Error during server setup:', error);
 | 
				
			||||||
    // Optionally handle the error more gracefully, e.g., prevent server start
 | 
					    // Optionally handle the error more gracefully, e.g., prevent server start
 | 
				
			||||||
    process.exit(1); // Exit if setup fails
 | 
					    process.exit(1); // Exit if setup fails
 | 
				
			||||||
| 
						 | 
					@ -65,22 +98,25 @@ export const create = defineSsrCreate((/* { ... } */) => {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // attackers can use this header to detect apps running Express
 | 
					  // attackers can use this header to detect apps running Express
 | 
				
			||||||
  // and then launch specifically-targeted attacks
 | 
					  // and then launch specifically-targeted attacks
 | 
				
			||||||
  app.disable('x-powered-by')
 | 
					  app.disable('x-powered-by');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // Add JSON body parsing middleware
 | 
					  // Add JSON body parsing middleware
 | 
				
			||||||
  app.use(express.json());
 | 
					  app.use(express.json());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  // Add API routes
 | 
					  // Add API routes
 | 
				
			||||||
  app.use('/api', apiRoutes);
 | 
					  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
 | 
					  // place here any middlewares that
 | 
				
			||||||
  // absolutely need to run before anything else
 | 
					  // absolutely need to run before anything else
 | 
				
			||||||
  if (process.env.PROD) {
 | 
					  if (process.env.PROD) 
 | 
				
			||||||
    app.use(compression())
 | 
					  {
 | 
				
			||||||
 | 
					    app.use(compression());
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return app
 | 
					  return app;
 | 
				
			||||||
})
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * You need to make the server listen to the indicated port
 | 
					 * You need to make the server listen to the indicated port
 | 
				
			||||||
| 
						 | 
					@ -95,14 +131,17 @@ export const create = defineSsrCreate((/* { ... } */) => {
 | 
				
			||||||
 *
 | 
					 *
 | 
				
			||||||
 * Can be async: defineSsrListen(async ({ app, devHttpsApp, port }) => { ... })
 | 
					 * Can be async: defineSsrListen(async ({ app, devHttpsApp, port }) => { ... })
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
export const listen = defineSsrListen(({ app, devHttpsApp, port }) => {
 | 
					export const listen = defineSsrListen(({ app, devHttpsApp, port }) => 
 | 
				
			||||||
  const server = devHttpsApp || app
 | 
					{
 | 
				
			||||||
  return server.listen(port, () => {
 | 
					  const server = devHttpsApp || app;
 | 
				
			||||||
    if (process.env.PROD) {
 | 
					  return server.listen(port, () => 
 | 
				
			||||||
      console.log('Server listening at port ' + port)
 | 
					  {
 | 
				
			||||||
 | 
					    if (process.env.PROD) 
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					      console.log('Server listening at port ' + port);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  })
 | 
					  });
 | 
				
			||||||
})
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * Should close the server and free up any resources.
 | 
					 * Should close the server and free up any resources.
 | 
				
			||||||
| 
						 | 
					@ -111,21 +150,25 @@ export const listen = defineSsrListen(({ app, devHttpsApp, port }) => {
 | 
				
			||||||
 *
 | 
					 *
 | 
				
			||||||
 * Can be async: defineSsrClose(async ({ ... }) => { ... })
 | 
					 * Can be async: defineSsrClose(async ({ ... }) => { ... })
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
export const close = defineSsrClose(async ({ listenResult }) => {
 | 
					export const close = defineSsrClose(async({ listenResult }) => 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
  // Close the database connection when the server shuts down
 | 
					  // Close the database connection when the server shuts down
 | 
				
			||||||
  try {
 | 
					  try 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    await prisma.$disconnect();
 | 
					    await prisma.$disconnect();
 | 
				
			||||||
    console.log('Prisma Client disconnected.');
 | 
					    console.log('Prisma Client disconnected.');
 | 
				
			||||||
  } catch (e) {
 | 
					  }
 | 
				
			||||||
 | 
					  catch (e) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    console.error('Error disconnecting Prisma Client:', e);
 | 
					    console.error('Error disconnecting Prisma Client:', e);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return listenResult.close()
 | 
					  return listenResult.close();
 | 
				
			||||||
})
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const maxAge = process.env.DEV
 | 
					const maxAge = process.env.DEV
 | 
				
			||||||
  ? 0
 | 
					  ? 0
 | 
				
			||||||
  : 1000 * 60 * 60 * 24 * 30
 | 
					  : 1000 * 60 * 60 * 24 * 30;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * Should return a function that will be used to configure the webserver
 | 
					 * Should return a function that will be used to configure the webserver
 | 
				
			||||||
| 
						 | 
					@ -136,53 +179,63 @@ const maxAge = process.env.DEV
 | 
				
			||||||
 * Can be async: defineSsrServeStaticContent(async ({ app, resolve }) => {
 | 
					 * Can be async: defineSsrServeStaticContent(async ({ app, resolve }) => {
 | 
				
			||||||
 * Can return an async function: return async ({ urlPath = '/', pathToServe = '.', opts = {} }) => {
 | 
					 * Can return an async function: return async ({ urlPath = '/', pathToServe = '.', opts = {} }) => {
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
export const serveStaticContent = defineSsrServeStaticContent(({ app, resolve }) => {
 | 
					export const serveStaticContent = defineSsrServeStaticContent(({ app, resolve }) => 
 | 
				
			||||||
  return ({ urlPath = '/', pathToServe = '.', opts = {} }) => {
 | 
					{
 | 
				
			||||||
    const serveFn = express.static(resolve.public(pathToServe), { maxAge, ...opts })
 | 
					  return ({ urlPath = '/', pathToServe = '.', opts = {} }) => 
 | 
				
			||||||
    app.use(resolve.urlPath(urlPath), serveFn)
 | 
					  {
 | 
				
			||||||
  }
 | 
					    const serveFn = express.static(resolve.public(pathToServe), { maxAge, ...opts });
 | 
				
			||||||
})
 | 
					    app.use(resolve.urlPath(urlPath), serveFn);
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const jsRE = /\.js$/
 | 
					const jsRE = /\.js$/;
 | 
				
			||||||
const cssRE = /\.css$/
 | 
					const cssRE = /\.css$/;
 | 
				
			||||||
const woffRE = /\.woff$/
 | 
					const woffRE = /\.woff$/;
 | 
				
			||||||
const woff2RE = /\.woff2$/
 | 
					const woff2RE = /\.woff2$/;
 | 
				
			||||||
const gifRE = /\.gif$/
 | 
					const gifRE = /\.gif$/;
 | 
				
			||||||
const jpgRE = /\.jpe?g$/
 | 
					const jpgRE = /\.jpe?g$/;
 | 
				
			||||||
const pngRE = /\.png$/
 | 
					const pngRE = /\.png$/;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * Should return a String with HTML output
 | 
					 * Should return a String with HTML output
 | 
				
			||||||
 * (if any) for preloading indicated file
 | 
					 * (if any) for preloading indicated file
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
export const renderPreloadTag = defineSsrRenderPreloadTag((file/* , { ssrContext } */) => {
 | 
					export const renderPreloadTag = defineSsrRenderPreloadTag((file/* , { ssrContext } */) => 
 | 
				
			||||||
  if (jsRE.test(file) === true) {
 | 
					{
 | 
				
			||||||
    return `<link rel="modulepreload" href="${file}" crossorigin>`
 | 
					  if (jsRE.test(file) === true) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    return `<link rel="modulepreload" href="${file}" crossorigin>`;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (cssRE.test(file) === true) {
 | 
					  if (cssRE.test(file) === true) 
 | 
				
			||||||
    return `<link rel="stylesheet" href="${file}" crossorigin>`
 | 
					  {
 | 
				
			||||||
 | 
					    return `<link rel="stylesheet" href="${file}" crossorigin>`;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (woffRE.test(file) === true) {
 | 
					  if (woffRE.test(file) === true) 
 | 
				
			||||||
    return `<link rel="preload" href="${file}" as="font" type="font/woff" crossorigin>`
 | 
					  {
 | 
				
			||||||
 | 
					    return `<link rel="preload" href="${file}" as="font" type="font/woff" crossorigin>`;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (woff2RE.test(file) === true) {
 | 
					  if (woff2RE.test(file) === true) 
 | 
				
			||||||
    return `<link rel="preload" href="${file}" as="font" type="font/woff2" crossorigin>`
 | 
					  {
 | 
				
			||||||
 | 
					    return `<link rel="preload" href="${file}" as="font" type="font/woff2" crossorigin>`;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (gifRE.test(file) === true) {
 | 
					  if (gifRE.test(file) === true) 
 | 
				
			||||||
    return `<link rel="preload" href="${file}" as="image" type="image/gif" crossorigin>`
 | 
					  {
 | 
				
			||||||
 | 
					    return `<link rel="preload" href="${file}" as="image" type="image/gif" crossorigin>`;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (jpgRE.test(file) === true) {
 | 
					  if (jpgRE.test(file) === true) 
 | 
				
			||||||
    return `<link rel="preload" href="${file}" as="image" type="image/jpeg" crossorigin>`
 | 
					  {
 | 
				
			||||||
 | 
					    return `<link rel="preload" href="${file}" as="image" type="image/jpeg" crossorigin>`;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (pngRE.test(file) === true) {
 | 
					  if (pngRE.test(file) === true) 
 | 
				
			||||||
    return `<link rel="preload" href="${file}" as="image" type="image/png" crossorigin>`
 | 
					  {
 | 
				
			||||||
 | 
					    return `<link rel="preload" href="${file}" as="image" type="image/png" crossorigin>`;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return ''
 | 
					  return '';
 | 
				
			||||||
})
 | 
					});
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
| 
						 | 
					@ -1,48 +1,45 @@
 | 
				
			||||||
import axios from 'axios';
 | 
					import axios from 'axios';
 | 
				
			||||||
import { GoogleGenAI } from '@google/genai';
 | 
					 | 
				
			||||||
import prisma from '../database.js'; // Import Prisma client
 | 
					import prisma from '../database.js'; // Import Prisma client
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// --- Environment Variables ---
 | 
					import { getSetting } from '../utils/settings.js';
 | 
				
			||||||
const {
 | 
					import { askGemini } from '../utils/gemini.js';
 | 
				
			||||||
  MANTIS_API_KEY,
 | 
					 | 
				
			||||||
  MANTIS_API_ENDPOINT,
 | 
					 | 
				
			||||||
  GOOGLE_API_KEY
 | 
					 | 
				
			||||||
} = process.env;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
// --- Mantis Summarizer Setup ---
 | 
					 | 
				
			||||||
const ai = GOOGLE_API_KEY ? new GoogleGenAI({
 | 
					 | 
				
			||||||
  apiKey: GOOGLE_API_KEY,
 | 
					 | 
				
			||||||
}) : null;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const usernameMap = {
 | 
					const usernameMap = {
 | 
				
			||||||
  'credmore': 'Cameron Redmore',
 | 
					  credmore: 'Cameron Redmore',
 | 
				
			||||||
  'dgibson': 'Dane Gibson',
 | 
					  dgibson: 'Dane Gibson',
 | 
				
			||||||
  'egzibovskis': 'Ed Gzibovskis',
 | 
					  egzibovskis: 'Ed Gzibovskis',
 | 
				
			||||||
  'ascotney': 'Amanda Scotney',
 | 
					  ascotney: 'Amanda Scotney',
 | 
				
			||||||
  'gclough': 'Garry Clough',
 | 
					  gclough: 'Garry Clough',
 | 
				
			||||||
  'slee': 'Sarah Lee',
 | 
					  slee: 'Sarah Lee',
 | 
				
			||||||
  'dwalker': 'Dave Walker',
 | 
					  dwalker: 'Dave Walker',
 | 
				
			||||||
  'askaith': 'Amy Skaith',
 | 
					  askaith: 'Amy Skaith',
 | 
				
			||||||
  'dpotter': 'Danny Potter',
 | 
					  dpotter: 'Danny Potter',
 | 
				
			||||||
  'msmart': 'Michael Smart',
 | 
					  msmart: 'Michael Smart',
 | 
				
			||||||
  // Add other usernames as needed
 | 
					  // Add other usernames as needed
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
async function getMantisTickets() {
 | 
					async function getMantisTickets() 
 | 
				
			||||||
  if (!MANTIS_API_ENDPOINT || !MANTIS_API_KEY) {
 | 
					{
 | 
				
			||||||
    throw new Error("Mantis API endpoint or key not configured in environment variables.");
 | 
					  const MANTIS_API_KEY = await getSetting('MANTIS_API_KEY');
 | 
				
			||||||
 | 
					  const MANTIS_API_ENDPOINT = await getSetting('MANTIS_API_ENDPOINT');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (!MANTIS_API_ENDPOINT || !MANTIS_API_KEY) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    throw new Error('Mantis API endpoint or key not configured in environment variables.');
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  const url = `${MANTIS_API_ENDPOINT}/issues?project_id=1&page_size=50&select=id,summary,description,created_at,updated_at,reporter,notes`;
 | 
					  const url = `${MANTIS_API_ENDPOINT}/issues?project_id=1&page_size=50&select=id,summary,description,created_at,updated_at,reporter,notes`;
 | 
				
			||||||
  const headers = {
 | 
					  const headers = {
 | 
				
			||||||
    'Authorization': `${MANTIS_API_KEY}`,
 | 
					    Authorization: `${MANTIS_API_KEY}`,
 | 
				
			||||||
    'Accept': 'application/json',
 | 
					    Accept: 'application/json',
 | 
				
			||||||
    'Content-Type': 'application/json',
 | 
					    'Content-Type': 'application/json',
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  try {
 | 
					  try 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    const response = await axios.get(url, { headers });
 | 
					    const response = await axios.get(url, { headers });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const tickets = response.data.issues.filter((ticket) => {
 | 
					    const tickets = response.data.issues.filter((ticket) => 
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
      const ticketDate = new Date(ticket.updated_at);
 | 
					      const ticketDate = new Date(ticket.updated_at);
 | 
				
			||||||
      const thresholdDate = new Date();
 | 
					      const thresholdDate = new Date();
 | 
				
			||||||
      const currentDay = thresholdDate.getDay(); // Sunday = 0, Monday = 1, ...
 | 
					      const currentDay = thresholdDate.getDay(); // Sunday = 0, Monday = 1, ...
 | 
				
			||||||
| 
						 | 
					@ -53,7 +50,8 @@ async function getMantisTickets() {
 | 
				
			||||||
      thresholdDate.setHours(0, 0, 0, 0); // Start of the day
 | 
					      thresholdDate.setHours(0, 0, 0, 0); // Start of the day
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      return ticketDate >= thresholdDate;
 | 
					      return ticketDate >= thresholdDate;
 | 
				
			||||||
    }).map((ticket) => {
 | 
					    }).map((ticket) => 
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
      return {
 | 
					      return {
 | 
				
			||||||
        id: ticket.id,
 | 
					        id: ticket.id,
 | 
				
			||||||
        summary: ticket.summary,
 | 
					        summary: ticket.summary,
 | 
				
			||||||
| 
						 | 
					@ -61,7 +59,8 @@ async function getMantisTickets() {
 | 
				
			||||||
        created_at: ticket.created_at,
 | 
					        created_at: ticket.created_at,
 | 
				
			||||||
        updated_at: ticket.updated_at,
 | 
					        updated_at: ticket.updated_at,
 | 
				
			||||||
        reporter: usernameMap[ticket.reporter?.username] || ticket.reporter?.name || 'Unknown Reporter', // Safer access
 | 
					        reporter: usernameMap[ticket.reporter?.username] || ticket.reporter?.name || 'Unknown Reporter', // Safer access
 | 
				
			||||||
        notes: (ticket.notes ? ticket.notes.filter((note) => {
 | 
					        notes: (ticket.notes ? ticket.notes.filter((note) => 
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
          const noteDate = new Date(note.created_at);
 | 
					          const noteDate = new Date(note.created_at);
 | 
				
			||||||
          const thresholdDate = new Date();
 | 
					          const thresholdDate = new Date();
 | 
				
			||||||
          const currentDay = thresholdDate.getDay();
 | 
					          const currentDay = thresholdDate.getDay();
 | 
				
			||||||
| 
						 | 
					@ -69,7 +68,8 @@ async function getMantisTickets() {
 | 
				
			||||||
          thresholdDate.setDate(thresholdDate.getDate() - daysToSubtract);
 | 
					          thresholdDate.setDate(thresholdDate.getDate() - daysToSubtract);
 | 
				
			||||||
          thresholdDate.setHours(0, 0, 0, 0); // Start of the day
 | 
					          thresholdDate.setHours(0, 0, 0, 0); // Start of the day
 | 
				
			||||||
          return noteDate >= thresholdDate;
 | 
					          return noteDate >= thresholdDate;
 | 
				
			||||||
        }) : []).map((note) => {
 | 
					        }) : []).map((note) => 
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
          const reporter = usernameMap[note.reporter?.username] || note.reporter?.name || 'Unknown Reporter'; // Safer access
 | 
					          const reporter = usernameMap[note.reporter?.username] || note.reporter?.name || 'Unknown Reporter'; // Safer access
 | 
				
			||||||
          return {
 | 
					          return {
 | 
				
			||||||
            reporter,
 | 
					            reporter,
 | 
				
			||||||
| 
						 | 
					@ -81,27 +81,24 @@ async function getMantisTickets() {
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return tickets;
 | 
					    return tickets;
 | 
				
			||||||
  } catch (error) {
 | 
					  }
 | 
				
			||||||
    console.error("Error fetching Mantis tickets:", error.message);
 | 
					  catch (error) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    console.error('Error fetching Mantis tickets:', error.message);
 | 
				
			||||||
    // Check if it's an Axios error and provide more details
 | 
					    // Check if it's an Axios error and provide more details
 | 
				
			||||||
    if (axios.isAxiosError(error)) {
 | 
					    if (axios.isAxiosError(error)) 
 | 
				
			||||||
        console.error("Axios error details:", error.response?.status, error.response?.data);
 | 
					    {
 | 
				
			||||||
 | 
					      console.error('Axios error details:', error.response?.status, error.response?.data);
 | 
				
			||||||
      throw new Error(`Failed to fetch Mantis tickets: ${error.response?.statusText || error.message}`);
 | 
					      throw new Error(`Failed to fetch Mantis tickets: ${error.response?.statusText || error.message}`);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    throw new Error(`Failed to fetch Mantis tickets: ${error.message}`);
 | 
					    throw new Error(`Failed to fetch Mantis tickets: ${error.message}`);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// --- Mantis Summary Logic (Exported) --- //
 | 
					export async function generateAndStoreMantisSummary() 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
export async function generateAndStoreMantisSummary() {
 | 
					  try 
 | 
				
			||||||
  console.log('Attempting to generate and store Mantis summary...');
 | 
					  {
 | 
				
			||||||
  if (!ai) {
 | 
					 | 
				
			||||||
    console.error('Google AI API key not configured. Skipping summary generation.');
 | 
					 | 
				
			||||||
    return;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  try {
 | 
					 | 
				
			||||||
    // Get the prompt from the database settings using Prisma
 | 
					    // Get the prompt from the database settings using Prisma
 | 
				
			||||||
    const setting = await prisma.setting.findUnique({
 | 
					    const setting = await prisma.setting.findUnique({
 | 
				
			||||||
      where: { key: 'mantisPrompt' },
 | 
					      where: { key: 'mantisPrompt' },
 | 
				
			||||||
| 
						 | 
					@ -109,7 +106,8 @@ export async function generateAndStoreMantisSummary() {
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
    const promptTemplate = setting?.value;
 | 
					    const promptTemplate = setting?.value;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (!promptTemplate) {
 | 
					    if (!promptTemplate) 
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
      console.error('Mantis prompt not found in database settings (key: mantisPrompt). Skipping summary generation.');
 | 
					      console.error('Mantis prompt not found in database settings (key: mantisPrompt). Skipping summary generation.');
 | 
				
			||||||
      return;
 | 
					      return;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
| 
						 | 
					@ -117,23 +115,18 @@ export async function generateAndStoreMantisSummary() {
 | 
				
			||||||
    const tickets = await getMantisTickets();
 | 
					    const tickets = await getMantisTickets();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let summaryText;
 | 
					    let summaryText;
 | 
				
			||||||
    if (tickets.length === 0) {
 | 
					    if (tickets.length === 0) 
 | 
				
			||||||
        summaryText = "No Mantis tickets updated recently.";
 | 
					    {
 | 
				
			||||||
 | 
					      summaryText = 'No Mantis tickets updated recently.';
 | 
				
			||||||
      console.log('No recent Mantis tickets found.');
 | 
					      console.log('No recent Mantis tickets found.');
 | 
				
			||||||
    } else {
 | 
					 | 
				
			||||||
        console.log(`Found ${tickets.length} recent Mantis tickets. Generating summary...`);
 | 
					 | 
				
			||||||
        let prompt = promptTemplate.replaceAll("$DATE", new Date().toISOString().split('T')[0]);
 | 
					 | 
				
			||||||
        prompt = prompt.replaceAll("$MANTIS_TICKETS", JSON.stringify(tickets, null, 2));
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        const response = await ai.models.generateContent({
 | 
					 | 
				
			||||||
            "model": "gemini-2.5-flash-preview-04-17",
 | 
					 | 
				
			||||||
            "contents": prompt,
 | 
					 | 
				
			||||||
            config: {
 | 
					 | 
				
			||||||
              temperature: 0
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
          });
 | 
					    else 
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					      console.log(`Found ${tickets.length} recent Mantis tickets. Generating summary...`);
 | 
				
			||||||
 | 
					      let prompt = promptTemplate.replaceAll('$DATE', new Date().toISOString().split('T')[0]);
 | 
				
			||||||
 | 
					      prompt = prompt.replaceAll('$MANTIS_TICKETS', JSON.stringify(tickets, null, 2));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        summaryText = response.text;
 | 
					      summaryText = await askGemini(prompt);
 | 
				
			||||||
      console.log('Mantis summary generated successfully by AI.');
 | 
					      console.log('Mantis summary generated successfully by AI.');
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -144,8 +137,7 @@ export async function generateAndStoreMantisSummary() {
 | 
				
			||||||
    await prisma.mantisSummary.upsert({
 | 
					    await prisma.mantisSummary.upsert({
 | 
				
			||||||
      where: { summaryDate: today },
 | 
					      where: { summaryDate: today },
 | 
				
			||||||
      update: {
 | 
					      update: {
 | 
				
			||||||
        summaryText: summaryText,
 | 
					        summaryText: summaryText
 | 
				
			||||||
        // generatedAt is updated automatically by @default(now())
 | 
					 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      create: {
 | 
					      create: {
 | 
				
			||||||
        summaryDate: today,
 | 
					        summaryDate: today,
 | 
				
			||||||
| 
						 | 
					@ -154,17 +146,23 @@ export async function generateAndStoreMantisSummary() {
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
    console.log(`Mantis summary for ${today.toISOString().split('T')[0]} stored/updated in the database.`);
 | 
					    console.log(`Mantis summary for ${today.toISOString().split('T')[0]} stored/updated in the database.`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  } catch (error) {
 | 
					  }
 | 
				
			||||||
    console.error("Error during Mantis summary generation/storage:", error);
 | 
					  catch (error) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    console.error('Error during Mantis summary generation/storage:', error);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export async function generateTodaysSummary() {
 | 
					export async function generateTodaysSummary() 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
  console.log('Triggering Mantis summary generation via generateTodaysSummary...');
 | 
					  console.log('Triggering Mantis summary generation via generateTodaysSummary...');
 | 
				
			||||||
  try {
 | 
					  try 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    await generateAndStoreMantisSummary();
 | 
					    await generateAndStoreMantisSummary();
 | 
				
			||||||
    return { success: true, message: 'Summary generation process initiated.' };
 | 
					    return { success: true, message: 'Summary generation process initiated.' };
 | 
				
			||||||
  } catch (error) {
 | 
					  }
 | 
				
			||||||
 | 
					  catch (error) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    console.error('Error occurred within generateTodaysSummary while calling generateAndStoreMantisSummary:', error);
 | 
					    console.error('Error occurred within generateTodaysSummary while calling generateAndStoreMantisSummary:', error);
 | 
				
			||||||
    throw new Error('Failed to initiate Mantis summary generation.');
 | 
					    throw new Error('Failed to initiate Mantis summary generation.');
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										162
									
								
								src-ssr/utils/gemini.js
									
										
									
									
									
										Normal 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
									
								
							
							
						
						| 
						 | 
					@ -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 }
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										137
									
								
								src/components/ChatInterface.vue
									
										
									
									
									
										Normal 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>
 | 
				
			||||||
| 
						 | 
					@ -7,98 +7,241 @@
 | 
				
			||||||
      :model-value="true"
 | 
					      :model-value="true"
 | 
				
			||||||
    >
 | 
					    >
 | 
				
			||||||
      <q-list>
 | 
					      <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
 | 
					        <q-item
 | 
				
			||||||
          clickable
 | 
					          clickable
 | 
				
			||||||
          v-ripple
 | 
					          v-ripple
 | 
				
			||||||
          :to="{ name: 'mantisSummaries' }"
 | 
					          @click="toggleLeftDrawer"
 | 
				
			||||||
          exact
 | 
					 | 
				
			||||||
        >
 | 
					        >
 | 
				
			||||||
          <q-tooltip anchor="center right" self="center left" >
 | 
					 | 
				
			||||||
            <span>Mantis Summaries</span>
 | 
					 | 
				
			||||||
          </q-tooltip>
 | 
					 | 
				
			||||||
          <q-item-section avatar>
 | 
					          <q-item-section avatar>
 | 
				
			||||||
            <q-icon name="summarize" />
 | 
					            <q-icon name="menu" />
 | 
				
			||||||
          </q-item-section>
 | 
					          </q-item-section>
 | 
				
			||||||
          <q-item-section>
 | 
					          <q-item-section>
 | 
				
			||||||
            <q-item-label>Mantis Summaries</q-item-label>
 | 
					            <q-item-label class="text-h6">
 | 
				
			||||||
            <q-item-label caption>View daily summaries</q-item-label>
 | 
					              StylePoint
 | 
				
			||||||
 | 
					            </q-item-label>
 | 
				
			||||||
          </q-item-section>
 | 
					          </q-item-section>
 | 
				
			||||||
        </q-item>
 | 
					        </q-item>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <!-- Dynamic Navigation Items -->
 | 
				
			||||||
        <q-item
 | 
					        <q-item
 | 
				
			||||||
 | 
					          v-for="item in navItems"
 | 
				
			||||||
 | 
					          :key="item.name"
 | 
				
			||||||
          clickable
 | 
					          clickable
 | 
				
			||||||
          v-ripple
 | 
					          v-ripple
 | 
				
			||||||
          :to="{ name: 'emailSummaries' }"
 | 
					          :to="{ name: item.name }"
 | 
				
			||||||
          exact
 | 
					          exact
 | 
				
			||||||
        >
 | 
					        >
 | 
				
			||||||
          <q-tooltip anchor="center right" self="center left" >
 | 
					          <q-tooltip
 | 
				
			||||||
            <span>Email Summaries</span>
 | 
					            anchor="center right"
 | 
				
			||||||
 | 
					            self="center left"
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            <span>{{ item.meta.title }}</span>
 | 
				
			||||||
          </q-tooltip>
 | 
					          </q-tooltip>
 | 
				
			||||||
          <q-item-section avatar>
 | 
					          <q-item-section avatar>
 | 
				
			||||||
            <q-icon name="email" />
 | 
					            <q-icon :name="item.meta.icon" />
 | 
				
			||||||
          </q-item-section>
 | 
					          </q-item-section>
 | 
				
			||||||
          <q-item-section>
 | 
					          <q-item-section>
 | 
				
			||||||
            <q-item-label>Email Summaries</q-item-label>
 | 
					            <q-item-label>{{ item.meta.title }}</q-item-label>
 | 
				
			||||||
            <q-item-label caption>View email summaries</q-item-label>
 | 
					            <q-item-label caption>
 | 
				
			||||||
 | 
					              {{ item.meta.caption }}
 | 
				
			||||||
 | 
					            </q-item-label>
 | 
				
			||||||
          </q-item-section>
 | 
					          </q-item-section>
 | 
				
			||||||
        </q-item>
 | 
					        </q-item>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <!-- Logout Button (Conditional) -->
 | 
				
			||||||
        <q-item
 | 
					        <q-item
 | 
				
			||||||
 | 
					          v-if="authStore.isAuthenticated"
 | 
				
			||||||
          clickable
 | 
					          clickable
 | 
				
			||||||
          to="/settings" exact
 | 
					          v-ripple
 | 
				
			||||||
 | 
					          @click="logout"
 | 
				
			||||||
        >
 | 
					        >
 | 
				
			||||||
          <q-tooltip anchor="center right" self="center left" >
 | 
					          <q-tooltip
 | 
				
			||||||
            <span>Settings</span>
 | 
					            anchor="center right"
 | 
				
			||||||
 | 
					            self="center left"
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            <span>Logout</span>
 | 
				
			||||||
          </q-tooltip>
 | 
					          </q-tooltip>
 | 
				
			||||||
          <q-item-section
 | 
					          <q-item-section avatar>
 | 
				
			||||||
            avatar
 | 
					            <q-icon name="logout" />
 | 
				
			||||||
          >
 | 
					 | 
				
			||||||
            <q-icon name="settings" />
 | 
					 | 
				
			||||||
          </q-item-section>
 | 
					          </q-item-section>
 | 
				
			||||||
 | 
					 | 
				
			||||||
          <q-item-section>
 | 
					          <q-item-section>
 | 
				
			||||||
            <q-item-label>Settings</q-item-label>
 | 
					            <q-item-label>Logout</q-item-label>
 | 
				
			||||||
            <q-item-label caption>Manage application settings</q-item-label>
 | 
					 | 
				
			||||||
          </q-item-section>
 | 
					          </q-item-section>
 | 
				
			||||||
        </q-item>
 | 
					        </q-item>
 | 
				
			||||||
 | 
					 | 
				
			||||||
      </q-list>
 | 
					      </q-list>
 | 
				
			||||||
    </q-drawer>
 | 
					    </q-drawer>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <q-page-container>
 | 
					    <q-page-container>
 | 
				
			||||||
      <router-view />
 | 
					      <router-view />
 | 
				
			||||||
    </q-page-container>
 | 
					    </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>
 | 
					  </q-layout>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script setup>
 | 
					<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 () {
 | 
					const fabOpen = ref(false); // Local state for FAB animation, not chat visibility
 | 
				
			||||||
  leftDrawerOpen.value = !leftDrawerOpen.value
 | 
					
 | 
				
			||||||
 | 
					// 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>
 | 
					</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>
 | 
				
			||||||
| 
						 | 
					@ -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>
 | 
					 | 
				
			||||||
| 
						 | 
					@ -5,7 +5,10 @@
 | 
				
			||||||
        404
 | 
					        404
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <div class="text-h2" style="opacity:.4">
 | 
					      <div
 | 
				
			||||||
 | 
					        class="text-h2"
 | 
				
			||||||
 | 
					        style="opacity:.4"
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
        Oops. Nothing here...
 | 
					        Oops. Nothing here...
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,51 +1,137 @@
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <q-page padding>
 | 
					  <q-page padding>
 | 
				
			||||||
        <div class="text-h4 q-mb-md">Create New Form</div>
 | 
					    <div class="text-h4 q-mb-md">
 | 
				
			||||||
 | 
					      Create New Form
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <q-form @submit.prevent="createForm" class="q-gutter-md">
 | 
					    <q-form
 | 
				
			||||||
            <q-input outlined v-model="form.title" label="Form Title *" lazy-rules
 | 
					      @submit.prevent="createForm"
 | 
				
			||||||
                :rules="[val => val && val.length > 0 || 'Please enter a title']" />
 | 
					      class="q-gutter-md"
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <q-input
 | 
				
			||||||
 | 
					        outlined
 | 
				
			||||||
 | 
					        v-model="form.title"
 | 
				
			||||||
 | 
					        label="Form Title *"
 | 
				
			||||||
 | 
					        lazy-rules
 | 
				
			||||||
 | 
					        :rules="[val => val && val.length > 0 || 'Please enter a title']"
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            <q-input outlined v-model="form.description" label="Form Description" type="textarea" autogrow />
 | 
					      <q-input
 | 
				
			||||||
 | 
					        outlined
 | 
				
			||||||
 | 
					        v-model="form.description"
 | 
				
			||||||
 | 
					        label="Form Description"
 | 
				
			||||||
 | 
					        type="textarea"
 | 
				
			||||||
 | 
					        autogrow
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <q-separator class="q-my-lg" />
 | 
					      <q-separator class="q-my-lg" />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            <div class="text-h6 q-mb-sm">Categories & Fields</div>
 | 
					      <div class="text-h6 q-mb-sm">
 | 
				
			||||||
 | 
					        Categories & Fields
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            <div v-for="(category, catIndex) in form.categories" :key="catIndex"
 | 
					      <div
 | 
				
			||||||
                class="q-mb-lg q-pa-md bordered rounded-borders">
 | 
					        v-for="(category, catIndex) in form.categories"
 | 
				
			||||||
 | 
					        :key="catIndex"
 | 
				
			||||||
 | 
					        class="q-mb-lg q-pa-md bordered rounded-borders"
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
        <div class="row items-center q-mb-sm">
 | 
					        <div class="row items-center q-mb-sm">
 | 
				
			||||||
                    <q-input outlined dense v-model="category.name" :label="`Category ${catIndex + 1} Name *`"
 | 
					          <q-input
 | 
				
			||||||
                        class="col q-mr-sm" lazy-rules
 | 
					            outlined
 | 
				
			||||||
                        :rules="[val => val && val.length > 0 || 'Category name required']" />
 | 
					            dense
 | 
				
			||||||
                    <q-btn flat round dense icon="delete" color="negative" @click="removeCategory(catIndex)"
 | 
					            v-model="category.name"
 | 
				
			||||||
                        title="Remove Category" />
 | 
					            :label="`Category ${catIndex + 1} Name *`"
 | 
				
			||||||
 | 
					            class="col q-mr-sm"
 | 
				
			||||||
 | 
					            lazy-rules
 | 
				
			||||||
 | 
					            :rules="[val => val && val.length > 0 || 'Category name required']"
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
 | 
					          <q-btn
 | 
				
			||||||
 | 
					            flat
 | 
				
			||||||
 | 
					            round
 | 
				
			||||||
 | 
					            dense
 | 
				
			||||||
 | 
					            icon="delete"
 | 
				
			||||||
 | 
					            color="negative"
 | 
				
			||||||
 | 
					            @click="removeCategory(catIndex)"
 | 
				
			||||||
 | 
					            title="Remove Category"
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                <div v-for="(field, fieldIndex) in category.fields" :key="fieldIndex"
 | 
					        <div
 | 
				
			||||||
                    class="q-ml-md q-mb-sm field-item">
 | 
					          v-for="(field, fieldIndex) in category.fields"
 | 
				
			||||||
 | 
					          :key="fieldIndex"
 | 
				
			||||||
 | 
					          class="q-ml-md q-mb-sm field-item"
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
          <div class="row items-center q-gutter-sm">
 | 
					          <div class="row items-center q-gutter-sm">
 | 
				
			||||||
                        <q-input outlined dense v-model="field.label" label="Field Label *" class="col" lazy-rules
 | 
					            <q-input
 | 
				
			||||||
                            :rules="[val => val && val.length > 0 || 'Field label required']" />
 | 
					              outlined
 | 
				
			||||||
                        <q-select outlined dense v-model="field.type" :options="fieldTypes" label="Field Type *"
 | 
					              dense
 | 
				
			||||||
                            class="col-auto" style="min-width: 150px;" lazy-rules
 | 
					              v-model="field.label"
 | 
				
			||||||
                            :rules="[val => !!val || 'Field type required']" />
 | 
					              label="Field Label *"
 | 
				
			||||||
                        <q-btn flat round dense icon="delete" color="negative"
 | 
					              class="col"
 | 
				
			||||||
                            @click="removeField(catIndex, fieldIndex)" title="Remove Field" />
 | 
					              lazy-rules
 | 
				
			||||||
 | 
					              :rules="[val => val && val.length > 0 || 'Field label required']"
 | 
				
			||||||
 | 
					            />
 | 
				
			||||||
 | 
					            <q-select
 | 
				
			||||||
 | 
					              outlined
 | 
				
			||||||
 | 
					              dense
 | 
				
			||||||
 | 
					              v-model="field.type"
 | 
				
			||||||
 | 
					              :options="fieldTypes"
 | 
				
			||||||
 | 
					              label="Field Type *"
 | 
				
			||||||
 | 
					              class="col-auto"
 | 
				
			||||||
 | 
					              style="min-width: 150px;"
 | 
				
			||||||
 | 
					              lazy-rules
 | 
				
			||||||
 | 
					              :rules="[val => !!val || 'Field type required']"
 | 
				
			||||||
 | 
					            />
 | 
				
			||||||
 | 
					            <q-btn
 | 
				
			||||||
 | 
					              flat
 | 
				
			||||||
 | 
					              round
 | 
				
			||||||
 | 
					              dense
 | 
				
			||||||
 | 
					              icon="delete"
 | 
				
			||||||
 | 
					              color="negative"
 | 
				
			||||||
 | 
					              @click="removeField(catIndex, fieldIndex)"
 | 
				
			||||||
 | 
					              title="Remove Field"
 | 
				
			||||||
 | 
					            />
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
                    <q-input v-model="field.description" outlined dense label="Field Description (Optional)" autogrow
 | 
					          <q-input
 | 
				
			||||||
                        class="q-mt-xs q-mb-xl" hint="This description will appear below the field label on the form." />
 | 
					            v-model="field.description"
 | 
				
			||||||
 | 
					            outlined
 | 
				
			||||||
 | 
					            dense
 | 
				
			||||||
 | 
					            label="Field Description (Optional)"
 | 
				
			||||||
 | 
					            autogrow
 | 
				
			||||||
 | 
					            class="q-mt-xs q-mb-xl"
 | 
				
			||||||
 | 
					            hint="This description will appear below the field label on the form."
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
                <q-btn color="primary" label="Add Field" @click="addField(catIndex)" class="q-ml-md q-mt-sm" />
 | 
					        <q-btn
 | 
				
			||||||
 | 
					          color="primary"
 | 
				
			||||||
 | 
					          label="Add Field"
 | 
				
			||||||
 | 
					          @click="addField(catIndex)"
 | 
				
			||||||
 | 
					          class="q-ml-md q-mt-sm"
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            <q-btn color="secondary" label="Add Category" @click="addCategory" />
 | 
					      <q-btn
 | 
				
			||||||
 | 
					        color="secondary"
 | 
				
			||||||
 | 
					        label="Add Category"
 | 
				
			||||||
 | 
					        @click="addCategory"
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <q-separator class="q-my-lg" />
 | 
					      <q-separator class="q-my-lg" />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <div>
 | 
					      <div>
 | 
				
			||||||
                <q-btn label="Create Form" type="submit" color="primary" :loading="submitting" />
 | 
					        <q-btn
 | 
				
			||||||
                <q-btn label="Cancel" type="reset" color="warning" class="q-ml-sm" :to="{ name: 'formList' }" />
 | 
					          label="Create Form"
 | 
				
			||||||
 | 
					          type="submit"
 | 
				
			||||||
 | 
					          color="primary"
 | 
				
			||||||
 | 
					          :loading="submitting"
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					        <q-btn
 | 
				
			||||||
 | 
					          label="Cancel"
 | 
				
			||||||
 | 
					          type="reset"
 | 
				
			||||||
 | 
					          color="warning"
 | 
				
			||||||
 | 
					          class="q-ml-sm"
 | 
				
			||||||
 | 
					          :to="{ name: 'formList' }"
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    </q-form>
 | 
					    </q-form>
 | 
				
			||||||
  </q-page>
 | 
					  </q-page>
 | 
				
			||||||
| 
						 | 
					@ -71,25 +157,31 @@ const form = ref({
 | 
				
			||||||
const fieldTypes = ref(['text', 'number', 'date', 'textarea', 'boolean']);
 | 
					const fieldTypes = ref(['text', 'number', 'date', 'textarea', 'boolean']);
 | 
				
			||||||
const submitting = ref(false);
 | 
					const submitting = ref(false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function addCategory() {
 | 
					function addCategory() 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
  form.value.categories.push({ name: `Category ${form.value.categories.length + 1}`, fields: [{ label: '', type: null, description: '' }] });
 | 
					  form.value.categories.push({ name: `Category ${form.value.categories.length + 1}`, fields: [{ label: '', type: null, description: '' }] });
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function removeCategory(index) {
 | 
					function removeCategory(index) 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
  form.value.categories.splice(index, 1);
 | 
					  form.value.categories.splice(index, 1);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function addField(catIndex) {
 | 
					function addField(catIndex) 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
  form.value.categories[catIndex].fields.push({ label: '', type: 'text', description: '' });
 | 
					  form.value.categories[catIndex].fields.push({ label: '', type: 'text', description: '' });
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function removeField(catIndex, fieldIndex) {
 | 
					function removeField(catIndex, fieldIndex) 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
  form.value.categories[catIndex].fields.splice(fieldIndex, 1);
 | 
					  form.value.categories[catIndex].fields.splice(fieldIndex, 1);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
async function createForm() {
 | 
					async function createForm() 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
  submitting.value = true;
 | 
					  submitting.value = true;
 | 
				
			||||||
    try {
 | 
					  try 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    const response = await axios.post('/api/forms', form.value);
 | 
					    const response = await axios.post('/api/forms', form.value);
 | 
				
			||||||
    $q.notify({
 | 
					    $q.notify({
 | 
				
			||||||
      color: 'positive',
 | 
					      color: 'positive',
 | 
				
			||||||
| 
						 | 
					@ -98,7 +190,9 @@ async function createForm() {
 | 
				
			||||||
      icon: 'check_circle'
 | 
					      icon: 'check_circle'
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
    router.push({ name: 'formList' });
 | 
					    router.push({ name: 'formList' });
 | 
				
			||||||
    } catch (error) {
 | 
					  }
 | 
				
			||||||
 | 
					  catch (error) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    console.error('Error creating form:', error);
 | 
					    console.error('Error creating form:', error);
 | 
				
			||||||
    const message = error.response?.data?.error || 'Failed to create form. Please check the details and try again.';
 | 
					    const message = error.response?.data?.error || 'Failed to create form. Please check the details and try again.';
 | 
				
			||||||
    $q.notify({
 | 
					    $q.notify({
 | 
				
			||||||
| 
						 | 
					@ -107,7 +201,9 @@ async function createForm() {
 | 
				
			||||||
      message: message,
 | 
					      message: message,
 | 
				
			||||||
      icon: 'report_problem'
 | 
					      icon: 'report_problem'
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
    } finally {
 | 
					  }
 | 
				
			||||||
 | 
					  finally 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    submitting.value = false;
 | 
					    submitting.value = false;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,8 +1,14 @@
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <q-page padding>
 | 
					  <q-page padding>
 | 
				
			||||||
    <div class="text-h4 q-mb-md">Edit Form</div>
 | 
					    <div class="text-h4 q-mb-md">
 | 
				
			||||||
 | 
					      Edit Form
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <q-form v-if="!loading && form" @submit.prevent="updateForm" class="q-gutter-md">
 | 
					    <q-form
 | 
				
			||||||
 | 
					      v-if="!loading && form"
 | 
				
			||||||
 | 
					      @submit.prevent="updateForm"
 | 
				
			||||||
 | 
					      class="q-gutter-md"
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
      <q-input
 | 
					      <q-input
 | 
				
			||||||
        outlined
 | 
					        outlined
 | 
				
			||||||
        v-model="form.title"
 | 
					        v-model="form.title"
 | 
				
			||||||
| 
						 | 
					@ -21,25 +27,45 @@
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <q-separator class="q-my-lg" />
 | 
					      <q-separator class="q-my-lg" />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <div class="text-h6 q-mb-sm">Categories & Fields</div>
 | 
					      <div class="text-h6 q-mb-sm">
 | 
				
			||||||
 | 
					        Categories & Fields
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <div v-for="(category, catIndex) in form.categories" :key="category.id || catIndex" class="q-mb-lg q-pa-md bordered rounded-borders">
 | 
					      <div
 | 
				
			||||||
 | 
					        v-for="(category, catIndex) in form.categories"
 | 
				
			||||||
 | 
					        :key="category.id || catIndex"
 | 
				
			||||||
 | 
					        class="q-mb-lg q-pa-md bordered rounded-borders"
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
        <div class="row items-center q-mb-sm">
 | 
					        <div class="row items-center q-mb-sm">
 | 
				
			||||||
          <q-input
 | 
					          <q-input
 | 
				
			||||||
            outlined dense
 | 
					            outlined
 | 
				
			||||||
 | 
					            dense
 | 
				
			||||||
            v-model="category.name"
 | 
					            v-model="category.name"
 | 
				
			||||||
            :label="`Category ${catIndex + 1} Name *`"
 | 
					            :label="`Category ${catIndex + 1} Name *`"
 | 
				
			||||||
            class="col q-mr-sm"
 | 
					            class="col q-mr-sm"
 | 
				
			||||||
            lazy-rules
 | 
					            lazy-rules
 | 
				
			||||||
            :rules="[ val => val && val.length > 0 || 'Category name required']"
 | 
					            :rules="[ val => val && val.length > 0 || 'Category name required']"
 | 
				
			||||||
          />
 | 
					          />
 | 
				
			||||||
          <q-btn flat round dense icon="delete" color="negative" @click="removeCategory(catIndex)" title="Remove Category" />
 | 
					          <q-btn
 | 
				
			||||||
 | 
					            flat
 | 
				
			||||||
 | 
					            round
 | 
				
			||||||
 | 
					            dense
 | 
				
			||||||
 | 
					            icon="delete"
 | 
				
			||||||
 | 
					            color="negative"
 | 
				
			||||||
 | 
					            @click="removeCategory(catIndex)"
 | 
				
			||||||
 | 
					            title="Remove Category"
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <div v-for="(field, fieldIndex) in category.fields" :key="field.id || fieldIndex" class="q-ml-md q-mb-sm">
 | 
					        <div
 | 
				
			||||||
 | 
					          v-for="(field, fieldIndex) in category.fields"
 | 
				
			||||||
 | 
					          :key="field.id || fieldIndex"
 | 
				
			||||||
 | 
					          class="q-ml-md q-mb-sm"
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
          <div class="row items-center q-gutter-sm">
 | 
					          <div class="row items-center q-gutter-sm">
 | 
				
			||||||
            <q-input
 | 
					            <q-input
 | 
				
			||||||
              outlined dense
 | 
					              outlined
 | 
				
			||||||
 | 
					              dense
 | 
				
			||||||
              v-model="field.label"
 | 
					              v-model="field.label"
 | 
				
			||||||
              label="Field Label *"
 | 
					              label="Field Label *"
 | 
				
			||||||
              class="col"
 | 
					              class="col"
 | 
				
			||||||
| 
						 | 
					@ -47,7 +73,8 @@
 | 
				
			||||||
              :rules="[ val => val && val.length > 0 || 'Field label required']"
 | 
					              :rules="[ val => val && val.length > 0 || 'Field label required']"
 | 
				
			||||||
            />
 | 
					            />
 | 
				
			||||||
            <q-select
 | 
					            <q-select
 | 
				
			||||||
              outlined dense
 | 
					              outlined
 | 
				
			||||||
 | 
					              dense
 | 
				
			||||||
              v-model="field.type"
 | 
					              v-model="field.type"
 | 
				
			||||||
              :options="fieldTypes"
 | 
					              :options="fieldTypes"
 | 
				
			||||||
              label="Field Type *"
 | 
					              label="Field Type *"
 | 
				
			||||||
| 
						 | 
					@ -56,7 +83,15 @@
 | 
				
			||||||
              lazy-rules
 | 
					              lazy-rules
 | 
				
			||||||
              :rules="[ val => !!val || 'Field type required']"
 | 
					              :rules="[ val => !!val || 'Field type required']"
 | 
				
			||||||
            />
 | 
					            />
 | 
				
			||||||
            <q-btn flat round dense icon="delete" color="negative" @click="removeField(catIndex, fieldIndex)" title="Remove Field" />
 | 
					            <q-btn
 | 
				
			||||||
 | 
					              flat
 | 
				
			||||||
 | 
					              round
 | 
				
			||||||
 | 
					              dense
 | 
				
			||||||
 | 
					              icon="delete"
 | 
				
			||||||
 | 
					              color="negative"
 | 
				
			||||||
 | 
					              @click="removeField(catIndex, fieldIndex)"
 | 
				
			||||||
 | 
					              title="Remove Field"
 | 
				
			||||||
 | 
					            />
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
          <q-input
 | 
					          <q-input
 | 
				
			||||||
            v-model="field.description"
 | 
					            v-model="field.description"
 | 
				
			||||||
| 
						 | 
					@ -68,23 +103,53 @@
 | 
				
			||||||
            hint="This description will appear below the field label on the form."
 | 
					            hint="This description will appear below the field label on the form."
 | 
				
			||||||
          />
 | 
					          />
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
        <q-btn outline color="primary" label="Add Field" @click="addField(catIndex)" class="q-ml-md q-mt-sm" />
 | 
					        <q-btn
 | 
				
			||||||
 | 
					          outline
 | 
				
			||||||
 | 
					          color="primary"
 | 
				
			||||||
 | 
					          label="Add Field"
 | 
				
			||||||
 | 
					          @click="addField(catIndex)"
 | 
				
			||||||
 | 
					          class="q-ml-md q-mt-sm"
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <q-btn outline color="secondary" label="Add Category" @click="addCategory" />
 | 
					      <q-btn
 | 
				
			||||||
 | 
					        outline
 | 
				
			||||||
 | 
					        color="secondary"
 | 
				
			||||||
 | 
					        label="Add Category"
 | 
				
			||||||
 | 
					        @click="addCategory"
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <q-separator class="q-my-lg" />
 | 
					      <q-separator class="q-my-lg" />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <div>
 | 
					      <div>
 | 
				
			||||||
        <q-btn outline label="Update Form" type="submit" color="primary" :loading="submitting"/>
 | 
					        <q-btn
 | 
				
			||||||
        <q-btn outline label="Cancel" type="reset" color="warning" class="q-ml-sm" :to="{ name: 'formList' }" />
 | 
					          outline
 | 
				
			||||||
 | 
					          label="Update Form"
 | 
				
			||||||
 | 
					          type="submit"
 | 
				
			||||||
 | 
					          color="primary"
 | 
				
			||||||
 | 
					          :loading="submitting"
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					        <q-btn
 | 
				
			||||||
 | 
					          outline
 | 
				
			||||||
 | 
					          label="Cancel"
 | 
				
			||||||
 | 
					          type="reset"
 | 
				
			||||||
 | 
					          color="warning"
 | 
				
			||||||
 | 
					          class="q-ml-sm"
 | 
				
			||||||
 | 
					          :to="{ name: 'formList' }"
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    </q-form>
 | 
					    </q-form>
 | 
				
			||||||
    <div v-else-if="loading">
 | 
					    <div v-else-if="loading">
 | 
				
			||||||
      <q-spinner-dots color="primary" size="40px" />
 | 
					      <q-spinner-dots
 | 
				
			||||||
 | 
					        color="primary"
 | 
				
			||||||
 | 
					        size="40px"
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
      Loading form details...
 | 
					      Loading form details...
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
    <div v-else class="text-negative">
 | 
					    <div
 | 
				
			||||||
 | 
					      v-else
 | 
				
			||||||
 | 
					      class="text-negative"
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
      Failed to load form details.
 | 
					      Failed to load form details.
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  </q-page>
 | 
					  </q-page>
 | 
				
			||||||
| 
						 | 
					@ -112,17 +177,22 @@ const loading = ref(true);
 | 
				
			||||||
const fieldTypes = ref(['text', 'number', 'date', 'textarea', 'boolean']);
 | 
					const fieldTypes = ref(['text', 'number', 'date', 'textarea', 'boolean']);
 | 
				
			||||||
const submitting = ref(false);
 | 
					const submitting = ref(false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
async function fetchForm() {
 | 
					async function fetchForm() 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
  loading.value = true;
 | 
					  loading.value = true;
 | 
				
			||||||
  try {
 | 
					  try 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    const response = await axios.get(`/api/forms/${props.id}`);
 | 
					    const response = await axios.get(`/api/forms/${props.id}`);
 | 
				
			||||||
    // Ensure categories and fields exist, even if empty
 | 
					    // Ensure categories and fields exist, even if empty
 | 
				
			||||||
    response.data.categories = response.data.categories || [];
 | 
					    response.data.categories = response.data.categories || [];
 | 
				
			||||||
    response.data.categories.forEach(cat => {
 | 
					    response.data.categories.forEach(cat => 
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
      cat.fields = cat.fields || [];
 | 
					      cat.fields = cat.fields || [];
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
    form.value = response.data;
 | 
					    form.value = response.data;
 | 
				
			||||||
  } catch (error) {
 | 
					  }
 | 
				
			||||||
 | 
					  catch (error) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    console.error('Error fetching form details:', error);
 | 
					    console.error('Error fetching form details:', error);
 | 
				
			||||||
    $q.notify({
 | 
					    $q.notify({
 | 
				
			||||||
      color: 'negative',
 | 
					      color: 'negative',
 | 
				
			||||||
| 
						 | 
					@ -131,38 +201,48 @@ async function fetchForm() {
 | 
				
			||||||
      icon: 'report_problem'
 | 
					      icon: 'report_problem'
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
    form.value = null; // Indicate failure
 | 
					    form.value = null; // Indicate failure
 | 
				
			||||||
  } finally {
 | 
					  }
 | 
				
			||||||
 | 
					  finally 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    loading.value = false;
 | 
					    loading.value = false;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
onMounted(fetchForm);
 | 
					onMounted(fetchForm);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function addCategory() {
 | 
					function addCategory() 
 | 
				
			||||||
  if (!form.value.categories) {
 | 
					{
 | 
				
			||||||
 | 
					  if (!form.value.categories) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    form.value.categories = [];
 | 
					    form.value.categories = [];
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  form.value.categories.push({ name: `Category ${form.value.categories.length + 1}`, fields: [{ label: '', type: 'text', description: '' }] });
 | 
					  form.value.categories.push({ name: `Category ${form.value.categories.length + 1}`, fields: [{ label: '', type: 'text', description: '' }] });
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function removeCategory(index) {
 | 
					function removeCategory(index) 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
  form.value.categories.splice(index, 1);
 | 
					  form.value.categories.splice(index, 1);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function addField(catIndex) {
 | 
					function addField(catIndex) 
 | 
				
			||||||
   if (!form.value.categories[catIndex].fields) {
 | 
					{
 | 
				
			||||||
 | 
					  if (!form.value.categories[catIndex].fields) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    form.value.categories[catIndex].fields = [];
 | 
					    form.value.categories[catIndex].fields = [];
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  form.value.categories[catIndex].fields.push({ label: '', type: 'text', description: '' });
 | 
					  form.value.categories[catIndex].fields.push({ label: '', type: 'text', description: '' });
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function removeField(catIndex, fieldIndex) {
 | 
					function removeField(catIndex, fieldIndex) 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
  form.value.categories[catIndex].fields.splice(fieldIndex, 1);
 | 
					  form.value.categories[catIndex].fields.splice(fieldIndex, 1);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
async function updateForm() {
 | 
					async function updateForm() 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
  submitting.value = true;
 | 
					  submitting.value = true;
 | 
				
			||||||
  try {
 | 
					  try 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    // Prepare payload, potentially removing temporary IDs if any were added client-side
 | 
					    // Prepare payload, potentially removing temporary IDs if any were added client-side
 | 
				
			||||||
    const payload = JSON.parse(JSON.stringify(form.value));
 | 
					    const payload = JSON.parse(JSON.stringify(form.value));
 | 
				
			||||||
    // The backend PUT expects title, description, categories (with name, fields (with label, type, description))
 | 
					    // The backend PUT expects title, description, categories (with name, fields (with label, type, description))
 | 
				
			||||||
| 
						 | 
					@ -176,7 +256,9 @@ async function updateForm() {
 | 
				
			||||||
      icon: 'check_circle'
 | 
					      icon: 'check_circle'
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
    router.push({ name: 'formList' }); // Or maybe back to the form details/responses page
 | 
					    router.push({ name: 'formList' }); // Or maybe back to the form details/responses page
 | 
				
			||||||
  } catch (error) {
 | 
					  }
 | 
				
			||||||
 | 
					  catch (error) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    console.error('Error updating form:', error);
 | 
					    console.error('Error updating form:', error);
 | 
				
			||||||
    const message = error.response?.data?.error || 'Failed to update form. Please check the details and try again.';
 | 
					    const message = error.response?.data?.error || 'Failed to update form. Please check the details and try again.';
 | 
				
			||||||
    $q.notify({
 | 
					    $q.notify({
 | 
				
			||||||
| 
						 | 
					@ -185,7 +267,9 @@ async function updateForm() {
 | 
				
			||||||
      message: message,
 | 
					      message: message,
 | 
				
			||||||
      icon: 'report_problem'
 | 
					      icon: 'report_problem'
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  } finally {
 | 
					  }
 | 
				
			||||||
 | 
					  finally 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    submitting.value = false;
 | 
					    submitting.value = false;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,20 +1,47 @@
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <q-page padding>
 | 
					  <q-page padding>
 | 
				
			||||||
    <q-inner-loading :showing="loading">
 | 
					    <q-inner-loading :showing="loading">
 | 
				
			||||||
      <q-spinner-gears size="50px" color="primary" />
 | 
					      <q-spinner-gears
 | 
				
			||||||
 | 
					        size="50px"
 | 
				
			||||||
 | 
					        color="primary"
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
    </q-inner-loading>
 | 
					    </q-inner-loading>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <div v-if="!loading && form">
 | 
					    <div v-if="!loading && form">
 | 
				
			||||||
      <div class="text-h4 q-mb-xs">{{ form.title }}</div>
 | 
					      <div class="text-h4 q-mb-xs">
 | 
				
			||||||
      <div class="text-subtitle1 text-grey q-mb-lg">{{ form.description }}</div>
 | 
					        {{ form.title }}
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      <div class="text-subtitle1 text-grey q-mb-lg">
 | 
				
			||||||
 | 
					        {{ form.description }}
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <q-form @submit.prevent="submitResponse" class="q-gutter-md">
 | 
					      <q-form
 | 
				
			||||||
 | 
					        @submit.prevent="submitResponse"
 | 
				
			||||||
        <div v-for="category in form.categories" :key="category.id" class="q-mb-lg">
 | 
					        class="q-gutter-md"
 | 
				
			||||||
          <div class="text-h6 q-mb-sm">{{ category.name }}</div>
 | 
					      >
 | 
				
			||||||
          <div v-for="field in category.fields" :key="field.id" class="q-mb-md">
 | 
					        <div
 | 
				
			||||||
            <q-item-label class="q-mb-xs">{{ field.label }}</q-item-label>
 | 
					          v-for="category in form.categories"
 | 
				
			||||||
            <q-item-label caption v-if="field.description" class="q-mb-xs text-grey-7">{{ field.description }}</q-item-label>
 | 
					          :key="category.id"
 | 
				
			||||||
 | 
					          class="q-mb-lg"
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          <div class="text-h6 q-mb-sm">
 | 
				
			||||||
 | 
					            {{ category.name }}
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					          <div
 | 
				
			||||||
 | 
					            v-for="field in category.fields"
 | 
				
			||||||
 | 
					            :key="field.id"
 | 
				
			||||||
 | 
					            class="q-mb-md"
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            <q-item-label class="q-mb-xs">
 | 
				
			||||||
 | 
					              {{ field.label }}
 | 
				
			||||||
 | 
					            </q-item-label>
 | 
				
			||||||
 | 
					            <q-item-label
 | 
				
			||||||
 | 
					              caption
 | 
				
			||||||
 | 
					              v-if="field.description"
 | 
				
			||||||
 | 
					              class="q-mb-xs text-grey-7"
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              {{ field.description }}
 | 
				
			||||||
 | 
					            </q-item-label>
 | 
				
			||||||
            <q-input
 | 
					            <q-input
 | 
				
			||||||
              v-if="field.type === 'text'"
 | 
					              v-if="field.type === 'text'"
 | 
				
			||||||
              outlined
 | 
					              outlined
 | 
				
			||||||
| 
						 | 
					@ -58,19 +85,39 @@
 | 
				
			||||||
        <q-separator class="q-my-lg" />
 | 
					        <q-separator class="q-my-lg" />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <div>
 | 
					        <div>
 | 
				
			||||||
          <q-btn outline label="Submit Response" type="submit" color="primary" :loading="submitting"/>
 | 
					          <q-btn
 | 
				
			||||||
          <q-btn outline label="Cancel" type="reset" color="default" class="q-ml-sm" :to="{ name: 'formList' }" />
 | 
					            outline
 | 
				
			||||||
 | 
					            label="Submit Response"
 | 
				
			||||||
 | 
					            type="submit"
 | 
				
			||||||
 | 
					            color="primary"
 | 
				
			||||||
 | 
					            :loading="submitting"
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
 | 
					          <q-btn
 | 
				
			||||||
 | 
					            outline
 | 
				
			||||||
 | 
					            label="Cancel"
 | 
				
			||||||
 | 
					            type="reset"
 | 
				
			||||||
 | 
					            color="default"
 | 
				
			||||||
 | 
					            class="q-ml-sm"
 | 
				
			||||||
 | 
					            :to="{ name: 'formList' }"
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
 | 
					 | 
				
			||||||
      </q-form>
 | 
					      </q-form>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
     <q-banner v-else-if="!loading && !form" class="bg-negative text-white">
 | 
					    <q-banner
 | 
				
			||||||
      <template v-slot:avatar>
 | 
					      v-else-if="!loading && !form"
 | 
				
			||||||
 | 
					      class="bg-negative text-white"
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <template #avatar>
 | 
				
			||||||
        <q-icon name="error" />
 | 
					        <q-icon name="error" />
 | 
				
			||||||
      </template>
 | 
					      </template>
 | 
				
			||||||
      Form not found or could not be loaded.
 | 
					      Form not found or could not be loaded.
 | 
				
			||||||
      <template v-slot:action>
 | 
					      <template #action>
 | 
				
			||||||
        <q-btn flat color="white" label="Back to Forms" :to="{ name: 'formList' }" />
 | 
					        <q-btn
 | 
				
			||||||
 | 
					          flat
 | 
				
			||||||
 | 
					          color="white"
 | 
				
			||||||
 | 
					          label="Back to Forms"
 | 
				
			||||||
 | 
					          :to="{ name: 'formList' }"
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
      </template>
 | 
					      </template>
 | 
				
			||||||
    </q-banner>
 | 
					    </q-banner>
 | 
				
			||||||
  </q-page>
 | 
					  </q-page>
 | 
				
			||||||
| 
						 | 
					@ -97,19 +144,25 @@ const responses = reactive({}); // Use reactive for dynamic properties
 | 
				
			||||||
const loading = ref(true);
 | 
					const loading = ref(true);
 | 
				
			||||||
const submitting = ref(false);
 | 
					const submitting = ref(false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
async function fetchFormDetails() {
 | 
					async function fetchFormDetails() 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
  loading.value = true;
 | 
					  loading.value = true;
 | 
				
			||||||
  form.value = null; // Reset form data
 | 
					  form.value = null; // Reset form data
 | 
				
			||||||
  try {
 | 
					  try 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    const response = await axios.get(`/api/forms/${props.id}`);
 | 
					    const response = await axios.get(`/api/forms/${props.id}`);
 | 
				
			||||||
    form.value = response.data;
 | 
					    form.value = response.data;
 | 
				
			||||||
    // Initialize responses object based on fields
 | 
					    // Initialize responses object based on fields
 | 
				
			||||||
    form.value.categories.forEach(cat => {
 | 
					    form.value.categories.forEach(cat => 
 | 
				
			||||||
      cat.fields.forEach(field => {
 | 
					    {
 | 
				
			||||||
 | 
					      cat.fields.forEach(field => 
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
        responses[field.id] = null; // Initialize all fields to null or default
 | 
					        responses[field.id] = null; // Initialize all fields to null or default
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  } catch (error) {
 | 
					  }
 | 
				
			||||||
 | 
					  catch (error) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    console.error(`Error fetching form ${props.id}:`, error);
 | 
					    console.error(`Error fetching form ${props.id}:`, error);
 | 
				
			||||||
    $q.notify({
 | 
					    $q.notify({
 | 
				
			||||||
      color: 'negative',
 | 
					      color: 'negative',
 | 
				
			||||||
| 
						 | 
					@ -117,14 +170,18 @@ async function fetchFormDetails() {
 | 
				
			||||||
      message: 'Failed to load form details.',
 | 
					      message: 'Failed to load form details.',
 | 
				
			||||||
      icon: 'report_problem'
 | 
					      icon: 'report_problem'
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  } finally {
 | 
					  }
 | 
				
			||||||
 | 
					  finally 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    loading.value = false;
 | 
					    loading.value = false;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
async function submitResponse() {
 | 
					async function submitResponse() 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
  submitting.value = true;
 | 
					  submitting.value = true;
 | 
				
			||||||
  try {
 | 
					  try 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    // Basic check if any response is provided (optional)
 | 
					    // Basic check if any response is provided (optional)
 | 
				
			||||||
    // const hasResponse = Object.values(responses).some(val => val !== null && val !== '');
 | 
					    // const hasResponse = Object.values(responses).some(val => val !== null && val !== '');
 | 
				
			||||||
    // if (!hasResponse) {
 | 
					    // if (!hasResponse) {
 | 
				
			||||||
| 
						 | 
					@ -144,7 +201,9 @@ async function submitResponse() {
 | 
				
			||||||
    // Or clear the form:
 | 
					    // Or clear the form:
 | 
				
			||||||
    // Object.keys(responses).forEach(key => { responses[key] = null; });
 | 
					    // Object.keys(responses).forEach(key => { responses[key] = null; });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  } catch (error) {
 | 
					  }
 | 
				
			||||||
 | 
					  catch (error) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    console.error('Error submitting response:', error);
 | 
					    console.error('Error submitting response:', error);
 | 
				
			||||||
    const message = error.response?.data?.error || 'Failed to submit response.';
 | 
					    const message = error.response?.data?.error || 'Failed to submit response.';
 | 
				
			||||||
    $q.notify({
 | 
					    $q.notify({
 | 
				
			||||||
| 
						 | 
					@ -153,7 +212,9 @@ async function submitResponse() {
 | 
				
			||||||
      message: message,
 | 
					      message: message,
 | 
				
			||||||
      icon: 'report_problem'
 | 
					      icon: 'report_problem'
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  } finally {
 | 
					  }
 | 
				
			||||||
 | 
					  finally 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    submitting.value = false;
 | 
					    submitting.value = false;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,38 +1,96 @@
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <q-page padding>
 | 
					  <q-page padding>
 | 
				
			||||||
    <div class="q-mb-md row justify-between items-center">
 | 
					    <div class="q-mb-md row justify-between items-center">
 | 
				
			||||||
      <div class="text-h4">Forms</div>
 | 
					      <div class="text-h4">
 | 
				
			||||||
      <q-btn outline label="Create New Form" color="primary" :to="{ name: 'formCreate' }" />
 | 
					        Forms
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					      <q-btn
 | 
				
			||||||
 | 
					        outline
 | 
				
			||||||
 | 
					        label="Create New Form"
 | 
				
			||||||
 | 
					        color="primary"
 | 
				
			||||||
 | 
					        :to="{ name: 'formCreate' }"
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <q-list bordered separator v-if="forms.length > 0">
 | 
					    <q-list
 | 
				
			||||||
      <q-item v-for="form in forms" :key="form.id">
 | 
					      bordered
 | 
				
			||||||
 | 
					      separator
 | 
				
			||||||
 | 
					      v-if="forms.length > 0"
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <q-item
 | 
				
			||||||
 | 
					        v-for="form in forms"
 | 
				
			||||||
 | 
					        :key="form.id"
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
        <q-item-section>
 | 
					        <q-item-section>
 | 
				
			||||||
          <q-item-label>{{ form.title }}</q-item-label>
 | 
					          <q-item-label>{{ form.title }}</q-item-label>
 | 
				
			||||||
          <q-item-label caption>{{ form.description || 'No description' }}</q-item-label>
 | 
					          <q-item-label caption>
 | 
				
			||||||
          <q-item-label caption>Created: {{ formatDate(form.createdAt) }}</q-item-label>
 | 
					            {{ form.description || 'No description' }}
 | 
				
			||||||
 | 
					          </q-item-label>
 | 
				
			||||||
 | 
					          <q-item-label caption>
 | 
				
			||||||
 | 
					            Created: {{ formatDate(form.createdAt) }}
 | 
				
			||||||
 | 
					          </q-item-label>
 | 
				
			||||||
        </q-item-section>
 | 
					        </q-item-section>
 | 
				
			||||||
        <q-item-section side>
 | 
					        <q-item-section side>
 | 
				
			||||||
          <div class="q-gutter-sm">
 | 
					          <div class="q-gutter-sm">
 | 
				
			||||||
            <q-btn flat round dense icon="edit_note" color="info" :to="{ name: 'formFill', params: { id: form.id } }" title="Fill Form" />
 | 
					            <q-btn
 | 
				
			||||||
            <q-btn flat round dense icon="visibility" color="secondary" :to="{ name: 'formResponses', params: { id: form.id } }" title="View Responses" />
 | 
					              flat
 | 
				
			||||||
            <q-btn flat round dense icon="edit" color="warning" :to="{ name: 'formEdit', params: { id: form.id } }" title="Edit Form" />
 | 
					              round
 | 
				
			||||||
            <q-btn flat round dense icon="delete" color="negative" @click.stop="confirmDeleteForm(form.id)" title="Delete Form" />
 | 
					              dense
 | 
				
			||||||
 | 
					              icon="edit_note"
 | 
				
			||||||
 | 
					              color="info"
 | 
				
			||||||
 | 
					              :to="{ name: 'formFill', params: { id: form.id } }"
 | 
				
			||||||
 | 
					              title="Fill Form"
 | 
				
			||||||
 | 
					            />
 | 
				
			||||||
 | 
					            <q-btn
 | 
				
			||||||
 | 
					              flat
 | 
				
			||||||
 | 
					              round
 | 
				
			||||||
 | 
					              dense
 | 
				
			||||||
 | 
					              icon="visibility"
 | 
				
			||||||
 | 
					              color="secondary"
 | 
				
			||||||
 | 
					              :to="{ name: 'formResponses', params: { id: form.id } }"
 | 
				
			||||||
 | 
					              title="View Responses"
 | 
				
			||||||
 | 
					            />
 | 
				
			||||||
 | 
					            <q-btn
 | 
				
			||||||
 | 
					              flat
 | 
				
			||||||
 | 
					              round
 | 
				
			||||||
 | 
					              dense
 | 
				
			||||||
 | 
					              icon="edit"
 | 
				
			||||||
 | 
					              color="warning"
 | 
				
			||||||
 | 
					              :to="{ name: 'formEdit', params: { id: form.id } }"
 | 
				
			||||||
 | 
					              title="Edit Form"
 | 
				
			||||||
 | 
					            />
 | 
				
			||||||
 | 
					            <q-btn
 | 
				
			||||||
 | 
					              flat
 | 
				
			||||||
 | 
					              round
 | 
				
			||||||
 | 
					              dense
 | 
				
			||||||
 | 
					              icon="delete"
 | 
				
			||||||
 | 
					              color="negative"
 | 
				
			||||||
 | 
					              @click.stop="confirmDeleteForm(form.id)"
 | 
				
			||||||
 | 
					              title="Delete Form"
 | 
				
			||||||
 | 
					            />
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
        </q-item-section>
 | 
					        </q-item-section>
 | 
				
			||||||
      </q-item>
 | 
					      </q-item>
 | 
				
			||||||
    </q-list>
 | 
					    </q-list>
 | 
				
			||||||
    <q-banner v-else class="bg-info text-white">
 | 
					    <q-banner
 | 
				
			||||||
      <template v-slot:avatar>
 | 
					      v-else
 | 
				
			||||||
        <q-icon name="info" color="white" />
 | 
					      class="bg-info text-white"
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <template #avatar>
 | 
				
			||||||
 | 
					        <q-icon
 | 
				
			||||||
 | 
					          name="info"
 | 
				
			||||||
 | 
					          color="white"
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
      </template>
 | 
					      </template>
 | 
				
			||||||
      No forms created yet. Click the button above to create your first form.
 | 
					      No forms created yet. Click the button above to create your first form.
 | 
				
			||||||
    </q-banner>
 | 
					    </q-banner>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <q-inner-loading :showing="loading">
 | 
					    <q-inner-loading :showing="loading">
 | 
				
			||||||
      <q-spinner-gears size="50px" color="primary" />
 | 
					      <q-spinner-gears
 | 
				
			||||||
 | 
					        size="50px"
 | 
				
			||||||
 | 
					        color="primary"
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
    </q-inner-loading>
 | 
					    </q-inner-loading>
 | 
				
			||||||
 | 
					 | 
				
			||||||
  </q-page>
 | 
					  </q-page>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -45,12 +103,16 @@ const $q = useQuasar();
 | 
				
			||||||
const forms = ref([]);
 | 
					const forms = ref([]);
 | 
				
			||||||
const loading = ref(false);
 | 
					const loading = ref(false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
async function fetchForms() {
 | 
					async function fetchForms() 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
  loading.value = true;
 | 
					  loading.value = true;
 | 
				
			||||||
  try {
 | 
					  try 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    const response = await axios.get('/api/forms');
 | 
					    const response = await axios.get('/api/forms');
 | 
				
			||||||
    forms.value = response.data;
 | 
					    forms.value = response.data;
 | 
				
			||||||
  } catch (error) {
 | 
					  }
 | 
				
			||||||
 | 
					  catch (error) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    console.error('Error fetching forms:', error);
 | 
					    console.error('Error fetching forms:', error);
 | 
				
			||||||
    $q.notify({
 | 
					    $q.notify({
 | 
				
			||||||
      color: 'negative',
 | 
					      color: 'negative',
 | 
				
			||||||
| 
						 | 
					@ -58,13 +120,16 @@ async function fetchForms() {
 | 
				
			||||||
      message: 'Failed to load forms. Please try again later.',
 | 
					      message: 'Failed to load forms. Please try again later.',
 | 
				
			||||||
      icon: 'report_problem'
 | 
					      icon: 'report_problem'
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  } finally {
 | 
					  }
 | 
				
			||||||
 | 
					  finally 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    loading.value = false;
 | 
					    loading.value = false;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Add function to handle delete confirmation
 | 
					// Add function to handle delete confirmation
 | 
				
			||||||
function confirmDeleteForm(id) {
 | 
					function confirmDeleteForm(id) 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
  $q.dialog({
 | 
					  $q.dialog({
 | 
				
			||||||
    title: 'Confirm Delete',
 | 
					    title: 'Confirm Delete',
 | 
				
			||||||
    message: 'Are you sure you want to delete this form and all its responses? This action cannot be undone.',
 | 
					    message: 'Are you sure you want to delete this form and all its responses? This action cannot be undone.',
 | 
				
			||||||
| 
						 | 
					@ -79,14 +144,17 @@ function confirmDeleteForm(id) {
 | 
				
			||||||
      label: 'Cancel',
 | 
					      label: 'Cancel',
 | 
				
			||||||
      flat: true
 | 
					      flat: true
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }).onOk(() => {
 | 
					  }).onOk(() => 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    deleteForm(id);
 | 
					    deleteForm(id);
 | 
				
			||||||
  });
 | 
					  });
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Add function to call the delete API
 | 
					// Add function to call the delete API
 | 
				
			||||||
async function deleteForm(id) {
 | 
					async function deleteForm(id) 
 | 
				
			||||||
  try {
 | 
					{
 | 
				
			||||||
 | 
					  try 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    await axios.delete(`/api/forms/${id}`);
 | 
					    await axios.delete(`/api/forms/${id}`);
 | 
				
			||||||
    forms.value = forms.value.filter(form => form.id !== id);
 | 
					    forms.value = forms.value.filter(form => form.id !== id);
 | 
				
			||||||
    $q.notify({
 | 
					    $q.notify({
 | 
				
			||||||
| 
						 | 
					@ -95,7 +163,9 @@ async function deleteForm(id) {
 | 
				
			||||||
      message: 'Form deleted successfully.',
 | 
					      message: 'Form deleted successfully.',
 | 
				
			||||||
      icon: 'check_circle'
 | 
					      icon: 'check_circle'
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  } catch (error) {
 | 
					  }
 | 
				
			||||||
 | 
					  catch (error) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    console.error(`Error deleting form ${id}:`, error);
 | 
					    console.error(`Error deleting form ${id}:`, error);
 | 
				
			||||||
    const errorMessage = error.response?.data?.error || 'Failed to delete form. Please try again.';
 | 
					    const errorMessage = error.response?.data?.error || 'Failed to delete form. Please try again.';
 | 
				
			||||||
    $q.notify({
 | 
					    $q.notify({
 | 
				
			||||||
| 
						 | 
					@ -108,7 +178,8 @@ async function deleteForm(id) {
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Add function to format date
 | 
					// Add function to format date
 | 
				
			||||||
function formatDate(date) {
 | 
					function formatDate(date) 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
  return new Date(date).toLocaleString();
 | 
					  return new Date(date).toLocaleString();
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,12 +1,17 @@
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <q-page padding>
 | 
					  <q-page padding>
 | 
				
			||||||
    <q-inner-loading :showing="loading">
 | 
					    <q-inner-loading :showing="loading">
 | 
				
			||||||
      <q-spinner-gears size="50px" color="primary" />
 | 
					      <q-spinner-gears
 | 
				
			||||||
 | 
					        size="50px"
 | 
				
			||||||
 | 
					        color="primary"
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
    </q-inner-loading>
 | 
					    </q-inner-loading>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <div v-if="!loading && formTitle">
 | 
					    <div v-if="!loading && formTitle">
 | 
				
			||||||
      <div class="row justify-between items-center q-mb-md">
 | 
					      <div class="row justify-between items-center q-mb-md">
 | 
				
			||||||
        <div class="text-h4">Responses for: {{ formTitle }}</div>
 | 
					        <div class="text-h4">
 | 
				
			||||||
 | 
					          Responses for: {{ formTitle }}
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <!-- Add Search Input -->
 | 
					      <!-- Add Search Input -->
 | 
				
			||||||
| 
						 | 
					@ -19,7 +24,7 @@
 | 
				
			||||||
        placeholder="Search responses..."
 | 
					        placeholder="Search responses..."
 | 
				
			||||||
        class="q-mb-md"
 | 
					        class="q-mb-md"
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        <template v-slot:append>
 | 
					        <template #append>
 | 
				
			||||||
          <q-icon name="search" />
 | 
					          <q-icon name="search" />
 | 
				
			||||||
        </template>
 | 
					        </template>
 | 
				
			||||||
      </q-input>
 | 
					      </q-input>
 | 
				
			||||||
| 
						 | 
					@ -29,22 +34,25 @@
 | 
				
			||||||
        :rows="formattedResponses"
 | 
					        :rows="formattedResponses"
 | 
				
			||||||
        :columns="columns"
 | 
					        :columns="columns"
 | 
				
			||||||
        row-key="id"
 | 
					        row-key="id"
 | 
				
			||||||
        flat bordered
 | 
					        flat
 | 
				
			||||||
 | 
					        bordered
 | 
				
			||||||
        separator="cell"
 | 
					        separator="cell"
 | 
				
			||||||
        wrap-cells
 | 
					        wrap-cells
 | 
				
			||||||
        :filter="filterText"
 | 
					        :filter="filterText"
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        <template v-slot:body-cell-submittedAt="props">
 | 
					        <template #body-cell-submittedAt="props">
 | 
				
			||||||
          <q-td :props="props">
 | 
					          <q-td :props="props">
 | 
				
			||||||
            {{ new Date(props.value).toLocaleString() }}
 | 
					            {{ new Date(props.value).toLocaleString() }}
 | 
				
			||||||
          </q-td>
 | 
					          </q-td>
 | 
				
			||||||
        </template>
 | 
					        </template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <!-- Slot for Actions column -->
 | 
					        <!-- Slot for Actions column -->
 | 
				
			||||||
        <template v-slot:body-cell-actions="props">
 | 
					        <template #body-cell-actions="props">
 | 
				
			||||||
          <q-td :props="props">
 | 
					          <q-td :props="props">
 | 
				
			||||||
            <q-btn
 | 
					            <q-btn
 | 
				
			||||||
              flat dense round
 | 
					              flat
 | 
				
			||||||
 | 
					              dense
 | 
				
			||||||
 | 
					              round
 | 
				
			||||||
              icon="download"
 | 
					              icon="download"
 | 
				
			||||||
              color="primary"
 | 
					              color="primary"
 | 
				
			||||||
              @click="downloadResponsePdf(props.row.id)"
 | 
					              @click="downloadResponsePdf(props.row.id)"
 | 
				
			||||||
| 
						 | 
					@ -54,24 +62,36 @@
 | 
				
			||||||
            </q-btn>
 | 
					            </q-btn>
 | 
				
			||||||
          </q-td>
 | 
					          </q-td>
 | 
				
			||||||
        </template>
 | 
					        </template>
 | 
				
			||||||
 | 
					 | 
				
			||||||
      </q-table>
 | 
					      </q-table>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <q-banner v-else class="">
 | 
					      <q-banner
 | 
				
			||||||
        <template v-slot:avatar>
 | 
					        v-else
 | 
				
			||||||
          <q-icon name="info" color="info" />
 | 
					        class=""
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        <template #avatar>
 | 
				
			||||||
 | 
					          <q-icon
 | 
				
			||||||
 | 
					            name="info"
 | 
				
			||||||
 | 
					            color="info"
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
        </template>
 | 
					        </template>
 | 
				
			||||||
        No responses have been submitted for this form yet.
 | 
					        No responses have been submitted for this form yet.
 | 
				
			||||||
      </q-banner>
 | 
					      </q-banner>
 | 
				
			||||||
 | 
					 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
    <q-banner v-else-if="!loading && !formTitle" class="bg-negative text-white">
 | 
					    <q-banner
 | 
				
			||||||
      <template v-slot:avatar>
 | 
					      v-else-if="!loading && !formTitle"
 | 
				
			||||||
 | 
					      class="bg-negative text-white"
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <template #avatar>
 | 
				
			||||||
        <q-icon name="error" />
 | 
					        <q-icon name="error" />
 | 
				
			||||||
      </template>
 | 
					      </template>
 | 
				
			||||||
      Form not found or could not load responses.
 | 
					      Form not found or could not load responses.
 | 
				
			||||||
      <template v-slot:action>
 | 
					      <template #action>
 | 
				
			||||||
        <q-btn flat color="white" label="Back to Forms" :to="{ name: 'formList' }" />
 | 
					        <q-btn
 | 
				
			||||||
 | 
					          flat
 | 
				
			||||||
 | 
					          color="white"
 | 
				
			||||||
 | 
					          label="Back to Forms"
 | 
				
			||||||
 | 
					          :to="{ name: 'formList' }"
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
      </template>
 | 
					      </template>
 | 
				
			||||||
    </q-banner>
 | 
					    </q-banner>
 | 
				
			||||||
  </q-page>
 | 
					  </q-page>
 | 
				
			||||||
| 
						 | 
					@ -81,9 +101,8 @@
 | 
				
			||||||
import { ref, onMounted, computed } from 'vue';
 | 
					import { ref, onMounted, computed } from 'vue';
 | 
				
			||||||
import axios from 'axios';
 | 
					import axios from 'axios';
 | 
				
			||||||
import { useQuasar } from 'quasar';
 | 
					import { useQuasar } from 'quasar';
 | 
				
			||||||
import { useRoute } from 'vue-router';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = defineProps({
 | 
					const componentProps = defineProps({
 | 
				
			||||||
  id: {
 | 
					  id: {
 | 
				
			||||||
    type: [String, Number],
 | 
					    type: [String, Number],
 | 
				
			||||||
    required: true
 | 
					    required: true
 | 
				
			||||||
| 
						 | 
					@ -93,34 +112,37 @@ const props = defineProps({
 | 
				
			||||||
const $q = useQuasar();
 | 
					const $q = useQuasar();
 | 
				
			||||||
const formTitle = ref('');
 | 
					const formTitle = ref('');
 | 
				
			||||||
const responses = ref([]);
 | 
					const responses = ref([]);
 | 
				
			||||||
const columns = ref([]); // Columns will be generated dynamically
 | 
					const columns = ref([]);
 | 
				
			||||||
const loading = ref(true);
 | 
					const loading = ref(true);
 | 
				
			||||||
const filterText = ref(''); // Add ref for filter text
 | 
					const filterText = ref('');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Fetch both form details (for title and field labels/order) and responses
 | 
					// Fetch both form details (for title and field labels/order) and responses
 | 
				
			||||||
async function fetchData() {
 | 
					async function fetchData() 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
  loading.value = true;
 | 
					  loading.value = true;
 | 
				
			||||||
  formTitle.value = '';
 | 
					  formTitle.value = '';
 | 
				
			||||||
  responses.value = [];
 | 
					  responses.value = [];
 | 
				
			||||||
  columns.value = [];
 | 
					  columns.value = [];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  try {
 | 
					  try 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    // Fetch form details first to get the structure
 | 
					    // Fetch form details first to get the structure
 | 
				
			||||||
    const formDetailsResponse = await axios.get(`/api/forms/${props.id}`);
 | 
					    const formDetailsResponse = await axios.get(`/api/forms/${componentProps.id}`);
 | 
				
			||||||
    const form = formDetailsResponse.data;
 | 
					    const form = formDetailsResponse.data;
 | 
				
			||||||
    formTitle.value = form.title;
 | 
					    formTitle.value = form.title;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Generate columns based on form fields in correct order
 | 
					    // Generate columns based on form fields in correct order
 | 
				
			||||||
    const generatedColumns = [{ name: 'submittedAt', label: 'Submitted At', field: 'submittedAt', align: 'left', sortable: true }];
 | 
					    const generatedColumns = [{ name: 'submittedAt', label: 'Submitted At', field: 'submittedAt', align: 'left', sortable: true }];
 | 
				
			||||||
    form.categories.forEach(cat => {
 | 
					    form.categories.forEach(cat => 
 | 
				
			||||||
      cat.fields.forEach(field => {
 | 
					    {
 | 
				
			||||||
 | 
					      cat.fields.forEach(field => 
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
        generatedColumns.push({
 | 
					        generatedColumns.push({
 | 
				
			||||||
          name: `field_${field.id}`, // Unique name for column
 | 
					          name: `field_${field.id}`,
 | 
				
			||||||
          label: field.label,
 | 
					          label: field.label,
 | 
				
			||||||
          field: row => row.values[field.id]?.value ?? '', // Access nested value safely
 | 
					          field: row => row.values[field.id]?.value ?? '',
 | 
				
			||||||
          align: 'left',
 | 
					          align: 'left',
 | 
				
			||||||
          sortable: true,
 | 
					          sortable: true,
 | 
				
			||||||
          // Add formatting based on field.type if needed
 | 
					 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
| 
						 | 
					@ -135,25 +157,31 @@ async function fetchData() {
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Fetch responses
 | 
					    // Fetch responses
 | 
				
			||||||
    const responsesResponse = await axios.get(`/api/forms/${props.id}/responses`);
 | 
					    const responsesResponse = await axios.get(`/api/forms/${componentProps.id}/responses`);
 | 
				
			||||||
    responses.value = responsesResponse.data; // API already groups them
 | 
					    responses.value = responsesResponse.data;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  } catch (error) {
 | 
					  }
 | 
				
			||||||
    console.error(`Error fetching data for form ${props.id}:`, error);
 | 
					  catch (error) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
 | 
					    console.error(`Error fetching data for form ${componentProps.id}:`, error);
 | 
				
			||||||
    $q.notify({
 | 
					    $q.notify({
 | 
				
			||||||
      color: 'negative',
 | 
					      color: 'negative',
 | 
				
			||||||
      position: 'top',
 | 
					      position: 'top',
 | 
				
			||||||
      message: 'Failed to load form responses.',
 | 
					      message: 'Failed to load form responses.',
 | 
				
			||||||
      icon: 'report_problem'
 | 
					      icon: 'report_problem'
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  } finally {
 | 
					  }
 | 
				
			||||||
 | 
					  finally 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    loading.value = false;
 | 
					    loading.value = false;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Computed property to match the structure expected by QTable rows
 | 
					// Computed property to match the structure expected by QTable rows
 | 
				
			||||||
const formattedResponses = computed(() => {
 | 
					const formattedResponses = computed(() => 
 | 
				
			||||||
  return responses.value.map(response => {
 | 
					{
 | 
				
			||||||
 | 
					  return responses.value.map(response => 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    const row = {
 | 
					    const row = {
 | 
				
			||||||
      id: response.id,
 | 
					      id: response.id,
 | 
				
			||||||
      submittedAt: response.submittedAt,
 | 
					      submittedAt: response.submittedAt,
 | 
				
			||||||
| 
						 | 
					@ -165,8 +193,10 @@ const formattedResponses = computed(() => {
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Function to download a single response as PDF
 | 
					// Function to download a single response as PDF
 | 
				
			||||||
async function downloadResponsePdf(responseId) {
 | 
					async function downloadResponsePdf(responseId) 
 | 
				
			||||||
  try {
 | 
					{
 | 
				
			||||||
 | 
					  try 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    const response = await axios.get(`/api/responses/${responseId}/export/pdf`, {
 | 
					    const response = await axios.get(`/api/responses/${responseId}/export/pdf`, {
 | 
				
			||||||
      responseType: 'blob', // Important for handling file downloads
 | 
					      responseType: 'blob', // Important for handling file downloads
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
| 
						 | 
					@ -179,9 +209,11 @@ async function downloadResponsePdf(responseId) {
 | 
				
			||||||
    // Try to get filename from content-disposition header
 | 
					    // Try to get filename from content-disposition header
 | 
				
			||||||
    const contentDisposition = response.headers['content-disposition'];
 | 
					    const contentDisposition = response.headers['content-disposition'];
 | 
				
			||||||
    let filename = `response-${responseId}.pdf`; // Default filename
 | 
					    let filename = `response-${responseId}.pdf`; // Default filename
 | 
				
			||||||
    if (contentDisposition) {
 | 
					    if (contentDisposition) 
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
      const filenameMatch = contentDisposition.match(/filename="?(.+)"?/i);
 | 
					      const filenameMatch = contentDisposition.match(/filename="?(.+)"?/i);
 | 
				
			||||||
      if (filenameMatch && filenameMatch.length > 1) {
 | 
					      if (filenameMatch && filenameMatch.length > 1) 
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
        filename = filenameMatch[1];
 | 
					        filename = filenameMatch[1];
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
| 
						 | 
					@ -201,7 +233,9 @@ async function downloadResponsePdf(responseId) {
 | 
				
			||||||
      icon: 'check_circle'
 | 
					      icon: 'check_circle'
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  } catch (error) {
 | 
					  }
 | 
				
			||||||
 | 
					  catch (error) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    console.error(`Error downloading PDF for response ${responseId}:`, error);
 | 
					    console.error(`Error downloading PDF for response ${responseId}:`, error);
 | 
				
			||||||
    $q.notify({
 | 
					    $q.notify({
 | 
				
			||||||
      color: 'negative',
 | 
					      color: 'negative',
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										54
									
								
								src/pages/LandingPage.vue
									
										
									
									
									
										Normal 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
									
								
							
							
						
						| 
						 | 
					@ -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>
 | 
				
			||||||
| 
						 | 
					@ -1,8 +1,13 @@
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <q-page padding>
 | 
					  <q-page padding>
 | 
				
			||||||
    <q-card flat bordered>
 | 
					    <q-card
 | 
				
			||||||
 | 
					      flat
 | 
				
			||||||
 | 
					      bordered
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
      <q-card-section class="row items-center justify-between">
 | 
					      <q-card-section class="row items-center justify-between">
 | 
				
			||||||
        <div class="text-h6">Mantis Summaries</div>
 | 
					        <div class="text-h6">
 | 
				
			||||||
 | 
					          Mantis Summaries
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
        <q-btn
 | 
					        <q-btn
 | 
				
			||||||
          label="Generate Today's Summary"
 | 
					          label="Generate Today's Summary"
 | 
				
			||||||
          color="primary"
 | 
					          color="primary"
 | 
				
			||||||
| 
						 | 
					@ -15,8 +20,11 @@
 | 
				
			||||||
      <q-separator />
 | 
					      <q-separator />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <q-card-section v-if="generationError">
 | 
					      <q-card-section v-if="generationError">
 | 
				
			||||||
        <q-banner inline-actions class="text-white bg-red">
 | 
					        <q-banner
 | 
				
			||||||
          <template v-slot:avatar>
 | 
					          inline-actions
 | 
				
			||||||
 | 
					          class="text-white bg-red"
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          <template #avatar>
 | 
				
			||||||
            <q-icon name="error" />
 | 
					            <q-icon name="error" />
 | 
				
			||||||
          </template>
 | 
					          </template>
 | 
				
			||||||
          {{ generationError }}
 | 
					          {{ generationError }}
 | 
				
			||||||
| 
						 | 
					@ -24,30 +32,53 @@
 | 
				
			||||||
      </q-card-section>
 | 
					      </q-card-section>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <q-card-section v-if="loading">
 | 
					      <q-card-section v-if="loading">
 | 
				
			||||||
        <q-spinner-dots size="40px" color="primary" />
 | 
					        <q-spinner-dots
 | 
				
			||||||
 | 
					          size="40px"
 | 
				
			||||||
 | 
					          color="primary"
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
        <span class="q-ml-md">Loading summaries...</span>
 | 
					        <span class="q-ml-md">Loading summaries...</span>
 | 
				
			||||||
      </q-card-section>
 | 
					      </q-card-section>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <q-card-section v-if="error && !generationError">
 | 
					      <q-card-section v-if="error && !generationError">
 | 
				
			||||||
        <q-banner inline-actions class="text-white bg-red">
 | 
					        <q-banner
 | 
				
			||||||
          <template v-slot:avatar>
 | 
					          inline-actions
 | 
				
			||||||
 | 
					          class="text-white bg-red"
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          <template #avatar>
 | 
				
			||||||
            <q-icon name="error" />
 | 
					            <q-icon name="error" />
 | 
				
			||||||
          </template>
 | 
					          </template>
 | 
				
			||||||
          {{ error }}
 | 
					          {{ error }}
 | 
				
			||||||
        </q-banner>
 | 
					        </q-banner>
 | 
				
			||||||
      </q-card-section>
 | 
					      </q-card-section>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <q-list separator v-if="!loading && !error && summaries.length > 0">
 | 
					      <q-list
 | 
				
			||||||
        <q-item v-for="summary in summaries" :key="summary.id">
 | 
					        separator
 | 
				
			||||||
 | 
					        v-if="!loading && !error && summaries.length > 0"
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        <q-item
 | 
				
			||||||
 | 
					          v-for="summary in summaries"
 | 
				
			||||||
 | 
					          :key="summary.id"
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
          <q-item-section>
 | 
					          <q-item-section>
 | 
				
			||||||
            <q-item-label class="text-weight-bold">{{ formatDate(summary.summaryDate) }}</q-item-label>
 | 
					            <q-item-label class="text-weight-bold">
 | 
				
			||||||
            <q-item-label caption>Generated: {{ formatDateTime(summary.generatedAt) }}</q-item-label>
 | 
					              {{ formatDate(summary.summaryDate) }}
 | 
				
			||||||
            <q-item-label class="q-mt-sm markdown-content" v-html="parseMarkdown(summary.summaryText)"></q-item-label>
 | 
					            </q-item-label>
 | 
				
			||||||
 | 
					            <q-item-label caption>
 | 
				
			||||||
 | 
					              Generated: {{ formatDateTime(summary.generatedAt) }}
 | 
				
			||||||
 | 
					            </q-item-label>
 | 
				
			||||||
 | 
					            <q-item-label
 | 
				
			||||||
 | 
					              class="q-mt-sm markdown-content"
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              <div v-html="parseMarkdown(summary.content)" />
 | 
				
			||||||
 | 
					            </q-item-label>
 | 
				
			||||||
          </q-item-section>
 | 
					          </q-item-section>
 | 
				
			||||||
        </q-item>
 | 
					        </q-item>
 | 
				
			||||||
      </q-list>
 | 
					      </q-list>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <q-card-section v-if="totalPages > 1" class="flex flex-center q-mt-md">
 | 
					      <q-card-section
 | 
				
			||||||
 | 
					        v-if="totalPages > 1"
 | 
				
			||||||
 | 
					        class="flex flex-center q-mt-md"
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
        <q-pagination
 | 
					        <q-pagination
 | 
				
			||||||
          v-model="currentPage"
 | 
					          v-model="currentPage"
 | 
				
			||||||
          :max="totalPages"
 | 
					          :max="totalPages"
 | 
				
			||||||
| 
						 | 
					@ -60,9 +91,10 @@
 | 
				
			||||||
      </q-card-section>
 | 
					      </q-card-section>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <q-card-section v-if="!loading && !error && summaries.length === 0">
 | 
					      <q-card-section v-if="!loading && !error && summaries.length === 0">
 | 
				
			||||||
        <div class="text-center text-grey">No summaries found.</div>
 | 
					        <div class="text-center text-grey">
 | 
				
			||||||
 | 
					          No summaries found.
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
      </q-card-section>
 | 
					      </q-card-section>
 | 
				
			||||||
 | 
					 | 
				
			||||||
    </q-card>
 | 
					    </q-card>
 | 
				
			||||||
  </q-page>
 | 
					  </q-page>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
| 
						 | 
					@ -86,18 +118,21 @@ const totalItems = ref(0);
 | 
				
			||||||
// Create a custom renderer
 | 
					// Create a custom renderer
 | 
				
			||||||
const renderer = new marked.Renderer();
 | 
					const renderer = new marked.Renderer();
 | 
				
			||||||
const linkRenderer = renderer.link;
 | 
					const linkRenderer = renderer.link;
 | 
				
			||||||
renderer.link = (href, title, text) => {
 | 
					renderer.link = (href, title, text) => 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
  const html = linkRenderer.call(renderer, href, title, text);
 | 
					  const html = linkRenderer.call(renderer, href, title, text);
 | 
				
			||||||
  // Add target="_blank" to the link
 | 
					  // Add target="_blank" to the link
 | 
				
			||||||
  return html.replace(/^<a /, '<a target="_blank" rel="noopener noreferrer" ');
 | 
					  return html.replace(/^<a /, '<a target="_blank" rel="noopener noreferrer" ');
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const fetchSummaries = async (page = 1) => {
 | 
					const fetchSummaries = async(page = 1) => 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
  loading.value = true;
 | 
					  loading.value = true;
 | 
				
			||||||
  error.value = null;
 | 
					  error.value = null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  try {
 | 
					  try 
 | 
				
			||||||
    const response = await axios.get(`/api/mantis-summaries`, {
 | 
					  {
 | 
				
			||||||
 | 
					    const response = await axios.get('/api/mantis-summaries', {
 | 
				
			||||||
      params: {
 | 
					      params: {
 | 
				
			||||||
        page: page,
 | 
					        page: page,
 | 
				
			||||||
        limit: itemsPerPage.value
 | 
					        limit: itemsPerPage.value
 | 
				
			||||||
| 
						 | 
					@ -106,19 +141,25 @@ const fetchSummaries = async (page = 1) => {
 | 
				
			||||||
    summaries.value = response.data.summaries;
 | 
					    summaries.value = response.data.summaries;
 | 
				
			||||||
    totalItems.value = response.data.total;
 | 
					    totalItems.value = response.data.total;
 | 
				
			||||||
    currentPage.value = page;
 | 
					    currentPage.value = page;
 | 
				
			||||||
  } catch (err) {
 | 
					  }
 | 
				
			||||||
 | 
					  catch (err) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    console.error('Error fetching Mantis summaries:', err);
 | 
					    console.error('Error fetching Mantis summaries:', err);
 | 
				
			||||||
    error.value = err.response?.data?.error || 'Failed to load summaries. Please try again later.';
 | 
					    error.value = err.response?.data?.error || 'Failed to load summaries. Please try again later.';
 | 
				
			||||||
  } finally {
 | 
					  }
 | 
				
			||||||
 | 
					  finally 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    loading.value = false;
 | 
					    loading.value = false;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const generateSummary = async () => {
 | 
					const generateSummary = async() => 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
  generating.value = true;
 | 
					  generating.value = true;
 | 
				
			||||||
  generationError.value = null;
 | 
					  generationError.value = null;
 | 
				
			||||||
  error.value = null; // Clear previous loading errors
 | 
					  error.value = null; // Clear previous loading errors
 | 
				
			||||||
  try {
 | 
					  try 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    await axios.post('/api/mantis-summaries/generate');
 | 
					    await axios.post('/api/mantis-summaries/generate');
 | 
				
			||||||
    $q.notify({
 | 
					    $q.notify({
 | 
				
			||||||
      color: 'positive',
 | 
					      color: 'positive',
 | 
				
			||||||
| 
						 | 
					@ -128,7 +169,9 @@ const generateSummary = async () => {
 | 
				
			||||||
    // Optionally, refresh the list after a short delay or immediately
 | 
					    // Optionally, refresh the list after a short delay or immediately
 | 
				
			||||||
    // Consider that generation might be async on the backend
 | 
					    // Consider that generation might be async on the backend
 | 
				
			||||||
    setTimeout(() => fetchSummaries(1), 3000); // Refresh after 3 seconds
 | 
					    setTimeout(() => fetchSummaries(1), 3000); // Refresh after 3 seconds
 | 
				
			||||||
  } catch (err) {
 | 
					  }
 | 
				
			||||||
 | 
					  catch (err) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    console.error('Error generating Mantis summary:', err);
 | 
					    console.error('Error generating Mantis summary:', err);
 | 
				
			||||||
    generationError.value = err.response?.data?.error || 'Failed to start summary generation.';
 | 
					    generationError.value = err.response?.data?.error || 'Failed to start summary generation.';
 | 
				
			||||||
    $q.notify({
 | 
					    $q.notify({
 | 
				
			||||||
| 
						 | 
					@ -136,31 +179,38 @@ const generateSummary = async () => {
 | 
				
			||||||
      icon: 'error',
 | 
					      icon: 'error',
 | 
				
			||||||
      message: generationError.value,
 | 
					      message: generationError.value,
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  } finally {
 | 
					  }
 | 
				
			||||||
 | 
					  finally 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    generating.value = false;
 | 
					    generating.value = false;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const formatDate = (dateString) => {
 | 
					const formatDate = (dateString) => 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
  // Assuming dateString is YYYY-MM-DD
 | 
					  // Assuming dateString is YYYY-MM-DD
 | 
				
			||||||
  return date.formatDate(dateString + 'T00:00:00', 'DD MMMM YYYY');
 | 
					  return date.formatDate(dateString + 'T00:00:00', 'DD MMMM YYYY');
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const formatDateTime = (dateTimeString) => {
 | 
					const formatDateTime = (dateTimeString) => 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
  return date.formatDate(dateTimeString, 'DD MMMM YYYY HH:mm');
 | 
					  return date.formatDate(dateTimeString, 'DD MMMM YYYY HH:mm');
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const parseMarkdown = (markdownText) => {
 | 
					const parseMarkdown = (markdownText) => 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
  if (!markdownText) return '';
 | 
					  if (!markdownText) return '';
 | 
				
			||||||
  // Use the custom renderer with marked
 | 
					  // Use the custom renderer with marked
 | 
				
			||||||
  return marked(markdownText, { renderer });
 | 
					  return marked(markdownText, { renderer });
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const totalPages = computed(() => {
 | 
					const totalPages = computed(() => 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
  return Math.ceil(totalItems.value / itemsPerPage.value);
 | 
					  return Math.ceil(totalItems.value / itemsPerPage.value);
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
onMounted(() => {
 | 
					onMounted(() => 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
  fetchSummaries(currentPage.value);
 | 
					  fetchSummaries(currentPage.value);
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										371
									
								
								src/pages/PasskeyManagementPage.vue
									
										
									
									
									
										Normal 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
									
								
							
							
						
						| 
						 | 
					@ -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>
 | 
				
			||||||
| 
						 | 
					@ -1,11 +1,21 @@
 | 
				
			||||||
<template>
 | 
					<template>
 | 
				
			||||||
  <q-page padding>
 | 
					  <q-page padding>
 | 
				
			||||||
    <div class="q-gutter-md" style="max-width: 800px; margin: auto;">
 | 
					    <div
 | 
				
			||||||
      <h5 class="q-mt-none q-mb-md">Settings</h5>
 | 
					      class="q-gutter-md"
 | 
				
			||||||
 | 
					      style="max-width: 800px; margin: auto;"
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <h5 class="q-mt-none q-mb-md">
 | 
				
			||||||
 | 
					        Settings
 | 
				
			||||||
 | 
					      </h5>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <q-card flat bordered>
 | 
					      <q-card
 | 
				
			||||||
 | 
					        flat
 | 
				
			||||||
 | 
					        bordered
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
        <q-card-section>
 | 
					        <q-card-section>
 | 
				
			||||||
          <div class="text-h6">Mantis Summary Prompt</div>
 | 
					          <div class="text-h6">
 | 
				
			||||||
 | 
					            Mantis Summary Prompt
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
          <div class="text-caption text-grey q-mb-sm">
 | 
					          <div class="text-caption text-grey q-mb-sm">
 | 
				
			||||||
            Edit the prompt used to generate Mantis summaries. Use $DATE and $MANTIS_TICKETS as placeholders.
 | 
					            Edit the prompt used to generate Mantis summaries. Use $DATE and $MANTIS_TICKETS as placeholders.
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
| 
						 | 
					@ -30,9 +40,14 @@
 | 
				
			||||||
        </q-card-actions>
 | 
					        </q-card-actions>
 | 
				
			||||||
      </q-card>
 | 
					      </q-card>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <q-card flat bordered>
 | 
					      <q-card
 | 
				
			||||||
 | 
					        flat
 | 
				
			||||||
 | 
					        bordered
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
        <q-card-section>
 | 
					        <q-card-section>
 | 
				
			||||||
          <div class="text-h6">Email Summary Prompt</div>
 | 
					          <div class="text-h6">
 | 
				
			||||||
 | 
					            Email Summary Prompt
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
          <div class="text-caption text-grey q-mb-sm">
 | 
					          <div class="text-caption text-grey q-mb-sm">
 | 
				
			||||||
            Edit the prompt used to generate Email summaries. Use $EMAIL_DATA as a placeholder for the JSON email array.
 | 
					            Edit the prompt used to generate Email summaries. Use $EMAIL_DATA as a placeholder for the JSON email array.
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
| 
						 | 
					@ -56,7 +71,6 @@
 | 
				
			||||||
          />
 | 
					          />
 | 
				
			||||||
        </q-card-actions>
 | 
					        </q-card-actions>
 | 
				
			||||||
      </q-card>
 | 
					      </q-card>
 | 
				
			||||||
 | 
					 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  </q-page>
 | 
					  </q-page>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
| 
						 | 
					@ -72,40 +86,52 @@ const mantisPrompt = ref('');
 | 
				
			||||||
const loadingPrompt = ref(false);
 | 
					const loadingPrompt = ref(false);
 | 
				
			||||||
const savingPrompt = ref(false);
 | 
					const savingPrompt = ref(false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const fetchMantisPrompt = async () => {
 | 
					const fetchMantisPrompt = async() => 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
  loadingPrompt.value = true;
 | 
					  loadingPrompt.value = true;
 | 
				
			||||||
  try {
 | 
					  try 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    const response = await axios.get('/api/settings/mantisPrompt');
 | 
					    const response = await axios.get('/api/settings/mantisPrompt');
 | 
				
			||||||
    mantisPrompt.value = response.data.value || ''; // Handle case where setting might not exist yet
 | 
					    mantisPrompt.value = response.data.value || ''; // Handle case where setting might not exist yet
 | 
				
			||||||
  } catch (error) {
 | 
					  }
 | 
				
			||||||
 | 
					  catch (error) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    console.error('Error fetching Mantis prompt:', error);
 | 
					    console.error('Error fetching Mantis prompt:', error);
 | 
				
			||||||
    $q.notify({
 | 
					    $q.notify({
 | 
				
			||||||
      color: 'negative',
 | 
					      color: 'negative',
 | 
				
			||||||
      message: 'Failed to load Mantis prompt setting.',
 | 
					      message: 'Failed to load Mantis prompt setting.',
 | 
				
			||||||
      icon: 'report_problem'
 | 
					      icon: 'report_problem'
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  } finally {
 | 
					  }
 | 
				
			||||||
 | 
					  finally 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    loadingPrompt.value = false;
 | 
					    loadingPrompt.value = false;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const saveMantisPrompt = async () => {
 | 
					const saveMantisPrompt = async() => 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
  savingPrompt.value = true;
 | 
					  savingPrompt.value = true;
 | 
				
			||||||
  try {
 | 
					  try 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    await axios.put('/api/settings/mantisPrompt', { value: mantisPrompt.value });
 | 
					    await axios.put('/api/settings/mantisPrompt', { value: mantisPrompt.value });
 | 
				
			||||||
    $q.notify({
 | 
					    $q.notify({
 | 
				
			||||||
      color: 'positive',
 | 
					      color: 'positive',
 | 
				
			||||||
      message: 'Mantis prompt updated successfully.',
 | 
					      message: 'Mantis prompt updated successfully.',
 | 
				
			||||||
      icon: 'check_circle'
 | 
					      icon: 'check_circle'
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  } catch (error) {
 | 
					  }
 | 
				
			||||||
 | 
					  catch (error) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    console.error('Error saving Mantis prompt:', error);
 | 
					    console.error('Error saving Mantis prompt:', error);
 | 
				
			||||||
    $q.notify({
 | 
					    $q.notify({
 | 
				
			||||||
      color: 'negative',
 | 
					      color: 'negative',
 | 
				
			||||||
      message: 'Failed to save Mantis prompt setting.',
 | 
					      message: 'Failed to save Mantis prompt setting.',
 | 
				
			||||||
      icon: 'report_problem'
 | 
					      icon: 'report_problem'
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  } finally {
 | 
					  }
 | 
				
			||||||
 | 
					  finally 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    savingPrompt.value = false;
 | 
					    savingPrompt.value = false;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
| 
						 | 
					@ -114,45 +140,58 @@ const emailPrompt = ref('');
 | 
				
			||||||
const loadingEmailPrompt = ref(false);
 | 
					const loadingEmailPrompt = ref(false);
 | 
				
			||||||
const savingEmailPrompt = ref(false);
 | 
					const savingEmailPrompt = ref(false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const fetchEmailPrompt = async () => {
 | 
					const fetchEmailPrompt = async() => 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
  loadingEmailPrompt.value = true;
 | 
					  loadingEmailPrompt.value = true;
 | 
				
			||||||
  try {
 | 
					  try 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    const response = await axios.get('/api/settings/emailPrompt');
 | 
					    const response = await axios.get('/api/settings/emailPrompt');
 | 
				
			||||||
    emailPrompt.value = response.data.value || ''; // Handle case where setting might not exist yet
 | 
					    emailPrompt.value = response.data.value || ''; // Handle case where setting might not exist yet
 | 
				
			||||||
  } catch (error) {
 | 
					  }
 | 
				
			||||||
 | 
					  catch (error) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    console.error('Error fetching Email prompt:', error);
 | 
					    console.error('Error fetching Email prompt:', error);
 | 
				
			||||||
    $q.notify({
 | 
					    $q.notify({
 | 
				
			||||||
      color: 'negative',
 | 
					      color: 'negative',
 | 
				
			||||||
      message: 'Failed to load Email prompt setting.',
 | 
					      message: 'Failed to load Email prompt setting.',
 | 
				
			||||||
      icon: 'report_problem'
 | 
					      icon: 'report_problem'
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  } finally {
 | 
					  }
 | 
				
			||||||
 | 
					  finally 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    loadingEmailPrompt.value = false;
 | 
					    loadingEmailPrompt.value = false;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const saveEmailPrompt = async () => {
 | 
					const saveEmailPrompt = async() => 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
  savingEmailPrompt.value = true;
 | 
					  savingEmailPrompt.value = true;
 | 
				
			||||||
  try {
 | 
					  try 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    await axios.put('/api/settings/emailPrompt', { value: emailPrompt.value });
 | 
					    await axios.put('/api/settings/emailPrompt', { value: emailPrompt.value });
 | 
				
			||||||
    $q.notify({
 | 
					    $q.notify({
 | 
				
			||||||
      color: 'positive',
 | 
					      color: 'positive',
 | 
				
			||||||
      message: 'Email prompt updated successfully.',
 | 
					      message: 'Email prompt updated successfully.',
 | 
				
			||||||
      icon: 'check_circle'
 | 
					      icon: 'check_circle'
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  } catch (error) {
 | 
					  }
 | 
				
			||||||
 | 
					  catch (error) 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    console.error('Error saving Email prompt:', error);
 | 
					    console.error('Error saving Email prompt:', error);
 | 
				
			||||||
    $q.notify({
 | 
					    $q.notify({
 | 
				
			||||||
      color: 'negative',
 | 
					      color: 'negative',
 | 
				
			||||||
      message: 'Failed to save Email prompt setting.',
 | 
					      message: 'Failed to save Email prompt setting.',
 | 
				
			||||||
      icon: 'report_problem'
 | 
					      icon: 'report_problem'
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  } finally {
 | 
					  }
 | 
				
			||||||
 | 
					  finally 
 | 
				
			||||||
 | 
					  {
 | 
				
			||||||
    savingEmailPrompt.value = false;
 | 
					    savingEmailPrompt.value = false;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
onMounted(() => {
 | 
					onMounted(() => 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
  fetchMantisPrompt();
 | 
					  fetchMantisPrompt();
 | 
				
			||||||
  fetchEmailPrompt();
 | 
					  fetchEmailPrompt();
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,6 +1,7 @@
 | 
				
			||||||
import { defineRouter } from '#q-app/wrappers'
 | 
					import { defineRouter } from '#q-app/wrappers';
 | 
				
			||||||
import { createRouter, createMemoryHistory, createWebHistory, createWebHashHistory } from 'vue-router'
 | 
					import { createRouter, createMemoryHistory, createWebHistory, createWebHashHistory } from 'vue-router';
 | 
				
			||||||
import routes from './routes'
 | 
					import routes from './routes';
 | 
				
			||||||
 | 
					import { useAuthStore } from 'stores/auth'; // Import the auth store
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/*
 | 
					/*
 | 
				
			||||||
 * If not building with SSR mode, you can
 | 
					 * If not building with SSR mode, you can
 | 
				
			||||||
| 
						 | 
					@ -11,10 +12,11 @@ import routes from './routes'
 | 
				
			||||||
 * with the Router instance.
 | 
					 * with the Router instance.
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default defineRouter(function (/* { store, ssrContext } */) {
 | 
					export default defineRouter(function({ store /* { store, ssrContext } */ }) 
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
  const createHistory = process.env.SERVER
 | 
					  const createHistory = process.env.SERVER
 | 
				
			||||||
    ? createMemoryHistory
 | 
					    ? createMemoryHistory
 | 
				
			||||||
    : (process.env.VUE_ROUTER_MODE === 'history' ? createWebHistory : createWebHashHistory)
 | 
					    : (process.env.VUE_ROUTER_MODE === 'history' ? createWebHistory : createWebHashHistory);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const Router = createRouter({
 | 
					  const Router = createRouter({
 | 
				
			||||||
    scrollBehavior: () => ({ left: 0, top: 0 }),
 | 
					    scrollBehavior: () => ({ left: 0, top: 0 }),
 | 
				
			||||||
| 
						 | 
					@ -24,7 +26,46 @@ export default defineRouter(function (/* { store, ssrContext } */) {
 | 
				
			||||||
    // quasar.conf.js -> build -> vueRouterMode
 | 
					    // quasar.conf.js -> build -> vueRouterMode
 | 
				
			||||||
    // quasar.conf.js -> build -> publicPath
 | 
					    // quasar.conf.js -> build -> publicPath
 | 
				
			||||||
    history: createHistory(process.env.VUE_ROUTER_BASE)
 | 
					    history: createHistory(process.env.VUE_ROUTER_BASE)
 | 
				
			||||||
  })
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  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;
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -3,15 +3,89 @@ const routes = [
 | 
				
			||||||
    path: '/',
 | 
					    path: '/',
 | 
				
			||||||
    component: () => import('layouts/MainLayout.vue'),
 | 
					    component: () => import('layouts/MainLayout.vue'),
 | 
				
			||||||
    children: [
 | 
					    children: [
 | 
				
			||||||
      { path: '', name: 'home', component: () => import('pages/FormListPage.vue') },
 | 
					      {
 | 
				
			||||||
      { path: 'forms', name: 'formList', component: () => import('pages/FormListPage.vue') },
 | 
					        path: '',
 | 
				
			||||||
      { path: 'forms/new', name: 'formCreate', component: () => import('pages/FormCreatePage.vue') },
 | 
					        name: 'home',
 | 
				
			||||||
      { path: 'forms/:id/edit', name: 'formEdit', component: () => import('pages/FormEditPage.vue'), props: true },
 | 
					        component: () => import('pages/LandingPage.vue'),
 | 
				
			||||||
      { path: 'forms/:id/fill', name: 'formFill', component: () => import('pages/FormFillPage.vue'), props: true },
 | 
					        meta: { requiresAuth: false } // Keep home accessible, but don't show in nav
 | 
				
			||||||
      { 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: '/login',
 | 
				
			||||||
      { path: 'settings', name: 'settings', component: () => import('pages/SettingsPage.vue') }
 | 
					        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(.*)*',
 | 
					    path: '/:catchAll(.*)*',
 | 
				
			||||||
    component: () => import('pages/ErrorNotFound.vue')
 | 
					    component: () => import('pages/ErrorNotFound.vue')
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
]
 | 
					];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default routes
 | 
					export default routes;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										49
									
								
								src/stores/auth.js
									
										
									
									
									
										Normal 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
									
								
							
							
						
						| 
						 | 
					@ -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
									
								
							
							
						
						| 
						 | 
					@ -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;
 | 
				
			||||||
 | 
					});
 | 
				
			||||||