import { Router } from 'express'; import { getDb } from '../database.js'; import PDFDocument from 'pdfkit'; // Import pdfkit import axios from 'axios'; // Added for Mantis import { GoogleGenAI } from '@google/genai'; // Added for GenAI import * as fs from 'fs'; // Added for reading prompt file import * as path from 'path'; // Added for path manipulation const router = Router(); const __dirname = new URL('.', import.meta.url).pathname.replace(/\/$/, ''); import { join } from 'path'; // --- Environment Variables (Ensure these are set in your .env file) --- const { MANTIS_API_KEY, MANTIS_API_ENDPOINT, GOOGLE_API_KEY } = process.env; // --- Mantis Summarizer Setup --- const promptFilePath = join(__dirname, 'prompt.txt'); // Path relative to this file const ai = GOOGLE_API_KEY ? new GoogleGenAI({ // Check if API key exists apiKey: GOOGLE_API_KEY, }) : null; const usernameMap = { 'credmore': 'Cameron Redmore', 'dgibson': 'Dane Gibson', 'egzibovskis': 'Ed Gzibovskis', 'ascotney': 'Amanda Scotney', 'gclough': 'Garry Clough', 'slee': 'Sarah Lee', 'dwalker': 'Dave Walker', 'askaith': 'Amy Skaith', 'dpotter': 'Danny Potter', 'msmart': 'Michael Smart', // Add other usernames as needed }; async function getMantisTickets() { if (!MANTIS_API_ENDPOINT || !MANTIS_API_KEY) { throw new Error("Mantis API endpoint or key not configured in environment variables."); } const url = `${MANTIS_API_ENDPOINT}/issues?project_id=1&page_size=50&select=id,summary,description,created_at,updated_at,reporter,notes`; const headers = { 'Authorization': `${MANTIS_API_KEY}`, 'Accept': 'application/json', 'Content-Type': 'application/json', }; try { const response = await axios.get(url, { headers }); const tickets = response.data.issues.filter((ticket) => { const ticketDate = new Date(ticket.updated_at); const thresholdDate = new Date(); const currentDay = thresholdDate.getDay(); // Sunday = 0, Monday = 1, ... // Go back 4 days if Monday (to include Fri, Sat, Sun), otherwise 2 days const daysToSubtract = currentDay === 1 ? 4 : 2; thresholdDate.setDate(thresholdDate.getDate() - daysToSubtract); thresholdDate.setHours(0, 0, 0, 0); // Start of the day return ticketDate >= thresholdDate; }).map((ticket) => { return { id: ticket.id, summary: ticket.summary, description: ticket.description, created_at: ticket.created_at, updated_at: ticket.updated_at, reporter: usernameMap[ticket.reporter?.username] || ticket.reporter?.name || 'Unknown Reporter', // Safer access notes: (ticket.notes ? ticket.notes.filter((note) => { const noteDate = new Date(note.created_at); const thresholdDate = new Date(); const currentDay = thresholdDate.getDay(); const daysToSubtract = currentDay === 1 ? 4 : 2; thresholdDate.setDate(thresholdDate.getDate() - daysToSubtract); thresholdDate.setHours(0, 0, 0, 0); // Start of the day return noteDate >= thresholdDate; }) : []).map((note) => { const reporter = usernameMap[note.reporter?.username] || note.reporter?.name || 'Unknown Reporter'; // Safer access return { reporter, created_at: note.created_at, text: note.text, }; }), }; }); return tickets; } catch (error) { console.error("Error fetching Mantis tickets:", error.message); // Check if it's an Axios error and provide more details if (axios.isAxiosError(error)) { console.error("Axios error details:", error.response?.status, error.response?.data); throw new Error(`Failed to fetch Mantis tickets: ${error.response?.statusText || error.message}`); } throw new Error(`Failed to fetch Mantis tickets: ${error.message}`); } } // --- Forms API --- // // GET /api/forms - List all forms router.get('/forms', (req, res) => { try { const db = getDb(); const forms = db.prepare('SELECT id, title, description, createdAt FROM forms ORDER BY createdAt DESC').all(); res.json(forms); } catch (err) { console.error('Error fetching forms:', err.message); res.status(500).json({ error: 'Failed to fetch forms' }); } }); // POST /api/forms - Create a new form router.post('/forms', (req, res) => { const { title, description, categories } = req.body; if (!title) { return res.status(400).json({ error: 'Form title is required' }); } const db = getDb(); const insertForm = db.prepare('INSERT INTO forms (title, description) VALUES (?, ?)'); const insertCategory = db.prepare('INSERT INTO categories (formId, name, sortOrder) VALUES (?, ?, ?)'); const insertField = db.prepare('INSERT INTO fields (categoryId, label, type, description, sortOrder) VALUES (?, ?, ?, ?, ?)'); const createTransaction = db.transaction((formData) => { const { title, description, categories } = formData; const formResult = insertForm.run(title, description); const formId = formResult.lastInsertRowid; if (categories && categories.length > 0) { for (const [catIndex, category] of categories.entries()) { if (!category.name) throw new Error('Category name is required'); const catResult = insertCategory.run(formId, category.name, catIndex); const categoryId = catResult.lastInsertRowid; if (category.fields && category.fields.length > 0) { for (const [fieldIndex, field] of category.fields.entries()) { if (!field.label || !field.type) { throw new Error('Field label and type are required'); } const validTypes = ['text', 'number', 'date', 'textarea', 'boolean']; if (!validTypes.includes(field.type)) { throw new Error(`Invalid field type: ${field.type}`); } insertField.run(categoryId, field.label, field.type, field.description || null, fieldIndex); } } } } return { id: formId, title, description }; }); try { const resultData = createTransaction({ title, description, categories }); res.status(201).json(resultData); } catch (err) { console.error('Error creating form:', err.message); res.status(500).json({ error: `Failed to create form: ${err.message}` }); } }); // GET /api/forms/:id - Get a specific form with its structure router.get('/forms/:id', (req, res) => { const { id } = req.params; try { const db = getDb(); const form = db.prepare('SELECT id, title, description FROM forms WHERE id = ?').get(id); if (!form) { return res.status(404).json({ error: 'Form not found' }); } const categories = db.prepare(` SELECT c.id, c.name, c.sortOrder FROM categories c WHERE c.formId = ? ORDER BY c.sortOrder `).all(id); const getFieldsStmt = db.prepare(` SELECT f.id, f.label, f.type, f.description, f.sortOrder FROM fields f WHERE f.categoryId = ? ORDER BY f.sortOrder `); for (const category of categories) { category.fields = getFieldsStmt.all(category.id); } form.categories = categories; res.json(form); } catch (err) { console.error(`Error fetching form ${id}:`, err.message); res.status(500).json({ error: 'Failed to fetch form details' }); } }); // DELETE /api/forms/:id - Delete a specific form and all related data router.delete('/forms/:id', (req, res) => { const formId = req.params.id; // Corrected destructuring const db = getDb(); const checkFormStmt = db.prepare('SELECT id FROM forms WHERE id = ?'); const deleteValuesStmt = db.prepare('DELETE FROM response_values WHERE responseId IN (SELECT id FROM responses WHERE formId = ?)'); const deleteResponsesStmt = db.prepare('DELETE FROM responses WHERE formId = ?'); const deleteFieldsStmt = db.prepare('DELETE FROM fields WHERE categoryId IN (SELECT id FROM categories WHERE formId = ?)'); const deleteCategoriesStmt = db.prepare('DELETE FROM categories WHERE formId = ?'); const deleteFormStmt = db.prepare('DELETE FROM forms WHERE id = ?'); const deleteTransaction = db.transaction((id) => { const form = checkFormStmt.get(id); if (!form) { const err = new Error('Form not found'); err.statusCode = 404; throw err; } // Delete in order of dependency: values -> responses -> fields -> categories -> form deleteValuesStmt.run(id); deleteResponsesStmt.run(id); deleteFieldsStmt.run(id); deleteCategoriesStmt.run(id); deleteFormStmt.run(id); return { message: `Form ${id} and all related data deleted successfully.` }; }); try { const resultData = deleteTransaction(formId); res.status(200).json(resultData); } catch (err) { console.error(`Error deleting form ${formId}:`, err.message); const statusCode = err.statusCode || 500; res.status(statusCode).json({ error: `Failed to delete form: ${err.message}` }); } }); // --- Responses API --- // // POST /api/forms/:id/responses - Submit a response for a form router.post('/forms/:id/responses', (req, res) => { const { id: formId } = req.params; const { values } = req.body; if (!values || typeof values !== 'object' || Object.keys(values).length === 0) { return res.status(400).json({ error: 'Response values are required' }); } const db = getDb(); const checkFormStmt = db.prepare('SELECT id FROM forms WHERE id = ?'); const checkFieldStmt = db.prepare('SELECT f.id FROM fields f JOIN categories c ON f.categoryId = c.id WHERE f.id = ? AND c.formId = ?'); const insertResponseStmt = db.prepare('INSERT INTO responses (formId) VALUES (?)'); const insertValueStmt = db.prepare('INSERT INTO response_values (responseId, fieldId, value) VALUES (?, ?, ?)'); const submitTransaction = db.transaction((formIdParam, responseValues) => { const form = checkFormStmt.get(formIdParam); if (!form) { const err = new Error('Form not found'); err.statusCode = 404; throw err; } const responseResult = insertResponseStmt.run(formIdParam); const responseId = responseResult.lastInsertRowid; for (const [fieldIdStr, value] of Object.entries(responseValues)) { const fieldId = parseInt(fieldIdStr, 10); const field = checkFieldStmt.get(fieldId, formIdParam); if (!field) { console.warn(`Attempted to submit value for field ${fieldId} not belonging to form ${formIdParam}`); continue; } const valueToStore = (value === null || typeof value === 'undefined') ? null : String(value); insertValueStmt.run(responseId, fieldId, valueToStore); } return { responseId }; }); try { const resultData = submitTransaction(formId, values); res.status(201).json(resultData); } catch (err) { console.error(`Error submitting response for form ${formId}:`, err.message); const statusCode = err.statusCode || 500; res.status(statusCode).json({ error: `Failed to submit response: ${err.message}` }); } }); // GET /api/forms/:id/responses - Get all responses for a form router.get('/forms/:id/responses', (req, res) => { const { id: formId } = req.params; try { const db = getDb(); const formExists = db.prepare('SELECT id FROM forms WHERE id = ?').get(formId); if (!formExists) { return res.status(404).json({ error: 'Form not found' }); } const responses = db.prepare(` SELECT r.id as responseId, r.submittedAt, rv.fieldId, f.label as fieldLabel, f.type as fieldType, rv.value FROM responses r JOIN response_values rv ON r.id = rv.responseId JOIN fields f ON rv.fieldId = f.id JOIN categories c ON f.categoryId = c.id WHERE r.formId = ? ORDER BY r.submittedAt DESC, r.id, c.sortOrder, f.sortOrder `).all(formId); const groupedResponses = responses.reduce((acc, row) => { const { responseId, submittedAt, fieldId, fieldLabel, fieldType, value } = row; if (!acc[responseId]) { acc[responseId] = { id: responseId, submittedAt, values: {} }; } acc[responseId].values[fieldId] = { label: fieldLabel, type: fieldType, value }; return acc; }, {}); res.json(Object.values(groupedResponses)); } catch (err) { console.error(`Error fetching responses for form ${formId}:`, err.message); res.status(500).json({ error: 'Failed to fetch responses' }); } }); // PUT /api/forms/:id - Update an existing form router.put('/forms/:id', (req, res) => { const { id: formId } = req.params; const { title, description, categories } = req.body; if (!title) { return res.status(400).json({ error: 'Form title is required' }); } const db = getDb(); const checkFormStmt = db.prepare('SELECT id FROM forms WHERE id = ?'); const updateFormStmt = db.prepare('UPDATE forms SET title = ?, description = ? WHERE id = ?'); const deleteFieldsStmt = db.prepare('DELETE FROM fields WHERE categoryId IN (SELECT id FROM categories WHERE formId = ?)'); const deleteCategoriesStmt = db.prepare('DELETE FROM categories WHERE formId = ?'); const insertCategoryStmt = db.prepare('INSERT INTO categories (formId, name, sortOrder) VALUES (?, ?, ?)'); const insertFieldStmt = db.prepare('INSERT INTO fields (categoryId, label, type, description, sortOrder) VALUES (?, ?, ?, ?, ?)'); const updateTransaction = db.transaction((formData) => { const { formId, title, description, categories } = formData; // 1. Check if form exists const existingForm = checkFormStmt.get(formId); if (!existingForm) { const err = new Error('Form not found'); err.statusCode = 404; throw err; } // 2. Delete existing categories and fields for this form deleteFieldsStmt.run(formId); deleteCategoriesStmt.run(formId); // 3. Update form details updateFormStmt.run(title, description, formId); // 4. Re-insert categories and fields if (categories && categories.length > 0) { for (const [catIndex, category] of categories.entries()) { if (!category.name) throw new Error('Category name is required'); const catResult = insertCategoryStmt.run(formId, category.name, catIndex); const categoryId = catResult.lastInsertRowid; if (category.fields && category.fields.length > 0) { for (const [fieldIndex, field] of category.fields.entries()) { if (!field.label || !field.type) { throw new Error('Field label and type are required'); } const validTypes = ['text', 'number', 'date', 'textarea', 'boolean']; if (!validTypes.includes(field.type)) { throw new Error(`Invalid field type: ${field.type}`); } insertFieldStmt.run(categoryId, field.label, field.type, field.description || null, fieldIndex); } } } } // Return the updated form ID and title (or potentially the full updated form structure) return { id: formId, title, description }; }); try { const resultData = updateTransaction({ formId, title, description, categories }); // Optionally fetch the full updated form structure here if needed for the response res.status(200).json(resultData); // Send back basic info for now } catch (err) { console.error(`Error updating form ${formId}:`, err.message); const statusCode = err.statusCode || 500; res.status(statusCode).json({ error: `Failed to update form: ${err.message}` }); } }); router.get('/responses/:responseId/export/pdf', async (req, res) => { const { responseId } = req.params; try { const db = getDb(); // 1. Fetch the response and its associated form ID const response = db.prepare(` SELECT r.id, r.formId, r.submittedAt, f.title as formTitle FROM responses r JOIN forms f ON r.formId = f.id WHERE r.id = ? `).get(responseId); if (!response) { return res.status(404).json({ error: 'Response not found' }); } const formId = response.formId; const formTitle = response.formTitle; // 2. Fetch the form structure (categories and fields) const categories = db.prepare(` SELECT c.id, c.name FROM categories c WHERE c.formId = ? ORDER BY c.sortOrder `).all(formId); const getFieldsStmt = db.prepare(` SELECT f.id, f.label, f.type, f.description FROM fields f WHERE f.categoryId = ? ORDER BY f.sortOrder `); for (const category of categories) { category.fields = getFieldsStmt.all(category.id); } // 3. Fetch the values for this specific response const valuesResult = db.prepare(` SELECT fieldId, value FROM response_values WHERE responseId = ? `).all(responseId); const responseValues = valuesResult.reduce((acc, row) => { acc[row.fieldId] = (row.value === null || typeof row.value === 'undefined') ? '' : String(row.value); return acc; }, {}); // 4. Generate PDF using pdfkit const doc = new PDFDocument({ margin: 50, size: 'A4' }); // Set size to A4 doc.registerFont('Roboto-Bold', join(fontsDir, 'Roboto-Bold.ttf')); doc.registerFont('Roboto-SemiBold', join(fontsDir, 'Roboto-SemiBold.ttf')); doc.registerFont('Roboto-Italics', join(fontsDir, 'Roboto-Italic.ttf')); doc.registerFont('Roboto-Regular', join(fontsDir, 'Roboto-Regular.ttf')); res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Disposition', `inline; filename=response_${responseId}_${formTitle.replace(/[\s\\/]/g, '_') || 'form'}.pdf`); // Use inline to preview doc.pipe(res); // --- PDF Content --- // Title doc.fontSize(18).font('Roboto-Bold').text(formTitle, { align: 'center' }); doc.moveDown(); // Iterate through categories and fields for (const category of categories) { if (category.name) { doc.fontSize(14).font('Roboto-Bold').text(category.name); doc.moveDown(0.5); } for (const field of category.fields) { const value = responseValues[field.id] || ''; // Get the value for this field // Field Label doc.fontSize(12).font('Roboto-SemiBold').text(field.label + ':', { continued: false }); // Use continued: false to reset position potentially // Optional: Add description if (field.description) { doc.fontSize(9).font('Roboto-Italics').text(field.description); } doc.moveDown(0.2); // Field Value (mimic input) doc.fontSize(11).font('Roboto-Regular'); if (field.type === 'textarea') { // Draw a box and put text inside for textarea const textHeight = doc.heightOfString(value, { width: 500 }); // Estimate height doc.rect(doc.x, doc.y, 500, Math.max(textHeight + 10, 30)).stroke(); // Draw rectangle doc.text(value, doc.x + 5, doc.y + 5, { width: 490 }); // Add text inside with padding doc.y += Math.max(textHeight + 10, 30) + 10; // Move below the box } else if (field.type === 'date') { // Format date as DD/MM/YYYY let formattedDate = ''; if (value) { try { const dateObj = new Date(value + 'T00:00:00'); // Add time part to avoid timezone issues with just YYYY-MM-DD if (!isNaN(dateObj.getTime())) { const day = String(dateObj.getDate()).padStart(2, '0'); const month = String(dateObj.getMonth() + 1).padStart(2, '0'); // Month is 0-indexed const year = dateObj.getFullYear(); formattedDate = `${day}/${month}/${year}`; } else { formattedDate = value; // Keep original if invalid } } catch (e) { console.error('Error formatting date:', value, e); formattedDate = value; // Keep original on error } } doc.text(formattedDate || ' '); // Add space if empty doc.lineCap('butt').moveTo(doc.x, doc.y).lineTo(doc.x + 500, doc.y).stroke(); // Draw line underneath doc.moveDown(1.5); // Space between fields } else if (field.type === 'boolean') { // Display boolean as Yes/No const displayValue = value === 'true' ? 'Yes' : (value === 'false' ? 'No' : ' '); doc.text(displayValue); doc.lineCap('butt').moveTo(doc.x, doc.y).lineTo(doc.x + 500, doc.y).stroke(); // Draw line underneath doc.moveDown(1.5); // Space between fields } else { // Simple line for other types doc.text(value || ' '); // Add space if empty to ensure line moves doc.lineCap('butt').moveTo(doc.x, doc.y).lineTo(doc.x + 500, doc.y).stroke(); // Draw line underneath doc.moveDown(1.5); // Space between fields } } doc.moveDown(1); // Space between categories } // --- Finalize PDF --- doc.end(); } catch (err) { console.error(`Error generating PDF for response ${responseId}:`, err.message); if (!res.headersSent) { res.status(500).json({ error: 'Failed to generate PDF' }); } else { console.error("Headers already sent, could not send JSON error for PDF generation failure."); // Ensure the stream is ended if an error occurs after piping res.end(); } } }); // --- Mantis Summary API --- // router.get('/mantis-summary', async (req, res) => { if (!ai) { return res.status(500).json({ error: 'Google AI API key not configured.' }); } if (!fs.existsSync(promptFilePath)) { return res.status(500).json({ error: `Prompt file not found at ${promptFilePath}` }); } try { // Read the prompt from the file let promptTemplate = fs.readFileSync(promptFilePath, 'utf8'); const tickets = await getMantisTickets(); if (tickets.length === 0) { return res.json({ summary: "No Mantis tickets updated recently." }); } let prompt = promptTemplate.replaceAll("$DATE", new Date().toISOString().split('T')[0]); prompt = prompt.replaceAll("$MANTIS_TICKETS", JSON.stringify(tickets, null, 2)); // Use the specific model and configuration from your original script const model = ai.getGenerativeModel({ model: "gemini-2.5-flash-exp" }); // Or your specific model like "gemini-2.0-flash-exp" if available const result = await model.generateContent(prompt); const response = await result.response; const summaryText = response.text(); res.json({ summary: summaryText }); } catch (error) { console.error("Error generating Mantis summary:", error); res.status(500).json({ error: `Failed to generate summary: ${error.message}` }); } }); export default router;