Moved away from SSR to regular Node API server.

This commit is contained in:
Cameron Redmore 2025-04-25 12:50:44 +01:00
parent 9aea69c7be
commit 83d93aefc0
30 changed files with 939 additions and 1024 deletions

705
src-server/routes/api.js Normal file
View file

@ -0,0 +1,705 @@
import { Router } from 'express';
import prisma from '../database.js';
import PDFDocument from 'pdfkit';
import { join } from 'path';
import { generateTodaysSummary } from '../services/mantisSummarizer.js';
import { FieldType } from '@prisma/client';
const router = Router();
const __dirname = new URL('.', import.meta.url).pathname.replace(/\/$/, '');
// Helper function for consistent error handling
const handlePrismaError = (res, err, context) =>
{
console.error(`Error ${context}:`, err.message);
// Basic error handling, can be expanded (e.g., check for Prisma-specific error codes)
if (err.code === 'P2025')
{ // Prisma code for record not found
return res.status(404).json({ error: `${context}: Record not found` });
}
res.status(500).json({ error: `Failed to ${context}: ${err.message}` });
};
// --- Forms API --- //
// GET /api/forms - List all forms
router.get('/forms', async(req, res) =>
{
try
{
const forms = await prisma.form.findMany({
orderBy: {
createdAt: 'desc',
},
select: { // Select only necessary fields
id: true,
title: true,
description: true,
createdAt: true,
}
});
res.json(forms);
}
catch (err)
{
handlePrismaError(res, err, 'fetch forms');
}
});
// POST /api/forms - Create a new form
router.post('/forms', async(req, res) =>
{
const { title, description, categories } = req.body;
if (!title)
{
return res.status(400).json({ error: 'Form title is required' });
}
try
{
const newForm = await prisma.form.create({
data: {
title,
description,
categories: {
create: categories?.map((category, catIndex) => ({
name: category.name,
sortOrder: catIndex,
fields: {
create: category.fields?.map((field, fieldIndex) =>
{
// Validate field type against Prisma Enum
if (!Object.values(FieldType).includes(field.type))
{
throw new Error(`Invalid field type: ${field.type}`);
}
if (!field.label)
{
throw new Error('Field label is required');
}
return {
label: field.label,
type: field.type,
description: field.description || null,
sortOrder: fieldIndex,
};
}) || [],
},
})) || [],
},
},
select: { // Return basic form info
id: true,
title: true,
description: true,
}
});
res.status(201).json(newForm);
}
catch (err)
{
handlePrismaError(res, err, 'create form');
}
});
// GET /api/forms/:id - Get a specific form with its structure
router.get('/forms/:id', async(req, res) =>
{
const { id } = req.params;
const formId = parseInt(id, 10);
if (isNaN(formId))
{
return res.status(400).json({ error: 'Invalid form ID' });
}
try
{
const form = await prisma.form.findUnique({
where: { id: formId },
include: {
categories: {
orderBy: { sortOrder: 'asc' },
include: {
fields: {
orderBy: { sortOrder: 'asc' },
},
},
},
},
});
if (!form)
{
return res.status(404).json({ error: 'Form not found' });
}
res.json(form);
}
catch (err)
{
handlePrismaError(res, err, `fetch form ${formId}`);
}
});
// DELETE /api/forms/:id - Delete a specific form and all related data
router.delete('/forms/:id', async(req, res) =>
{
const { id } = req.params;
const formId = parseInt(id, 10);
if (isNaN(formId))
{
return res.status(400).json({ error: 'Invalid form ID' });
}
try
{
// Prisma automatically handles cascading deletes based on schema relations (onDelete: Cascade)
const deletedForm = await prisma.form.delete({
where: { id: formId },
});
res.status(200).json({ message: `Form ${formId} and all related data deleted successfully.` });
}
catch (err)
{
handlePrismaError(res, err, `delete form ${formId}`);
}
});
// PUT /api/forms/:id - Update an existing form
router.put('/forms/:id', async(req, res) =>
{
const { id } = req.params;
const formId = parseInt(id, 10);
const { title, description, categories } = req.body;
if (isNaN(formId))
{
return res.status(400).json({ error: 'Invalid form ID' });
}
if (!title)
{
return res.status(400).json({ error: 'Form title is required' });
}
try
{
// Use a transaction to ensure atomicity: delete old structure, update form, create new structure
const result = await prisma.$transaction(async(tx) =>
{
// 1. Check if form exists (optional, delete/update will fail if not found anyway)
const existingForm = await tx.form.findUnique({ where: { id: formId } });
if (!existingForm)
{
throw { code: 'P2025' }; // Simulate Prisma not found error
}
// 2. Delete existing categories (fields and response values cascade)
await tx.category.deleteMany({ where: { formId: formId } });
// 3. Update form details and recreate categories/fields in one go
const updatedForm = await tx.form.update({
where: { id: formId },
data: {
title,
description,
categories: {
create: categories?.map((category, catIndex) => ({
name: category.name,
sortOrder: catIndex,
fields: {
create: category.fields?.map((field, fieldIndex) =>
{
if (!Object.values(FieldType).includes(field.type))
{
throw new Error(`Invalid field type: ${field.type}`);
}
if (!field.label)
{
throw new Error('Field label is required');
}
return {
label: field.label,
type: field.type,
description: field.description || null,
sortOrder: fieldIndex,
};
}) || [],
},
})) || [],
},
},
select: { // Return basic form info
id: true,
title: true,
description: true,
}
});
return updatedForm;
});
res.status(200).json(result);
}
catch (err)
{
handlePrismaError(res, err, `update form ${formId}`);
}
});
// --- Responses API --- //
// POST /api/forms/:id/responses - Submit a response for a form
router.post('/forms/:id/responses', async(req, res) =>
{
const { id } = req.params;
const formId = parseInt(id, 10);
const { values } = req.body; // values is expected to be { fieldId: value, ... }
if (isNaN(formId))
{
return res.status(400).json({ error: 'Invalid form ID' });
}
if (!values || typeof values !== 'object' || Object.keys(values).length === 0)
{
return res.status(400).json({ error: 'Response values are required' });
}
try
{
// Use transaction to ensure response and values are created together
const result = await prisma.$transaction(async(tx) =>
{
// 1. Verify form exists
const form = await tx.form.findUnique({ where: { id: formId }, select: { id: true } });
if (!form)
{
throw { code: 'P2025' }; // Simulate Prisma not found error
}
// 2. Create the response record
const newResponse = await tx.response.create({
data: {
formId: formId,
},
select: { id: true }
});
// 3. Prepare response values data
const responseValuesData = [];
const fieldIds = Object.keys(values).map(k => parseInt(k, 10));
// Optional: Verify all field IDs belong to the form (more robust)
const validFields = await tx.field.findMany({
where: {
id: { 'in': fieldIds },
category: { formId: formId }
},
select: { id: true }
});
const validFieldIds = new Set(validFields.map(f => f.id));
for (const fieldIdStr in values)
{
const fieldId = parseInt(fieldIdStr, 10);
if (validFieldIds.has(fieldId))
{
const value = values[fieldIdStr];
responseValuesData.push({
responseId: newResponse.id,
fieldId: fieldId,
value: (value === null || typeof value === 'undefined') ? null : String(value),
});
}
else
{
console.warn(`Attempted to submit value for field ${fieldId} not belonging to form ${formId}`);
// Decide whether to throw an error or just skip invalid fields
// throw new Error(`Field ${fieldId} does not belong to form ${formId}`);
}
}
// 4. Create all response values
if (responseValuesData.length > 0)
{
await tx.responseValue.createMany({
data: responseValuesData,
});
}
return { responseId: newResponse.id };
});
res.status(201).json(result);
}
catch (err)
{
handlePrismaError(res, err, `submit response for form ${formId}`);
}
});
// GET /api/forms/:id/responses - Get all responses for a form
router.get('/forms/:id/responses', async(req, res) =>
{
const { id } = req.params;
const formId = parseInt(id, 10);
if (isNaN(formId))
{
return res.status(400).json({ error: 'Invalid form ID' });
}
try
{
// 1. Check if form exists
const formExists = await prisma.form.findUnique({ where: { id: formId }, select: { id: true } });
if (!formExists)
{
return res.status(404).json({ error: 'Form not found' });
}
// 2. Fetch responses with their values and related field info
const responses = await prisma.response.findMany({
where: { formId: formId },
orderBy: { submittedAt: 'desc' },
include: {
responseValues: {
include: {
field: {
select: { label: true, type: true, category: { select: { sortOrder: true } }, sortOrder: true } // Include sort orders
}
}
}
}
});
// 3. Group data similar to the old structure for frontend compatibility
const groupedResponses = responses.map(response => ({
id: response.id,
submittedAt: response.submittedAt,
values: response.responseValues
.sort((a, b) =>
{
// Sort by category order, then field order
const catSort = a.field.category.sortOrder - b.field.category.sortOrder;
if (catSort !== 0) return catSort;
return a.field.sortOrder - b.field.sortOrder;
})
.reduce((acc, rv) =>
{
acc[rv.fieldId] = {
label: rv.field.label,
type: rv.field.type,
value: rv.value
};
return acc;
}, {})
}));
res.json(groupedResponses);
}
catch (err)
{
handlePrismaError(res, err, `fetch responses for form ${formId}`);
}
});
// GET /responses/:responseId/export/pdf - Export response as PDF
router.get('/responses/:responseId/export/pdf', async(req, res) =>
{
const { responseId: responseIdStr } = req.params;
const responseId = parseInt(responseIdStr, 10);
if (isNaN(responseId))
{
return res.status(400).json({ error: 'Invalid response ID' });
}
try
{
// 1. Fetch the response, form title, form structure, and values in one go
const responseData = await prisma.response.findUnique({
where: { id: responseId },
include: {
form: {
select: {
title: true,
categories: {
orderBy: { sortOrder: 'asc' },
include: {
fields: {
orderBy: { sortOrder: 'asc' },
select: { id: true, label: true, type: true, description: true }
}
}
}
}
},
responseValues: {
select: { fieldId: true, value: true }
}
}
});
if (!responseData)
{
return res.status(404).json({ error: 'Response not found' });
}
const formTitle = responseData.form.title;
const categories = responseData.form.categories;
const responseValues = responseData.responseValues.reduce((acc, rv) =>
{
acc[rv.fieldId] = (rv.value === null || typeof rv.value === 'undefined') ? '' : String(rv.value);
return acc;
}, {});
// 4. Generate PDF using pdfkit (logic remains largely the same)
const doc = new PDFDocument({ margin: 50, size: 'A4' });
const fontsDir = join(__dirname, '../../public/fonts');
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`);
doc.pipe(res);
// --- PDF Content (remains the same as before) ---
doc.fontSize(18).font('Roboto-Bold').text(formTitle, { align: 'center' });
doc.moveDown();
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] || '';
doc.fontSize(12).font('Roboto-SemiBold').text(field.label + ':', { continued: false });
if (field.description)
{
doc.fontSize(9).font('Roboto-Italics').text(field.description);
}
doc.moveDown(0.2);
doc.fontSize(11).font('Roboto-Regular');
if (field.type === 'textarea')
{
const textHeight = doc.heightOfString(value, { width: 500 });
doc.rect(doc.x, doc.y, 500, Math.max(textHeight + 10, 30)).stroke();
doc.text(value, doc.x + 5, doc.y + 5, { width: 490 });
doc.y += Math.max(textHeight + 10, 30) + 10;
}
else if (field.type === 'date')
{
let formattedDate = '';
if (value)
{
try
{
const dateObj = new Date(value + 'T00:00:00');
if (!isNaN(dateObj.getTime()))
{
const day = String(dateObj.getDate()).padStart(2, '0');
const month = String(dateObj.getMonth() + 1).padStart(2, '0');
const year = dateObj.getFullYear();
formattedDate = `${day}/${month}/${year}`;
}
else
{
formattedDate = value;
}
}
catch (e)
{
console.error('Error formatting date:', value, e);
formattedDate = value;
}
}
doc.text(formattedDate || ' ');
doc.lineCap('butt').moveTo(doc.x, doc.y).lineTo(doc.x + 500, doc.y).stroke();
doc.moveDown(1.5);
}
else if (field.type === 'boolean')
{
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();
doc.moveDown(1.5);
}
else
{
doc.text(value || ' ');
doc.lineCap('butt').moveTo(doc.x, doc.y).lineTo(doc.x + 500, doc.y).stroke();
doc.moveDown(1.5);
}
}
doc.moveDown(1);
}
doc.end();
}
catch (err)
{
console.error(`Error generating PDF for response ${responseId}:`, err.message);
if (!res.headersSent)
{
// Use the helper function
handlePrismaError(res, err, `generate PDF for response ${responseId}`);
}
else
{
console.error('Headers already sent, could not send JSON error for PDF generation failure.');
res.end();
}
}
});
// --- Mantis Summary API Route --- //
// GET /api/mantis-summary/today - Get today's summary specifically
router.get('/mantis-summary/today', async(req, res) =>
{
try
{
const today = new Date();
today.setHours(0, 0, 0, 0); // Set to start of day UTC for comparison
const todaySummary = await prisma.mantisSummary.findUnique({
where: { summaryDate: today },
select: { summaryDate: true, summaryText: true, generatedAt: true }
});
if (todaySummary)
{
res.json(todaySummary);
}
else
{
res.status(404).json({ message: `No Mantis summary found for today (${today.toISOString().split('T')[0]}).` });
}
}
catch (error)
{
handlePrismaError(res, error, 'fetch today\'s Mantis summary');
}
});
// GET /api/mantis-summaries - Get ALL summaries from the DB, with pagination
router.get('/mantis-summaries', async(req, res) =>
{
const page = parseInt(req.query.page, 10) || 1;
const limit = parseInt(req.query.limit, 10) || 10;
const skip = (page - 1) * limit;
try
{
const [summaries, totalItems] = await prisma.$transaction([
prisma.mantisSummary.findMany({
orderBy: { summaryDate: 'desc' },
take: limit,
skip: skip,
select: { id: true, summaryDate: true, summaryText: true, generatedAt: true }
}),
prisma.mantisSummary.count()
]);
res.json({ summaries, total: totalItems });
}
catch (error)
{
handlePrismaError(res, error, 'fetch paginated Mantis summaries');
}
});
// POST /api/mantis-summaries/generate - Trigger summary generation
router.post('/mantis-summaries/generate', async(req, res) =>
{
try
{
// Trigger generation asynchronously, don't wait for it
generateTodaysSummary()
.then(() =>
{
console.log('Summary generation process finished successfully (async).');
})
.catch(error =>
{
console.error('Background summary generation failed:', error);
});
res.status(202).json({ message: 'Summary generation started.' });
}
catch (error)
{
handlePrismaError(res, error, 'initiate Mantis summary generation');
}
});
// --- Settings API --- //
// GET /api/settings/:key - Get a specific setting value
router.get('/settings/:key', async(req, res) =>
{
const { key } = req.params;
try
{
const setting = await prisma.setting.findUnique({
where: { key: key },
select: { value: true }
});
if (setting !== null)
{
res.json({ key, value: setting.value });
}
else
{
res.json({ key, value: '' }); // Return empty value if not found
}
}
catch (err)
{
handlePrismaError(res, err, `fetch setting '${key}'`);
}
});
// PUT /api/settings/:key - Update or create a specific setting
router.put('/settings/:key', async(req, res) =>
{
const { key } = req.params;
const { value } = req.body;
if (typeof value === 'undefined')
{
return res.status(400).json({ error: 'Setting value is required in the request body' });
}
try
{
const upsertedSetting = await prisma.setting.upsert({
where: { key: key },
update: { value: String(value) },
create: { key: key, value: String(value) },
select: { key: true, value: true } // Select to return the updated/created value
});
res.status(200).json(upsertedSetting);
}
catch (err)
{
handlePrismaError(res, err, `update setting '${key}'`);
}
});
export default router;

459
src-server/routes/auth.js Normal file
View file

@ -0,0 +1,459 @@
// src-ssr/routes/auth.js
import express from 'express';
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers'; // Ensure this is imported if not already
import prisma from '../database.js';
import { rpID, rpName, origin, challengeStore } from '../server.js'; // Import RP details and challenge store
const router = express.Router();
// Helper function to get user authenticators
async function getUserAuthenticators(userId)
{
return prisma.authenticator.findMany({
where: { userId },
select: {
credentialID: true,
credentialPublicKey: true,
counter: true,
transports: true,
},
});
}
// Helper function to get a user by username
async function getUserByUsername(username)
{
return prisma.user.findUnique({ where: { username } });
}
// Helper function to get a user by ID
async function getUserById(id)
{
return prisma.user.findUnique({ where: { id } });
}
// Helper function to get an authenticator by credential ID
async function getAuthenticatorByCredentialID(credentialID)
{
return prisma.authenticator.findUnique({ where: { credentialID } });
}
// Generate Registration Options
router.post('/generate-registration-options', async(req, res) =>
{
const { username } = req.body;
if (!username)
{
return res.status(400).json({ error: 'Username is required' });
}
try
{
let user = await getUserByUsername(username);
// If user doesn't exist, create one
if (!user)
{
user = await prisma.user.create({
data: { username },
});
}
const userAuthenticators = await getUserAuthenticators(user.id);
if(userAuthenticators.length > 0)
{
//The user is trying to register a new authenticator, so we need to check if the user registering is the same as the one in the session
if (!req.session.loggedInUserId || req.session.loggedInUserId !== user.id)
{
return res.status(403).json({ error: 'Invalid registration attempt.' });
}
}
const options = await generateRegistrationOptions({
rpName,
rpID,
userName: user.username,
// Don't prompt users for additional authenticators if they've already registered some
excludeCredentials: userAuthenticators.map(auth => ({
id: auth.credentialID, // Use isoBase64URL helper
type: 'public-key',
// Optional: Specify transports if you know them
transports: auth.transports ? auth.transports.split(',') : undefined,
})),
authenticatorSelection: {
// Defaults
residentKey: 'required',
userVerification: 'preferred',
},
// Strong advice: Always require attestation for registration
attestationType: 'none', // Use 'none' for simplicity, 'direct' or 'indirect' recommended for production
});
// Store the challenge
challengeStore.set(user.id, options.challenge);
req.session.userId = user.id; // Temporarily store userId in session for verification step
res.json(options);
}
catch (error)
{
console.error('Registration options error:', error);
res.status(500).json({ error: 'Failed to generate registration options' });
}
});
// Verify Registration
router.post('/verify-registration', async(req, res) =>
{
const { registrationResponse } = req.body;
const userId = req.session.userId; // Retrieve userId stored during options generation
if (!userId)
{
return res.status(400).json({ error: 'User session not found. Please start registration again.' });
}
const expectedChallenge = challengeStore.get(userId);
if (!expectedChallenge)
{
return res.status(400).json({ error: 'Challenge not found or expired' });
}
try
{
const user = await getUserById(userId);
if (!user)
{
return res.status(404).json({ error: 'User not found' });
}
const verification = await verifyRegistrationResponse({
response: registrationResponse,
expectedChallenge: expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
requireUserVerification: false, // Adjust based on your requirements
});
const { verified, registrationInfo } = verification;
console.log(verification);
if (verified && registrationInfo)
{
const { credential, credentialDeviceType, credentialBackedUp } = registrationInfo;
const credentialID = credential.id;
const credentialPublicKey = credential.publicKey;
const counter = credential.counter;
const transports = credential.transports || []; // Use empty array if transports are not provided
// Check if authenticator with this ID already exists
const existingAuthenticator = await getAuthenticatorByCredentialID(isoBase64URL.fromBuffer(credentialID));
if (existingAuthenticator)
{
return res.status(409).json({ error: 'Authenticator already registered' });
}
// Save the authenticator
await prisma.authenticator.create({
data: {
credentialID, // Store as Base64URL string
credentialPublicKey: Buffer.from(credentialPublicKey), // Store as Bytes
counter: BigInt(counter), // Store as BigInt
credentialDeviceType,
credentialBackedUp,
transports: transports.join(','), // Store transports as comma-separated string
userId: user.id,
},
});
// Clear the challenge and temporary userId
challengeStore.delete(userId);
delete req.session.userId;
// Log the user in by setting the final session userId
req.session.loggedInUserId = user.id;
res.json({ verified: true });
}
else
{
res.status(400).json({ error: 'Registration verification failed' });
}
}
catch (error)
{
console.error('Registration verification error:', error);
challengeStore.delete(userId); // Clean up challenge on error
delete req.session.userId;
res.status(500).json({ error: 'Failed to verify registration', details: error.message });
}
});
// Generate Authentication Options
router.post('/generate-authentication-options', async(req, res) =>
{
const { username } = req.body;
try
{
let user;
if (username)
{
user = await getUserByUsername(username);
}
else if (req.session.loggedInUserId)
{
// If already logged in, allow re-authentication (e.g., for step-up)
user = await getUserById(req.session.loggedInUserId);
}
if (!user)
{
return res.status(404).json({ error: 'User not found' });
}
console.log('User found:', user);
const userAuthenticators = await getUserAuthenticators(user.id);
console.log('User authenticators:', userAuthenticators);
const options = await generateAuthenticationOptions({
rpID,
// Require users to use a previously-registered authenticator
allowCredentials: userAuthenticators.map(auth => ({
id: auth.credentialID,
type: 'public-key',
transports: auth.transports ? auth.transports.split(',') : undefined,
})),
userVerification: 'preferred',
});
// Store the challenge associated with the user ID for verification
challengeStore.set(user.id, options.challenge);
req.session.challengeUserId = user.id; // Store user ID associated with this challenge
res.json(options);
}
catch (error)
{
console.error('Authentication options error:', error);
res.status(500).json({ error: 'Failed to generate authentication options' });
}
});
// Verify Authentication
router.post('/verify-authentication', async(req, res) =>
{
const { authenticationResponse } = req.body;
const challengeUserId = req.session.challengeUserId; // Get user ID associated with the challenge
if (!challengeUserId)
{
return res.status(400).json({ error: 'Challenge session not found. Please try logging in again.' });
}
const expectedChallenge = challengeStore.get(challengeUserId);
if (!expectedChallenge)
{
return res.status(400).json({ error: 'Challenge not found or expired' });
}
try
{
const user = await getUserById(challengeUserId);
if (!user)
{
return res.status(404).json({ error: 'User associated with challenge not found' });
}
const authenticator = await getAuthenticatorByCredentialID(authenticationResponse.id);
if (!authenticator)
{
return res.status(404).json({ error: 'Authenticator not found' });
}
// Ensure the authenticator belongs to the user attempting to log in
if (authenticator.userId !== user.id)
{
return res.status(403).json({ error: 'Authenticator does not belong to this user' });
}
const verification = await verifyAuthenticationResponse({
response: authenticationResponse,
expectedChallenge: expectedChallenge,
expectedOrigin: origin,
expectedRPID: rpID,
credential: {
id: authenticator.credentialID,
publicKey: authenticator.credentialPublicKey,
counter: authenticator.counter.toString(), // Convert BigInt to string for comparison
transports: authenticator.transports ? authenticator.transports.split(',') : undefined,
},
requireUserVerification: false, // Enforce user verification
});
const { verified, authenticationInfo } = verification;
if (verified)
{
// Update the authenticator counter
await prisma.authenticator.update({
where: { credentialID: authenticator.credentialID },
data: { counter: BigInt(authenticationInfo.newCounter) }, // Update with the new counter
});
// Clear the challenge and associated user ID
challengeStore.delete(challengeUserId);
delete req.session.challengeUserId;
// Log the user in
req.session.loggedInUserId = user.id;
res.json({ verified: true, user: { id: user.id, username: user.username } });
}
else
{
res.status(400).json({ error: 'Authentication verification failed' });
}
}
catch (error)
{
console.error('Authentication verification error:', error);
challengeStore.delete(challengeUserId); // Clean up challenge on error
delete req.session.challengeUserId;
res.status(500).json({ error: 'Failed to verify authentication', details: error.message });
}
});
// GET Passkeys for Logged-in User
router.get('/passkeys', async(req, res) =>
{
if (!req.session.loggedInUserId)
{
return res.status(401).json({ error: 'Not authenticated' });
}
try
{
const userId = req.session.loggedInUserId;
const authenticators = await prisma.authenticator.findMany({
where: { userId },
select: {
credentialID: true, // Already Base64URL string
// Add other fields if needed, e.g., createdAt if you add it to the schema
// createdAt: true,
},
});
// No need to convert credentialID here as it's stored as Base64URL string
res.json(authenticators);
}
catch (error)
{
console.error('Error fetching passkeys:', error);
res.status(500).json({ error: 'Failed to fetch passkeys' });
}
});
// DELETE Passkey
router.delete('/passkeys/:credentialID', async(req, res) =>
{
if (!req.session.loggedInUserId)
{
return res.status(401).json({ error: 'Not authenticated' });
}
const { credentialID } = req.params; // This is already a Base64URL string from the client
if (!credentialID)
{
return res.status(400).json({ error: 'Credential ID is required' });
}
try
{
const userId = req.session.loggedInUserId;
// Find the authenticator first to ensure it belongs to the logged-in user
const authenticator = await prisma.authenticator.findUnique({
where: { credentialID: credentialID }, // Use the Base64URL string directly
});
if (!authenticator)
{
return res.status(404).json({ error: 'Passkey not found' });
}
// Security check: Ensure the passkey belongs to the user trying to delete it
if (authenticator.userId !== userId)
{
return res.status(403).json({ error: 'Permission denied' });
}
// Delete the authenticator
await prisma.authenticator.delete({
where: { credentialID: credentialID },
});
res.json({ message: 'Passkey deleted successfully' });
}
catch (error)
{
console.error('Error deleting passkey:', error);
// Handle potential Prisma errors, e.g., record not found if deleted between check and delete
if (error.code === 'P2025')
{ // Prisma code for record not found on delete/update
return res.status(404).json({ error: 'Passkey not found' });
}
res.status(500).json({ error: 'Failed to delete passkey' });
}
});
// Check Authentication Status
router.get('/status', async(req, res) =>
{
if (req.session.loggedInUserId)
{
const user = await getUserById(req.session.loggedInUserId);
if (!user)
{
req.session.destroy(err =>
{});
return res.status(401).json({ status: 'unauthenticated' });
}
return res.json({ status: 'authenticated', user: { id: user.id, username: user.username, email: user.email } });
}
res.json({ status: 'unauthenticated' });
});
// Logout
router.post('/logout', (req, res) =>
{
req.session.destroy(err =>
{
if (err)
{
console.error('Logout error:', err);
return res.status(500).json({ error: 'Failed to logout' });
}
res.json({ message: 'Logged out successfully' });
});
});
export default router;

164
src-server/routes/chat.js Normal file
View file

@ -0,0 +1,164 @@
import { Router } from 'express';
import prisma from '../database.js';
import { requireAuth } from '../middlewares/authMiddleware.js'; // Import the middleware
import { askGeminiChat } from '../utils/gemini.js';
const router = Router();
// Apply the authentication middleware to all chat routes
router.use(requireAuth);
// POST /api/chat/threads - Create a new chat thread (optionally with a first message)
router.post('/threads', async(req, res) =>
{
const { content } = req.body; // Content is now optional
// If content is provided, validate it
if (content && (typeof content !== 'string' || content.trim().length === 0))
{
return res.status(400).json({ error: 'Message content cannot be empty if provided.' });
}
try
{
const createData = {};
if (content)
{
// If content exists, create the thread with the first message
createData.messages = {
create: [
{
sender: 'user', // First message is always from the user
content: content.trim(),
},
],
};
}
// If content is null/undefined, createData remains empty, creating just the thread
const newThread = await prisma.chatThread.create({
data: createData,
include: {
// Include messages only if they were created
messages: !!content,
},
});
if(content)
{
await askGeminiChat(newThread.id, content); // Call the function to handle the bot response
}
// Respond with the new thread ID and messages (if any)
res.status(201).json({
threadId: newThread.id,
// Ensure messages array is empty if no content was provided
messages: newThread.messages ? newThread.messages.map(msg => ({ ...msg, createdAt: msg.createdAt.toISOString() })) : []
});
}
catch (error)
{
console.error('Error creating chat thread:', error);
res.status(500).json({ error: 'Failed to create chat thread.' });
}
});
// GET /api/chat/threads/:threadId/messages - Get messages for a specific thread
router.get('/threads/:threadId/messages', async(req, res) =>
{
const { threadId } = req.params;
try
{
const messages = await prisma.chatMessage.findMany({
where: {
threadId: threadId,
},
orderBy: {
createdAt: 'asc', // Get messages in chronological order
},
});
if (!messages)
{ // Check if thread exists indirectly
// If findMany returns empty, the thread might not exist or has no messages.
// Check if thread exists explicitly
const thread = await prisma.chatThread.findUnique({ where: { id: threadId } });
if (!thread)
{
return res.status(404).json({ error: 'Chat thread not found.' });
}
}
res.status(200).json(messages.map(msg => ({ ...msg, createdAt: msg.createdAt.toISOString() })));
}
catch (error)
{
console.error(`Error fetching messages for thread ${threadId}:`, error);
// Basic error handling, check for specific Prisma errors if needed
if (error.code === 'P2023' || error.message.includes('Malformed UUID'))
{ // Example: Invalid UUID format
return res.status(400).json({ error: 'Invalid thread ID format.' });
}
res.status(500).json({ error: 'Failed to fetch messages.' });
}
});
// POST /api/chat/threads/:threadId/messages - Add a message to an existing thread
router.post('/threads/:threadId/messages', async(req, res) =>
{
const { threadId } = req.params;
const { content, sender = 'user' } = req.body; // Default sender to 'user'
if (!content || typeof content !== 'string' || content.trim().length === 0)
{
return res.status(400).json({ error: 'Message content cannot be empty.' });
}
if (sender !== 'user' && sender !== 'bot')
{
return res.status(400).json({ error: 'Invalid sender type.' });
}
try
{
// Verify thread exists first
const thread = await prisma.chatThread.findUnique({
where: { id: threadId },
});
if (!thread)
{
return res.status(404).json({ error: 'Chat thread not found.' });
}
const newMessage = await prisma.chatMessage.create({
data: {
threadId: threadId,
sender: sender,
content: content.trim(),
},
});
// Optionally: Update the thread's updatedAt timestamp
await prisma.chatThread.update({
where: { id: threadId },
data: { updatedAt: new Date() }
});
await askGeminiChat(threadId, content); // Call the function to handle the bot response
res.status(201).json({ ...newMessage, createdAt: newMessage.createdAt.toISOString() });
}
catch (error)
{
console.error(`Error adding message to thread ${threadId}:`, error);
if (error.code === 'P2023' || error.message.includes('Malformed UUID'))
{ // Example: Invalid UUID format
return res.status(400).json({ error: 'Invalid thread ID format.' });
}
res.status(500).json({ error: 'Failed to add message.' });
}
});
export default router;