import express from 'express'; import { v4 as uuidv4 } from 'uuid'; // Import uuid for unique filenames import { getMantisSettings, saveTicketToDatabase } from '../services/mantisDownloader.js'; import axios from 'axios'; import MsgReader from '@kenjiuno/msgreader'; import { askGemini } from '../utils/gemini.js'; import { usernameMap } from '../services/mantisSummarizer.js'; import { getS3Client } from '../utils/s3.js'; import { getUserById } from './auth.js'; import prisma from '../database.js'; const router = express.Router(); // Helper function to fetch distinct values const getDistinctValues = async(field, res) => { try { const values = await prisma.mantisIssue.findMany({ distinct: [field], select: { [field]: true, }, where: { // Exclude null values if necessary NOT: { [field]: '' } }, orderBy: { [field]: 'asc', }, }); res.json(values.map(item => item[field])); } catch (error) { console.error(`Error fetching distinct ${field} values:`, error.message); res.status(500).json({ error: `Failed to fetch distinct ${field} values` }); } }; // GET /mantis/filters/statuses - Fetch unique status values router.get('/filters/statuses', async(req, res) => { await getDistinctValues('status', res); }); // GET /mantis/filters/priorities - Fetch unique priority values router.get('/filters/priorities', async(req, res) => { await getDistinctValues('priority', res); }); // GET /mantis/filters/severities - Fetch unique severity values router.get('/filters/severities', async(req, res) => { await getDistinctValues('severity', res); }); // GET /mantis/filters/reporters - Fetch unique reporter usernames router.get('/filters/reporters', async(req, res) => { await getDistinctValues('reporterUsername', res); }); // 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, sortBy = 'updatedAt', sortOrder = 'desc' } = req.query; // Add sortBy and sortOrder 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' } }, { comments: { some: { comment: { contains: search, mode: 'insensitive' } } } }, // Search in comments ]; // 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 }); } } // Validate sortOrder const validSortOrder = ['asc', 'desc'].includes(sortOrder) ? sortOrder : 'desc'; // Define allowed sort fields to prevent arbitrary sorting const allowedSortFields = ['id', 'title', 'status', 'priority', 'severity', 'reporterUsername', 'createdAt', 'updatedAt']; const validSortBy = allowedSortFields.includes(sortBy) ? sortBy : 'updatedAt'; const orderBy = {}; orderBy[validSortBy] = validSortOrder; try { let [issues, totalCount] = await prisma.$transaction([ prisma.mantisIssue.findMany({ where, skip, take: limitNum, orderBy: orderBy, // Use dynamic orderBy // 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: orderBy, // Use dynamic orderBy here as well }); 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 { let issue = await prisma.mantisIssue.findUnique({ // Changed const to let where: { id: issueId }, include: { comments: { // Include comments orderBy: { createdAt: 'asc' }, // Keep original order for comments unless preference changes it include: { attachments: true } // And include attachments for each comment }, files: { // Include user-uploaded files orderBy: { uploadedAt: 'desc' } }, notes: { // Include internal notes orderBy: { createdAt: 'desc' } // Show newest notes first } } }); if (!issue) { //Try to download the issue from Mantis let data; try { data = await saveTicketToDatabase(issueId); } catch (downloadError) { console.error(`Error downloading ticket ${issueId} from Mantis:`, downloadError.message); // Don't immediately return 404, maybe it exists locally but download failed } if (!data) { // Check if it exists locally even if download failed or wasn't attempted const localIssue = await prisma.mantisIssue.findUnique({ where: { id: issueId } }); if (!localIssue) { return res.status(404).json({ error: 'Mantis issue not found locally or via download.' }); } } // Fetch the issue again from the database (it might have been created by saveTicketToDatabase) issue = await prisma.mantisIssue.findUnique({ // Assign to issue where: { id: issueId }, include: { comments: { // Include comments orderBy: { createdAt: 'asc' }, include: { attachments: true } // And include attachments for each comment }, files: { // Include user-uploaded files orderBy: { uploadedAt: 'desc' } }, notes: { // Include internal notes orderBy: { createdAt: 'desc' } } } }); // Check again if issue is still null after attempting download/check if (!issue) { return res.status(404).json({ error: 'Mantis issue not found after attempting download/check.' }); } } res.json(issue); } catch (error) { console.error(`Error fetching Mantis issue ${issueId}:`, error.message); res.status(500).json({ error: 'Failed to fetch Mantis issue' }); } }); // REMOVE OLD UPLOAD ROUTE // POST /mantis/:id/files - Upload a file for a Mantis issue // router.post('/:id/files', upload.single('file'), async(req, res) => { ... }); // NEW ROUTE: Generate Presigned URL for Upload router.post('/:id/files/presign', async(req, res) => { const { id } = req.params; const issueId = parseInt(id, 10); const { filename, filetype } = req.body; // Expect filename and filetype from client if (isNaN(issueId)) { return res.status(400).json({ error: 'Invalid issue ID format' }); } if (!filename || !filetype) { return res.status(400).json({ error: 'Missing filename or filetype in request body' }); } // Sanitize filename (optional, but recommended) const safeFilename = filename.replace(/[^a-zA-Z0-9._-]/g, '_'); const fileKey = `mantis/${issueId}/${uuidv4()}/${safeFilename}`; // Unique key try { // 1. Check if the Mantis issue exists const issueExists = await prisma.mantisIssue.findUnique({ where: { id: issueId }, select: { id: true } // Only select id for efficiency }); if (!issueExists) { return res.status(404).json({ error: 'Mantis issue not found' }); } const s3Client = await getS3Client(); // 2. Generate a presigned URL for the file upload const presignedUrl = await s3Client.file(fileKey).presign({ method: 'PUT', type: filetype, // Use the provided filetype expiresIn: 3600 // URL expires in 1 hour }); // 3. Return the URL and the key to the client res.status(200).json({ presignedUrl, fileKey }); } catch (error) { console.error(`Error generating presigned URL for Mantis issue ${issueId}:`, error); res.status(500).json({ error: `Failed to generate presigned URL. ${error.message}` }); } }); // NEW ROUTE: Confirm Upload and Save Metadata router.post('/:id/files/confirm', async(req, res) => { const { id } = req.params; const issueId = parseInt(id, 10); const user = await getUserById(req.session.loggedInUserId); const { fileKey, filename, mimeType, size /*, description */ } = req.body; // Expect details from client if (isNaN(issueId)) { return res.status(400).json({ error: 'Invalid issue ID format' }); } if (!fileKey || !filename || !mimeType || size === undefined) { return res.status(400).json({ error: 'Missing required file details (fileKey, filename, mimeType, size)' }); } try { // Optional: Verify file exists in S3 (requires HEAD request capability in S3 client or separate SDK call) // const s3Client = await getS3Client(); // const s3file = s3Client.file(fileKey); // try { // await s3file.head(); // Or equivalent method to check existence/metadata // } catch (s3Error) { // console.error(`File not found in S3 or error checking: ${fileKey}`, s3Error); // return res.status(400).json({ error: 'File upload confirmation failed: File not found in S3.' }); // } // Save file metadata to database const mantisFile = await prisma.mantisFile.create({ data: { mantisIssueId: issueId, filename: filename, fileKey: fileKey, mimeType: mimeType, size: parseInt(size, 10), // Ensure size is an integer uploadedBy: user?.username || 'unknown', // Get username from authenticated user // description: description || null, }, }); res.status(201).json(mantisFile); // Return the created DB record } catch (error) { console.error(`Error confirming upload for Mantis issue ${issueId}, fileKey ${fileKey}:`, error); // If the error is due to duplicate fileKey or other constraint, handle appropriately if (error.code === 'P2002') { // Example: Prisma unique constraint violation return res.status(409).json({ error: 'File metadata already exists or conflict.' }); } res.status(500).json({ error: `Failed to confirm upload. ${error.message}` }); } }); // GET /mantis/:id/files - List files for a Mantis issue router.get('/:id/files', 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 files = await prisma.mantisFile.findMany({ where: { mantisIssueId: issueId }, orderBy: { uploadedAt: 'desc' }, }); res.json(files); } catch (error) { console.error(`Error fetching files for Mantis issue ${issueId}:`, error); res.status(500).json({ error: 'Failed to fetch files' }); } }); // GET /mantis/files/:fileId/download - Download a specific file router.get('/files/:fileId/download', async(req, res) => { const { fileId } = req.params; const id = parseInt(fileId, 10); if (isNaN(id)) { return res.status(400).json({ error: 'Invalid file ID format' }); } try { const fileRecord = await prisma.mantisFile.findUnique({ where: { id: id }, }); if (!fileRecord) { return res.status(404).json({ error: 'File not found' }); } const s3Client = await getS3Client(); const presignedUrl = await s3Client.file(fileRecord.fileKey).presign({ method: 'GET', expiresIn: 3600 // URL expires in 1 hour }); //Redirect to the presigned URL res.redirect(307, presignedUrl); } catch (error) { console.error(`Error preparing file download for ID ${id}:`, error); if (!res.headersSent) { res.status(500).json({ error: `Failed to download file. ${error.message}` }); } } }); 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); // Use content_type from Mantis API 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); // Check if the error is from Axios and has a response status if (error.response && error.response.status === 404) { res.status(404).json({ error: 'Attachment not found on Mantis server' }); } else { 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'); const msgReader = new MsgReader(buffer); const msg = msgReader.getFileData(); res.status(200).json(msg); } catch (error) { console.error('Error fetching or parsing Mantis MSG attachment:', error.message); if (error.response && error.response.status === 404) { res.status(404).json({ error: 'Attachment not found on Mantis server' }); } else { res.status(500).json({ error: 'Failed to fetch or parse Mantis MSG attachment' }); } } }); router.get('/msg-extract/:ticketId/:attachmentId/:innerAttachmentId', async(req, res) => { const { url, headers } = await getMantisSettings(); const { ticketId, attachmentId, innerAttachmentId } = req.params; const innerIndex = parseInt(innerAttachmentId, 10); // Ensure index is a number if (isNaN(innerIndex)) { return res.status(400).json({ error: 'Invalid inner attachment ID format' }); } 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 by index if (!msg || !msg.attachments || innerIndex < 0 || innerIndex >= msg.attachments.length) { return res.status(404).json({ error: 'Inner attachment not found at the specified index' }); } const innerAttachment = msg.attachments[innerIndex]; const attachmentData = reader.getAttachment(innerAttachment); // Or reader.getAttachment(innerIndex) if that's the API // Assuming attachmentData.content is base64 encoded if it's binary // Check the structure of attachmentData - it might already be a buffer let innerBuffer; if (Buffer.isBuffer(attachmentData.content)) { innerBuffer = attachmentData.content; } else if (typeof attachmentData.content === 'string') { // Attempt base64 decoding if it's a string, might need adjustment based on actual content innerBuffer = Buffer.from(attachmentData.content, 'base64'); } else { console.error('Unexpected inner attachment content type:', typeof attachmentData.content); return res.status(500).json({ error: 'Could not process inner attachment content' }); } // Determine Content-Type if possible, otherwise use a default // The msgreader library might provide a mime type, check innerAttachment properties const mimeType = innerAttachment.mimeType || 'application/octet-stream'; // Example fallback res.setHeader('Content-Type', mimeType); res.setHeader('Content-Disposition', `attachment; filename="${innerAttachment.fileName}"`); // Use fileName from inner attachment res.setHeader('Content-Length', innerBuffer.length); res.status(200).send(innerBuffer); } catch (error) { console.error('Error fetching or processing inner MSG attachment:', error.message); if (error.response && error.response.status === 404) { res.status(404).json({ error: 'Outer attachment not found on Mantis server' }); } else { res.status(500).json({ error: 'Failed to fetch or process inner MSG attachment' }); } } }); // GET /mantis/stats/issues - Get daily count of new Mantis issues (last 7 days) router.get('/stats/issues', async(req, res) => { try { // Calculate the date range (last 7 days) const endDate = new Date(); endDate.setHours(23, 59, 59, 999); // End of today const startDate = new Date(); startDate.setDate(startDate.getDate() - 6); // 7 days ago startDate.setHours(0, 0, 0, 0); // Start of the day // Query for daily counts of issues created in the last 7 days // Ensure table and column names match Prisma schema (case-sensitive in raw queries sometimes) const dailyIssues = await prisma.$queryRaw` SELECT DATE(created_at) as date, COUNT(*) as count FROM "MantisIssue" WHERE created_at >= ${startDate} AND created_at <= ${endDate} GROUP BY DATE(created_at) ORDER BY date ASC `; // Convert count to number as BigInt might not serialize correctly const result = dailyIssues.map(row => ({ date: row.date, count: Number(row.count) // Convert BigInt to Number })); res.status(200).json(result); } catch (error) { console.error('Error fetching Mantis issue statistics:', error.message); res.status(500).json({ error: 'Failed to fetch Mantis issue statistics' }); } }); // GET /mantis/stats/comments - Get daily count of new Mantis comments (last 7 days) router.get('/stats/comments', async(req, res) => { try { // Calculate the date range (last 7 days) const endDate = new Date(); endDate.setHours(23, 59, 59, 999); // End of today const startDate = new Date(); startDate.setDate(startDate.getDate() - 6); // 7 days ago startDate.setHours(0, 0, 0, 0); // Start of the day // Query for daily counts of comments created in the last 7 days const dailyComments = await prisma.$queryRaw` SELECT DATE(created_at) as date, COUNT(*) as count FROM "MantisComment" WHERE created_at >= ${startDate} AND created_at <= ${endDate} GROUP BY DATE(created_at) ORDER BY date ASC `; // Convert count to number as BigInt might not serialize correctly const result = dailyComments.map(row => ({ date: row.date, count: Number(row.count) // Convert BigInt to Number })); res.status(200).json(result); } catch (error) { console.error('Error fetching Mantis comment statistics:', error.message); res.status(500).json({ error: 'Failed to fetch Mantis comment statistics' }); } }); router.get('/summary/:ticketId', async(req, res) => { const { ticketId } = req.params; const id = parseInt(ticketId, 10); if (isNaN(id)) { return res.status(400).json({ error: 'Invalid ticket ID format' }); } try { const ticket = await prisma.mantisIssue.findUnique({ where: { id: id }, include: { comments: { orderBy: { createdAt: 'asc' } // Ensure comments are ordered for summary }, }, }); if (!ticket) { return res.status(404).json({ error: 'Mantis ticket not found' }); } //We need to change the usernames using the usernameMap ticket.reporterUsername = usernameMap[ticket.reporterUsername] || ticket.reporterUsername; ticket.comments = ticket.comments.map((comment) => { comment.senderUsername = usernameMap[comment.senderUsername] || comment.senderUsername; return comment; }); //Ask Gemini to summarize the ticket const summary = await askGemini(`Please summarize the following Mantis ticket in the form of a markdown list of bullet points formatted as "[Date] Point" (ensure a newline between each point, format the date as DD/MM/YYYY and surround it with square brackets "[]"). Then after your summary, list any outstanding actions as a markdown list in the format "[Name] Action" (again surrounding the name with square brackets). Output a heading 6 "Summary:", a newline, the summary, then two newlines, a heading 6 "Actions:" then the actions. Do not wrap the output in a code block.\n\n### Ticket Data ###\n\n` + JSON.stringify(ticket, null, 2)); res.status(200).json({ summary }); } catch (error) { console.error('Error fetching Mantis summary:', error.message); res.status(500).json({ error: 'Failed to fetch Mantis summary' }); } }); // NEW ROUTE: Add an internal note to a Mantis issue router.post('/:id/notes', async(req, res) => { const { id } = req.params; const issueId = parseInt(id, 10); const { content } = req.body; const user = await getUserById(req.session.loggedInUserId); // Assumes user is logged in if (isNaN(issueId)) { return res.status(400).json({ error: 'Invalid issue ID format' }); } if (!content) { return res.status(400).json({ error: 'Note content cannot be empty' }); } if (!user) { return res.status(401).json({ error: 'User not authenticated' }); } try { // 1. Check if the Mantis issue exists const issueExists = await prisma.mantisIssue.findUnique({ where: { id: issueId }, select: { id: true } }); if (!issueExists) { return res.status(404).json({ error: 'Mantis issue not found' }); } // 2. Create the new note const newNote = await prisma.mantisNote.create({ data: { mantisIssueId: issueId, content: content, createdBy: user.username, // Store the username of the creator }, }); res.status(201).json(newNote); // Return the created note } catch (error) { console.error(`Error adding note to Mantis issue ${issueId}:`, error); res.status(500).json({ error: `Failed to add note. ${error.message}` }); } }); export default router;