diff --git a/.gitignore b/.gitignore index 998ab1b..3511062 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,6 @@ yarn-error.log* /postgres -docker-compose.yml \ No newline at end of file +docker-compose.yml + +bruno \ No newline at end of file diff --git a/package.json b/package.json index 2bc22ba..85c8836 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@google/genai": "^0.9.0", + "@kenjiuno/msgreader": "^1.22.0", "@prisma/client": "^6.6.0", "@quasar/extras": "^1.16.4", "@quixo3/prisma-session-store": "^3.1.13", @@ -22,6 +23,7 @@ "axios": "^1.8.4", "better-sqlite3": "^11.9.1", "date-fns": "^4.1.0", + "dompurify": "^3.2.5", "dotenv": "^16.5.0", "express-session": "^1.18.1", "mailparser": "^3.7.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1cf1c1..8b64008 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@google/genai': specifier: ^0.9.0 version: 0.9.0 + '@kenjiuno/msgreader': + specifier: ^1.22.0 + version: 1.22.0 '@prisma/client': specifier: ^6.6.0 version: 6.6.0(prisma@6.6.0(typescript@5.8.3))(typescript@5.8.3) @@ -35,6 +38,9 @@ importers: date-fns: specifier: ^4.1.0 version: 4.1.0 + dompurify: + specifier: ^3.2.5 + version: 3.2.5 dotenv: specifier: ^16.5.0 version: 16.5.0 @@ -418,6 +424,13 @@ packages: '@jridgewell/trace-mapping@0.3.25': 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': resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==} @@ -733,6 +746,9 @@ packages: '@types/serve-static@1.15.7': resolution: {integrity: sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/uuid@10.0.0': resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} @@ -1266,6 +1282,9 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} + dompurify@3.2.5: + resolution: {integrity: sha512-mLPd29uoRe9HpvwP2TxClGQBzGXeEC/we/q+bFlmPPmj2p2Ugl3r6ATu/UU1v77DXNcehiBg9zsr1dREyA/dJQ==} + domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -3383,6 +3402,13 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@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': {} '@noble/hashes@1.8.0': {} @@ -3737,6 +3763,9 @@ snapshots: '@types/node': 22.14.1 '@types/send': 0.17.4 + '@types/trusted-types@2.0.7': + optional: true + '@types/uuid@10.0.0': {} '@typescript-eslint/scope-manager@8.31.0': @@ -4320,6 +4349,10 @@ snapshots: dependencies: domelementtype: 2.3.0 + dompurify@3.2.5: + optionalDependencies: + '@types/trusted-types': 2.0.7 + domutils@3.2.2: dependencies: dom-serializer: 2.0.0 diff --git a/prisma/migrations/20250425195011_add_mantis_tables/migration.sql b/prisma/migrations/20250425195011_add_mantis_tables/migration.sql new file mode 100644 index 0000000..244d659 --- /dev/null +++ b/prisma/migrations/20250425195011_add_mantis_tables/migration.sql @@ -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; diff --git a/prisma/migrations/20250425200121_fix_database/migration.sql b/prisma/migrations/20250425200121_fix_database/migration.sql new file mode 100644 index 0000000..7d0a9c3 --- /dev/null +++ b/prisma/migrations/20250425200121_fix_database/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 27c2fa7..fbf861e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -137,3 +137,44 @@ model Log { message String 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 --- diff --git a/src-server/routes/mantis.js b/src-server/routes/mantis.js new file mode 100644 index 0000000..1d96572 --- /dev/null +++ b/src-server/routes/mantis.js @@ -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; \ No newline at end of file diff --git a/src-server/server.js b/src-server/server.js index 854a018..a7518f8 100644 --- a/src-server/server.js +++ b/src-server/server.js @@ -16,58 +16,23 @@ import session from 'express-session'; import { PrismaSessionStore } from '@quixo3/prisma-session-store'; import { PrismaClient } from '@prisma/client'; import { v4 as uuidv4 } from 'uuid'; -import pino from 'pino'; import pinoHttp from 'pino-http'; import apiRoutes from './routes/api.js'; import authRoutes from './routes/auth.js'; import chatRoutes from './routes/chat.js'; import settingsRoutes from './routes/settings.js'; import userPreferencesRoutes from './routes/userPreferences.js'; +import mantisRoutes from './routes/mantis.js'; // Import Mantis routes import cron from 'node-cron'; import { generateAndStoreMantisSummary } from './services/mantisSummarizer.js'; import { requireAuth } from './middlewares/authMiddleware.js'; +import { setup as setupMantisDownloader } from './services/mantisDownloader.js'; + +import { logger } from './utils/logging.js'; + 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 }); // 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 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(); const prisma = new PrismaClient(); const app = express(); -// Add pino-http middleware app.use(httpLogger); if(!process.env.SESSION_SECRET) @@ -142,6 +105,7 @@ app.use('/api/auth', authRoutes); app.use('/api/chat', requireAuth, chatRoutes); app.use('/api/user-preferences', requireAuth, userPreferencesRoutes); app.use('/api/settings', requireAuth, settingsRoutes); +app.use('/api/mantis', requireAuth, mantisRoutes); // Register Mantis routes app.use('/api', requireAuth, apiRoutes); if (process.env.PROD) @@ -154,4 +118,6 @@ app.use(express.static('public', { index: false })); app.listen(8000, () => { logger.info('Server is running on http://localhost:8000'); + + setupMantisDownloader(); }); \ No newline at end of file diff --git a/src-server/services/mantisDownloader.js b/src-server/services/mantisDownloader.js new file mode 100644 index 0000000..02bc4d2 --- /dev/null +++ b/src-server/services/mantisDownloader.js @@ -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 +} \ No newline at end of file diff --git a/src-server/utils/logging.js b/src-server/utils/logging.js new file mode 100644 index 0000000..c4ac680 --- /dev/null +++ b/src-server/utils/logging.js @@ -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 + } +}); \ No newline at end of file diff --git a/src/components/MantisTicketDialog.vue b/src/components/MantisTicketDialog.vue new file mode 100644 index 0000000..da3ca85 --- /dev/null +++ b/src/components/MantisTicketDialog.vue @@ -0,0 +1,611 @@ + + + + + diff --git a/src/layouts/MainLayout.vue b/src/layouts/MainLayout.vue index ccc0e6a..f88e85a 100644 --- a/src/layouts/MainLayout.vue +++ b/src/layouts/MainLayout.vue @@ -5,6 +5,7 @@ bordered persistent :model-value="true" + :side="preferencesStore.values.drawerSide || 'left'" > + + + +
+ Mantis Tickets +
+ + + +
+ + + + + + +
+ + +
+ + + + + diff --git a/src/router/routes.js b/src/router/routes.js index 875e221..c331962 100644 --- a/src/router/routes.js +++ b/src/router/routes.js @@ -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/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/: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', name: 'mantisSummaries', diff --git a/src/stores/preferences.js b/src/stores/preferences.js index 1e16532..bd89fcb 100644 --- a/src/stores/preferences.js +++ b/src/stores/preferences.js @@ -17,6 +17,24 @@ export const usePreferencesStore = defineStore('preferences', () => { 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: [ {