stock-management-demo/src-server/routes/mantis.js

802 lines
No EOL
24 KiB
JavaScript

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;