Adds in Mantis features. Enabling automated downloading of Mantises into the internal database, browsing of them, and viewing of attachments (including .msg files).
Resolves #14
This commit is contained in:
parent
0e77e310bd
commit
5268d6aecd
15 changed files with 1583 additions and 44 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -36,3 +36,5 @@ yarn-error.log*
|
||||||
/postgres
|
/postgres
|
||||||
|
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
|
|
||||||
|
bruno
|
|
@ -14,6 +14,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google/genai": "^0.9.0",
|
"@google/genai": "^0.9.0",
|
||||||
|
"@kenjiuno/msgreader": "^1.22.0",
|
||||||
"@prisma/client": "^6.6.0",
|
"@prisma/client": "^6.6.0",
|
||||||
"@quasar/extras": "^1.16.4",
|
"@quasar/extras": "^1.16.4",
|
||||||
"@quixo3/prisma-session-store": "^3.1.13",
|
"@quixo3/prisma-session-store": "^3.1.13",
|
||||||
|
@ -22,6 +23,7 @@
|
||||||
"axios": "^1.8.4",
|
"axios": "^1.8.4",
|
||||||
"better-sqlite3": "^11.9.1",
|
"better-sqlite3": "^11.9.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"dompurify": "^3.2.5",
|
||||||
"dotenv": "^16.5.0",
|
"dotenv": "^16.5.0",
|
||||||
"express-session": "^1.18.1",
|
"express-session": "^1.18.1",
|
||||||
"mailparser": "^3.7.2",
|
"mailparser": "^3.7.2",
|
||||||
|
|
33
pnpm-lock.yaml
generated
33
pnpm-lock.yaml
generated
|
@ -11,6 +11,9 @@ importers:
|
||||||
'@google/genai':
|
'@google/genai':
|
||||||
specifier: ^0.9.0
|
specifier: ^0.9.0
|
||||||
version: 0.9.0
|
version: 0.9.0
|
||||||
|
'@kenjiuno/msgreader':
|
||||||
|
specifier: ^1.22.0
|
||||||
|
version: 1.22.0
|
||||||
'@prisma/client':
|
'@prisma/client':
|
||||||
specifier: ^6.6.0
|
specifier: ^6.6.0
|
||||||
version: 6.6.0(prisma@6.6.0(typescript@5.8.3))(typescript@5.8.3)
|
version: 6.6.0(prisma@6.6.0(typescript@5.8.3))(typescript@5.8.3)
|
||||||
|
@ -35,6 +38,9 @@ importers:
|
||||||
date-fns:
|
date-fns:
|
||||||
specifier: ^4.1.0
|
specifier: ^4.1.0
|
||||||
version: 4.1.0
|
version: 4.1.0
|
||||||
|
dompurify:
|
||||||
|
specifier: ^3.2.5
|
||||||
|
version: 3.2.5
|
||||||
dotenv:
|
dotenv:
|
||||||
specifier: ^16.5.0
|
specifier: ^16.5.0
|
||||||
version: 16.5.0
|
version: 16.5.0
|
||||||
|
@ -418,6 +424,13 @@ packages:
|
||||||
'@jridgewell/trace-mapping@0.3.25':
|
'@jridgewell/trace-mapping@0.3.25':
|
||||||
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
|
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
|
||||||
|
|
||||||
|
'@kenjiuno/decompressrtf@0.1.4':
|
||||||
|
resolution: {integrity: sha512-v9c/iFz17jRWyd2cRnrvJg4VOg/4I/VCk+bG8JnoX2gJ9sAesPzo3uTqcmlVXdpasTI8hChpBVw00pghKe3qTQ==}
|
||||||
|
|
||||||
|
'@kenjiuno/msgreader@1.22.0':
|
||||||
|
resolution: {integrity: sha512-uhImwxvKLSxER8+ikuKpI2mnX7viL+POSnKAgDCFx+sVuRTD9/KkHFH1Gaw7EegVbCtDNKBTnECIK+GWxkpZRA==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
|
||||||
'@levischuck/tiny-cbor@0.2.11':
|
'@levischuck/tiny-cbor@0.2.11':
|
||||||
resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==}
|
resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==}
|
||||||
|
|
||||||
|
@ -733,6 +746,9 @@ packages:
|
||||||
'@types/serve-static@1.15.7':
|
'@types/serve-static@1.15.7':
|
||||||
resolution: {integrity: sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==}
|
resolution: {integrity: sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==}
|
||||||
|
|
||||||
|
'@types/trusted-types@2.0.7':
|
||||||
|
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||||
|
|
||||||
'@types/uuid@10.0.0':
|
'@types/uuid@10.0.0':
|
||||||
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
|
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
|
||||||
|
|
||||||
|
@ -1266,6 +1282,9 @@ packages:
|
||||||
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
|
|
||||||
|
dompurify@3.2.5:
|
||||||
|
resolution: {integrity: sha512-mLPd29uoRe9HpvwP2TxClGQBzGXeEC/we/q+bFlmPPmj2p2Ugl3r6ATu/UU1v77DXNcehiBg9zsr1dREyA/dJQ==}
|
||||||
|
|
||||||
domutils@3.2.2:
|
domutils@3.2.2:
|
||||||
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
|
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
|
||||||
|
|
||||||
|
@ -3383,6 +3402,13 @@ snapshots:
|
||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.0
|
'@jridgewell/sourcemap-codec': 1.5.0
|
||||||
|
|
||||||
|
'@kenjiuno/decompressrtf@0.1.4': {}
|
||||||
|
|
||||||
|
'@kenjiuno/msgreader@1.22.0':
|
||||||
|
dependencies:
|
||||||
|
'@kenjiuno/decompressrtf': 0.1.4
|
||||||
|
iconv-lite: 0.6.3
|
||||||
|
|
||||||
'@levischuck/tiny-cbor@0.2.11': {}
|
'@levischuck/tiny-cbor@0.2.11': {}
|
||||||
|
|
||||||
'@noble/hashes@1.8.0': {}
|
'@noble/hashes@1.8.0': {}
|
||||||
|
@ -3737,6 +3763,9 @@ snapshots:
|
||||||
'@types/node': 22.14.1
|
'@types/node': 22.14.1
|
||||||
'@types/send': 0.17.4
|
'@types/send': 0.17.4
|
||||||
|
|
||||||
|
'@types/trusted-types@2.0.7':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@types/uuid@10.0.0': {}
|
'@types/uuid@10.0.0': {}
|
||||||
|
|
||||||
'@typescript-eslint/scope-manager@8.31.0':
|
'@typescript-eslint/scope-manager@8.31.0':
|
||||||
|
@ -4320,6 +4349,10 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
domelementtype: 2.3.0
|
domelementtype: 2.3.0
|
||||||
|
|
||||||
|
dompurify@3.2.5:
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/trusted-types': 2.0.7
|
||||||
|
|
||||||
domutils@3.2.2:
|
domutils@3.2.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
dom-serializer: 2.0.0
|
dom-serializer: 2.0.0
|
||||||
|
|
|
@ -0,0 +1,50 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "MantisIssue" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"reporter_id" TEXT NOT NULL,
|
||||||
|
"status" TEXT NOT NULL,
|
||||||
|
"priority" TEXT NOT NULL,
|
||||||
|
"severity" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "MantisIssue_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "MantisComment" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"mantis_issue_id" INTEGER NOT NULL,
|
||||||
|
"sender_id" TEXT NOT NULL,
|
||||||
|
"comment" TEXT NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "MantisComment_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "MantisAttachment" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"comment_id" INTEGER NOT NULL,
|
||||||
|
"filename" TEXT NOT NULL,
|
||||||
|
"url" TEXT NOT NULL,
|
||||||
|
"mime_type" TEXT,
|
||||||
|
"size" INTEGER,
|
||||||
|
"uploaded_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "MantisAttachment_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "MantisIssue" ADD CONSTRAINT "MantisIssue_reporter_id_fkey" FOREIGN KEY ("reporter_id") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "MantisComment" ADD CONSTRAINT "MantisComment_mantis_issue_id_fkey" FOREIGN KEY ("mantis_issue_id") REFERENCES "MantisIssue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "MantisComment" ADD CONSTRAINT "MantisComment_sender_id_fkey" FOREIGN KEY ("sender_id") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "MantisAttachment" ADD CONSTRAINT "MantisAttachment_comment_id_fkey" FOREIGN KEY ("comment_id") REFERENCES "MantisComment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
20
prisma/migrations/20250425200121_fix_database/migration.sql
Normal file
20
prisma/migrations/20250425200121_fix_database/migration.sql
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `sender_id` on the `MantisComment` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the column `reporter_id` on the `MantisIssue` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "MantisComment" DROP CONSTRAINT "MantisComment_sender_id_fkey";
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "MantisIssue" DROP CONSTRAINT "MantisIssue_reporter_id_fkey";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "MantisComment" DROP COLUMN "sender_id",
|
||||||
|
ADD COLUMN "sender_username" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "MantisIssue" DROP COLUMN "reporter_id",
|
||||||
|
ADD COLUMN "reporter_username" TEXT;
|
|
@ -137,3 +137,44 @@ model Log {
|
||||||
message String
|
message String
|
||||||
meta Json? // Optional field for additional structured data
|
meta Json? // Optional field for additional structured data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Mantis Models Start ---
|
||||||
|
|
||||||
|
model MantisIssue {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
title String
|
||||||
|
description String?
|
||||||
|
reporterUsername String? @map("reporter_username")
|
||||||
|
status String
|
||||||
|
priority String
|
||||||
|
severity String
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
comments MantisComment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model MantisComment {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
mantisIssueId Int @map("mantis_issue_id")
|
||||||
|
senderUsername String? @map("sender_username")
|
||||||
|
comment String
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
mantisIssue MantisIssue @relation(fields: [mantisIssueId], references: [id], onDelete: Cascade)
|
||||||
|
attachments MantisAttachment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model MantisAttachment {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
commentId Int @map("comment_id")
|
||||||
|
filename String
|
||||||
|
url String // Store path or URL to the file
|
||||||
|
mimeType String? @map("mime_type")
|
||||||
|
size Int?
|
||||||
|
uploadedAt DateTime @default(now()) @map("uploaded_at")
|
||||||
|
|
||||||
|
comment MantisComment @relation(fields: [commentId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Mantis Models End ---
|
||||||
|
|
284
src-server/routes/mantis.js
Normal file
284
src-server/routes/mantis.js
Normal file
|
@ -0,0 +1,284 @@
|
||||||
|
import express from 'express';
|
||||||
|
import { PrismaClient } from '@prisma/client'; // Import Prisma Client
|
||||||
|
import { getMantisSettings, saveTicketToDatabase } from '../services/mantisDownloader.js';
|
||||||
|
import axios from 'axios';
|
||||||
|
import reader from '@kenjiuno/msgreader';
|
||||||
|
const MsgReader = reader.default;
|
||||||
|
|
||||||
|
const prisma = new PrismaClient(); // Instantiate Prisma Client
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// GET /mantis - Fetch multiple Mantis issues with filtering and pagination
|
||||||
|
router.get('/', async(req, res) =>
|
||||||
|
{
|
||||||
|
const { page = 1, limit = 10, status, priority, severity, reporterUsername, search } = req.query;
|
||||||
|
|
||||||
|
const pageNum = parseInt(page, 10);
|
||||||
|
const limitNum = parseInt(limit, 10);
|
||||||
|
const skip = (pageNum - 1) * limitNum;
|
||||||
|
|
||||||
|
const where = {};
|
||||||
|
if (status) where.status = status;
|
||||||
|
if (priority) where.priority = priority;
|
||||||
|
if (severity) where.severity = severity;
|
||||||
|
if (reporterUsername) where.reporterUsername = reporterUsername;
|
||||||
|
if (search)
|
||||||
|
{
|
||||||
|
where.OR = [
|
||||||
|
{ title: { contains: search, mode: 'insensitive' } },
|
||||||
|
{ description: { contains: search, mode: 'insensitive' } },
|
||||||
|
];
|
||||||
|
|
||||||
|
// If the search term is a number, treat it as an ID
|
||||||
|
const searchNum = parseInt(search, 10);
|
||||||
|
if (!isNaN(searchNum))
|
||||||
|
{
|
||||||
|
where.OR.push({ id: searchNum });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
let [issues, totalCount] = await prisma.$transaction([
|
||||||
|
prisma.mantisIssue.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: limitNum,
|
||||||
|
orderBy: {
|
||||||
|
updatedAt: 'desc', // Default sort order
|
||||||
|
},
|
||||||
|
// You might want to include related data like comments count later
|
||||||
|
// include: { _count: { select: { comments: true } } }
|
||||||
|
}),
|
||||||
|
prisma.mantisIssue.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!issues || issues.length === 0)
|
||||||
|
{
|
||||||
|
//If it's numeric, try to download the issue from Mantis
|
||||||
|
const searchNum = parseInt(search, 10);
|
||||||
|
|
||||||
|
if (!isNaN(searchNum))
|
||||||
|
{
|
||||||
|
let data;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
data = await saveTicketToDatabase(searchNum);
|
||||||
|
}
|
||||||
|
catch (error)
|
||||||
|
{
|
||||||
|
console.error('Error saving ticket to database:', error.message);
|
||||||
|
return res.status(404).json({ error: 'Mantis issue not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data)
|
||||||
|
{
|
||||||
|
return res.status(404).json({ error: 'Mantis issue not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the issue again from the database
|
||||||
|
issues = await prisma.mantisIssue.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: limitNum,
|
||||||
|
orderBy: {
|
||||||
|
updatedAt: 'desc', // Default sort order
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (issues.length === 0)
|
||||||
|
{
|
||||||
|
return res.status(404).json({ error: 'Mantis issue not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
totalCount = await prisma.mantisIssue.count({ where });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
data: issues,
|
||||||
|
pagination: {
|
||||||
|
total: totalCount,
|
||||||
|
page: pageNum,
|
||||||
|
limit: limitNum,
|
||||||
|
totalPages: Math.ceil(totalCount / limitNum),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (error)
|
||||||
|
{
|
||||||
|
console.error('Error fetching Mantis issues:', error.message);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch Mantis issues' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /mantis/:id - Fetch a single Mantis issue by ID
|
||||||
|
router.get('/:id', async(req, res) =>
|
||||||
|
{
|
||||||
|
const { id } = req.params;
|
||||||
|
const issueId = parseInt(id, 10);
|
||||||
|
|
||||||
|
if (isNaN(issueId))
|
||||||
|
{
|
||||||
|
return res.status(400).json({ error: 'Invalid issue ID format' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const issue = await prisma.mantisIssue.findUnique({
|
||||||
|
where: { id: issueId },
|
||||||
|
include: {
|
||||||
|
comments: { // Include comments
|
||||||
|
include: { attachments: true } // And include attachments for each comment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!issue)
|
||||||
|
{
|
||||||
|
//Try to download the issue from Mantis
|
||||||
|
const data = await saveTicketToDatabase(issueId);
|
||||||
|
|
||||||
|
if (!data)
|
||||||
|
{
|
||||||
|
return res.status(404).json({ error: 'Mantis issue not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the issue again from the database
|
||||||
|
const issue = await prisma.mantisIssue.findUnique({
|
||||||
|
where: { id: issueId },
|
||||||
|
include: {
|
||||||
|
comments: { // Include comments
|
||||||
|
include: { attachments: true } // And include attachments for each comment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(issue);
|
||||||
|
}
|
||||||
|
catch (error)
|
||||||
|
{
|
||||||
|
console.error(`Error fetching Mantis issue ${issueId}:`, error.message);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch Mantis issue' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
router.get('/attachment/:ticketId/:attachmentId', async(req, res) =>
|
||||||
|
{
|
||||||
|
const { url, headers } = await getMantisSettings();
|
||||||
|
|
||||||
|
const { ticketId, attachmentId } = req.params;
|
||||||
|
|
||||||
|
const attachmentUrl = `${url}/issues/${ticketId}/files/${attachmentId}`;
|
||||||
|
|
||||||
|
console.log('Fetching Mantis attachment from URL:', attachmentUrl);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const response = await axios.get(attachmentUrl, { headers });
|
||||||
|
const attachment = response.data.files[0];
|
||||||
|
|
||||||
|
if (!attachment)
|
||||||
|
{
|
||||||
|
return res.status(404).json({ error: 'Attachment not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(attachment.content, 'base64');
|
||||||
|
res.setHeader('Content-Type', attachment.content_type);
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${attachment.filename}"`);
|
||||||
|
res.setHeader('Content-Length', buffer.length);
|
||||||
|
res.send(buffer);
|
||||||
|
}
|
||||||
|
catch (error)
|
||||||
|
{
|
||||||
|
console.error('Error fetching Mantis attachment:', error.message);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch Mantis attachment' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/msg-extract/:ticketId/:attachmentId', async(req, res) =>
|
||||||
|
{
|
||||||
|
const { url, headers } = await getMantisSettings();
|
||||||
|
|
||||||
|
const { ticketId, attachmentId } = req.params;
|
||||||
|
|
||||||
|
const attachmentUrl = `${url}/issues/${ticketId}/files/${attachmentId}`;
|
||||||
|
|
||||||
|
console.log('Fetching Mantis attachment from URL:', attachmentUrl);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const response = await axios.get(attachmentUrl, { headers });
|
||||||
|
const attachment = response.data.files[0];
|
||||||
|
|
||||||
|
if (!attachment)
|
||||||
|
{
|
||||||
|
return res.status(404).json({ error: 'Attachment not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(attachment.content, 'base64');
|
||||||
|
|
||||||
|
console.log(MsgReader);
|
||||||
|
|
||||||
|
const reader = new MsgReader(buffer);
|
||||||
|
const msg = reader.getFileData();
|
||||||
|
|
||||||
|
res.status(200).json(msg);
|
||||||
|
}
|
||||||
|
catch (error)
|
||||||
|
{
|
||||||
|
console.error('Error fetching Mantis attachment:', error.message);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch Mantis attachment' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/msg-extract/:ticketId/:attachmentId/:innerAttachmentId', async(req, res) =>
|
||||||
|
{
|
||||||
|
const { url, headers } = await getMantisSettings();
|
||||||
|
|
||||||
|
const { ticketId, attachmentId, innerAttachmentId } = req.params;
|
||||||
|
|
||||||
|
const attachmentUrl = `${url}/issues/${ticketId}/files/${attachmentId}`;
|
||||||
|
|
||||||
|
console.log('Fetching Mantis attachment from URL:', attachmentUrl);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const response = await axios.get(attachmentUrl, { headers });
|
||||||
|
const attachment = response.data.files[0];
|
||||||
|
|
||||||
|
if (!attachment)
|
||||||
|
{
|
||||||
|
return res.status(404).json({ error: 'Attachment not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(attachment.content, 'base64');
|
||||||
|
|
||||||
|
const reader = new MsgReader(buffer);
|
||||||
|
const msg = reader.getFileData();
|
||||||
|
|
||||||
|
// Find the inner attachment
|
||||||
|
const innerAttachment = msg.attachments[innerAttachmentId];
|
||||||
|
|
||||||
|
if (!innerAttachment)
|
||||||
|
{
|
||||||
|
return res.status(404).json({ error: 'Inner attachment not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachmentData = reader.getAttachment(innerAttachment);
|
||||||
|
|
||||||
|
const innerBuffer = Buffer.from(attachmentData.content, 'base64');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${innerAttachment.fileName}"`);
|
||||||
|
|
||||||
|
res.status(200).send(innerBuffer);
|
||||||
|
}
|
||||||
|
catch (error)
|
||||||
|
{
|
||||||
|
console.error('Error fetching Mantis attachment:', error.message);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch Mantis attachment' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
|
@ -16,58 +16,23 @@ import session from 'express-session';
|
||||||
import { PrismaSessionStore } from '@quixo3/prisma-session-store';
|
import { PrismaSessionStore } from '@quixo3/prisma-session-store';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import pino from 'pino';
|
|
||||||
import pinoHttp from 'pino-http';
|
import pinoHttp from 'pino-http';
|
||||||
import apiRoutes from './routes/api.js';
|
import apiRoutes from './routes/api.js';
|
||||||
import authRoutes from './routes/auth.js';
|
import authRoutes from './routes/auth.js';
|
||||||
import chatRoutes from './routes/chat.js';
|
import chatRoutes from './routes/chat.js';
|
||||||
import settingsRoutes from './routes/settings.js';
|
import settingsRoutes from './routes/settings.js';
|
||||||
import userPreferencesRoutes from './routes/userPreferences.js';
|
import userPreferencesRoutes from './routes/userPreferences.js';
|
||||||
|
import mantisRoutes from './routes/mantis.js'; // Import Mantis routes
|
||||||
import cron from 'node-cron';
|
import cron from 'node-cron';
|
||||||
import { generateAndStoreMantisSummary } from './services/mantisSummarizer.js';
|
import { generateAndStoreMantisSummary } from './services/mantisSummarizer.js';
|
||||||
import { requireAuth } from './middlewares/authMiddleware.js';
|
import { requireAuth } from './middlewares/authMiddleware.js';
|
||||||
|
|
||||||
|
import { setup as setupMantisDownloader } from './services/mantisDownloader.js';
|
||||||
|
|
||||||
|
import { logger } from './utils/logging.js';
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
// Initialize Pino logger
|
|
||||||
const targets = [];
|
|
||||||
|
|
||||||
// Console logging (pretty-printed in development)
|
|
||||||
if (process.env.NODE_ENV !== 'production')
|
|
||||||
{
|
|
||||||
targets.push({
|
|
||||||
target: 'pino-pretty',
|
|
||||||
options: {
|
|
||||||
colorize: true
|
|
||||||
},
|
|
||||||
level: process.env.LOG_LEVEL || 'info'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Basic console logging in production
|
|
||||||
targets.push({
|
|
||||||
target: 'pino/file', // Log to stdout in production
|
|
||||||
options: { destination: 1 }, // 1 is stdout
|
|
||||||
level: process.env.LOG_LEVEL || 'info'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Database logging via custom transport
|
|
||||||
targets.push({
|
|
||||||
target: './utils/prisma-pino-transport.js', // Path to the custom transport
|
|
||||||
options: {}, // No specific options needed for this transport
|
|
||||||
level: process.env.DB_LOG_LEVEL || 'info' // Separate level for DB logging if needed
|
|
||||||
});
|
|
||||||
|
|
||||||
const logger = pino({
|
|
||||||
level: process.env.LOG_LEVEL || 'info', // Overall minimum level
|
|
||||||
transport: {
|
|
||||||
targets: targets
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize pino-http middleware
|
|
||||||
const httpLogger = pinoHttp({ logger });
|
const httpLogger = pinoHttp({ logger });
|
||||||
|
|
||||||
// Define Relying Party details (Update with your actual details)
|
// Define Relying Party details (Update with your actual details)
|
||||||
|
@ -75,14 +40,12 @@ export const rpID = process.env.NODE_ENV === 'production' ? 'stylepoint.uk' : 'l
|
||||||
export const rpName = 'StylePoint';
|
export const rpName = 'StylePoint';
|
||||||
export const origin = process.env.NODE_ENV === 'production' ? `https://${rpID}` : `http://${rpID}:9000`;
|
export const origin = process.env.NODE_ENV === 'production' ? `https://${rpID}` : `http://${rpID}:9000`;
|
||||||
|
|
||||||
// In-memory store for challenges (Replace with a persistent store in production)
|
|
||||||
export const challengeStore = new Map();
|
export const challengeStore = new Map();
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
// Add pino-http middleware
|
|
||||||
app.use(httpLogger);
|
app.use(httpLogger);
|
||||||
|
|
||||||
if(!process.env.SESSION_SECRET)
|
if(!process.env.SESSION_SECRET)
|
||||||
|
@ -142,6 +105,7 @@ app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/chat', requireAuth, chatRoutes);
|
app.use('/api/chat', requireAuth, chatRoutes);
|
||||||
app.use('/api/user-preferences', requireAuth, userPreferencesRoutes);
|
app.use('/api/user-preferences', requireAuth, userPreferencesRoutes);
|
||||||
app.use('/api/settings', requireAuth, settingsRoutes);
|
app.use('/api/settings', requireAuth, settingsRoutes);
|
||||||
|
app.use('/api/mantis', requireAuth, mantisRoutes); // Register Mantis routes
|
||||||
app.use('/api', requireAuth, apiRoutes);
|
app.use('/api', requireAuth, apiRoutes);
|
||||||
|
|
||||||
if (process.env.PROD)
|
if (process.env.PROD)
|
||||||
|
@ -154,4 +118,6 @@ app.use(express.static('public', { index: false }));
|
||||||
app.listen(8000, () =>
|
app.listen(8000, () =>
|
||||||
{
|
{
|
||||||
logger.info('Server is running on http://localhost:8000');
|
logger.info('Server is running on http://localhost:8000');
|
||||||
|
|
||||||
|
setupMantisDownloader();
|
||||||
});
|
});
|
215
src-server/services/mantisDownloader.js
Normal file
215
src-server/services/mantisDownloader.js
Normal file
|
@ -0,0 +1,215 @@
|
||||||
|
//This is a service which will download data for the latest updated Mantis tickets and store them in the database.
|
||||||
|
//It will also download all the notes and attachments for each ticket.
|
||||||
|
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
import { getSetting } from '../utils/settings.js';
|
||||||
|
|
||||||
|
import prisma from '../database.js';
|
||||||
|
|
||||||
|
import { logger } from '../utils/logging.js';
|
||||||
|
|
||||||
|
export async function getMantisSettings()
|
||||||
|
{
|
||||||
|
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 headers = {
|
||||||
|
Authorization: `${MANTIS_API_KEY}`,
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
return { url: MANTIS_API_ENDPOINT, headers };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLatestMantisTickets()
|
||||||
|
{
|
||||||
|
const { url, headers } = await getMantisSettings();
|
||||||
|
const ticketUrl = `${url}/issues?project_id=1&page_size=50&select=id,updated_at`;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const response = await axios.get(ticketUrl, { headers });
|
||||||
|
return response.data.issues;
|
||||||
|
}
|
||||||
|
catch (error)
|
||||||
|
{
|
||||||
|
logger.error('Error fetching tickets data:', error);
|
||||||
|
throw new Error('Failed to fetch tickets data from Mantis.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDataForMantisTicket(ticketId)
|
||||||
|
{
|
||||||
|
const { url, headers } = await getMantisSettings();
|
||||||
|
// Removed notes from select, as they are fetched separately with attachments
|
||||||
|
const ticketUrl = `${url}/issues/${ticketId}?select=id,summary,description,created_at,updated_at,reporter,status,severity,priority,notes`;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const response = await axios.get(ticketUrl, { headers });
|
||||||
|
// Assuming response.data contains the issue object directly
|
||||||
|
return response.data.issues && response.data.issues.length > 0 ? response.data.issues[0] : null;
|
||||||
|
}
|
||||||
|
catch (error)
|
||||||
|
{
|
||||||
|
logger.error(`Error fetching ticket data for ID ${ticketId}:`, error);
|
||||||
|
throw new Error(`Failed to fetch ticket data for ID ${ticketId} from Mantis.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveTicketToDatabase(ticketId)
|
||||||
|
{
|
||||||
|
const ticketData = await getDataForMantisTicket(ticketId);
|
||||||
|
|
||||||
|
if (!ticketData)
|
||||||
|
{
|
||||||
|
logger.warn(`No ticket data found for ID ${ticketId}. Skipping save.`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticketInDb = await prisma.$transaction(async(tx) =>
|
||||||
|
{
|
||||||
|
const reporterUsername = ticketData.reporter?.name;
|
||||||
|
|
||||||
|
const ticket = await tx.mantisIssue.upsert({
|
||||||
|
where: { id: ticketId },
|
||||||
|
update: {
|
||||||
|
title: ticketData.summary,
|
||||||
|
description: ticketData.description,
|
||||||
|
reporterUsername,
|
||||||
|
status: ticketData.status.name,
|
||||||
|
priority: ticketData.priority.name,
|
||||||
|
severity: ticketData.severity.name,
|
||||||
|
updatedAt: new Date(ticketData.updated_at),
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
id: ticketId,
|
||||||
|
title: ticketData.summary,
|
||||||
|
description: ticketData.description,
|
||||||
|
reporterUsername,
|
||||||
|
status: ticketData.status.name,
|
||||||
|
priority: ticketData.priority.name,
|
||||||
|
severity: ticketData.severity.name,
|
||||||
|
createdAt: new Date(ticketData.created_at),
|
||||||
|
updatedAt: new Date(ticketData.updated_at),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
logger.info(`Ticket ${ticketId} saved to database.`);
|
||||||
|
|
||||||
|
// Process notes
|
||||||
|
if (ticketData.notes && ticketData.notes.length > 0)
|
||||||
|
{
|
||||||
|
for (const note of ticketData.notes)
|
||||||
|
{
|
||||||
|
const noteReporter = note.reporter?.name || 'Unknown Reporter';
|
||||||
|
|
||||||
|
const comment = await tx.mantisComment.create({
|
||||||
|
data: {
|
||||||
|
mantisIssueId: ticketId,
|
||||||
|
senderUsername: noteReporter,
|
||||||
|
comment: note.text,
|
||||||
|
createdAt: new Date(note.created_at),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process attachments for the note
|
||||||
|
if (note.attachments && note.attachments.length > 0)
|
||||||
|
{
|
||||||
|
for (const attachment of note.attachments)
|
||||||
|
{
|
||||||
|
const attachmentData = {
|
||||||
|
commentId: comment.id,
|
||||||
|
filename: attachment.filename,
|
||||||
|
url: '/mantis/attachment/' + ticketId + '/' + attachment.id,
|
||||||
|
mimeType: attachment.content_type,
|
||||||
|
size: attachment.size,
|
||||||
|
uploadedAt: new Date(attachment.created_at),
|
||||||
|
};
|
||||||
|
|
||||||
|
await tx.mantisAttachment.create({
|
||||||
|
data: attachmentData,
|
||||||
|
});
|
||||||
|
logger.info(`Attachment ${attachment.filename} for ticket ${ticketId} saved to database.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ticket;
|
||||||
|
});
|
||||||
|
|
||||||
|
return ticketInDb;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processNewMantisTickets()
|
||||||
|
{
|
||||||
|
logger.info('Checking for new Mantis tickets...');
|
||||||
|
const issues = await getLatestMantisTickets();
|
||||||
|
|
||||||
|
if (!issues)
|
||||||
|
{
|
||||||
|
logger.warn('No issues returned from getLatestMantisTickets.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
//Check if the tickets exist, and if not, or if they have a newer updated_at date, add them to the download queue
|
||||||
|
for (const issue of issues)
|
||||||
|
{
|
||||||
|
const ticketId = issue.id;
|
||||||
|
const existingTicket = await prisma.mantisIssue.findUnique({ // Changed from prisma.ticket to prisma.mantisIssue
|
||||||
|
where: { id: ticketId },
|
||||||
|
select: { updatedAt: true } // Only select needed field
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingTicket || new Date(issue.updated_at) > new Date(existingTicket.updatedAt)) // Changed existingTicket.updated_at to existingTicket.updatedAt
|
||||||
|
{
|
||||||
|
// Avoid adding duplicates to the queue
|
||||||
|
if (!downloadQueue.includes(ticketId))
|
||||||
|
{
|
||||||
|
downloadQueue.push(ticketId);
|
||||||
|
logger.info(`Queueing ticket ${ticketId} for processing.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processTicketsInQueue()
|
||||||
|
{
|
||||||
|
if (downloadQueue.length === 0)
|
||||||
|
{
|
||||||
|
logger.info('No tickets to process.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticketId = downloadQueue.shift();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
logger.info(`Processing ticket ${ticketId}...`);
|
||||||
|
await saveTicketToDatabase(ticketId);
|
||||||
|
logger.info(`Ticket ${ticketId} processed and saved to database.`);
|
||||||
|
}
|
||||||
|
catch (error)
|
||||||
|
{
|
||||||
|
console.log(error);
|
||||||
|
logger.error(`Error processing ticket ${ticketId}:`, error);
|
||||||
|
// Optionally, you can re-add the ticket to the queue for retrying later
|
||||||
|
downloadQueue.push(ticketId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadQueue = [];
|
||||||
|
|
||||||
|
export function setup()
|
||||||
|
{
|
||||||
|
// Initialize the download queue
|
||||||
|
downloadQueue.length = 0;
|
||||||
|
|
||||||
|
// Start the process of checking for new tickets
|
||||||
|
processNewMantisTickets();
|
||||||
|
setInterval(processNewMantisTickets, 5 * 60 * 1000); // Check for new tickets every 5 minutes
|
||||||
|
setInterval(processTicketsInQueue, 10 * 1000); // Process the queue every 10 seconds
|
||||||
|
}
|
39
src-server/utils/logging.js
Normal file
39
src-server/utils/logging.js
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
import pino from 'pino';
|
||||||
|
|
||||||
|
// Initialize Pino logger
|
||||||
|
const targets = [];
|
||||||
|
|
||||||
|
// Console logging (pretty-printed in development)
|
||||||
|
if (process.env.NODE_ENV !== 'production')
|
||||||
|
{
|
||||||
|
targets.push({
|
||||||
|
target: 'pino-pretty',
|
||||||
|
options: {
|
||||||
|
colorize: true
|
||||||
|
},
|
||||||
|
level: process.env.LOG_LEVEL || 'info'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Basic console logging in production
|
||||||
|
targets.push({
|
||||||
|
target: 'pino/file', // Log to stdout in production
|
||||||
|
options: { destination: 1 }, // 1 is stdout
|
||||||
|
level: process.env.LOG_LEVEL || 'info'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Database logging via custom transport
|
||||||
|
targets.push({
|
||||||
|
target: './prisma-pino-transport.js', // Path to the custom transport
|
||||||
|
options: {}, // No specific options needed for this transport
|
||||||
|
level: process.env.DB_LOG_LEVEL || 'info' // Separate level for DB logging if needed
|
||||||
|
});
|
||||||
|
|
||||||
|
export const logger = pino({
|
||||||
|
level: process.env.LOG_LEVEL || 'info', // Overall minimum level
|
||||||
|
transport: {
|
||||||
|
targets: targets
|
||||||
|
}
|
||||||
|
});
|
611
src/components/MantisTicketDialog.vue
Normal file
611
src/components/MantisTicketDialog.vue
Normal file
|
@ -0,0 +1,611 @@
|
||||||
|
<template>
|
||||||
|
<q-dialog
|
||||||
|
:model-value="modelValue"
|
||||||
|
@update:model-value="emitUpdate"
|
||||||
|
@hide="resetDialog"
|
||||||
|
persistent
|
||||||
|
maximized
|
||||||
|
>
|
||||||
|
<q-card>
|
||||||
|
<q-card-section class="row items-center q-ma-none">
|
||||||
|
<div class="text-h6">
|
||||||
|
Mantis Ticket Details - {{ ticket ? ('M' + ticket.id) : '' }}
|
||||||
|
</div>
|
||||||
|
<q-space />
|
||||||
|
<q-btn
|
||||||
|
icon="close"
|
||||||
|
flat
|
||||||
|
round
|
||||||
|
dense
|
||||||
|
v-close-popup
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-separator />
|
||||||
|
|
||||||
|
<q-card-section
|
||||||
|
v-if="loading"
|
||||||
|
class="text-center"
|
||||||
|
>
|
||||||
|
<q-spinner
|
||||||
|
size="xl"
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
<p>Loading ticket details...</p>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-section
|
||||||
|
v-else-if="error"
|
||||||
|
class="text-negative"
|
||||||
|
>
|
||||||
|
<q-icon
|
||||||
|
name="error"
|
||||||
|
size="md"
|
||||||
|
class="q-mr-sm"
|
||||||
|
/>
|
||||||
|
{{ error }}
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-section
|
||||||
|
v-else-if="ticket"
|
||||||
|
class="q-pa-none"
|
||||||
|
>
|
||||||
|
<q-tabs
|
||||||
|
v-model="tab"
|
||||||
|
dense
|
||||||
|
class="text-grey"
|
||||||
|
active-color="primary"
|
||||||
|
indicator-color="primary"
|
||||||
|
align="justify"
|
||||||
|
narrow-indicator
|
||||||
|
>
|
||||||
|
<q-tab
|
||||||
|
name="details"
|
||||||
|
label="Details"
|
||||||
|
/>
|
||||||
|
<q-tab
|
||||||
|
name="files"
|
||||||
|
label="Files"
|
||||||
|
/>
|
||||||
|
<q-tab
|
||||||
|
name="notes"
|
||||||
|
label="Internal Notes"
|
||||||
|
/>
|
||||||
|
</q-tabs>
|
||||||
|
|
||||||
|
<q-separator />
|
||||||
|
|
||||||
|
<q-tab-panels
|
||||||
|
v-model="tab"
|
||||||
|
animated
|
||||||
|
class="scroll"
|
||||||
|
style="max-height: calc(100vh - 103px)"
|
||||||
|
>
|
||||||
|
<q-tab-panel
|
||||||
|
name="details"
|
||||||
|
class="q-pa-md"
|
||||||
|
>
|
||||||
|
<!-- Basic Info List (Moved Here) -->
|
||||||
|
<q-list
|
||||||
|
bordered
|
||||||
|
separator
|
||||||
|
class="q-mb-md"
|
||||||
|
>
|
||||||
|
<q-item>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label overline>
|
||||||
|
ID
|
||||||
|
</q-item-label>
|
||||||
|
<q-item-label>
|
||||||
|
<a
|
||||||
|
:href="`//styletech.mantishub.io/view.php?id=${ticket.id}`"
|
||||||
|
target="_blank"
|
||||||
|
>{{ ticket.id }}</a>
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label overline>
|
||||||
|
Status
|
||||||
|
</q-item-label>
|
||||||
|
<q-item-label>
|
||||||
|
<q-badge
|
||||||
|
:color="getStatusColor(ticket.status)"
|
||||||
|
:label="ticket.status"
|
||||||
|
/>
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label overline>
|
||||||
|
Priority
|
||||||
|
</q-item-label>
|
||||||
|
<q-item-label>
|
||||||
|
<q-badge
|
||||||
|
:color="getPriorityColor(ticket.priority)"
|
||||||
|
:label="ticket.priority"
|
||||||
|
/>
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label overline>
|
||||||
|
Severity
|
||||||
|
</q-item-label>
|
||||||
|
<q-item-label>
|
||||||
|
<q-badge
|
||||||
|
:color="getSeverityColor(ticket.severity)"
|
||||||
|
:label="ticket.severity"
|
||||||
|
/>
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label overline>
|
||||||
|
Reporter
|
||||||
|
</q-item-label>
|
||||||
|
<q-item-label>{{ ticket.reporterUsername }}</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label overline>
|
||||||
|
Created At
|
||||||
|
</q-item-label>
|
||||||
|
<q-item-label>{{ new Date(ticket.createdAt).toLocaleString() }}</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label overline>
|
||||||
|
Last Updated
|
||||||
|
</q-item-label>
|
||||||
|
<q-item-label>{{ new Date(ticket.updatedAt).toLocaleString() }}</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
|
||||||
|
<!-- Title, Description, Comments -->
|
||||||
|
<div>
|
||||||
|
<div class="text-h6 q-mb-sm">
|
||||||
|
{{ ticket.title }}
|
||||||
|
</div>
|
||||||
|
<q-separator class="q-mb-md" />
|
||||||
|
|
||||||
|
<div class="text-subtitle1 q-mb-xs">
|
||||||
|
Description
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-html="ticket.description"
|
||||||
|
class="q-mb-md mantis-description"
|
||||||
|
/>
|
||||||
|
<!-- Note: v-html can be risky if content isn't sanitized server-side -->
|
||||||
|
|
||||||
|
<q-separator class="q-mb-md" />
|
||||||
|
|
||||||
|
<div class="text-subtitle1 q-mb-xs">
|
||||||
|
Comments ({{ ticket.comments?.length || 0 }})
|
||||||
|
</div>
|
||||||
|
<q-list
|
||||||
|
bordered
|
||||||
|
separator
|
||||||
|
v-if="ticket.comments && ticket.comments.length > 0"
|
||||||
|
>
|
||||||
|
<template
|
||||||
|
v-for="comment in ticket.comments"
|
||||||
|
:key="comment.id"
|
||||||
|
>
|
||||||
|
<q-item
|
||||||
|
class="q-mb-sm"
|
||||||
|
>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label
|
||||||
|
overline
|
||||||
|
>
|
||||||
|
<strong class="text-subtitle1">{{ comment.senderUsername }}</strong> - {{ new Date(comment.createdAt).toLocaleString() }}
|
||||||
|
</q-item-label>
|
||||||
|
<q-item-label
|
||||||
|
caption
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="text-body1 comment-text"
|
||||||
|
v-html="sanitiseComment(comment.comment)"
|
||||||
|
/>
|
||||||
|
</q-item-label>
|
||||||
|
<q-item-section
|
||||||
|
v-if="comment.attachments && comment.attachments.length > 0"
|
||||||
|
>
|
||||||
|
<q-separator class="q-mt-sm q-mb-lg" />
|
||||||
|
<q-item-label
|
||||||
|
overline
|
||||||
|
class="q-mb-sm"
|
||||||
|
>
|
||||||
|
Attachments:
|
||||||
|
</q-item-label>
|
||||||
|
<q-list
|
||||||
|
bordered
|
||||||
|
separator
|
||||||
|
style="background-color: rgba(0,0,0,0.25);"
|
||||||
|
>
|
||||||
|
<q-item
|
||||||
|
v-for="attachment in comment.attachments"
|
||||||
|
:key="attachment.id"
|
||||||
|
>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>
|
||||||
|
<a
|
||||||
|
:href="`/api/${attachment.url}`"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{{ attachment.filename }}
|
||||||
|
</a>
|
||||||
|
<!-- Add MSG preview button for .msg files -->
|
||||||
|
<q-btn
|
||||||
|
v-if="isMsgFile(attachment.filename)"
|
||||||
|
flat
|
||||||
|
round
|
||||||
|
color="primary"
|
||||||
|
icon="email"
|
||||||
|
size="sm"
|
||||||
|
class="q-ml-sm"
|
||||||
|
@click="previewMsgFile(ticket.id, attachment.url.split('/')[attachment.url.split('/').length - 1], attachment.filename)"
|
||||||
|
:loading="loadingMsgId === attachment.id"
|
||||||
|
>
|
||||||
|
<q-tooltip>Preview Email</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</q-item-label>
|
||||||
|
<!-- Add image preview if it's an image file -->
|
||||||
|
<q-item-label
|
||||||
|
v-if="isImageFile(attachment.filename)"
|
||||||
|
class="q-mt-sm"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="`/api/${attachment.url}`"
|
||||||
|
style="max-width: 100%; max-height: 200px; border-radius: 4px;"
|
||||||
|
@click="openImageFullscreen(`/api/${attachment.url}`, attachment.filename)"
|
||||||
|
>
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item-section>
|
||||||
|
<!-- Add card for each attachment -->
|
||||||
|
</q-item>
|
||||||
|
<q-item style="background-color: rgba(0,0,0,0.25);" />
|
||||||
|
</template>
|
||||||
|
</q-list>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="text-grey q-mb-md"
|
||||||
|
>
|
||||||
|
No comments found.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-tab-panel>
|
||||||
|
|
||||||
|
<q-tab-panel
|
||||||
|
name="files"
|
||||||
|
class="q-pa-md"
|
||||||
|
>
|
||||||
|
<!-- Content for Files tab goes here -->
|
||||||
|
<div class="text-grey">
|
||||||
|
Files content will be added here.
|
||||||
|
</div>
|
||||||
|
</q-tab-panel>
|
||||||
|
|
||||||
|
<q-tab-panel
|
||||||
|
name="notes"
|
||||||
|
class="q-pa-md"
|
||||||
|
>
|
||||||
|
<!-- Content for Internal Notes tab goes here -->
|
||||||
|
<div class="text-grey">
|
||||||
|
Internal Notes content will be added here.
|
||||||
|
</div>
|
||||||
|
</q-tab-panel>
|
||||||
|
</q-tab-panels>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch, defineProps, defineEmits } from 'vue';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { useQuasar } from 'quasar';
|
||||||
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
|
import {usePreferencesStore} from 'stores/preferences.js';
|
||||||
|
|
||||||
|
const preferencesStore = usePreferencesStore();
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: Boolean, // Controls dialog visibility (v-model)
|
||||||
|
ticketId: {
|
||||||
|
type: [Number, null],
|
||||||
|
'default': null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'close']);
|
||||||
|
|
||||||
|
const $q = useQuasar();
|
||||||
|
const ticket = ref(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref(null);
|
||||||
|
const tab = ref('details'); // Add state for the active tab
|
||||||
|
const loadingMsgId = ref(null); // Track which MSG attachment is currently loading
|
||||||
|
|
||||||
|
const sanitiseComment = (comment) =>
|
||||||
|
{
|
||||||
|
//Replace all URLs with anchor tags
|
||||||
|
comment = comment.replace(/(https?:\/\/[^\s]+)/g, '<a href="$1" target="_blank">$1</a>');
|
||||||
|
comment = comment.replaceAll('\r\n', '<br />');
|
||||||
|
comment = comment.replaceAll('\n', '<br />');
|
||||||
|
comment = comment.replaceAll('\r', '<br />');
|
||||||
|
comment = comment.replaceAll('\t', ' ');
|
||||||
|
|
||||||
|
return DOMPurify.sanitize(comment, {
|
||||||
|
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
|
||||||
|
ALLOWED_ATTR: ['href']
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to preview MSG file content
|
||||||
|
const previewMsgFile = async(ticketId, attachmentId, filename) =>
|
||||||
|
{
|
||||||
|
loadingMsgId.value = attachmentId;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const response = await axios.get(`/api/mantis/msg-extract/${ticketId}/${attachmentId}`);
|
||||||
|
|
||||||
|
// Display email content in a dialog
|
||||||
|
$q.dialog({
|
||||||
|
title: `Email: ${filename}`,
|
||||||
|
message: createEmailPreview(response.data, ticketId, attachmentId),
|
||||||
|
html: true,
|
||||||
|
style: 'min-width: 70vw; min-height: 60vh;',
|
||||||
|
maximized: $q.screen.lt.md,
|
||||||
|
persistent: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (err)
|
||||||
|
{
|
||||||
|
console.error('Error fetching MSG file content:', err);
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: `Failed to load email preview: ${err.response?.data?.error || err.message}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
loadingMsgId.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format the email content for display
|
||||||
|
const createEmailPreview = (emailData, ticketId, attachmentId) =>
|
||||||
|
{
|
||||||
|
if (!emailData) return '<p>No email data available</p>';
|
||||||
|
|
||||||
|
let { subject, senderName, recipients, body, headers, attachments } = emailData;
|
||||||
|
|
||||||
|
body = body.trim();
|
||||||
|
|
||||||
|
// Collapse all whitespace in the body, preserving newlines
|
||||||
|
body = body.replace(/\r\n/g, '\n'); // Normalize line endings
|
||||||
|
body = body.replace(/\n+/g, '\n'); // Collapse multiple newlines
|
||||||
|
|
||||||
|
// Extract date from headers if available
|
||||||
|
const dateMatch = headers && headers.match(/Date:\s*([^\r\n]+)/i);
|
||||||
|
const date = dateMatch ? dateMatch[1] : null;
|
||||||
|
|
||||||
|
// Create recipient string from recipients array
|
||||||
|
const recipientStr = recipients && recipients.length > 0
|
||||||
|
? recipients.map(recipient => recipient.name).join(', ')
|
||||||
|
: 'Unknown';
|
||||||
|
|
||||||
|
let html = `
|
||||||
|
<div style="font-family: Arial, sans-serif; padding: 15px;">
|
||||||
|
<div style="margin-bottom: 20px; border-bottom: 1px solid #ddd; padding-bottom: 10px;">
|
||||||
|
<p><strong>Subject:</strong> ${subject || 'No subject'}</p>
|
||||||
|
<p><strong>From:</strong> ${senderName || 'Unknown'}</p>
|
||||||
|
<p><strong>To:</strong> ${recipientStr}</p>
|
||||||
|
${date ? `<p><strong>Date:</strong> ${date}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
<div style="line-height: 1.5; margin-bottom: 20px;">
|
||||||
|
${body ? DOMPurify.sanitize(body.replace(/\n/g, '<br>')) : 'No content'}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
// Add attachments section if there are any
|
||||||
|
if (attachments && attachments.length > 0)
|
||||||
|
{
|
||||||
|
html += `
|
||||||
|
<div style="border-top: 1px solid #ddd; padding-top: 10px;">
|
||||||
|
<p><strong>Attachments (${attachments.length}):</strong></p>
|
||||||
|
<ul>
|
||||||
|
${attachments.map(att =>
|
||||||
|
{
|
||||||
|
const index = attachments.indexOf(att);
|
||||||
|
return `<li><a href="/api/mantis/msg-extract/${ticketId}/${attachmentId}/${index}" target="_blank">${att.fileName}</a> (${formatFileSize(att.contentLength)})</li>`;
|
||||||
|
}).join('')}
|
||||||
|
</ul>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
return html;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format file size in bytes to human-readable format
|
||||||
|
const formatFileSize = (bytes) =>
|
||||||
|
{
|
||||||
|
if (!bytes || bytes === 0) return '0 Bytes';
|
||||||
|
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchTicketDetails = async(id) =>
|
||||||
|
{
|
||||||
|
if (!id) return;
|
||||||
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
ticket.value = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const response = await axios.get(`/api/mantis/${id}`);
|
||||||
|
ticket.value = response.data;
|
||||||
|
|
||||||
|
//Check user preference for comment order
|
||||||
|
if(preferencesStore.values.mantisCommentsOrder === 'newest')
|
||||||
|
{
|
||||||
|
ticket.value.comments.reverse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err)
|
||||||
|
{
|
||||||
|
console.error(`Error fetching Mantis ticket ${id}:`, err);
|
||||||
|
error.value = `Failed to load ticket details. ${err.response?.data?.error || err.message}`;
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: error.value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetDialog = () =>
|
||||||
|
{
|
||||||
|
ticket.value = null;
|
||||||
|
loading.value = false;
|
||||||
|
error.value = null;
|
||||||
|
// Emit close event if needed, though v-close-popup handles visibility
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
const emitUpdate = (value) =>
|
||||||
|
{
|
||||||
|
emit('update:modelValue', value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Watch for changes in ticketId and fetch data when the dialog is shown
|
||||||
|
watch(() => props.ticketId, (newId) =>
|
||||||
|
{
|
||||||
|
if (props.modelValue && newId)
|
||||||
|
{
|
||||||
|
fetchTicketDetails(newId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also fetch when the dialog becomes visible if ticketId is already set
|
||||||
|
watch(() => props.modelValue, (isVisible) =>
|
||||||
|
{
|
||||||
|
if (isVisible && props.ticketId && !ticket.value && !loading.value)
|
||||||
|
{
|
||||||
|
fetchTicketDetails(props.ticketId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper functions for badge colors (copied from MantisPage for consistency)
|
||||||
|
const getStatusColor = (status) =>
|
||||||
|
{
|
||||||
|
const lowerStatus = status?.toLowerCase();
|
||||||
|
if (lowerStatus === 'new') return 'blue';
|
||||||
|
if (lowerStatus === 'feedback') return 'orange';
|
||||||
|
if (lowerStatus === 'acknowledged') return 'purple';
|
||||||
|
if (lowerStatus === 'confirmed') return 'cyan';
|
||||||
|
if (lowerStatus === 'assigned') return 'teal';
|
||||||
|
if (lowerStatus === 'resolved') return 'green';
|
||||||
|
if (lowerStatus === 'closed') return 'grey';
|
||||||
|
return 'primary';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPriorityColor = (priority) =>
|
||||||
|
{
|
||||||
|
const lowerPriority = priority?.toLowerCase();
|
||||||
|
if (lowerPriority === 'none') return 'grey';
|
||||||
|
if (lowerPriority === 'low') return 'blue';
|
||||||
|
if (lowerPriority === 'normal') return 'green';
|
||||||
|
if (lowerPriority === 'high') return 'orange';
|
||||||
|
if (lowerPriority === 'urgent') return 'red';
|
||||||
|
if (lowerPriority === 'immediate') return 'purple';
|
||||||
|
return 'primary';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSeverityColor = (severity) =>
|
||||||
|
{
|
||||||
|
const lowerSeverity = severity?.toLowerCase();
|
||||||
|
if (lowerSeverity === 'feature') return 'info';
|
||||||
|
if (lowerSeverity === 'trivial') return 'grey';
|
||||||
|
if (lowerSeverity === 'text') return 'blue-grey';
|
||||||
|
if (lowerSeverity === 'tweak') return 'light-blue';
|
||||||
|
if (lowerSeverity === 'minor') return 'lime';
|
||||||
|
if (lowerSeverity === 'major') return 'amber';
|
||||||
|
if (lowerSeverity === 'crash') return 'deep-orange';
|
||||||
|
if (lowerSeverity === 'block') return 'red';
|
||||||
|
return 'primary';
|
||||||
|
};
|
||||||
|
|
||||||
|
const isImageFile = (filename) =>
|
||||||
|
{
|
||||||
|
return (/\.(jpg|jpeg|png|gif|bmp|webp)$/i).test(filename);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isMsgFile = (filename) =>
|
||||||
|
{
|
||||||
|
return (/\.(msg)$/i).test(filename);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openImageFullscreen = (src, filename) =>
|
||||||
|
{
|
||||||
|
$q.dialog({
|
||||||
|
title: filename,
|
||||||
|
style: 'min-width: 300px; min-height: 300px;',
|
||||||
|
message: `<img src="${src}" style="width: 100%; height: auto;" />`,
|
||||||
|
html: true,
|
||||||
|
position: {
|
||||||
|
x: 'center',
|
||||||
|
y: 'middle'
|
||||||
|
},
|
||||||
|
persistent: true
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
/* Scoped styles might not apply to v-html content easily */
|
||||||
|
/* Use a more global style or target specifically if needed */
|
||||||
|
.mantis-description {
|
||||||
|
white-space: pre-wrap; /* Preserve whitespace and newlines */
|
||||||
|
word-wrap: break-word;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #eee;
|
||||||
|
border-radius: 4px;
|
||||||
|
max-height: 300px; /* Limit height if descriptions are long */
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
a, .comment-text:deep(a) {
|
||||||
|
color: $primary;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover, .comment-text:deep(a:hover) {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:visited, .comment-text:deep(a:visited) {
|
||||||
|
color: $blue-5;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -5,6 +5,7 @@
|
||||||
bordered
|
bordered
|
||||||
persistent
|
persistent
|
||||||
:model-value="true"
|
:model-value="true"
|
||||||
|
:side="preferencesStore.values.drawerSide || 'left'"
|
||||||
>
|
>
|
||||||
<q-item
|
<q-item
|
||||||
clickable
|
clickable
|
||||||
|
@ -160,7 +161,6 @@
|
||||||
clickable
|
clickable
|
||||||
v-ripple
|
v-ripple
|
||||||
:to="{ name: item.name }"
|
:to="{ name: item.name }"
|
||||||
exact
|
|
||||||
dense
|
dense
|
||||||
>
|
>
|
||||||
<q-tooltip
|
<q-tooltip
|
||||||
|
@ -261,6 +261,7 @@ import { useRouter } from 'vue-router';
|
||||||
import { useQuasar } from 'quasar';
|
import { useQuasar } from 'quasar';
|
||||||
import { useAuthStore } from 'stores/auth'; // Import the auth store
|
import { useAuthStore } from 'stores/auth'; // Import the auth store
|
||||||
import { useChatStore } from 'stores/chat'; // Adjust path as needed
|
import { useChatStore } from 'stores/chat'; // Adjust path as needed
|
||||||
|
import { usePreferencesStore } from 'stores/preferences'; // Import the preferences store
|
||||||
import ChatInterface from 'components/ChatInterface.vue'; // Adjust path as needed
|
import ChatInterface from 'components/ChatInterface.vue'; // Adjust path as needed
|
||||||
import routes from '../router/routes'; // Import routes
|
import routes from '../router/routes'; // Import routes
|
||||||
|
|
||||||
|
@ -269,6 +270,7 @@ const leftDrawerOpen = ref(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const authStore = useAuthStore(); // Use the auth store
|
const authStore = useAuthStore(); // Use the auth store
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
|
const preferencesStore = usePreferencesStore(); // Import the preferences store
|
||||||
|
|
||||||
const fabOpen = ref(false); // Local state for FAB animation, not chat visibility
|
const fabOpen = ref(false); // Local state for FAB animation, not chat visibility
|
||||||
|
|
||||||
|
|
249
src/pages/MantisPage.vue
Normal file
249
src/pages/MantisPage.vue
Normal file
|
@ -0,0 +1,249 @@
|
||||||
|
<template>
|
||||||
|
<q-page padding>
|
||||||
|
<q-card
|
||||||
|
flat
|
||||||
|
bordered
|
||||||
|
class="q-mb-xl"
|
||||||
|
>
|
||||||
|
<q-card-section class="row items-center justify-between">
|
||||||
|
<div class="text-h4">
|
||||||
|
Mantis Tickets
|
||||||
|
</div>
|
||||||
|
<q-input
|
||||||
|
dense
|
||||||
|
debounce="300"
|
||||||
|
v-model="searchTerm"
|
||||||
|
placeholder="Search tickets..."
|
||||||
|
@update:model-value="fetchTickets(1)"
|
||||||
|
clearable
|
||||||
|
style="width: 300px"
|
||||||
|
>
|
||||||
|
<template #append>
|
||||||
|
<q-icon name="search" />
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-table
|
||||||
|
:rows="tickets"
|
||||||
|
:columns="columns"
|
||||||
|
row-key="id"
|
||||||
|
v-model:pagination="pagination"
|
||||||
|
:loading="loading"
|
||||||
|
@request="handleTableRequest"
|
||||||
|
binary-state-sort
|
||||||
|
flat
|
||||||
|
bordered
|
||||||
|
@row-click="onRowClick"
|
||||||
|
class="cursor-pointer"
|
||||||
|
>
|
||||||
|
<template #body-cell-status="statusProps">
|
||||||
|
<q-td :props="statusProps">
|
||||||
|
<q-badge
|
||||||
|
:color="getStatusColor(statusProps.row.status)"
|
||||||
|
:label="statusProps.row.status"
|
||||||
|
/>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
<template #body-cell-priority="priorityProps">
|
||||||
|
<q-td :props="priorityProps">
|
||||||
|
<q-badge
|
||||||
|
:color="getPriorityColor(priorityProps.row.priority)"
|
||||||
|
:label="priorityProps.row.priority"
|
||||||
|
/>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
<template #body-cell-severity="severityProps">
|
||||||
|
<q-td :props="severityProps">
|
||||||
|
<q-badge
|
||||||
|
:color="getSeverityColor(severityProps.row.severity)"
|
||||||
|
:label="severityProps.row.severity"
|
||||||
|
/>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<mantis-ticket-dialog
|
||||||
|
v-model="showDialog"
|
||||||
|
:ticket-id="selectedTicketId"
|
||||||
|
@close="closeDialog"
|
||||||
|
/>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, watch, defineProps } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { useQuasar } from 'quasar';
|
||||||
|
import MantisTicketDialog from 'src/components/MantisTicketDialog.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
ticketId: {
|
||||||
|
type: [String, Number],
|
||||||
|
'default': null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const $q = useQuasar();
|
||||||
|
const tickets = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const searchTerm = ref('');
|
||||||
|
const showDialog = ref(false);
|
||||||
|
const selectedTicketId = ref(null);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const pagination = ref({
|
||||||
|
sortBy: 'updatedAt',
|
||||||
|
descending: true,
|
||||||
|
page: 1,
|
||||||
|
rowsPerPage: 25,
|
||||||
|
rowsNumber: 0 // Total number of rows/tickets
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ name: 'id', label: 'ID', field: 'id', align: 'left', sortable: true },
|
||||||
|
{ name: 'title', label: 'Title', field: 'title', align: 'left', sortable: true },
|
||||||
|
{ name: 'status', label: 'Status', field: 'status', align: 'center', sortable: true },
|
||||||
|
{ name: 'priority', label: 'Priority', field: 'priority', align: 'center', sortable: true },
|
||||||
|
{ name: 'severity', label: 'Severity', field: 'severity', align: 'center', sortable: true },
|
||||||
|
{ name: 'reporterUsername', label: 'Reporter', field: 'reporterUsername', align: 'left', sortable: true },
|
||||||
|
{ name: 'updatedAt', label: 'Last Updated', field: 'updatedAt', align: 'left', sortable: true, format: (val) => new Date(val).toLocaleString() },
|
||||||
|
];
|
||||||
|
|
||||||
|
const fetchTickets = async(page = pagination.value.page) =>
|
||||||
|
{
|
||||||
|
loading.value = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const params = {
|
||||||
|
page: page,
|
||||||
|
limit: pagination.value.rowsPerPage,
|
||||||
|
search: searchTerm.value || undefined,
|
||||||
|
// Add sorting params if needed based on pagination.sortBy and pagination.descending
|
||||||
|
};
|
||||||
|
const response = await axios.get('/api/mantis', { params });
|
||||||
|
tickets.value = response.data.data;
|
||||||
|
pagination.value.rowsNumber = response.data.pagination.total;
|
||||||
|
pagination.value.page = response.data.pagination.page; // Update current page
|
||||||
|
}
|
||||||
|
catch (error)
|
||||||
|
{
|
||||||
|
console.error('Error fetching Mantis tickets:', error);
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'Failed to fetch Mantis tickets.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTableRequest = (props) =>
|
||||||
|
{
|
||||||
|
const { page, rowsPerPage, sortBy, descending } = props.pagination;
|
||||||
|
pagination.value.page = page;
|
||||||
|
pagination.value.rowsPerPage = rowsPerPage;
|
||||||
|
pagination.value.sortBy = sortBy;
|
||||||
|
pagination.value.descending = descending;
|
||||||
|
fetchTickets(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRowClick = (evt, row) =>
|
||||||
|
{
|
||||||
|
//Change the route
|
||||||
|
router.push({ name: 'mantis', params: { ticketId: row.id } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDialogForTicket = (id) =>
|
||||||
|
{
|
||||||
|
const ticketNum = Number(id);
|
||||||
|
if (!isNaN(ticketNum) && ticketNum > 0)
|
||||||
|
{
|
||||||
|
selectedTicketId.value = ticketNum;
|
||||||
|
showDialog.value = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDialog = () =>
|
||||||
|
{
|
||||||
|
showDialog.value = false;
|
||||||
|
selectedTicketId.value = null;
|
||||||
|
|
||||||
|
//Reset the route to remove the ticketId from the URL
|
||||||
|
router.push({ name: 'mantis' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Watch for changes in the ticketId prop
|
||||||
|
watch(() => props.ticketId, (newTicketId) =>
|
||||||
|
{
|
||||||
|
if (newTicketId)
|
||||||
|
{
|
||||||
|
openDialogForTicket(newTicketId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
closeDialog();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() =>
|
||||||
|
{
|
||||||
|
fetchTickets();
|
||||||
|
// Check initial prop value on mount
|
||||||
|
if (props.ticketId)
|
||||||
|
{
|
||||||
|
openDialogForTicket(props.ticketId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper functions for badge colors (customize as needed)
|
||||||
|
const getStatusColor = (status) =>
|
||||||
|
{
|
||||||
|
const lowerStatus = status?.toLowerCase();
|
||||||
|
if (lowerStatus === 'new') return 'blue';
|
||||||
|
if (lowerStatus === 'feedback') return 'orange';
|
||||||
|
if (lowerStatus === 'acknowledged') return 'purple';
|
||||||
|
if (lowerStatus === 'confirmed') return 'cyan';
|
||||||
|
if (lowerStatus === 'assigned') return 'teal';
|
||||||
|
if (lowerStatus === 'resolved') return 'green';
|
||||||
|
if (lowerStatus === 'closed') return 'grey';
|
||||||
|
return 'primary';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPriorityColor = (priority) =>
|
||||||
|
{
|
||||||
|
const lowerPriority = priority?.toLowerCase();
|
||||||
|
if (lowerPriority === 'none') return 'grey';
|
||||||
|
if (lowerPriority === 'low') return 'blue';
|
||||||
|
if (lowerPriority === 'normal') return 'green';
|
||||||
|
if (lowerPriority === 'high') return 'orange';
|
||||||
|
if (lowerPriority === 'urgent') return 'red';
|
||||||
|
if (lowerPriority === 'immediate') return 'purple';
|
||||||
|
return 'primary';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSeverityColor = (severity) =>
|
||||||
|
{
|
||||||
|
const lowerSeverity = severity?.toLowerCase();
|
||||||
|
if (lowerSeverity === 'feature') return 'info';
|
||||||
|
if (lowerSeverity === 'trivial') return 'grey';
|
||||||
|
if (lowerSeverity === 'text') return 'blue-grey';
|
||||||
|
if (lowerSeverity === 'tweak') return 'light-blue';
|
||||||
|
if (lowerSeverity === 'minor') return 'lime';
|
||||||
|
if (lowerSeverity === 'major') return 'amber';
|
||||||
|
if (lowerSeverity === 'crash') return 'deep-orange';
|
||||||
|
if (lowerSeverity === 'block') return 'red';
|
||||||
|
return 'primary';
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Add any specific styles here */
|
||||||
|
</style>
|
|
@ -61,6 +61,13 @@ const routes = [
|
||||||
{ path: 'forms/:id/edit', name: 'formEdit', component: () => import('pages/FormEditPage.vue'), props: true, 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/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: 'forms/:id/responses', name: 'formResponses', component: () => import('pages/FormResponsesPage.vue'), props: true, meta: { requiresAuth: true } }, // Not in nav
|
||||||
|
{
|
||||||
|
path: 'mantis/:ticketId?', // Make ticketId optional
|
||||||
|
name: 'mantis',
|
||||||
|
component: () => import('pages/MantisPage.vue'),
|
||||||
|
props: true, // Pass route params as props
|
||||||
|
meta: { navGroup: 'auth', title: 'Mantis Tickets', icon: 'bug_report' } // Added meta
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'mantis-summaries',
|
path: 'mantis-summaries',
|
||||||
name: 'mantisSummaries',
|
name: 'mantisSummaries',
|
||||||
|
|
|
@ -17,6 +17,24 @@ export const usePreferencesStore = defineStore('preferences', () =>
|
||||||
{ label: 'Dark', value: 'dark' },
|
{ label: 'Dark', value: 'dark' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Drawer Side',
|
||||||
|
key: 'drawerSide',
|
||||||
|
type: 'text',
|
||||||
|
options: [
|
||||||
|
{ label: 'Left', value: 'left' },
|
||||||
|
{ label: 'Right', value: 'right' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Mantis Comments Order',
|
||||||
|
key: 'mantisCommentsOrder',
|
||||||
|
type: 'text',
|
||||||
|
options: [
|
||||||
|
{ label: 'Oldest First', value: 'oldest' },
|
||||||
|
{ label: 'Newest First', value: 'newest' },
|
||||||
|
],
|
||||||
|
}
|
||||||
],
|
],
|
||||||
API_Tokens: [
|
API_Tokens: [
|
||||||
{
|
{
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue