stock-management-demo/src-ssr/routes/api.js
2025-04-23 15:55:28 +01:00

598 lines
No EOL
23 KiB
JavaScript

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;