Initial commit.

This commit is contained in:
Cameron Redmore 2025-04-23 15:55:28 +01:00
commit 2d11d0bd79
54 changed files with 6657 additions and 0 deletions

110
src-ssr/database.js Normal file
View file

@ -0,0 +1,110 @@
import Database from 'better-sqlite3';
import { join } from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs'; // Needed to check if db file exists
// Determine the database path relative to this file
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const dbPath = join(__dirname, 'forms.db');
let db = null;
export function initializeDatabase() {
if (db) {
return db;
}
try {
// Check if the directory exists, create if not (better-sqlite3 might need this)
const dbDir = join(__dirname);
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
// better-sqlite3 constructor opens/creates the database file
db = new Database(dbPath, { verbose: console.log }); // Enable verbose logging
console.log('Connected to the SQLite database using better-sqlite3.');
// Ensure WAL mode is enabled for better concurrency
db.pragma('journal_mode = WAL');
// Create tables if they don't exist (run sequentially)
db.exec(`
CREATE TABLE IF NOT EXISTS forms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP
);
`);
db.exec(`
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
formId INTEGER NOT NULL,
name TEXT NOT NULL,
sortOrder INTEGER DEFAULT 0,
FOREIGN KEY (formId) REFERENCES forms (id) ON DELETE CASCADE
);
`);
db.exec(`
CREATE TABLE IF NOT EXISTS fields (
id INTEGER PRIMARY KEY AUTOINCREMENT,
categoryId INTEGER NOT NULL,
label TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('text', 'number', 'date', 'textarea', 'boolean')),
description TEXT,
sortOrder INTEGER NOT NULL,
FOREIGN KEY (categoryId) REFERENCES categories(id) ON DELETE CASCADE
);
`);
db.exec(`
CREATE TABLE IF NOT EXISTS responses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
formId INTEGER NOT NULL,
submittedAt DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (formId) REFERENCES forms (id) ON DELETE CASCADE
);
`);
db.exec(`
CREATE TABLE IF NOT EXISTS response_values (
id INTEGER PRIMARY KEY AUTOINCREMENT,
responseId INTEGER NOT NULL,
fieldId INTEGER NOT NULL,
value TEXT,
FOREIGN KEY (responseId) REFERENCES responses (id) ON DELETE CASCADE,
FOREIGN KEY (fieldId) REFERENCES fields (id) ON DELETE CASCADE
);
`);
console.log('Database tables ensured.');
return db;
} catch (err) {
console.error('Error initializing database with better-sqlite3:', err.message);
throw err; // Re-throw the error
}
}
export function getDb() {
if (!db) {
// Try to initialize if not already done (e.g., during hot reload)
try {
initializeDatabase();
} catch (err) {
throw new Error('Database not initialized and initialization failed.');
}
if (!db) { // Check again after trying to initialize
throw new Error('Database not initialized. Call initializeDatabase first.');
}
}
return db;
}
// Optional: Add a function to close the database gracefully on server shutdown
export function closeDatabase() {
if (db) {
db.close();
db = null;
console.log('Database connection closed.');
}
}

View file

@ -0,0 +1,58 @@
import { defineSsrMiddleware } from '#q-app/wrappers'
// This middleware should execute as last one
// since it captures everything and tries to
// render the page with Vue
export default defineSsrMiddleware(({ app, resolve, render, serve }) => {
// we capture any other Express route and hand it
// over to Vue and Vue Router to render our page
app.get(resolve.urlPath('*'), (req, res) => {
res.setHeader('Content-Type', 'text/html')
render(/* the ssrContext: */ { req, res })
.then(html => {
// now let's send the rendered html to the client
res.send(html)
})
.catch(err => {
// oops, we had an error while rendering the page
// we were told to redirect to another URL
if (err.url) {
if (err.code) {
res.redirect(err.code, err.url)
} else {
res.redirect(err.url)
}
} else if (err.code === 404) {
// hmm, Vue Router could not find the requested route
// Should reach here only if no "catch-all" route
// is defined in /src/routes
res.status(404).send('404 | Page Not Found')
} else if (process.env.DEV) {
// well, we treat any other code as error;
// if we're in dev mode, then we can use Quasar CLI
// to display a nice error page that contains the stack
// and other useful information
// serve.error is available on dev only
serve.error({ err, req, res })
} else {
// we're in production, so we should have another method
// to display something to the client when we encounter an error
// (for security reasons, it's not ok to display the same wealth
// of information as we do in development)
// Render Error Page on production or
// create a route (/src/routes) for an error page and redirect to it
res.status(500).send('500 | Internal Server Error')
if (process.env.DEBUGGING) {
console.error(err.stack)
}
}
})
})
})

598
src-ssr/routes/api.js Normal file
View file

@ -0,0 +1,598 @@
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;

163
src-ssr/server.js Normal file
View file

@ -0,0 +1,163 @@
/**
* More info about this file:
* https://v2.quasar.dev/quasar-cli-vite/developing-ssr/ssr-webserver
*
* Runs in Node context.
*/
/**
* Make sure to yarn add / npm install (in your project root)
* anything you import here (except for express and compression).
*/
import express from 'express'
import compression from 'compression'
import {
defineSsrCreate,
defineSsrListen,
defineSsrClose,
defineSsrServeStaticContent,
defineSsrRenderPreloadTag
} from '#q-app/wrappers'
// Import database initialization and close function
import { initializeDatabase, closeDatabase } from './database.js';
import apiRoutes from './routes/api.js';
/**
* Create your webserver and return its instance.
* If needed, prepare your webserver to receive
* connect-like middlewares.
*
* Can be async: defineSsrCreate(async ({ ... }) => { ... })
*/
export const create = defineSsrCreate((/* { ... } */) => {
const app = express()
// Initialize the database (now synchronous)
try {
initializeDatabase();
console.log('Database initialized successfully.');
} catch (error) {
console.error('Failed to initialize database:', error);
// Optionally handle the error more gracefully, e.g., prevent server start
process.exit(1); // Exit if DB connection fails
}
// attackers can use this header to detect apps running Express
// and then launch specifically-targeted attacks
app.disable('x-powered-by')
// Add JSON body parsing middleware
app.use(express.json());
// Add API routes
app.use('/api', apiRoutes);
// place here any middlewares that
// absolutely need to run before anything else
if (process.env.PROD) {
app.use(compression())
}
return app
})
/**
* You need to make the server listen to the indicated port
* and return the listening instance or whatever you need to
* close the server with.
*
* The "listenResult" param for the "close()" definition below
* is what you return here.
*
* For production, you can instead export your
* handler for serverless use or whatever else fits your needs.
*
* Can be async: defineSsrListen(async ({ app, devHttpsApp, port }) => { ... })
*/
export const listen = defineSsrListen(({ app, devHttpsApp, port }) => {
const server = devHttpsApp || app
return server.listen(port, () => {
if (process.env.PROD) {
console.log('Server listening at port ' + port)
}
})
})
/**
* Should close the server and free up any resources.
* Will be used on development mode when the server needs
* to be restarted, or when the application shuts down.
*
* Can be async: defineSsrClose(async ({ ... }) => { ... })
*/
export const close = defineSsrClose(({ listenResult }) => {
// Close the database connection when the server shuts down
closeDatabase();
return listenResult.close()
})
const maxAge = process.env.DEV
? 0
: 1000 * 60 * 60 * 24 * 30
/**
* Should return a function that will be used to configure the webserver
* to serve static content at "urlPath" from "pathToServe" folder/file.
*
* Notice resolve.urlPath(urlPath) and resolve.public(pathToServe) usages.
*
* Can be async: defineSsrServeStaticContent(async ({ app, resolve }) => {
* Can return an async function: return async ({ urlPath = '/', pathToServe = '.', opts = {} }) => {
*/
export const serveStaticContent = defineSsrServeStaticContent(({ app, resolve }) => {
return ({ urlPath = '/', pathToServe = '.', opts = {} }) => {
const serveFn = express.static(resolve.public(pathToServe), { maxAge, ...opts })
app.use(resolve.urlPath(urlPath), serveFn)
}
})
const jsRE = /\.js$/
const cssRE = /\.css$/
const woffRE = /\.woff$/
const woff2RE = /\.woff2$/
const gifRE = /\.gif$/
const jpgRE = /\.jpe?g$/
const pngRE = /\.png$/
/**
* Should return a String with HTML output
* (if any) for preloading indicated file
*/
export const renderPreloadTag = defineSsrRenderPreloadTag((file/* , { ssrContext } */) => {
if (jsRE.test(file) === true) {
return `<link rel="modulepreload" href="${file}" crossorigin>`
}
if (cssRE.test(file) === true) {
return `<link rel="stylesheet" href="${file}" crossorigin>`
}
if (woffRE.test(file) === true) {
return `<link rel="preload" href="${file}" as="font" type="font/woff" crossorigin>`
}
if (woff2RE.test(file) === true) {
return `<link rel="preload" href="${file}" as="font" type="font/woff2" crossorigin>`
}
if (gifRE.test(file) === true) {
return `<link rel="preload" href="${file}" as="image" type="image/gif" crossorigin>`
}
if (jpgRE.test(file) === true) {
return `<link rel="preload" href="${file}" as="image" type="image/jpeg" crossorigin>`
}
if (pngRE.test(file) === true) {
return `<link rel="preload" href="${file}" as="image" type="image/png" crossorigin>`
}
return ''
})