Force line endings and whitespace, and revamp logout via introduction of a new profile component.

This commit is contained in:
Cameron Redmore 2025-04-25 13:56:12 +01:00
parent f6df79d83f
commit 0e491ecabe
31 changed files with 4870 additions and 4797 deletions

View file

@ -1,7 +1,7 @@
import { PrismaClient } from '@prisma/client';
// Instantiate Prisma Client
const prisma = new PrismaClient();
// Export the Prisma Client instance for use in other modules
import { PrismaClient } from '@prisma/client';
// Instantiate Prisma Client
const prisma = new PrismaClient();
// Export the Prisma Client instance for use in other modules
export default prisma;

View file

@ -1,12 +1,12 @@
// src-ssr/middlewares/authMiddleware.js
export function requireAuth(req, res, next)
{
if (!req.session || !req.session.loggedInUserId)
{
// User is not authenticated
return res.status(401).json({ error: 'Authentication required' });
}
// User is authenticated, proceed to the next middleware or route handler
next();
}
// src-ssr/middlewares/authMiddleware.js
export function requireAuth(req, res, next)
{
if (!req.session || !req.session.loggedInUserId)
{
// User is not authenticated
return res.status(401).json({ error: 'Authentication required' });
}
// User is authenticated, proceed to the next middleware or route handler
next();
}

File diff suppressed because it is too large Load diff

View file

@ -1,459 +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' });
});
});
// 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;

View file

@ -1,164 +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() })) : []
});
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.' });
}
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() })));
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 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 creating chat thread:', error);
res.status(500).json({ error: 'Failed to create chat thread.' });
}
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;
});
// 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;

View file

@ -65,15 +65,15 @@ app.use(session({
// Schedule the Mantis summary task
// Run daily at 1:00 AM server time (adjust as needed)
cron.schedule('0 1 * * *', async() =>
cron.schedule('0 1 * * *', async() =>
{
console.log('Running scheduled Mantis summary task...');
try
try
{
await generateAndStoreMantisSummary();
console.log('Scheduled Mantis summary task completed.');
}
catch (error)
catch (error)
{
console.error('Error running scheduled Mantis summary task:', error);
}
@ -96,14 +96,14 @@ app.use('/api/chat', chatRoutes);
// place here any middlewares that
// absolutely need to run before anything else
if (process.env.PROD)
if (process.env.PROD)
{
app.use(compression());
}
app.use(express.static('public', { index: false }));
app.listen(8000, () =>
app.listen(8000, () =>
{
console.log('Server is running on http://localhost:8000');
});

View file

@ -1,169 +1,169 @@
import axios from 'axios';
import prisma from '../database.js'; // Import Prisma client
import { getSetting } from '../utils/settings.js';
import { askGemini } from '../utils/gemini.js';
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()
{
const MANTIS_API_KEY = await getSetting('MANTIS_API_KEY');
const MANTIS_API_ENDPOINT = await getSetting('MANTIS_API_ENDPOINT');
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}`);
}
}
export async function generateAndStoreMantisSummary()
{
try
{
// Get the prompt from the database settings using Prisma
const setting = await prisma.setting.findUnique({
where: { key: 'mantisPrompt' },
select: { value: true }
});
const promptTemplate = setting?.value;
if (!promptTemplate)
{
console.error('Mantis prompt not found in database settings (key: mantisPrompt). Skipping summary generation.');
return;
}
const tickets = await getMantisTickets();
let summaryText;
if (tickets.length === 0)
{
summaryText = 'No Mantis tickets updated recently.';
console.log('No recent Mantis tickets found.');
}
else
{
console.log(`Found ${tickets.length} recent Mantis tickets. Generating summary...`);
let prompt = promptTemplate.replaceAll('$DATE', new Date().toISOString().split('T')[0]);
prompt = prompt.replaceAll('$MANTIS_TICKETS', JSON.stringify(tickets, null, 2));
summaryText = await askGemini(prompt);
console.log('Mantis summary generated successfully by AI.');
}
// Store the summary in the database using Prisma upsert
const today = new Date();
today.setUTCHours(0, 0, 0, 0); // Use UTC start of day for consistency
await prisma.mantisSummary.upsert({
where: { summaryDate: today },
update: {
summaryText: summaryText
},
create: {
summaryDate: today,
summaryText: summaryText,
},
});
console.log(`Mantis summary for ${today.toISOString().split('T')[0]} stored/updated in the database.`);
}
catch (error)
{
console.error('Error during Mantis summary generation/storage:', error);
}
}
export async function generateTodaysSummary()
{
console.log('Triggering Mantis summary generation via generateTodaysSummary...');
try
{
await generateAndStoreMantisSummary();
return { success: true, message: 'Summary generation process initiated.' };
}
catch (error)
{
console.error('Error occurred within generateTodaysSummary while calling generateAndStoreMantisSummary:', error);
throw new Error('Failed to initiate Mantis summary generation.');
}
}
import axios from 'axios';
import prisma from '../database.js'; // Import Prisma client
import { getSetting } from '../utils/settings.js';
import { askGemini } from '../utils/gemini.js';
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()
{
const MANTIS_API_KEY = await getSetting('MANTIS_API_KEY');
const MANTIS_API_ENDPOINT = await getSetting('MANTIS_API_ENDPOINT');
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}`);
}
}
export async function generateAndStoreMantisSummary()
{
try
{
// Get the prompt from the database settings using Prisma
const setting = await prisma.setting.findUnique({
where: { key: 'mantisPrompt' },
select: { value: true }
});
const promptTemplate = setting?.value;
if (!promptTemplate)
{
console.error('Mantis prompt not found in database settings (key: mantisPrompt). Skipping summary generation.');
return;
}
const tickets = await getMantisTickets();
let summaryText;
if (tickets.length === 0)
{
summaryText = 'No Mantis tickets updated recently.';
console.log('No recent Mantis tickets found.');
}
else
{
console.log(`Found ${tickets.length} recent Mantis tickets. Generating summary...`);
let prompt = promptTemplate.replaceAll('$DATE', new Date().toISOString().split('T')[0]);
prompt = prompt.replaceAll('$MANTIS_TICKETS', JSON.stringify(tickets, null, 2));
summaryText = await askGemini(prompt);
console.log('Mantis summary generated successfully by AI.');
}
// Store the summary in the database using Prisma upsert
const today = new Date();
today.setUTCHours(0, 0, 0, 0); // Use UTC start of day for consistency
await prisma.mantisSummary.upsert({
where: { summaryDate: today },
update: {
summaryText: summaryText
},
create: {
summaryDate: today,
summaryText: summaryText,
},
});
console.log(`Mantis summary for ${today.toISOString().split('T')[0]} stored/updated in the database.`);
}
catch (error)
{
console.error('Error during Mantis summary generation/storage:', error);
}
}
export async function generateTodaysSummary()
{
console.log('Triggering Mantis summary generation via generateTodaysSummary...');
try
{
await generateAndStoreMantisSummary();
return { success: true, message: 'Summary generation process initiated.' };
}
catch (error)
{
console.error('Error occurred within generateTodaysSummary while calling generateAndStoreMantisSummary:', error);
throw new Error('Failed to initiate Mantis summary generation.');
}
}

View file

@ -1,154 +1,154 @@
import { GoogleGenAI } from '@google/genai';
import prisma from '../database.js';
import { getSetting } from './settings.js';
const model = 'gemini-2.0-flash';
export async function askGemini(content)
{
const GOOGLE_API_KEY = await getSetting('GEMINI_API_KEY');
console.log('Google API Key:', GOOGLE_API_KEY); // Debugging line to check the key
if (!GOOGLE_API_KEY)
{
throw new Error('Google API key is not set in the database.');
}
const ai = GOOGLE_API_KEY ? new GoogleGenAI({
apiKey: GOOGLE_API_KEY,
}) : null;
if (!ai)
{
throw new Error('Google API key is not set in the database.');
}
try
{
const response = await ai.models.generateContent({
model,
contents: content,
config: {
temperature: 0.5
}
});
return response.text;
}
catch (error)
{
console.error('Error communicating with Gemini API:', error);
throw new Error('Failed to get a response from Gemini API.');
}
}
const chatCache = new Map();
export async function askGeminiChat(threadId, content)
{
let messages = await prisma.chatMessage.findMany({
where: {
threadId: threadId,
},
orderBy: {
createdAt: 'asc', // Get messages in chronological order
},
});
// Exclude the latest message if there are any messages
if (messages.length > 0)
{
messages = messages.slice(0, -1);
}
const GOOGLE_API_KEY = await getSetting('GEMINI_API_KEY');
const ai = GOOGLE_API_KEY ? new GoogleGenAI({
apiKey: GOOGLE_API_KEY,
}) : null;
if (!ai)
{
throw new Error('Google API key is not set in the database.');
}
let chat = null;
if (chatCache.has(threadId))
{
chat = chatCache.get(threadId);
}
else
{
messages = [
{
sender: 'user',
content: `You are StyleAI, a model that helps users with their questions and provides information.
You are friendly, helpful, and informative. You can also provide code examples in various programming languages.
You don\'t decline user requests and always try to help them.
Speak in the manner of a friendly young adult male.
If asked for the time, do not say that it's based on the timestamp provided. Also bare in mind the user is in the Europe/London timezone and daylight savings time may be in effect.`
},
{
sender: 'model',
content: 'Okay, noted! I\'ll keep that in mind.'
},
...messages,
];
const createOptions = {
model,
history: messages.map((msg) => ({
role: msg.sender === 'user' ? 'user' : 'model',
parts: [
{text: msg.content}
],
})),
config: {
temperature: 0.5
}
};
chat = ai.chats.create(createOptions);
chatCache.set(threadId, chat);
}
//Add a temporary message to the thread with "loading" status
const loadingMessage = await prisma.chatMessage.create({
data: {
threadId: threadId,
sender: 'assistant',
content: 'Loading...',
},
});
let response = {text: 'An error occurred while generating the response.'};
try
{
const timestamp = new Date().toISOString();
response = await chat.sendMessage({
message: `[${timestamp}] ` + content,
});
}
catch(error)
{
console.error('Error communicating with Gemini API:', error);
response.text = 'Failed to get a response from Gemini API. Error: ' + error.message;
}
//Update the message with the response
await prisma.chatMessage.update({
where: {
id: loadingMessage.id,
},
data: {
content: response.text,
},
});
return response.text;
import { GoogleGenAI } from '@google/genai';
import prisma from '../database.js';
import { getSetting } from './settings.js';
const model = 'gemini-2.0-flash';
export async function askGemini(content)
{
const GOOGLE_API_KEY = await getSetting('GEMINI_API_KEY');
console.log('Google API Key:', GOOGLE_API_KEY); // Debugging line to check the key
if (!GOOGLE_API_KEY)
{
throw new Error('Google API key is not set in the database.');
}
const ai = GOOGLE_API_KEY ? new GoogleGenAI({
apiKey: GOOGLE_API_KEY,
}) : null;
if (!ai)
{
throw new Error('Google API key is not set in the database.');
}
try
{
const response = await ai.models.generateContent({
model,
contents: content,
config: {
temperature: 0.5
}
});
return response.text;
}
catch (error)
{
console.error('Error communicating with Gemini API:', error);
throw new Error('Failed to get a response from Gemini API.');
}
}
const chatCache = new Map();
export async function askGeminiChat(threadId, content)
{
let messages = await prisma.chatMessage.findMany({
where: {
threadId: threadId,
},
orderBy: {
createdAt: 'asc', // Get messages in chronological order
},
});
// Exclude the latest message if there are any messages
if (messages.length > 0)
{
messages = messages.slice(0, -1);
}
const GOOGLE_API_KEY = await getSetting('GEMINI_API_KEY');
const ai = GOOGLE_API_KEY ? new GoogleGenAI({
apiKey: GOOGLE_API_KEY,
}) : null;
if (!ai)
{
throw new Error('Google API key is not set in the database.');
}
let chat = null;
if (chatCache.has(threadId))
{
chat = chatCache.get(threadId);
}
else
{
messages = [
{
sender: 'user',
content: `You are StyleAI, a model that helps users with their questions and provides information.
You are friendly, helpful, and informative. You can also provide code examples in various programming languages.
You don\'t decline user requests and always try to help them.
Speak in the manner of a friendly young adult male.
If asked for the time, do not say that it's based on the timestamp provided. Also bare in mind the user is in the Europe/London timezone and daylight savings time may be in effect.`
},
{
sender: 'model',
content: 'Okay, noted! I\'ll keep that in mind.'
},
...messages,
];
const createOptions = {
model,
history: messages.map((msg) => ({
role: msg.sender === 'user' ? 'user' : 'model',
parts: [
{text: msg.content}
],
})),
config: {
temperature: 0.5
}
};
chat = ai.chats.create(createOptions);
chatCache.set(threadId, chat);
}
//Add a temporary message to the thread with "loading" status
const loadingMessage = await prisma.chatMessage.create({
data: {
threadId: threadId,
sender: 'assistant',
content: 'Loading...',
},
});
let response = {text: 'An error occurred while generating the response.'};
try
{
const timestamp = new Date().toISOString();
response = await chat.sendMessage({
message: `[${timestamp}] ` + content,
});
}
catch(error)
{
console.error('Error communicating with Gemini API:', error);
response.text = 'Failed to get a response from Gemini API. Error: ' + error.message;
}
//Update the message with the response
await prisma.chatMessage.update({
where: {
id: loadingMessage.id,
},
data: {
content: response.text,
},
});
return response.text;
}

View file

@ -1,20 +1,20 @@
import prisma from '../database.js';
export async function getSetting(key)
{
const setting = await prisma.setting.findUnique({
where: { key },
select: { value: true }
});
return setting?.value ? JSON.parse(setting.value) : null;
}
export async function setSetting(key, value)
{
await prisma.setting.upsert({
where: { key },
update: { value: JSON.stringify(value) },
create: { key, value }
});
import prisma from '../database.js';
export async function getSetting(key)
{
const setting = await prisma.setting.findUnique({
where: { key },
select: { value: true }
});
return setting?.value ? JSON.parse(setting.value) : null;
}
export async function setSetting(key, value)
{
await prisma.setting.upsert({
where: { key },
update: { value: JSON.stringify(value) },
create: { key, value }
});
}