Initial commit.
This commit is contained in:
commit
2d11d0bd79
54 changed files with 6657 additions and 0 deletions
110
src-ssr/database.js
Normal file
110
src-ssr/database.js
Normal 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.');
|
||||
}
|
||||
}
|
58
src-ssr/middlewares/render.js
Normal file
58
src-ssr/middlewares/render.js
Normal 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
598
src-ssr/routes/api.js
Normal 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
163
src-ssr/server.js
Normal 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 ''
|
||||
})
|
Loading…
Add table
Add a link
Reference in a new issue