Adds in Mantis features. Enabling automated downloading of Mantises into the internal database, browsing of them, and viewing of attachments (including .msg files).

Resolves #14
This commit is contained in:
Cameron Redmore 2025-04-25 23:31:50 +01:00
parent 0e77e310bd
commit 5268d6aecd
15 changed files with 1583 additions and 44 deletions

284
src-server/routes/mantis.js Normal file
View file

@ -0,0 +1,284 @@
import express from 'express';
import { PrismaClient } from '@prisma/client'; // Import Prisma Client
import { getMantisSettings, saveTicketToDatabase } from '../services/mantisDownloader.js';
import axios from 'axios';
import reader from '@kenjiuno/msgreader';
const MsgReader = reader.default;
const prisma = new PrismaClient(); // Instantiate Prisma Client
const router = express.Router();
// GET /mantis - Fetch multiple Mantis issues with filtering and pagination
router.get('/', async(req, res) =>
{
const { page = 1, limit = 10, status, priority, severity, reporterUsername, search } = req.query;
const pageNum = parseInt(page, 10);
const limitNum = parseInt(limit, 10);
const skip = (pageNum - 1) * limitNum;
const where = {};
if (status) where.status = status;
if (priority) where.priority = priority;
if (severity) where.severity = severity;
if (reporterUsername) where.reporterUsername = reporterUsername;
if (search)
{
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
];
// If the search term is a number, treat it as an ID
const searchNum = parseInt(search, 10);
if (!isNaN(searchNum))
{
where.OR.push({ id: searchNum });
}
}
try
{
let [issues, totalCount] = await prisma.$transaction([
prisma.mantisIssue.findMany({
where,
skip,
take: limitNum,
orderBy: {
updatedAt: 'desc', // Default sort order
},
// You might want to include related data like comments count later
// include: { _count: { select: { comments: true } } }
}),
prisma.mantisIssue.count({ where }),
]);
if (!issues || issues.length === 0)
{
//If it's numeric, try to download the issue from Mantis
const searchNum = parseInt(search, 10);
if (!isNaN(searchNum))
{
let data;
try
{
data = await saveTicketToDatabase(searchNum);
}
catch (error)
{
console.error('Error saving ticket to database:', error.message);
return res.status(404).json({ error: 'Mantis issue not found' });
}
if (!data)
{
return res.status(404).json({ error: 'Mantis issue not found' });
}
// Fetch the issue again from the database
issues = await prisma.mantisIssue.findMany({
where,
skip,
take: limitNum,
orderBy: {
updatedAt: 'desc', // Default sort order
},
});
if (issues.length === 0)
{
return res.status(404).json({ error: 'Mantis issue not found' });
}
totalCount = await prisma.mantisIssue.count({ where });
}
}
res.json({
data: issues,
pagination: {
total: totalCount,
page: pageNum,
limit: limitNum,
totalPages: Math.ceil(totalCount / limitNum),
},
});
}
catch (error)
{
console.error('Error fetching Mantis issues:', error.message);
res.status(500).json({ error: 'Failed to fetch Mantis issues' });
}
});
// GET /mantis/:id - Fetch a single Mantis issue by ID
router.get('/:id', async(req, res) =>
{
const { id } = req.params;
const issueId = parseInt(id, 10);
if (isNaN(issueId))
{
return res.status(400).json({ error: 'Invalid issue ID format' });
}
try
{
const issue = await prisma.mantisIssue.findUnique({
where: { id: issueId },
include: {
comments: { // Include comments
include: { attachments: true } // And include attachments for each comment
}
}
});
if (!issue)
{
//Try to download the issue from Mantis
const data = await saveTicketToDatabase(issueId);
if (!data)
{
return res.status(404).json({ error: 'Mantis issue not found' });
}
// Fetch the issue again from the database
const issue = await prisma.mantisIssue.findUnique({
where: { id: issueId },
include: {
comments: { // Include comments
include: { attachments: true } // And include attachments for each comment
}
}
});
}
res.json(issue);
}
catch (error)
{
console.error(`Error fetching Mantis issue ${issueId}:`, error.message);
res.status(500).json({ error: 'Failed to fetch Mantis issue' });
}
});
router.get('/attachment/:ticketId/:attachmentId', async(req, res) =>
{
const { url, headers } = await getMantisSettings();
const { ticketId, attachmentId } = req.params;
const attachmentUrl = `${url}/issues/${ticketId}/files/${attachmentId}`;
console.log('Fetching Mantis attachment from URL:', attachmentUrl);
try
{
const response = await axios.get(attachmentUrl, { headers });
const attachment = response.data.files[0];
if (!attachment)
{
return res.status(404).json({ error: 'Attachment not found' });
}
const buffer = Buffer.from(attachment.content, 'base64');
res.setHeader('Content-Type', attachment.content_type);
res.setHeader('Content-Disposition', `attachment; filename="${attachment.filename}"`);
res.setHeader('Content-Length', buffer.length);
res.send(buffer);
}
catch (error)
{
console.error('Error fetching Mantis attachment:', error.message);
res.status(500).json({ error: 'Failed to fetch Mantis attachment' });
}
});
router.get('/msg-extract/:ticketId/:attachmentId', async(req, res) =>
{
const { url, headers } = await getMantisSettings();
const { ticketId, attachmentId } = req.params;
const attachmentUrl = `${url}/issues/${ticketId}/files/${attachmentId}`;
console.log('Fetching Mantis attachment from URL:', attachmentUrl);
try
{
const response = await axios.get(attachmentUrl, { headers });
const attachment = response.data.files[0];
if (!attachment)
{
return res.status(404).json({ error: 'Attachment not found' });
}
const buffer = Buffer.from(attachment.content, 'base64');
console.log(MsgReader);
const reader = new MsgReader(buffer);
const msg = reader.getFileData();
res.status(200).json(msg);
}
catch (error)
{
console.error('Error fetching Mantis attachment:', error.message);
res.status(500).json({ error: 'Failed to fetch Mantis attachment' });
}
});
router.get('/msg-extract/:ticketId/:attachmentId/:innerAttachmentId', async(req, res) =>
{
const { url, headers } = await getMantisSettings();
const { ticketId, attachmentId, innerAttachmentId } = req.params;
const attachmentUrl = `${url}/issues/${ticketId}/files/${attachmentId}`;
console.log('Fetching Mantis attachment from URL:', attachmentUrl);
try
{
const response = await axios.get(attachmentUrl, { headers });
const attachment = response.data.files[0];
if (!attachment)
{
return res.status(404).json({ error: 'Attachment not found' });
}
const buffer = Buffer.from(attachment.content, 'base64');
const reader = new MsgReader(buffer);
const msg = reader.getFileData();
// Find the inner attachment
const innerAttachment = msg.attachments[innerAttachmentId];
if (!innerAttachment)
{
return res.status(404).json({ error: 'Inner attachment not found' });
}
const attachmentData = reader.getAttachment(innerAttachment);
const innerBuffer = Buffer.from(attachmentData.content, 'base64');
res.setHeader('Content-Disposition', `attachment; filename="${innerAttachment.fileName}"`);
res.status(200).send(innerBuffer);
}
catch (error)
{
console.error('Error fetching Mantis attachment:', error.message);
res.status(500).json({ error: 'Failed to fetch Mantis attachment' });
}
});
export default router;

View file

@ -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();
});

View file

@ -0,0 +1,215 @@
//This is a service which will download data for the latest updated Mantis tickets and store them in the database.
//It will also download all the notes and attachments for each ticket.
import axios from 'axios';
import { getSetting } from '../utils/settings.js';
import prisma from '../database.js';
import { logger } from '../utils/logging.js';
export async function getMantisSettings()
{
const MANTIS_API_KEY = await getSetting('MANTIS_API_KEY');
const MANTIS_API_ENDPOINT = await getSetting('MANTIS_API_ENDPOINT');
if (!MANTIS_API_ENDPOINT || !MANTIS_API_KEY)
{
throw new Error('Mantis API endpoint or key not configured in environment variables.');
}
const headers = {
Authorization: `${MANTIS_API_KEY}`,
Accept: 'application/json',
'Content-Type': 'application/json',
};
return { url: MANTIS_API_ENDPOINT, headers };
}
export async function getLatestMantisTickets()
{
const { url, headers } = await getMantisSettings();
const ticketUrl = `${url}/issues?project_id=1&page_size=50&select=id,updated_at`;
try
{
const response = await axios.get(ticketUrl, { headers });
return response.data.issues;
}
catch (error)
{
logger.error('Error fetching tickets data:', error);
throw new Error('Failed to fetch tickets data from Mantis.');
}
}
export async function getDataForMantisTicket(ticketId)
{
const { url, headers } = await getMantisSettings();
// Removed notes from select, as they are fetched separately with attachments
const ticketUrl = `${url}/issues/${ticketId}?select=id,summary,description,created_at,updated_at,reporter,status,severity,priority,notes`;
try
{
const response = await axios.get(ticketUrl, { headers });
// Assuming response.data contains the issue object directly
return response.data.issues && response.data.issues.length > 0 ? response.data.issues[0] : null;
}
catch (error)
{
logger.error(`Error fetching ticket data for ID ${ticketId}:`, error);
throw new Error(`Failed to fetch ticket data for ID ${ticketId} from Mantis.`);
}
}
export async function saveTicketToDatabase(ticketId)
{
const ticketData = await getDataForMantisTicket(ticketId);
if (!ticketData)
{
logger.warn(`No ticket data found for ID ${ticketId}. Skipping save.`);
return null;
}
const ticketInDb = await prisma.$transaction(async(tx) =>
{
const reporterUsername = ticketData.reporter?.name;
const ticket = await tx.mantisIssue.upsert({
where: { id: ticketId },
update: {
title: ticketData.summary,
description: ticketData.description,
reporterUsername,
status: ticketData.status.name,
priority: ticketData.priority.name,
severity: ticketData.severity.name,
updatedAt: new Date(ticketData.updated_at),
},
create: {
id: ticketId,
title: ticketData.summary,
description: ticketData.description,
reporterUsername,
status: ticketData.status.name,
priority: ticketData.priority.name,
severity: ticketData.severity.name,
createdAt: new Date(ticketData.created_at),
updatedAt: new Date(ticketData.updated_at),
},
});
logger.info(`Ticket ${ticketId} saved to database.`);
// Process notes
if (ticketData.notes && ticketData.notes.length > 0)
{
for (const note of ticketData.notes)
{
const noteReporter = note.reporter?.name || 'Unknown Reporter';
const comment = await tx.mantisComment.create({
data: {
mantisIssueId: ticketId,
senderUsername: noteReporter,
comment: note.text,
createdAt: new Date(note.created_at),
},
});
// Process attachments for the note
if (note.attachments && note.attachments.length > 0)
{
for (const attachment of note.attachments)
{
const attachmentData = {
commentId: comment.id,
filename: attachment.filename,
url: '/mantis/attachment/' + ticketId + '/' + attachment.id,
mimeType: attachment.content_type,
size: attachment.size,
uploadedAt: new Date(attachment.created_at),
};
await tx.mantisAttachment.create({
data: attachmentData,
});
logger.info(`Attachment ${attachment.filename} for ticket ${ticketId} saved to database.`);
}
}
}
}
return ticket;
});
return ticketInDb;
}
async function processNewMantisTickets()
{
logger.info('Checking for new Mantis tickets...');
const issues = await getLatestMantisTickets();
if (!issues)
{
logger.warn('No issues returned from getLatestMantisTickets.');
return;
}
//Check if the tickets exist, and if not, or if they have a newer updated_at date, add them to the download queue
for (const issue of issues)
{
const ticketId = issue.id;
const existingTicket = await prisma.mantisIssue.findUnique({ // Changed from prisma.ticket to prisma.mantisIssue
where: { id: ticketId },
select: { updatedAt: true } // Only select needed field
});
if (!existingTicket || new Date(issue.updated_at) > new Date(existingTicket.updatedAt)) // Changed existingTicket.updated_at to existingTicket.updatedAt
{
// Avoid adding duplicates to the queue
if (!downloadQueue.includes(ticketId))
{
downloadQueue.push(ticketId);
logger.info(`Queueing ticket ${ticketId} for processing.`);
}
}
}
}
async function processTicketsInQueue()
{
if (downloadQueue.length === 0)
{
logger.info('No tickets to process.');
return;
}
const ticketId = downloadQueue.shift();
try
{
logger.info(`Processing ticket ${ticketId}...`);
await saveTicketToDatabase(ticketId);
logger.info(`Ticket ${ticketId} processed and saved to database.`);
}
catch (error)
{
console.log(error);
logger.error(`Error processing ticket ${ticketId}:`, error);
// Optionally, you can re-add the ticket to the queue for retrying later
downloadQueue.push(ticketId);
}
}
const downloadQueue = [];
export function setup()
{
// Initialize the download queue
downloadQueue.length = 0;
// Start the process of checking for new tickets
processNewMantisTickets();
setInterval(processNewMantisTickets, 5 * 60 * 1000); // Check for new tickets every 5 minutes
setInterval(processTicketsInQueue, 10 * 1000); // Process the queue every 10 seconds
}

View file

@ -0,0 +1,39 @@
import pino from 'pino';
// Initialize Pino logger
const targets = [];
// Console logging (pretty-printed in development)
if (process.env.NODE_ENV !== 'production')
{
targets.push({
target: 'pino-pretty',
options: {
colorize: true
},
level: process.env.LOG_LEVEL || 'info'
});
}
else
{
// Basic console logging in production
targets.push({
target: 'pino/file', // Log to stdout in production
options: { destination: 1 }, // 1 is stdout
level: process.env.LOG_LEVEL || 'info'
});
}
// Database logging via custom transport
targets.push({
target: './prisma-pino-transport.js', // Path to the custom transport
options: {}, // No specific options needed for this transport
level: process.env.DB_LOG_LEVEL || 'info' // Separate level for DB logging if needed
});
export const logger = pino({
level: process.env.LOG_LEVEL || 'info', // Overall minimum level
transport: {
targets: targets
}
});