Migrated to using Bun instead of Node and PNPM. This also brings in a Devcontainer which enables quick and easy development of the project. Additionally this adds connectivity to S3 (with a default Minio server pre-created) this enables Files to be uploaded against Mantises. There's also a new Internal Notes feature to store arbitrary text notes against a Mantis.
This commit is contained in:
parent
80ca48be70
commit
3b846b8c8e
23 changed files with 3210 additions and 6490 deletions
|
@ -1,13 +1,15 @@
|
|||
import express from 'express';
|
||||
import { PrismaClient } from '@prisma/client'; // Import Prisma Client
|
||||
import { v4 as uuidv4 } from 'uuid'; // Import uuid for unique filenames
|
||||
import { getMantisSettings, saveTicketToDatabase } from '../services/mantisDownloader.js';
|
||||
import axios from 'axios';
|
||||
import reader from '@kenjiuno/msgreader';
|
||||
import MsgReader from '@kenjiuno/msgreader';
|
||||
import { askGemini } from '../utils/gemini.js';
|
||||
import { usernameMap } from '../services/mantisSummarizer.js';
|
||||
const MsgReader = reader.default;
|
||||
import { getS3Client } from '../utils/s3.js';
|
||||
import { getUserById } from './auth.js';
|
||||
|
||||
import prisma from '../database.js';
|
||||
|
||||
const prisma = new PrismaClient(); // Instantiate Prisma Client
|
||||
const router = express.Router();
|
||||
|
||||
// Helper function to fetch distinct values
|
||||
|
@ -186,11 +188,18 @@ router.get('/:id', async(req, res) =>
|
|||
|
||||
try
|
||||
{
|
||||
const issue = await prisma.mantisIssue.findUnique({
|
||||
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
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -198,22 +207,50 @@ router.get('/:id', async(req, res) =>
|
|||
if (!issue)
|
||||
{
|
||||
//Try to download the issue from Mantis
|
||||
const data = await saveTicketToDatabase(issueId);
|
||||
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)
|
||||
{
|
||||
return res.status(404).json({ error: 'Mantis issue not found' });
|
||||
// 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
|
||||
const issue = await prisma.mantisIssue.findUnique({
|
||||
// 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);
|
||||
|
@ -225,6 +262,191 @@ router.get('/:id', async(req, res) =>
|
|||
}
|
||||
});
|
||||
|
||||
// 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) =>
|
||||
{
|
||||
|
@ -247,7 +469,7 @@ router.get('/attachment/:ticketId/:attachmentId', async(req, res) =>
|
|||
}
|
||||
|
||||
const buffer = Buffer.from(attachment.content, 'base64');
|
||||
res.setHeader('Content-Type', attachment.content_type);
|
||||
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);
|
||||
|
@ -255,7 +477,15 @@ router.get('/attachment/:ticketId/:attachmentId', async(req, res) =>
|
|||
catch (error)
|
||||
{
|
||||
console.error('Error fetching Mantis attachment:', error.message);
|
||||
res.status(500).json({ error: 'Failed to fetch Mantis attachment' });
|
||||
// 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' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -281,17 +511,22 @@ router.get('/msg-extract/:ticketId/:attachmentId', async(req, res) =>
|
|||
|
||||
const buffer = Buffer.from(attachment.content, 'base64');
|
||||
|
||||
console.log(MsgReader);
|
||||
|
||||
const reader = new MsgReader(buffer);
|
||||
const msg = reader.getFileData();
|
||||
const msgReader = new MsgReader(buffer);
|
||||
const msg = msgReader.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' });
|
||||
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' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -300,6 +535,12 @@ router.get('/msg-extract/:ticketId/:attachmentId/:innerAttachmentId', async(req,
|
|||
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}`;
|
||||
|
||||
|
@ -320,25 +561,55 @@ router.get('/msg-extract/:ticketId/:attachmentId/:innerAttachmentId', async(req,
|
|||
const reader = new MsgReader(buffer);
|
||||
const msg = reader.getFileData();
|
||||
|
||||
// Find the inner attachment
|
||||
const innerAttachment = msg.attachments[innerAttachmentId];
|
||||
|
||||
if (!innerAttachment)
|
||||
// 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' });
|
||||
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' });
|
||||
}
|
||||
|
||||
const attachmentData = reader.getAttachment(innerAttachment);
|
||||
// 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
|
||||
|
||||
const innerBuffer = Buffer.from(attachmentData.content, 'base64');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${innerAttachment.fileName}"`);
|
||||
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 Mantis attachment:', error.message);
|
||||
res.status(500).json({ error: 'Failed to fetch Mantis attachment' });
|
||||
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' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -356,6 +627,7 @@ router.get('/stats/issues', async(req, res) =>
|
|||
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,
|
||||
|
@ -370,7 +642,14 @@ router.get('/stats/issues', async(req, res) =>
|
|||
date ASC
|
||||
`;
|
||||
|
||||
res.status(200).json(dailyIssues);
|
||||
// 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)
|
||||
{
|
||||
|
@ -407,7 +686,13 @@ router.get('/stats/comments', async(req, res) =>
|
|||
date ASC
|
||||
`;
|
||||
|
||||
res.status(200).json(dailyComments);
|
||||
// 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)
|
||||
{
|
||||
|
@ -419,13 +704,21 @@ router.get('/stats/comments', async(req, res) =>
|
|||
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: parseInt(ticketId, 10) },
|
||||
where: { id: id },
|
||||
include: {
|
||||
comments: true,
|
||||
comments: {
|
||||
orderBy: { createdAt: 'asc' } // Ensure comments are ordered for summary
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -443,7 +736,7 @@ router.get('/summary/:ticketId', async(req, res) =>
|
|||
});
|
||||
|
||||
//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/YYY 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).
|
||||
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 });
|
||||
}
|
||||
|
@ -454,4 +747,56 @@ router.get('/summary/:ticketId', async(req, res) =>
|
|||
}
|
||||
});
|
||||
|
||||
// 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;
|
Loading…
Add table
Add a link
Reference in a new issue