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