https://project.mdnd-it.cc/work_packages/94
This commit is contained in:
2025-08-23 04:25:28 +02:00
parent 725516ad6c
commit 19cfa031d0
25823 changed files with 1095587 additions and 2801760 deletions
+250
View File
@@ -0,0 +1,250 @@
import express from 'express';
import { createServer } from 'http';
import cookieParser from 'cookie-parser';
import helmet from 'helmet';
import { AppDataSource } from '../Infrastructure/ormconfig';
import userRouter from './routers/userRouter';
import organizationRouter from './routers/organizationRouter';
import deckRouter from './routers/deckRouter';
import chatRouter from './routers/chatRouter';
import contactRouter from './routers/contactRouter';
import adminRouter from './routers/adminRouter';
import deckImportExportRouter from './routers/deckImportExportRouter';
import { LoggingService, logStartup, logConnection, logError, logRequest } from '../Application/Services/Logger';
import { WebSocketService } from '../Application/Services/WebSocketService';
import { setupSwagger } from './swagger/swaggerUiSetup';
const app = express();
const httpServer = createServer(app);
const PORT = process.env.PORT || 3000;
const isDevelopment = process.env.NODE_ENV === 'development';
const loggingService = LoggingService.getInstance();
logStartup('SerpentRace Backend starting up', {
environment: process.env.NODE_ENV || 'development',
port: PORT,
nodeVersion: process.version,
chatInactivityTimeout: process.env.CHAT_INACTIVITY_TIMEOUT_MINUTES || '30'
});
app.use(helmet({
contentSecurityPolicy: isDevelopment ? false : undefined
}));
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
app.use(cookieParser());
app.use(loggingService.requestLoggingMiddleware());
app.use((req, res, next) => {
const origin = req.headers.origin;
const allowedOrigins = ['http://localhost:3000', 'http://localhost:3001', 'http://localhost:8080'];
if (!origin || allowedOrigins.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin || '*');
}
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Cookie');
if (req.method === 'OPTIONS') {
res.status(200).end();
return;
}
next();
});
if (isDevelopment) {
app.use((req, res, next) => {
logRequest(`${req.method} ${req.path}`, req, res);
next();
});
}
// Setup Swagger documentation
setupSwagger(app);
app.get('/', (req, res) => {
res.json({
service: 'SerpentRace Backend API',
status: 'running',
version: '1.0.0',
endpoints: {
swagger: '/api-docs',
users: '/api/users',
organizations: '/api/organizations',
decks: '/api/decks',
chats: '/api/chats',
contacts: '/api/contacts',
admin: '/api/admin',
deckImportExport: '/api/deck-import-export',
health: '/health'
},
websocket: {
enabled: true,
events: [
'chat:join', 'chat:leave', 'message:send',
'group:create', 'chat:direct', 'game:chat:create',
'chat:history'
]
}
});
});
app.get('/health', async (req, res) => {
try {
const isDbConnected = AppDataSource.isInitialized;
res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
service: 'SerpentRace Backend API',
version: '1.0.0',
environment: process.env.NODE_ENV || 'development',
database: {
connected: isDbConnected,
type: AppDataSource.options.type
},
websocket: {
enabled: true
},
uptime: process.uptime()
});
} catch (error) {
res.status(503).json({
status: 'unhealthy',
timestamp: new Date().toISOString(),
error: 'Service health check failed'
});
}
});
// API Routes
app.use('/api/users', userRouter);
app.use('/api/organizations', organizationRouter);
app.use('/api/decks', deckRouter);
app.use('/api/chats', chatRouter);
app.use('/api/contacts', contactRouter);
app.use('/api/admin', adminRouter);
app.use('/api/deck-import-export', deckImportExportRouter);
// Global error handler (must be after routes)
app.use(loggingService.errorLoggingMiddleware());
app.use((error: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
logError('Global error handler caught unhandled error', error, req, res);
// Don't expose internal error details in production
const isDevelopment = process.env.NODE_ENV === 'development';
res.status(500).json({
error: 'Internal server error',
timestamp: new Date().toISOString(),
...(isDevelopment && { details: error.message, stack: error.stack })
});
});
// Handle 404 routes
app.use((req: express.Request, res: express.Response) => {
res.status(404).json({
error: 'Route not found',
path: req.originalUrl,
method: req.method,
timestamp: new Date().toISOString()
});
});
// Initialize WebSocket service after database connection
let webSocketService: WebSocketService;
// Initialize database connection
AppDataSource.initialize()
.then(() => {
const dbOptions = AppDataSource.options as any;
logConnection('Database connection established', 'postgresql', 'success', {
type: dbOptions.type,
host: dbOptions.host,
database: dbOptions.database
});
// Initialize WebSocket service after database is connected
webSocketService = new WebSocketService(httpServer);
logStartup('WebSocket service initialized', {
chatInactivityTimeout: process.env.CHAT_INACTIVITY_TIMEOUT_MINUTES || '30'
});
})
.catch((error) => {
const dbOptions = AppDataSource.options as any;
logConnection('Database connection failed', 'postgresql', 'failure', {
error: error.message,
type: dbOptions.type,
host: dbOptions.host,
database: dbOptions.database
});
process.exit(1);
});
// Start server with WebSocket support
const server = httpServer.listen(PORT, () => {
logStartup('Server started successfully', {
port: PORT,
environment: process.env.NODE_ENV || 'development',
timestamp: new Date().toISOString(),
endpoints: {
health: `/health`,
swagger: `/api-docs`,
users: `/api/users`,
organizations: `/api/organizations`,
decks: `/api/decks`,
chats: `/api/chats`
},
websocket: {
enabled: true,
chatInactivityTimeout: `${process.env.CHAT_INACTIVITY_TIMEOUT_MINUTES || '30'} minutes`
}
});
});
// Graceful shutdown
const gracefulShutdown = async (signal: string) => {
logStartup(`Received ${signal}. Shutting down gracefully...`);
server.close(() => {
logStartup('HTTP server closed');
if (AppDataSource.isInitialized) {
AppDataSource.destroy()
.then(() => {
logConnection('Database connection closed', 'postgresql', 'success');
process.exit(0);
})
.catch((error) => {
logError('Error during database shutdown', error);
process.exit(1);
});
} else {
process.exit(0);
}
});
};
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
// Handle uncaught exceptions
process.on('uncaughtException', (error) => {
logError('Uncaught Exception - Server will shut down', error);
process.exit(1);
});
// Handle unhandled promise rejections
process.on('unhandledRejection', (reason, promise) => {
logError('Unhandled Rejection - Server will shut down', new Error(String(reason)), undefined, undefined);
process.exit(1);
});
// Export WebSocket service for game integration
export { webSocketService };
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,287 @@
import express from 'express';
import { authRequired } from '../../Application/Services/AuthMiddleware';
import { container } from '../../Application/Services/DIContainer';
import { ErrorResponseService } from '../../Application/Services/ErrorResponseService';
import { ValidationMiddleware } from '../../Application/Services/ValidationMiddleware';
import { logAuth, logError, logRequest, logWarning } from '../../Application/Services/Logger';
const chatRouter = express.Router();
// Get user's chats
chatRouter.get('/user-chats', authRequired, async (req, res) => {
try {
const userId = (req as any).user.userId;
const includeArchived = req.query.includeArchived === 'true';
logRequest('Get user chats endpoint accessed', req, res, { userId, includeArchived });
const chats = await container.getUserChatsQueryHandler.execute({
userId,
includeArchived
});
logRequest('User chats retrieved successfully', req, res, {
userId,
chatCount: chats.length
});
res.json(chats);
} catch (error) {
logError('Get user chats endpoint error', error as Error, req, res);
return ErrorResponseService.sendInternalServerError(res);
}
});
// Get chat history
chatRouter.get('/history/:chatId',
authRequired,
ValidationMiddleware.validateUUIDFormat(['chatId']),
async (req, res) => {
try {
const userId = (req as any).user.userId;
const chatId = req.params.chatId;
logRequest('Get chat history endpoint accessed', req, res, { userId, chatId });
const history = await container.getChatHistoryQueryHandler.execute({
chatId,
userId
});
if (!history) {
logWarning('Chat history not found or unauthorized', { userId, chatId }, req, res);
return ErrorResponseService.sendNotFound(res, 'Chat not found or unauthorized');
}
logRequest('Chat history retrieved successfully', req, res, {
userId,
chatId,
messageCount: history.messages.length,
isArchived: history.isArchived
});
res.json(history);
} catch (error) {
logError('Get chat history endpoint error', error as Error, req, res);
return ErrorResponseService.sendInternalServerError(res);
}
});
// Create new chat (direct/group)
chatRouter.post('/create',
authRequired,
ValidationMiddleware.combine([
ValidationMiddleware.validateRequiredFields(['type', 'userIds']),
ValidationMiddleware.validateAllowedValues({ type: ['direct', 'group'] }),
ValidationMiddleware.validateNonEmptyArrays(['userIds'])
]),
async (req, res) => {
try {
const userId = (req as any).user.userId;
const { type, name, userIds } = req.body;
logRequest('Create chat endpoint accessed', req, res, {
userId,
type,
targetUserCount: userIds?.length || 0
});
if (type === 'group' && !name?.trim()) {
return ErrorResponseService.sendBadRequest(res, 'Group name is required');
}
const chat = await container.createChatCommandHandler.execute({
type,
name: name?.trim(),
createdBy: userId,
userIds
});
if (!chat) {
return ErrorResponseService.sendBadRequest(res, 'Failed to create chat');
}
logRequest('Chat created successfully', req, res, {
userId,
chatId: chat.id,
chatType: chat.type
});
res.json({
id: chat.id,
type: chat.type,
name: chat.name,
users: chat.users,
messages: chat.messages
});
} catch (error) {
logError('Create chat endpoint error', error as Error, req, res);
if (error instanceof Error) {
if (error.message.includes('Premium subscription required')) {
return ErrorResponseService.sendForbidden(res, 'Premium subscription required to create groups');
}
if (error.message.includes('not found')) {
return ErrorResponseService.sendNotFound(res, 'One or more users not found');
}
}
return ErrorResponseService.sendInternalServerError(res);
}
});
// Send message (REST endpoint - mainly for testing, real messaging is via WebSocket)
chatRouter.post('/message',
authRequired,
ValidationMiddleware.combine([
ValidationMiddleware.validateRequiredFields(['chatId', 'message']),
ValidationMiddleware.validateUUIDFormat(['chatId']),
ValidationMiddleware.validateStringLength({ message: { min: 1, max: 2000 } })
]),
async (req, res) => {
try {
const userId = (req as any).user.userId;
const { chatId, message } = req.body;
logRequest('Send message endpoint accessed', req, res, {
userId,
chatId,
messageLength: message?.length || 0
});
const sentMessage = await container.sendMessageCommandHandler.execute({
chatId,
userId,
message
});
if (!sentMessage) {
return ErrorResponseService.sendBadRequest(res, 'Failed to send message');
}
logRequest('Message sent successfully', req, res, {
userId,
chatId,
messageId: sentMessage.id
});
res.json(sentMessage);
} catch (error) {
logError('Send message endpoint error', error as Error, req, res);
if (error instanceof Error) {
if (error.message.includes('Chat not found')) {
return ErrorResponseService.sendNotFound(res, 'Chat not found');
}
if (error.message.includes('not a member')) {
return ErrorResponseService.sendForbidden(res, 'Not authorized to send messages to this chat');
}
if (error.message.includes('non-empty string')) {
return ErrorResponseService.sendBadRequest(res, 'Message must be a non-empty string');
}
}
return ErrorResponseService.sendInternalServerError(res);
}
});
// Archive chat manually
chatRouter.post('/archive/:chatId',
authRequired,
ValidationMiddleware.validateUUIDFormat(['chatId']),
async (req, res) => {
try {
const userId = (req as any).user.userId;
const chatId = req.params.chatId;
logRequest('Archive chat endpoint accessed', req, res, { userId, chatId });
// Check if user has access to this chat
const chat = await container.chatRepository.findById(chatId);
if (!chat) {
return ErrorResponseService.sendNotFound(res, 'Chat not found');
}
if (!chat.users.includes(userId)) {
return ErrorResponseService.sendForbidden(res, 'Not authorized to archive this chat');
}
const success = await container.archiveChatCommandHandler.execute({ chatId });
if (!success) {
return ErrorResponseService.sendBadRequest(res, 'Failed to archive chat');
}
logRequest('Chat archived successfully', req, res, { userId, chatId });
res.json({ success: true, message: 'Chat archived successfully' });
} catch (error) {
logError('Archive chat endpoint error', error as Error, req, res);
return ErrorResponseService.sendInternalServerError(res);
}
});
// Restore chat from archive
chatRouter.post('/restore/:chatId',
authRequired,
ValidationMiddleware.validateUUIDFormat(['chatId']),
async (req, res) => {
try {
const userId = (req as any).user.userId;
const chatId = req.params.chatId;
logRequest('Restore chat endpoint accessed', req, res, { userId, chatId });
// Check if user has access to this archived chat
const archive = await container.chatArchiveRepository.findByChatId(chatId);
const userArchive = archive.find((a: any) => a.participants.includes(userId));
if (!userArchive) {
return ErrorResponseService.sendNotFound(res, 'Archived chat not found or unauthorized');
}
const success = await container.restoreChatCommandHandler.execute({ chatId });
if (!success) {
return ErrorResponseService.sendBadRequest(res, 'Failed to restore chat (game chats cannot be restored)');
}
logRequest('Chat restored successfully', req, res, { userId, chatId });
res.json({ success: true, message: 'Chat restored successfully' });
} catch (error) {
logError('Restore chat endpoint error', error as Error, req, res);
return ErrorResponseService.sendInternalServerError(res);
}
});
// Get archived chats for a game
chatRouter.get('/archived/game/:gameId',
authRequired,
ValidationMiddleware.validateUUIDFormat(['gameId']),
async (req, res) => {
try {
const userId = (req as any).user.userId;
const gameId = req.params.gameId;
logRequest('Get archived game chats endpoint accessed', req, res, { userId, gameId });
const archivedChats = await container.getArchivedChatsQueryHandler.execute({
userId,
gameId
});
logRequest('Archived game chats retrieved successfully', req, res, {
userId,
gameId,
chatCount: archivedChats.length
});
res.json(archivedChats);
} catch (error) {
logError('Get archived game chats endpoint error', error as Error, req, res);
return ErrorResponseService.sendInternalServerError(res);
}
});
export default chatRouter;
@@ -0,0 +1,53 @@
import { Router } from 'express';
import { container } from '../../Application/Services/DIContainer';
import { logRequest, logError } from '../../Application/Services/Logger';
import { ContactType } from '../../Domain/Contact/ContactAggregate';
const contactRouter = Router();
// Public endpoint - anyone can create a contact
contactRouter.post('/', async (req, res) => {
try {
// Get user ID if authenticated (optional)
const userId = (req as any).user?.userId || null;
const { name, email, type, txt } = req.body;
// Validate required fields
if (!name || !email || type === undefined || !txt) {
return res.status(400).json({
error: 'Missing required fields: name, email, type, and txt are required'
});
}
// Validate type
if (!Object.values(ContactType).includes(Number(type))) {
return res.status(400).json({
error: 'Invalid contact type. Must be one of: 0 (Bug), 1 (Problem), 2 (Question), 3 (Sales), 4 (Other)'
});
}
logRequest('Create contact endpoint accessed', req, res, { name, email, type, userId });
const result = await container.createContactCommandHandler.execute({
name,
email,
userid: userId,
type: Number(type),
txt
});
logRequest('Contact created successfully', req, res, { contactId: result.id, name, email, type });
res.status(201).json(result);
} catch (error) {
logError('Create contact endpoint error', error as Error, req, res);
if (error instanceof Error && error.message.includes('validation')) {
return res.status(400).json({ error: 'Invalid input data', details: error.message });
}
res.status(500).json({ error: 'Internal server error' });
}
});
export default contactRouter;
@@ -0,0 +1,124 @@
import express, { Request, Response } from 'express';
import multer from 'multer';
import { DIContainer } from '../../Application/Services/DIContainer';
import { authRequired } from '../../Application/Services/AuthMiddleware';
import { logRequest, logError, logWarning } from '../../Application/Services/Logger';
// Extend Express Request interface for file uploads
declare global {
namespace Express {
interface Request {
file?: Express.Multer.File;
}
}
}
const router = express.Router();
const container = DIContainer.getInstance();
// Configure multer for file uploads
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 10 * 1024 * 1024, // 10MB limit
},
fileFilter: (req: any, file: any, cb: any) => {
if (file.mimetype === 'application/json' || file.originalname.endsWith('.spr')) {
cb(null, true);
} else {
cb(new Error('Only JSON and .spr files are allowed'));
}
}
});
// Export deck to .spr file (encrypted) - users can only export their own decks
router.get('/export/:deckId', authRequired, async (req: Request, res: Response) => {
try {
const { deckId } = req.params;
const userId = (req as any).user.userId;
logRequest('Export deck endpoint accessed', req, res, { deckId, userId });
// Check if user owns the deck
const deck = await container.deckRepository.findById(deckId);
if (!deck) {
logWarning('Deck not found for export', { deckId, userId }, req, res);
return res.status(404).json({ error: 'Deck not found' });
}
// Users can only export their own decks
if (deck.userid !== userId) {
logWarning('Access denied - user attempted to export deck they do not own', {
deckId,
userId,
deckOwnerId: deck.userid
}, req, res);
return res.status(403).json({ error: 'Access denied - you can only export your own decks' });
}
const sprData = await container.deckImportExportService.exportDeckToSpr(deckId, userId);
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${deck.name || 'deck'}.spr"`);
logRequest('Deck exported successfully', req, res, {
deckId,
userId,
deckName: deck.name,
fileSize: sprData.length
});
res.send(sprData);
} catch (error) {
logError('Export deck endpoint error', error as Error, req, res);
res.status(500).json({ error: 'Internal server error' });
}
});
// Import deck from .spr file (encrypted) - imported deck will be owned by the importing user
router.post('/import', authRequired, upload.single('file'), async (req: Request, res: Response) => {
try {
const userId = (req as any).user.userId;
logRequest('Import deck endpoint accessed', req, res, {
userId,
hasFile: !!req.file,
fileName: req.file?.originalname,
fileSize: req.file?.size
});
if (!req.file) {
logWarning('No file uploaded for deck import', { userId }, req, res);
return res.status(400).json({ error: 'No file uploaded' });
}
const fileBuffer = req.file!.buffer;
// Import the deck and assign ownership to the current user
const result = await container.deckImportExportService.importDeckFromSpr(fileBuffer, userId);
logRequest('Deck imported successfully', req, res, {
userId,
deckId: result.id,
deckName: result.name || 'Unknown',
fileName: req.file.originalname,
fileSize: req.file.size
});
res.json({
success: true,
message: 'Deck imported successfully and added to your collection',
deckId: result.id
});
} catch (error) {
logError('Import deck endpoint error', error as Error, req, res);
if (error instanceof Error && error.message.includes('Invalid')) {
return res.status(400).json({ error: 'Invalid file format or corrupted data' });
} else {
res.status(500).json({ error: 'Internal server error' });
}
}
});
export default router;
@@ -0,0 +1,193 @@
import { Router } from 'express';
import { authRequired } from '../../Application/Services/AuthMiddleware';
import { container } from '../../Application/Services/DIContainer';
import { ErrorResponseService } from '../../Application/Services/ErrorResponseService';
import { ValidationMiddleware } from '../../Application/Services/ValidationMiddleware';
import { GeneralSearchService } from '../../Application/Search/Generalsearch';
import { logRequest, logError, logWarning } from '../../Application/Services/Logger';
const deckRouter = Router();
// Create search service that isn't in the container yet
const searchService = new GeneralSearchService(container.userRepository, container.organizationRepository, container.deckRepository);
// Authenticated routes - Get decks with pagination (RECOMMENDED)
deckRouter.get('/page/:from/:to', authRequired, async (req, res) => {
try {
const userId = (req as any).user.userId;
const userOrgId = (req as any).user.orgId;
const isAdmin = (req as any).user.authLevel === 1;
const from = parseInt(req.params.from);
const to = parseInt(req.params.to);
if (isNaN(from) || isNaN(to) || from < 0 || to < from) {
return res.status(400).json({ error: 'Invalid page parameters. "from" and "to" must be valid numbers with to >= from >= 0' });
}
logRequest('Get decks by page endpoint accessed', req, res, {
userId,
userOrgId,
isAdmin,
from,
to
});
// Use paginated query handler for memory efficiency
const result = await container.getDecksByPageQueryHandler.execute({
userId,
userOrgId,
isAdmin,
from,
to
});
logRequest('Get decks page completed successfully', req, res, {
userId,
from,
to,
returnedCount: result.decks.length,
totalCount: result.totalCount
});
res.json(result);
} catch (error) {
logError('Get decks by page endpoint error', error as Error, req, res);
res.status(500).json({ error: 'Internal server error' });
}
});
deckRouter.post('/', authRequired, async (req, res) => {
try {
const userId = (req as any).user.userId;
logRequest('Create deck endpoint accessed', req, res, { name: req.body.name, userId });
const result = await container.createDeckCommandHandler.execute(req.body);
logRequest('Deck created successfully', req, res, { deckId: result.id, name: req.body.name, userId });
res.json(result);
} catch (error) {
logError('Create deck endpoint error', error as Error, req, res);
if (error instanceof Error && (error.message.includes('duplicate') || error.message.includes('unique constraint'))) {
return res.status(409).json({ error: 'Deck with this name already exists' });
}
if (error instanceof Error && error.message.includes('validation')) {
return res.status(400).json({ error: 'Invalid input data', details: error.message });
}
res.status(500).json({ error: 'Internal server error' });
}
});
deckRouter.get('/search', authRequired, async (req, res) => {
try {
const { query, limit, offset } = req.query;
logRequest('Search decks endpoint accessed', req, res, { query, limit, offset });
if (!query || typeof query !== 'string') {
logWarning('Deck search attempted without query', { query, hasQuery: !!query }, req, res);
return res.status(400).json({ error: 'Search query is required' });
}
const searchQuery = {
query: query.trim(),
limit: limit ? parseInt(limit as string) : 20,
offset: offset ? parseInt(offset as string) : 0
};
// Validate pagination parameters
if (searchQuery.limit < 1 || searchQuery.limit > 100) {
logWarning('Invalid deck search limit parameter', { limit: searchQuery.limit }, req, res);
return res.status(400).json({ error: 'Limit must be between 1 and 100' });
}
if (searchQuery.offset < 0) {
logWarning('Invalid deck search offset parameter', { offset: searchQuery.offset }, req, res);
return res.status(400).json({ error: 'Offset must be non-negative' });
}
const result = await searchService.searchFromUrl(req.originalUrl, searchQuery);
logRequest('Deck search completed successfully', req, res, {
query: searchQuery.query,
resultCount: Array.isArray(result) ? result.length : 0
});
res.json(result);
} catch (error) {
logError('Search decks endpoint error', error as Error, req, res);
res.status(500).json({ error: 'Internal server error' });
}
});
deckRouter.get('/:id', authRequired, async (req, res) => {
try {
const deckId = req.params.id;
logRequest('Get deck by id endpoint accessed', req, res, { deckId });
const result = await container.getDeckByIdQueryHandler.execute({ id: deckId });
if (!result) {
logWarning('Deck not found', { deckId }, req, res);
return res.status(404).json({ error: 'Deck not found' });
}
logRequest('Deck retrieved successfully', req, res, { deckId });
res.json(result);
} catch (error) {
logError('Get deck by id endpoint error', error as Error, req, res);
res.status(500).json({ error: 'Internal server error' });
}
});
deckRouter.put('/:id', authRequired, async (req, res) => {
try {
const deckId = req.params.id;
const userId = (req as any).user.userId;
logRequest('Update deck endpoint accessed', req, res, { deckId, userId, updateFields: Object.keys(req.body) });
const result = await container.updateDeckCommandHandler.execute({ id: deckId, ...req.body });
logRequest('Deck updated successfully', req, res, { deckId, userId });
res.json(result);
} catch (error) {
logError('Update deck endpoint error', error as Error, req, res);
if (error instanceof Error && error.message.includes('not found')) {
return res.status(404).json({ error: 'Deck not found' });
}
if (error instanceof Error && (error.message.includes('duplicate') || error.message.includes('unique constraint'))) {
return res.status(409).json({ error: 'Deck with this name already exists' });
}
if (error instanceof Error && error.message.includes('validation')) {
return res.status(400).json({ error: 'Invalid input data', details: error.message });
}
res.status(500).json({ error: 'Internal server error' });
}
});
deckRouter.delete('/:id', authRequired, async (req, res) => {
try {
const deckId = req.params.id;
const userId = (req as any).user.userId;
logRequest('Soft delete deck endpoint accessed', req, res, { deckId, userId });
const result = await container.deleteDeckCommandHandler.execute({ id: deckId, soft: true });
logRequest('Deck soft delete successful', req, res, { deckId, userId, success: result });
res.json({ success: result });
} catch (error) {
logError('Soft delete deck endpoint error', error as Error, req, res);
if (error instanceof Error && error.message.includes('not found')) {
return res.status(404).json({ error: 'Deck not found' });
}
res.status(500).json({ error: 'Internal server error' });
}
});
export default deckRouter;
@@ -0,0 +1,204 @@
import { Router } from 'express';
import { authRequired } from '../../Application/Services/AuthMiddleware';
import { container } from '../../Application/Services/DIContainer';
import { ErrorResponseService } from '../../Application/Services/ErrorResponseService';
import { ValidationMiddleware } from '../../Application/Services/ValidationMiddleware';
import { GeneralSearchService } from '../../Application/Search/Generalsearch';
import { logRequest, logError, logWarning, logAuth } from '../../Application/Services/Logger';
const organizationRouter = Router();
// Create search service that isn't in the container yet
const searchService = new GeneralSearchService(container.userRepository, container.organizationRepository, container.deckRepository);
// Auth routes - Get organizations with pagination (RECOMMENDED)
organizationRouter.get('/page/:from/:to', authRequired, async (req, res) => {
try {
const from = parseInt(req.params.from);
const to = parseInt(req.params.to);
if (isNaN(from) || isNaN(to) || from < 0 || to < from) {
return res.status(400).json({ error: 'Invalid page parameters. "from" and "to" must be valid numbers with to >= from >= 0' });
}
logRequest('Get organizations by page endpoint accessed', req, res, { from, to });
const result = await container.getOrganizationsByPageQueryHandler.execute({ from, to });
logRequest('Organizations page retrieved successfully', req, res, {
from,
to,
count: result.organizations.length,
totalCount: result.totalCount
});
res.json(result);
} catch (error) {
logError('Get organizations by page endpoint error', error as Error, req, res);
res.status(500).json({ error: 'Internal server error' });
}
});
organizationRouter.get('/search', authRequired, async (req, res) => {
try {
const { query, limit, offset } = req.query;
logRequest('Search organizations endpoint accessed', req, res, { query, limit, offset });
if (!query || typeof query !== 'string') {
logWarning('Organization search attempted without query', { query, hasQuery: !!query }, req, res);
return res.status(400).json({ error: 'Search query is required' });
}
const searchQuery = {
query: query.trim(),
limit: limit ? parseInt(limit as string) : 20,
offset: offset ? parseInt(offset as string) : 0
};
// Validate pagination parameters
if (searchQuery.limit < 1 || searchQuery.limit > 100) {
logWarning('Invalid organization search limit parameter', { limit: searchQuery.limit }, req, res);
return res.status(400).json({ error: 'Limit must be between 1 and 100' });
}
if (searchQuery.offset < 0) {
logWarning('Invalid organization search offset parameter', { offset: searchQuery.offset }, req, res);
return res.status(400).json({ error: 'Offset must be non-negative' });
}
const result = await searchService.searchFromUrl(req.originalUrl, searchQuery);
logRequest('Organization search completed successfully', req, res, {
query: searchQuery.query,
resultCount: Array.isArray(result) ? result.length : 0
});
res.json(result);
} catch (error) {
logError('Search organizations endpoint error', error as Error, req, res);
res.status(500).json({ error: 'Internal server error' });
}
});
// Get organization login URL
organizationRouter.get('/:orgId/login-url', authRequired, async (req, res) => {
try {
const userId = (req as any).user.userId;
const { orgId } = req.params;
logRequest('Get organization login URL endpoint accessed', req, res, {
userId,
organizationId: orgId
});
const result = await container.getOrganizationLoginUrlQueryHandler.execute({
organizationId: orgId
});
if (!result) {
logWarning('Organization login URL not found', {
organizationId: orgId,
userId
}, req, res);
return ErrorResponseService.sendNotFound(res, 'Organization login URL not found');
}
logRequest('Organization login URL retrieved successfully', req, res, {
organizationId: orgId,
organizationName: result.organizationName,
hasUrl: !!result.loginUrl,
userId
});
res.json(result);
} catch (error) {
logError('Get organization login URL endpoint error', error as Error, req, res);
return ErrorResponseService.sendInternalServerError(res);
}
});
// Process third-party authentication callback
organizationRouter.post('/auth-callback', authRequired, async (req, res) => {
try {
const userId = (req as any).user.userId;
const { organizationId, status, authToken } = req.body;
logRequest('Organization auth callback endpoint accessed', req, res, {
userId,
organizationId,
status,
hasAuthToken: !!authToken
});
// Validate required fields
if (!organizationId || !status) {
logWarning('Missing required fields for organization auth callback', {
organizationId: !!organizationId,
status: !!status,
userId
}, req, res);
return ErrorResponseService.sendBadRequest(res, 'organizationId and status are required');
}
if (status !== 'ok' && status !== 'not_ok') {
logWarning('Invalid status value for organization auth callback', {
status,
userId,
organizationId
}, req, res);
return ErrorResponseService.sendBadRequest(res, 'status must be either "ok" or "not_ok"');
}
const result = await container.processOrgAuthCallbackCommandHandler.execute({
organizationId,
userId,
status,
authToken
});
if (!result.success) {
if (result.message.includes('not found')) {
logWarning('Organization auth callback failed - entity not found', {
userId,
organizationId,
message: result.message
}, req, res);
return ErrorResponseService.sendNotFound(res, result.message);
}
if (result.message.includes('does not belong')) {
logWarning('Organization auth callback failed - authorization error', {
userId,
organizationId,
message: result.message
}, req, res);
return ErrorResponseService.sendForbidden(res, result.message);
}
if (result.message.includes('authentication failed')) {
logAuth('Organization authentication failed via callback', userId, {
organizationId,
status
}, req, res);
return ErrorResponseService.sendUnauthorized(res, result.message);
}
logError('Organization auth callback internal error', new Error(result.message), req, res);
return ErrorResponseService.sendInternalServerError(res);
}
logAuth('Organization auth callback processed successfully', userId, {
organizationId,
status,
updatedFields: result.updatedFields
}, req, res);
res.json({
success: result.success,
message: result.message,
updatedFields: result.updatedFields
});
} catch (error) {
logError('Organization auth callback endpoint error', error as Error, req, res);
return ErrorResponseService.sendInternalServerError(res);
}
});
export default organizationRouter;
@@ -0,0 +1,168 @@
import { Router } from 'express';
import { authRequired } from '../../Application/Services/AuthMiddleware';
import { container } from '../../Application/Services/DIContainer';
import { ErrorResponseService } from '../../Application/Services/ErrorResponseService';
import { ValidationMiddleware } from '../../Application/Services/ValidationMiddleware';
import { GeneralSearchService } from '../../Application/Search/Generalsearch';
import { logRequest, logError, logAuth, logWarning } from '../../Application/Services/Logger';
const userRouter = Router();
// Create search service that isn't in the container yet
const searchService = new GeneralSearchService(container.userRepository, container.organizationRepository, container.deckRepository);
// Login endpoint
userRouter.post('/login',
ValidationMiddleware.combine([
ValidationMiddleware.validateRequiredFields(['username', 'password']),
ValidationMiddleware.validateStringLength({
username: { min: 3, max: 50 },
password: { min: 6, max: 100 }
})
]),
async (req, res) => {
try {
logRequest('Login endpoint accessed', req, res, { username: req.body.username });
const { username, password } = req.body;
const result = await container.loginCommandHandler.execute({ username, password }, res);
if (result) {
logAuth('User login successful', result.user.id, { username: result.user.username }, req, res);
res.json(result);
} else {
return ErrorResponseService.sendUnauthorized(res, 'Invalid username or password');
}
} catch (error) {
logError('Login endpoint error', error as Error, req, res);
if (error instanceof Error) {
if (error.message.includes('Invalid username')) {
return ErrorResponseService.sendUnauthorized(res, 'Invalid username or password');
}
if (error.message.includes('Invalid password')) {
return ErrorResponseService.sendUnauthorized(res, 'Invalid username or password');
}
if (error.message.includes('not verified')) {
return ErrorResponseService.sendUnauthorized(res, 'Please verify your email address');
}
if (error.message.includes('deactivated')) {
return ErrorResponseService.sendUnauthorized(res, 'Account has been deactivated');
}
}
return ErrorResponseService.sendInternalServerError(res);
}
});
// Create user endpoint
userRouter.post('/create',
ValidationMiddleware.combine([
ValidationMiddleware.validateRequiredFields(['username', 'email', 'password']),
ValidationMiddleware.validateEmailFormat(['email']),
ValidationMiddleware.validateStringLength({
username: { min: 3, max: 50 },
password: { min: 6, max: 100 }
})
]),
async (req, res) => {
try {
logRequest('Create user endpoint accessed', req, res, {
username: req.body.username,
email: req.body.email
});
const result = await container.createUserCommandHandler.execute(req.body);
logRequest('User created successfully', req, res, {
userId: result.id,
username: result.username
});
res.status(201).json(result);
} catch (error) {
logError('Create user endpoint error', error as Error, req, res);
if (error instanceof Error) {
if (error.message.includes('already exists')) {
return ErrorResponseService.sendConflict(res, error.message);
}
if (error.message.includes('validation')) {
return ErrorResponseService.sendBadRequest(res, error.message);
}
}
return ErrorResponseService.sendInternalServerError(res);
}
});
// Get user profile (current user)
userRouter.get('/profile', authRequired, async (req, res) => {
try {
const userId = (req as any).user.userId;
logRequest('Get user profile endpoint accessed', req, res, { userId });
const result = await container.getUserByIdQueryHandler.execute({ id: userId });
if (!result) {
logWarning('User profile not found', { userId }, req, res);
return ErrorResponseService.sendNotFound(res, 'User not found');
}
logRequest('User profile retrieved successfully', req, res, {
userId,
username: result.username
});
res.json(result);
} catch (error) {
logError('Get user profile endpoint error', error as Error, req, res);
return ErrorResponseService.sendInternalServerError(res);
}
});
// Update user profile (current user)
userRouter.patch('/profile', authRequired, async (req, res) => {
try {
const userId = (req as any).user.userId;
logRequest('Update user profile endpoint accessed', req, res, {
userId,
fieldsToUpdate: Object.keys(req.body)
});
const result = await container.updateUserCommandHandler.execute({ id: userId, ...req.body });
if (!result) {
return ErrorResponseService.sendNotFound(res, 'User not found');
}
logRequest('User profile updated successfully', req, res, {
userId,
username: result.username
});
res.json(result);
} catch (error) {
logError('Update user profile endpoint error', error as Error, req, res);
if (error instanceof Error) {
if (error.message.includes('already exists')) {
return ErrorResponseService.sendConflict(res, error.message);
}
if (error.message.includes('validation')) {
return ErrorResponseService.sendBadRequest(res, error.message);
}
}
return ErrorResponseService.sendInternalServerError(res);
}
});
export default userRouter;
@@ -0,0 +1,72 @@
import swaggerJSDoc from 'swagger-jsdoc';
export const swaggerOptions = {
definition: {
openapi: '3.0.0',
info: {
title: 'SerpentRace API',
version: '1.0.0',
description: 'Comprehensive API documentation for SerpentRace Backend',
contact: {
name: 'SerpentRace Development Team',
email: 'dev@serpentrace.com'
},
license: {
name: 'MIT',
url: 'https://opensource.org/licenses/MIT'
}
},
servers: [
{
url: 'http://localhost:3000',
description: 'Local development server'
},
{
url: 'https://api.serpentrace.com',
description: 'Production server'
}
],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'Enter JWT token obtained from /api/users/login'
}
}
},
security: [{ bearerAuth: [] }],
tags: [
{
name: 'Users',
description: 'User authentication and profile management'
},
{
name: 'Organizations',
description: 'Organization management and authentication'
},
{
name: 'Decks',
description: 'Deck creation, management, and gameplay'
},
{
name: 'Chats',
description: 'Real-time chat and messaging system'
},
{
name: 'Contacts',
description: 'Contact form and support requests'
},
{
name: 'Deck Import/Export',
description: 'Import and export deck functionality'
}
]
},
apis: [
'./src/Api/swagger/swaggerDefinitions.ts'
],
};
export const swaggerSpec = swaggerJSDoc(swaggerOptions);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,7 @@
import express from 'express';
import swaggerUi from 'swagger-ui-express';
import { swaggerSpec } from './swaggerConfig';
export function setupSwagger(app: express.Application) {
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
}
@@ -0,0 +1,69 @@
import { ArchiveChatCommand, RestoreChatCommand } from './ChatCommands';
import { IChatRepository } from '../../../Domain/IRepository/IChatRepository';
import { ChatType } from '../../../Domain/Chat/ChatAggregate';
import { logAuth, logError, logWarning } from '../../Services/Logger';
export class ArchiveChatCommandHandler {
constructor(private chatRepository: IChatRepository) {}
async execute(command: ArchiveChatCommand): Promise<boolean> {
try {
const chat = await this.chatRepository.findById(command.chatId);
if (!chat) {
throw new Error('Chat not found');
}
await this.chatRepository.archiveChat(chat);
logAuth('Chat archived manually', undefined, {
chatId: command.chatId,
chatType: chat.type,
messageCount: chat.messages.length
});
return true;
} catch (error) {
logError('ArchiveChatCommandHandler error', error as Error);
return false;
}
}
}
export class RestoreChatCommandHandler {
constructor(private chatRepository: IChatRepository) {}
async execute(command: RestoreChatCommand): Promise<boolean> {
try {
const archive = await this.chatRepository.getArchivedChat(command.chatId);
if (!archive) {
throw new Error('Archived chat not found');
}
// Game chats cannot be restored, only viewed
if (archive.chatType === ChatType.GAME) {
logWarning('Attempt to restore game chat blocked', {
chatId: command.chatId,
chatType: archive.chatType
});
return false;
}
const restoredChat = await this.chatRepository.restoreFromArchive(command.chatId);
if (!restoredChat) {
throw new Error('Failed to restore chat from archive');
}
logAuth('Chat restored from archive', undefined, {
chatId: command.chatId,
messageCount: archive.archivedMessages.length
});
return true;
} catch (error) {
logError('RestoreChatCommandHandler error', error as Error);
return false;
}
}
}
@@ -0,0 +1,21 @@
export interface CreateChatCommand {
type: 'direct' | 'group' | 'game';
name?: string;
gameId?: string;
createdBy: string;
userIds: string[];
}
export interface SendMessageCommand {
chatId: string;
userId: string;
message: string;
}
export interface ArchiveChatCommand {
chatId: string;
}
export interface RestoreChatCommand {
chatId: string;
}
@@ -0,0 +1,85 @@
import { CreateChatCommand } from './ChatCommands';
import { IChatRepository } from '../../../Domain/IRepository/IChatRepository';
import { IUserRepository } from '../../../Domain/IRepository/IUserRepository';
import { ChatType, ChatAggregate } from '../../../Domain/Chat/ChatAggregate';
import { UserState } from '../../../Domain/User/UserAggregate';
import { logAuth, logError } from '../../Services/Logger';
export class CreateChatCommandHandler {
constructor(
private chatRepository: IChatRepository,
private userRepository: IUserRepository
) {}
async execute(command: CreateChatCommand): Promise<ChatAggregate | null> {
try {
// Validate creator exists
const creator = await this.userRepository.findById(command.createdBy);
if (!creator) {
throw new Error('Creator not found');
}
// For group chats, check if creator is premium
if (command.type === 'group' && creator.state !== UserState.VERIFIED_PREMIUM) {
throw new Error('Premium subscription required to create groups');
}
// Validate all target users exist
const targetUsers = await Promise.all(
command.userIds.map(id => this.userRepository.findById(id))
);
if (targetUsers.some(user => !user)) {
throw new Error('One or more target users not found');
}
// For direct chats, check if already exists
if (command.type === 'direct' && command.userIds.length === 1) {
const existingChats = await this.chatRepository.findByUserId(command.createdBy);
const existingDirectChat = existingChats.find(chat =>
chat.type === ChatType.DIRECT &&
chat.users.length === 2 &&
chat.users.includes(command.userIds[0])
);
if (existingDirectChat) {
return existingDirectChat;
}
}
// For game chats, check if already exists
if (command.type === 'game' && command.gameId) {
const existingGameChat = await this.chatRepository.findByGameId(command.gameId);
if (existingGameChat) {
return existingGameChat;
}
}
// Create chat
const chatData: Partial<ChatAggregate> = {
type: command.type as any,
name: command.name,
gameId: command.gameId,
createdBy: command.createdBy,
users: [command.createdBy, ...command.userIds],
messages: [],
lastActivity: new Date()
};
const chat = await this.chatRepository.create(chatData);
logAuth('Chat created successfully', command.createdBy, {
chatId: chat.id,
chatType: command.type,
participantCount: chat.users.length,
gameId: command.gameId
});
return chat;
} catch (error) {
logError('CreateChatCommandHandler error', error as Error);
return null;
}
}
}
@@ -0,0 +1,84 @@
import { SendMessageCommand } from './ChatCommands';
import { IChatRepository } from '../../../Domain/IRepository/IChatRepository';
import { Message } from '../../../Domain/Chat/ChatAggregate';
import { logAuth, logError } from '../../Services/Logger';
import { v4 as uuidv4 } from 'uuid';
export class SendMessageCommandHandler {
constructor(private chatRepository: IChatRepository) {}
async execute(command: SendMessageCommand): Promise<Message | null> {
try {
// Validate message is non-empty string
if (typeof command.message !== 'string' || !command.message.trim()) {
throw new Error('Message must be a non-empty string');
}
const chat = await this.chatRepository.findById(command.chatId);
if (!chat) {
throw new Error('Chat not found');
}
// Check if user is member of this chat
if (!chat.users.includes(command.userId)) {
throw new Error('User is not a member of this chat');
}
// Create message
const message: Message = {
id: uuidv4(),
date: new Date(),
userid: command.userId,
text: command.message.trim()
};
// Manage message history (keep last 10 per user, up to 2 weeks)
let updatedMessages = [...chat.messages, message];
updatedMessages = this.pruneMessages(updatedMessages);
// Update chat
await this.chatRepository.update(command.chatId, {
messages: updatedMessages,
lastActivity: new Date()
});
logAuth('Message sent successfully', command.userId, {
chatId: command.chatId,
messageLength: command.message.length,
totalMessages: updatedMessages.length
});
return message;
} catch (error) {
logError('SendMessageCommandHandler error', error as Error);
return null;
}
}
private pruneMessages(messages: Message[]): Message[] {
const twoWeeksAgo = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000);
// Remove messages older than 2 weeks
let prunedMessages = messages.filter(msg => new Date(msg.date) > twoWeeksAgo);
// Group by user and keep last 10 messages per user
const messagesByUser = new Map<string, Message[]>();
prunedMessages.forEach(msg => {
if (!messagesByUser.has(msg.userid)) {
messagesByUser.set(msg.userid, []);
}
messagesByUser.get(msg.userid)!.push(msg);
});
// Keep only last 10 messages per user
const finalMessages: Message[] = [];
messagesByUser.forEach((userMessages, userId) => {
const last10 = userMessages.slice(-10);
finalMessages.push(...last10);
});
// Sort by date
return finalMessages.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
}
}
@@ -0,0 +1,141 @@
import { GetChatHistoryQuery, GetArchivedChatsQuery } from './ChatQueries';
import { IChatRepository } from '../../../Domain/IRepository/IChatRepository';
import { IChatArchiveRepository } from '../../../Domain/IRepository/IChatArchiveRepository';
import { Message } from '../../../Domain/Chat/ChatAggregate';
import { logAuth, logError, logWarning } from '../../Services/Logger';
interface ChatHistoryResult {
chatId: string;
messages: Message[];
isArchived: boolean;
chatInfo: {
type: string;
name: string | null;
gameId: string | null;
users: string[];
};
}
export class GetChatHistoryQueryHandler {
constructor(
private chatRepository: IChatRepository,
private chatArchiveRepository: IChatArchiveRepository
) {}
async execute(query: GetChatHistoryQuery): Promise<ChatHistoryResult | null> {
try {
// First try to find active chat
const chat = await this.chatRepository.findById(query.chatId);
if (chat) {
// Check authorization
if (!chat.users.includes(query.userId)) {
logWarning('Unauthorized chat history access attempt', {
chatId: query.chatId,
userId: query.userId
});
return null;
}
logAuth('Chat history retrieved', query.userId, {
chatId: query.chatId,
messageCount: chat.messages.length,
isArchived: false
});
return {
chatId: query.chatId,
messages: chat.messages,
isArchived: false,
chatInfo: {
type: chat.type,
name: chat.name,
gameId: chat.gameId,
users: chat.users
}
};
}
// Try to find in archives
const archives = await this.chatArchiveRepository.findByChatId(query.chatId);
const userArchive = archives.find(archive =>
archive.participants.includes(query.userId)
);
if (userArchive) {
logAuth('Archived chat history retrieved', query.userId, {
chatId: query.chatId,
messageCount: userArchive.archivedMessages.length,
isArchived: true
});
return {
chatId: query.chatId,
messages: userArchive.archivedMessages,
isArchived: true,
chatInfo: {
type: userArchive.chatType,
name: userArchive.chatName,
gameId: userArchive.gameId,
users: userArchive.participants
}
};
}
logWarning('Chat history not found', {
chatId: query.chatId,
userId: query.userId
});
return null;
} catch (error) {
logError('GetChatHistoryQueryHandler error', error as Error);
return null;
}
}
}
export class GetArchivedChatsQueryHandler {
constructor(private chatArchiveRepository: IChatArchiveRepository) {}
async execute(query: GetArchivedChatsQuery): Promise<ChatHistoryResult[]> {
try {
let archives: any[] = [];
if (query.gameId) {
// Get archived game chats
archives = await this.chatArchiveRepository.findByGameId(query.gameId);
} else {
// Get all archived chats for user (would need different query)
// For now, return empty - this would need a new repository method
archives = [];
}
const result = archives
.filter(archive => archive.participants.includes(query.userId))
.map(archive => ({
chatId: archive.chatId,
messages: archive.archivedMessages,
isArchived: true,
chatInfo: {
type: archive.chatType,
name: archive.chatName,
gameId: archive.gameId,
users: archive.participants
}
}));
logAuth('Archived chats retrieved', query.userId, {
count: result.length,
gameId: query.gameId
});
return result;
} catch (error) {
logError('GetArchivedChatsQueryHandler error', error as Error);
return [];
}
}
}
@@ -0,0 +1,14 @@
export interface GetUserChatsQuery {
userId: string;
includeArchived?: boolean;
}
export interface GetChatHistoryQuery {
chatId: string;
userId: string; // For authorization
}
export interface GetArchivedChatsQuery {
userId: string;
gameId?: string;
}
@@ -0,0 +1,5 @@
export interface GetChatsByPageQuery {
from: number;
to: number;
includeDeleted?: boolean;
}
@@ -0,0 +1,55 @@
import { IChatRepository } from '../../../Domain/IRepository/IChatRepository';
import { GetChatsByPageQuery } from './GetChatsByPageQuery';
import { ShortChatDto } from '../../DTOs/ChatDto';
import { ChatMapper } from '../../DTOs/Mappers/ChatMapper';
import { logRequest, logError } from '../../Services/Logger';
export class GetChatsByPageQueryHandler {
constructor(private readonly chatRepo: IChatRepository) {}
async execute(query: GetChatsByPageQuery): Promise<{ chats: ShortChatDto[], totalCount: number }> {
try {
// Validate pagination parameters
if (query.from < 0 || query.to < query.from) {
throw new Error('Invalid pagination parameters');
}
const limit = query.to - query.from + 1;
if (limit > 100) {
throw new Error('Page size too large. Maximum 100 records per request');
}
logRequest('Get chats by page query started', undefined, undefined, {
from: query.from,
to: query.to,
includeDeleted: query.includeDeleted || false
});
const result = query.includeDeleted
? await this.chatRepo.findByPageIncludingDeleted(query.from, query.to)
: await this.chatRepo.findByPage(query.from, query.to);
logRequest('Get chats by page query completed', undefined, undefined, {
from: query.from,
to: query.to,
returned: result.chats.length,
totalCount: result.totalCount,
includeDeleted: query.includeDeleted || false
});
return {
chats: ChatMapper.toShortDtoList(result.chats),
totalCount: result.totalCount
};
} catch (error) {
logError('GetChatsByPageQueryHandler error', error instanceof Error ? error : new Error(String(error)));
// Re-throw validation errors as-is
if (error instanceof Error && (error.message.includes('Invalid pagination') || error.message.includes('Page size'))) {
throw error;
}
throw new Error('Failed to retrieve chats page');
}
}
}
@@ -0,0 +1,97 @@
import { GetUserChatsQuery } from './ChatQueries';
import { IChatRepository } from '../../../Domain/IRepository/IChatRepository';
import { IChatArchiveRepository } from '../../../Domain/IRepository/IChatArchiveRepository';
import { ChatAggregate } from '../../../Domain/Chat/ChatAggregate';
import { ChatArchiveAggregate } from '../../../Domain/Chat/ChatArchiveAggregate';
import { logAuth, logError } from '../../Services/Logger';
interface ChatWithMetadata {
id: string;
type: string;
name: string | null;
gameId: string | null;
users: string[];
lastActivity: Date | null;
isArchived: boolean;
messageCount: number;
unreadCount?: number;
}
export class GetUserChatsQueryHandler {
constructor(
private chatRepository: IChatRepository,
private chatArchiveRepository: IChatArchiveRepository
) {}
async execute(query: GetUserChatsQuery): Promise<ChatWithMetadata[]> {
try {
const result: ChatWithMetadata[] = [];
// Get active chats
const activeChats = await this.chatRepository.findActiveChatsForUser(query.userId);
result.push(...activeChats.map(chat => ({
id: chat.id,
type: chat.type,
name: chat.name,
gameId: chat.gameId,
users: chat.users,
lastActivity: chat.lastActivity,
isArchived: false,
messageCount: chat.messages.length,
unreadCount: this.calculateUnreadMessages(chat, query.userId)
})));
// Get archived chats if requested
if (query.includeArchived) {
const userActiveChats = await this.chatRepository.findByUserId(query.userId);
const archivedChatIds = userActiveChats
.filter(chat => chat.archiveDate !== null)
.map(chat => chat.id);
const archives = await Promise.all(
archivedChatIds.map(id => this.chatArchiveRepository.findByChatId(id))
);
archives.forEach(archiveArray => {
archiveArray.forEach(archive => {
if (archive.participants.includes(query.userId)) {
result.push({
id: archive.chatId,
type: archive.chatType,
name: archive.chatName,
gameId: archive.gameId,
users: archive.participants,
lastActivity: archive.archivedAt,
isArchived: true,
messageCount: archive.archivedMessages.length,
unreadCount: 0 // Archived chats have no unread messages
});
}
});
});
}
logAuth('User chats retrieved', query.userId, {
activeCount: activeChats.length,
totalCount: result.length,
includeArchived: query.includeArchived
});
return result.sort((a, b) => {
if (!a.lastActivity) return 1;
if (!b.lastActivity) return -1;
return new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime();
});
} catch (error) {
logError('GetUserChatsQueryHandler error', error as Error);
return [];
}
}
private calculateUnreadMessages(chat: ChatAggregate, userId: string): number {
// Simple implementation - count messages from other users
// In production, you'd store lastSeen timestamp per user per chat
return chat.messages.filter(msg => msg.userid !== userId).length;
}
}
@@ -0,0 +1,9 @@
import { ContactType } from '../../../Domain/Contact/ContactAggregate';
export interface CreateContactCommand {
name: string;
email: string;
userid?: string;
type: ContactType;
txt: string;
}
@@ -0,0 +1,26 @@
import { IContactRepository } from '../../../Domain/IRepository/IContactRepository';
import { CreateContactCommand } from './CreateContactCommand';
import { ShortContactDto } from '../../DTOs/ContactDto';
import { ContactAggregate, ContactState } from '../../../Domain/Contact/ContactAggregate';
import { ContactMapper } from '../../DTOs/Mappers/ContactMapper';
export class CreateContactCommandHandler {
constructor(private readonly contactRepo: IContactRepository) {}
async execute(cmd: CreateContactCommand): Promise<ShortContactDto> {
try {
const contact = new ContactAggregate();
contact.name = cmd.name;
contact.email = cmd.email;
contact.userid = cmd.userid || null;
contact.type = cmd.type;
contact.txt = cmd.txt;
contact.state = ContactState.ACTIVE;
const created = await this.contactRepo.create(contact);
return ContactMapper.toShortDto(created);
} catch (error) {
throw new Error('Failed to create contact');
}
}
}
@@ -0,0 +1,4 @@
export interface DeleteContactCommand {
id: string;
hard?: boolean; // true for permanent delete, false/undefined for soft delete
}
@@ -0,0 +1,42 @@
import { IContactRepository } from '../../../Domain/IRepository/IContactRepository';
import { DeleteContactCommand } from './DeleteContactCommand';
import { AdminAuditService } from '../../Services/AdminBypassService';
import { logRequest } from '../../Services/Logger';
export class DeleteContactCommandHandler {
constructor(private readonly contactRepo: IContactRepository) {}
async execute(cmd: DeleteContactCommand): Promise<boolean> {
try {
const existingContact = await this.contactRepo.findById(cmd.id);
if (!existingContact) {
throw new Error('Contact not found');
}
if (cmd.hard) {
// Permanent delete
await this.contactRepo.delete(cmd.id);
logRequest('Contact hard deleted', undefined, undefined, {
contactId: cmd.id,
contactEmail: existingContact.email,
deleteType: 'hard'
});
} else {
// Soft delete (default)
await this.contactRepo.softDelete(cmd.id);
logRequest('Contact soft deleted', undefined, undefined, {
contactId: cmd.id,
contactEmail: existingContact.email,
deleteType: 'soft'
});
}
return true;
} catch (error) {
if (error instanceof Error && error.message === 'Contact not found') {
throw error;
}
throw new Error('Failed to delete contact');
}
}
}
@@ -0,0 +1,6 @@
export interface UpdateContactCommand {
id: string;
adminResponse?: string;
state?: number;
respondedBy?: string;
}
@@ -0,0 +1,45 @@
import { IContactRepository } from '../../../Domain/IRepository/IContactRepository';
import { UpdateContactCommand } from './UpdateContactCommand';
import { DetailContactDto } from '../../DTOs/ContactDto';
import { ContactMapper } from '../../DTOs/Mappers/ContactMapper';
import { ContactState } from '../../../Domain/Contact/ContactAggregate';
export class UpdateContactCommandHandler {
constructor(private readonly contactRepo: IContactRepository) {}
async execute(cmd: UpdateContactCommand): Promise<DetailContactDto> {
try {
const existingContact = await this.contactRepo.findById(cmd.id);
if (!existingContact) {
throw new Error('Contact not found');
}
const updateData: any = {};
if (cmd.adminResponse !== undefined) {
updateData.adminResponse = cmd.adminResponse;
updateData.responseDate = new Date();
}
if (cmd.state !== undefined) {
updateData.state = cmd.state;
}
if (cmd.respondedBy !== undefined) {
updateData.respondedBy = cmd.respondedBy;
}
const updated = await this.contactRepo.update(cmd.id, updateData);
if (!updated) {
throw new Error('Failed to update contact');
}
return ContactMapper.toDetailDto(updated);
} catch (error) {
if (error instanceof Error && error.message === 'Contact not found') {
throw error;
}
throw new Error('Failed to update contact');
}
}
}
@@ -0,0 +1,3 @@
export interface GetContactByIdQuery {
id: string;
}
@@ -0,0 +1,16 @@
import { IContactRepository } from '../../../Domain/IRepository/IContactRepository';
import { GetContactByIdQuery } from './GetContactByIdQuery';
import { DetailContactDto } from '../../DTOs/ContactDto';
import { ContactMapper } from '../../DTOs/Mappers/ContactMapper';
export class GetContactByIdQueryHandler {
constructor(private readonly contactRepo: IContactRepository) {}
async execute(query: GetContactByIdQuery): Promise<DetailContactDto | null> {
const contact = await this.contactRepo.findById(query.id);
if (!contact) {
return null;
}
return ContactMapper.toDetailDto(contact);
}
}
@@ -0,0 +1,4 @@
export interface GetContactsByPageQuery {
from: number;
to: number;
}
@@ -0,0 +1,18 @@
import { IContactRepository } from '../../../Domain/IRepository/IContactRepository';
import { GetContactsByPageQuery } from './GetContactsByPageQuery';
import { ContactPageDto } from '../../DTOs/ContactDto';
import { ContactMapper } from '../../DTOs/Mappers/ContactMapper';
export class GetContactsByPageQueryHandler {
constructor(private readonly contactRepo: IContactRepository) {}
async execute(query: GetContactsByPageQuery): Promise<ContactPageDto> {
const result = await this.contactRepo.findByPage(query.from, query.to);
return {
contacts: ContactMapper.toShortDtoList(result.contacts),
totalCount: result.totalCount,
from: query.from,
to: query.to,
};
}
}
@@ -0,0 +1,26 @@
export interface CreateChatDto {
users: string[];
messages: import('../../Domain/Chat/ChatAggregate').Message[];
state?: number;
}
export interface UpdateChatDto {
id: string;
users?: string[];
messages?: import('../../Domain/Chat/ChatAggregate').Message[];
state?: number;
}
export interface ShortChatDto {
id: string;
userCount: number;
state: number;
}
export interface DetailChatDto {
id: string;
users: string[];
messages: import('../../Domain/Chat/ChatAggregate').Message[];
updateDate: Date;
state: number;
}
@@ -0,0 +1,47 @@
import { ContactType } from '../../Domain/Contact/ContactAggregate';
export interface CreateContactDto {
name: string;
email: string;
userid?: string;
type: ContactType;
txt: string;
}
export interface UpdateContactDto {
id: string;
adminResponse?: string;
state?: number;
respondedBy?: string;
}
export interface ShortContactDto {
id: string;
name: string;
email: string;
type: ContactType;
createDate: Date;
state: number;
}
export interface DetailContactDto {
id: string;
name: string;
email: string;
userid: string | null;
type: ContactType;
txt: string;
state: number;
createDate: Date;
updateDate: Date;
adminResponse: string | null;
responseDate: Date | null;
respondedBy: string | null;
}
export interface ContactPageDto {
contacts: ShortContactDto[];
totalCount: number;
from: number;
to: number;
}
@@ -0,0 +1,29 @@
export interface CreateDeckDto {
name: string;
description?: string;
}
export interface UpdateDeckDto {
id: string;
name?: string;
description?: string;
}
export interface ShortDeckDto {
id: string;
name: string;
type: number;
playedNumber: number;
ctype: number;
}
export interface DetailDeckDto {
id: string;
name: string;
type: number;
userid: string;
creationdate: Date;
cards: any[];
playedNumber: number;
ctype: number;
}
@@ -0,0 +1,19 @@
export abstract class BaseMapper<TEntity, TShortDto, TDetailDto> {
abstract toShortDto(entity: TEntity): TShortDto;
abstract toDetailDto(entity: TEntity): TDetailDto;
toShortDtoList(entities: TEntity[]): TShortDto[] {
return entities.map(entity => this.toShortDto(entity));
}
toDetailDtoList(entities: TEntity[]): TDetailDto[] {
return entities.map(entity => this.toDetailDto(entity));
}
static toShortDtoListStatic<T, TDto>(
entities: T[],
mapperFn: (entity: T) => TDto
): TDto[] {
return entities.map(mapperFn);
}
}
@@ -0,0 +1,26 @@
import { ChatAggregate } from '../../../Domain/Chat/ChatAggregate';
import { ShortChatDto, DetailChatDto } from '../ChatDto';
export class ChatMapper {
static toShortDto(chat: ChatAggregate): ShortChatDto {
return {
id: chat.id,
userCount: chat.users?.length ?? 0,
state: chat.state,
};
}
static toDetailDto(chat: ChatAggregate): DetailChatDto {
return {
id: chat.id,
users: chat.users ?? [],
messages: chat.messages,
updateDate: chat.updateDate,
state: chat.state,
};
}
static toShortDtoList(chats: ChatAggregate[]): ShortChatDto[] {
return chats.map(this.toShortDto);
}
}
@@ -0,0 +1,36 @@
import { ContactAggregate } from '../../../Domain/Contact/ContactAggregate';
import { CreateContactDto, UpdateContactDto, ShortContactDto, DetailContactDto } from '../ContactDto';
export class ContactMapper {
static toShortDto(contact: ContactAggregate): ShortContactDto {
return {
id: contact.id,
name: contact.name,
email: contact.email,
type: contact.type,
createDate: contact.createDate,
state: contact.state,
};
}
static toDetailDto(contact: ContactAggregate): DetailContactDto {
return {
id: contact.id,
name: contact.name,
email: contact.email,
userid: contact.userid,
type: contact.type,
txt: contact.txt,
state: contact.state,
createDate: contact.createDate,
updateDate: contact.updateDate,
adminResponse: contact.adminResponse,
responseDate: contact.responseDate,
respondedBy: contact.respondedBy,
};
}
static toShortDtoList(contacts: ContactAggregate[]): ShortContactDto[] {
return contacts.map(this.toShortDto);
}
}
@@ -0,0 +1,31 @@
import { DeckAggregate } from '../../../Domain/Deck/DeckAggregate';
import { CreateDeckDto, UpdateDeckDto, ShortDeckDto, DetailDeckDto } from '../DeckDto';
export class DeckMapper {
static toShortDto(deck: DeckAggregate): ShortDeckDto {
return {
id: deck.id,
name: deck.name,
type: deck.type,
playedNumber: deck.playedNumber,
ctype: deck.ctype,
};
}
static toDetailDto(deck: DeckAggregate): DetailDeckDto {
return {
id: deck.id,
name: deck.name,
type: deck.type,
userid: deck.userid,
creationdate: deck.creationdate,
cards: deck.cards,
playedNumber: deck.playedNumber,
ctype: deck.ctype,
};
}
static toShortDtoList(decks: DeckAggregate[]): ShortDeckDto[] {
return decks.map(this.toShortDto);
}
}
@@ -0,0 +1,36 @@
import { OrganizationAggregate } from '../../../Domain/Organization/OrganizationAggregate';
import { CreateOrganizationDto, UpdateOrganizationDto, ShortOrganizationDto, DetailOrganizationDto } from '../OrganizationDto';
export class OrganizationMapper {
static toShortDto(org: OrganizationAggregate): ShortOrganizationDto {
return {
id: org.id,
name: org.name,
state: org.state,
userinorg: org.userinorg,
maxOrganizationalDecks: org.maxOrganizationalDecks,
};
}
static toDetailDto(org: OrganizationAggregate): DetailOrganizationDto {
return {
id: org.id,
name: org.name,
contactfname: org.contactfname,
contactlname: org.contactlname,
contactphone: org.contactphone,
contactemail: org.contactemail,
state: org.state,
regdate: org.regdate,
updatedate: org.updatedate,
url: org.url,
userinorg: org.userinorg,
maxOrganizationalDecks: org.maxOrganizationalDecks,
users: org.users?.map(u => u.id) ?? [],
};
}
static toShortDtoList(orgs: OrganizationAggregate[]): ShortOrganizationDto[] {
return orgs.map(this.toShortDto);
}
}
@@ -0,0 +1,33 @@
import { UserAggregate, UserState } from '../../../Domain/User/UserAggregate';
import { CreateUserDto, UpdateUserDto, ShortUserDto, DetailUserDto } from '../UserDto';
import { BaseMapper } from './BaseMapper';
export class UserMapper {
static toShortDto(user: UserAggregate): ShortUserDto {
return {
id: user.id,
username: user.username,
state: user.state,
authLevel: (user.state === UserState.ADMIN ? 1 : 0) as 0 | 1,
};
}
static toDetailDto(user: UserAggregate): DetailUserDto {
return {
id: user.id,
orgid: user.orgid,
username: user.username,
email: user.email,
fname: user.fname,
lname: user.lname,
code: user.token,
type: user.type,
phone: user.phone,
state: user.state,
};
}
static toShortDtoList(users: UserAggregate[]): ShortUserDto[] {
return BaseMapper.toShortDtoListStatic(users, UserMapper.toShortDto);
}
}
@@ -0,0 +1,48 @@
export interface CreateOrganizationDto {
name: string;
description?: string;
maxOrganizationalDecks?: number | null;
}
export interface UpdateOrganizationDto {
id: string;
name?: string;
description?: string;
}
export interface ShortOrganizationDto {
id: string;
name: string;
state: number;
userinorg: number;
maxOrganizationalDecks?: number | null;
}
export interface DetailOrganizationDto {
id: string;
name: string;
contactfname: string;
contactlname: string;
contactphone: string;
contactemail: string;
state: number;
regdate: Date;
updatedate: Date;
url: string | null;
userinorg: number;
maxOrganizationalDecks: number | null;
users: string[];
}
export interface OrganizationLoginUrlDto {
organizationId: string;
organizationName: string;
loginUrl: string;
}
export interface OrganizationAuthCallbackDto {
organizationId: string;
userId: string;
status: 'ok' | 'not_ok';
authToken?: string;
}
@@ -0,0 +1,13 @@
export interface SearchQuery {
query: string;
limit?: number;
offset?: number;
}
export interface SearchResult<T> {
results: T[];
totalCount: number;
hasMore: boolean;
searchQuery: string;
searchType: 'users' | 'organizations' | 'decks';
}
@@ -0,0 +1,30 @@
export interface CreateUserDto {
username: string;
email: string;
}
export interface UpdateUserDto {
id: string;
username?: string;
email?: string;
}
export interface ShortUserDto {
id: string;
username: string;
state: number;
authLevel: 0 | 1;
}
export interface DetailUserDto {
id: string;
orgid: string | null;
username: string;
email: string;
fname: string;
lname: string;
code: string | null;
type: string;
phone: string | null;
state: number;
}
@@ -0,0 +1,7 @@
export interface CreateDeckCommand {
name: string;
type: number;
userid: string;
cards: any[];
ctype?: number;
}
@@ -0,0 +1,125 @@
import { IDeckRepository } from '../../../Domain/IRepository/IDeckRepository';
import { IUserRepository } from '../../../Domain/IRepository/IUserRepository';
import { IOrganizationRepository } from '../../../Domain/IRepository/IOrganizationRepository';
import { CreateDeckCommand } from './CreateDeckCommand';
import { ShortDeckDto } from '../../DTOs/DeckDto';
import { DeckAggregate, State, CType } from '../../../Domain/Deck/DeckAggregate';
import { UserState } from '../../../Domain/User/UserAggregate';
import { DeckMapper } from '../../DTOs/Mappers/DeckMapper';
import { AdminBypassService } from '../../Services/AdminBypassService';
import { logRequest } from '../../Services/Logger';
export class CreateDeckCommandHandler {
constructor(
private readonly deckRepo: IDeckRepository,
private readonly userRepo: IUserRepository,
private readonly orgRepo: IOrganizationRepository
) {}
async execute(cmd: CreateDeckCommand): Promise<ShortDeckDto> {
try {
// 1. Get user details
const user = await this.userRepo.findById(cmd.userid);
if (!user) {
throw new Error('User not found');
}
// 2. ADMIN BYPASS - Skip all restrictions
if (AdminBypassService.shouldBypassRestrictions(user.state)) {
AdminBypassService.logAdminBypass(
'CREATE_DECK_BYPASS',
user.id,
'new-deck',
{
deckName: cmd.name,
deckType: cmd.type,
cardCount: cmd.cards.length,
ctype: cmd.ctype
}
);
return this.createDeck(cmd);
}
// 3. Check deck count limits for regular users
const userDeckCount = await this.deckRepo.countActiveByUserId(cmd.userid);
const maxDecks = user.state === UserState.VERIFIED_PREMIUM ? 12 : 8;
if (userDeckCount >= maxDecks) {
throw new Error(`Deck limit exceeded. Maximum ${maxDecks} decks allowed for your account type.`);
}
// 4. Organizational deck restrictions
if (cmd.ctype === CType.ORGANIZATION) {
// Only premium users can create organizational decks
if (user.state !== UserState.VERIFIED_PREMIUM) {
throw new Error('Only premium users can create organizational decks.');
}
// User must belong to an organization
if (!user.orgid) {
throw new Error('You must be a member of an organization to create organizational decks.');
}
// Check organization limits
const org = await this.orgRepo.findById(user.orgid);
if (!org) {
throw new Error('Organization not found.');
}
if (org.maxOrganizationalDecks === null) {
throw new Error('Organization deck limit not configured. Contact administrator.');
}
const userOrgDeckCount = await this.deckRepo.countOrganizationalByUserId(cmd.userid);
if (userOrgDeckCount >= org.maxOrganizationalDecks) {
throw new Error(`Organization deck limit exceeded. Maximum ${org.maxOrganizationalDecks} organizational decks allowed.`);
}
}
// 5. Create deck with restrictions passed
return this.createDeck(cmd);
} catch (error) {
if (error instanceof Error) {
throw error; // Re-throw known errors with original message
}
throw new Error('Failed to create deck');
}
}
/**
* Private method to create deck after all validations
*/
private async createDeck(cmd: CreateDeckCommand): Promise<ShortDeckDto> {
const deck = new DeckAggregate();
deck.name = cmd.name;
deck.type = cmd.type;
deck.userid = cmd.userid;
deck.cards = cmd.cards;
deck.ctype = cmd.ctype ?? CType.PUBLIC;
deck.state = State.ACTIVE;
// Set organization reference for organizational decks
if (cmd.ctype === CType.ORGANIZATION) {
const user = await this.userRepo.findById(cmd.userid);
if (user?.orgid) {
const org = await this.orgRepo.findById(user.orgid);
if (org) {
deck.organization = org;
}
}
}
const created = await this.deckRepo.create(deck);
logRequest('Deck created successfully', undefined, undefined, {
deckId: created.id,
userId: cmd.userid,
deckName: cmd.name,
deckType: cmd.type,
ctype: cmd.ctype,
cardCount: cmd.cards.length
});
return DeckMapper.toShortDto(created);
}
}
@@ -0,0 +1,4 @@
export interface DeleteDeckCommand {
id: string;
soft?: boolean;
}
@@ -0,0 +1,15 @@
import { IDeckRepository } from '../../../Domain/IRepository/IDeckRepository';
import { DeleteDeckCommand } from './DeleteDeckCommand';
export class DeleteDeckCommandHandler {
constructor(private readonly deckRepo: IDeckRepository) {}
async execute(cmd: DeleteDeckCommand): Promise<boolean> {
if (cmd.soft) {
await this.deckRepo.softDelete(cmd.id);
} else {
await this.deckRepo.delete(cmd.id);
}
return true;
}
}
@@ -0,0 +1,9 @@
export interface UpdateDeckCommand {
id: string;
name?: string;
type?: number;
userid?: string;
cards?: any[];
ctype?: number;
state?: number;
}
@@ -0,0 +1,14 @@
import { IDeckRepository } from '../../../Domain/IRepository/IDeckRepository';
import { UpdateDeckCommand } from './UpdateDeckCommand';
import { ShortDeckDto } from '../../DTOs/DeckDto';
import { DeckMapper } from '../../DTOs/Mappers/DeckMapper';
export class UpdateDeckCommandHandler {
constructor(private readonly deckRepo: IDeckRepository) {}
async execute(cmd: UpdateDeckCommand): Promise<ShortDeckDto | null> {
const updated = await this.deckRepo.update(cmd.id, { ...cmd });
if (!updated) return null;
return DeckMapper.toShortDto(updated);
}
}
@@ -0,0 +1,3 @@
export interface GetDeckByIdQuery {
id: string;
}
@@ -0,0 +1,14 @@
import { IDeckRepository } from '../../../Domain/IRepository/IDeckRepository';
import { GetDeckByIdQuery } from './GetDeckByIdQuery';
import { ShortDeckDto } from '../../DTOs/DeckDto';
import { DeckMapper } from '../../DTOs/Mappers/DeckMapper';
export class GetDeckByIdQueryHandler {
constructor(private readonly deckRepo: IDeckRepository) {}
async execute(query: GetDeckByIdQuery): Promise<ShortDeckDto | null> {
const deck = await this.deckRepo.findById(query.id);
if (!deck) return null;
return DeckMapper.toShortDto(deck);
}
}
@@ -0,0 +1,8 @@
export interface GetDecksByPageQuery {
from: number;
to: number;
userId: string;
userOrgId?: string;
isAdmin: boolean;
includeDeleted?: boolean;
}
@@ -0,0 +1,82 @@
import { IDeckRepository } from '../../../Domain/IRepository/IDeckRepository';
import { GetDecksByPageQuery } from './GetDecksByPageQuery';
import { ShortDeckDto } from '../../DTOs/DeckDto';
import { DeckMapper } from '../../DTOs/Mappers/DeckMapper';
import { AdminBypassService } from '../../Services/AdminBypassService';
import { logRequest, logError } from '../../Services/Logger';
export class GetDecksByPageQueryHandler {
constructor(private readonly deckRepo: IDeckRepository) {}
async execute(query: GetDecksByPageQuery): Promise<{ decks: ShortDeckDto[], totalCount: number }> {
try {
// Validate pagination parameters
if (query.from < 0 || query.to < query.from) {
throw new Error('Invalid pagination parameters');
}
const limit = query.to - query.from + 1;
if (limit > 100) {
throw new Error('Page size too large. Maximum 100 records per request');
}
// Log admin bypass if applicable
if (query.isAdmin) {
AdminBypassService.logAdminBypass(
'GET_DECKS_PAGE_BYPASS',
query.userId,
'paginated-decks',
{
from: query.from,
to: query.to,
includesDeleted: query.includeDeleted || false,
operation: 'read'
}
);
}
logRequest('Get decks by page query started', undefined, undefined, {
userId: query.userId,
userOrgId: query.userOrgId,
isAdmin: query.isAdmin,
from: query.from,
to: query.to,
includeDeleted: query.includeDeleted || false
});
// Use paginated filtered deck finding method
const result = await this.deckRepo.findFilteredDecks(
query.userId,
query.userOrgId,
query.isAdmin,
query.from,
query.to
);
logRequest('Get decks by page query completed', undefined, undefined, {
userId: query.userId,
userOrgId: query.userOrgId,
isAdmin: query.isAdmin,
from: query.from,
to: query.to,
returned: result.decks.length,
totalCount: result.totalCount,
includeDeleted: query.includeDeleted || false
});
return {
decks: DeckMapper.toShortDtoList(result.decks),
totalCount: result.totalCount
};
} catch (error) {
logError('GetDecksByPageQueryHandler error', error instanceof Error ? error : new Error(String(error)));
// Re-throw validation errors as-is
if (error instanceof Error && (error.message.includes('Invalid pagination') || error.message.includes('Page size'))) {
throw error;
}
throw new Error('Failed to retrieve decks page');
}
}
}
@@ -0,0 +1,8 @@
export interface CreateOrganizationCommand {
name: string;
contactfname: string;
contactlname: string;
contactphone: string;
contactemail: string;
url?: string;
}
@@ -0,0 +1,32 @@
import { IOrganizationRepository } from '../../../Domain/IRepository/IOrganizationRepository';
import { CreateOrganizationCommand } from './CreateOrganizationCommand';
import { ShortOrganizationDto } from '../../DTOs/OrganizationDto';
import { OrganizationAggregate, OrganizationState } from '../../../Domain/Organization/OrganizationAggregate';
import { OrganizationMapper } from '../../DTOs/Mappers/OrganizationMapper';
export class CreateOrganizationCommandHandler {
constructor(private readonly orgRepo: IOrganizationRepository) {}
async execute(cmd: CreateOrganizationCommand): Promise<ShortOrganizationDto> {
try {
const org = new OrganizationAggregate();
org.name = cmd.name;
org.contactfname = cmd.contactfname;
org.contactlname = cmd.contactlname;
org.contactphone = cmd.contactphone;
org.contactemail = cmd.contactemail;
org.url = cmd.url || null;
org.state = OrganizationState.REGISTERED;
const created = await this.orgRepo.create(org);
return OrganizationMapper.toShortDto(created);
} catch (error) {
if (error instanceof Error) {
if (error.message.includes('duplicate key value violates unique constraint')) {
throw new Error('Organization with this name or contact email already exists');
}
}
throw new Error('Failed to create organization');
}
}
}
@@ -0,0 +1,4 @@
export interface DeleteOrganizationCommand {
id: string;
soft?: boolean;
}
@@ -0,0 +1,16 @@
import { IOrganizationRepository } from '../../../Domain/IRepository/IOrganizationRepository';
import { DeleteOrganizationCommand } from './DeleteOrganizationCommand';
export class DeleteOrganizationCommandHandler {
constructor(private readonly orgRepo: IOrganizationRepository) {}
async execute(cmd: DeleteOrganizationCommand): Promise<boolean> {
if (cmd.soft) {
await this.orgRepo.softDelete(cmd.id);
} else {
await this.orgRepo.delete(cmd.id);
}
return true;
}
}
@@ -0,0 +1,6 @@
export interface ProcessOrgAuthCallbackCommand {
organizationId: string;
userId: string;
status: 'ok' | 'not_ok';
authToken?: string;
}
@@ -0,0 +1,123 @@
import { IUserRepository } from '../../../Domain/IRepository/IUserRepository';
import { IOrganizationRepository } from '../../../Domain/IRepository/IOrganizationRepository';
import { ProcessOrgAuthCallbackCommand } from './ProcessOrgAuthCallbackCommand';
import { logAuth, logDatabase, logError, logWarning } from '../../Services/Logger';
export interface ProcessOrgAuthCallbackResponse {
success: boolean;
message: string;
updatedFields?: string[];
}
export class ProcessOrgAuthCallbackCommandHandler {
constructor(
private readonly userRepo: IUserRepository,
private readonly orgRepo: IOrganizationRepository
) {}
async execute(cmd: ProcessOrgAuthCallbackCommand): Promise<ProcessOrgAuthCallbackResponse> {
const startTime = Date.now();
try {
logAuth('Processing organization authentication callback', cmd.userId, {
organizationId: cmd.organizationId,
status: cmd.status,
hasAuthToken: !!cmd.authToken
});
// Verify organization exists
const organization = await this.orgRepo.findById(cmd.organizationId);
if (!organization) {
logWarning('Organization not found for auth callback', {
organizationId: cmd.organizationId,
userId: cmd.userId
});
return {
success: false,
message: 'Organization not found'
};
}
// Verify user exists
const user = await this.userRepo.findById(cmd.userId);
if (!user) {
logWarning('User not found for auth callback', {
organizationId: cmd.organizationId,
userId: cmd.userId
});
return {
success: false,
message: 'User not found'
};
}
// Verify user belongs to the organization
if (user.orgid !== cmd.organizationId) {
logWarning('User does not belong to organization for auth callback', {
organizationId: cmd.organizationId,
userId: cmd.userId,
userOrgId: user.orgid
});
return {
success: false,
message: 'User does not belong to this organization'
};
}
if (cmd.status === 'not_ok') {
logAuth('Organization authentication failed', cmd.userId, {
organizationId: cmd.organizationId,
organizationName: organization.name
});
return {
success: false,
message: 'Organization authentication failed'
};
}
// Update user's organization login date
const now = new Date();
const updatedUser = await this.userRepo.update(cmd.userId, {
Orglogindate: now
});
if (!updatedUser) {
logError('Failed to update user organization login date', new Error('User update returned null'));
return {
success: false,
message: 'Failed to update user login information'
};
}
logAuth('Organization authentication successful', cmd.userId, {
organizationId: cmd.organizationId,
organizationName: organization.name,
orgLoginDate: now.toISOString(),
executionTime: Date.now() - startTime
});
logDatabase('User organization login date updated',
`userId: ${cmd.userId}, orgId: ${cmd.organizationId}`,
Date.now() - startTime,
{
userId: cmd.userId,
organizationId: cmd.organizationId,
newOrgLoginDate: now.toISOString()
}
);
return {
success: true,
message: 'Organization authentication successful',
updatedFields: ['Orglogindate']
};
} catch (error) {
logError('ProcessOrgAuthCallbackCommandHandler error', error as Error);
return {
success: false,
message: 'Internal error processing authentication callback'
};
}
}
}
@@ -0,0 +1,14 @@
import { OrganizationStateType } from '../../../Domain/Organization/OrganizationAggregate';
export interface UpdateOrganizationCommand {
id: string;
name?: string;
contactfname?: string;
contactlname?: string;
contactphone?: string;
contactemail?: string;
url?: string;
state?: OrganizationStateType;
userinorg?: number;
maxOrganizationalDecks?: number | null;
}
@@ -0,0 +1,15 @@
import { IOrganizationRepository } from '../../../Domain/IRepository/IOrganizationRepository';
import { UpdateOrganizationCommand } from './UpdateOrganizationCommand';
import { ShortOrganizationDto } from '../../DTOs/OrganizationDto';
import { OrganizationMapper } from '../../DTOs/Mappers/OrganizationMapper';
export class UpdateOrganizationCommandHandler {
constructor(private readonly orgRepo: IOrganizationRepository) {}
async execute(cmd: UpdateOrganizationCommand): Promise<ShortOrganizationDto | null> {
const updated = await this.orgRepo.update(cmd.id, { ...cmd });
if (!updated) return null;
return OrganizationMapper.toShortDto(updated);
}
}
@@ -0,0 +1,3 @@
export interface GetOrganizationByIdQuery {
id: string;
}
@@ -0,0 +1,15 @@
import { IOrganizationRepository } from '../../../Domain/IRepository/IOrganizationRepository';
import { GetOrganizationByIdQuery } from './GetOrganizationByIdQuery';
import { ShortOrganizationDto } from '../../DTOs/OrganizationDto';
import { OrganizationMapper } from '../../DTOs/Mappers/OrganizationMapper';
export class GetOrganizationByIdQueryHandler {
constructor(private readonly orgRepo: IOrganizationRepository) {}
async execute(query: GetOrganizationByIdQuery): Promise<ShortOrganizationDto | null> {
const org = await this.orgRepo.findById(query.id);
if (!org) return null;
return OrganizationMapper.toShortDto(org);
}
}
@@ -0,0 +1,3 @@
export interface GetOrganizationLoginUrlQuery {
organizationId: string;
}
@@ -0,0 +1,56 @@
import { IOrganizationRepository } from '../../../Domain/IRepository/IOrganizationRepository';
import { GetOrganizationLoginUrlQuery } from './GetOrganizationLoginUrlQuery';
import { OrganizationLoginUrlDto } from '../../DTOs/OrganizationDto';
import { logDatabase, logError, logWarning } from '../../Services/Logger';
export class GetOrganizationLoginUrlQueryHandler {
constructor(private readonly orgRepo: IOrganizationRepository) {}
async execute(query: GetOrganizationLoginUrlQuery): Promise<OrganizationLoginUrlDto | null> {
const startTime = Date.now();
try {
logDatabase('Getting organization login URL', `organizationId: ${query.organizationId}`, 0, {
organizationId: query.organizationId
});
const organization = await this.orgRepo.findById(query.organizationId);
if (!organization) {
logWarning('Organization not found for login URL request', {
organizationId: query.organizationId
});
return null;
}
if (!organization.url) {
logWarning('Organization has no configured login URL', {
organizationId: query.organizationId,
organizationName: organization.name
});
return null;
}
const result: OrganizationLoginUrlDto = {
organizationId: organization.id,
organizationName: organization.name,
loginUrl: organization.url
};
logDatabase('Organization login URL retrieved successfully',
`organizationId: ${query.organizationId}`,
Date.now() - startTime,
{
organizationId: organization.id,
organizationName: organization.name,
hasUrl: !!organization.url
}
);
return result;
} catch (error) {
logError('GetOrganizationLoginUrlQueryHandler error', error as Error);
throw new Error('Failed to retrieve organization login URL');
}
}
}
@@ -0,0 +1,5 @@
export interface GetOrganizationsByPageQuery {
from: number;
to: number;
includeDeleted?: boolean;
}
@@ -0,0 +1,60 @@
import { IOrganizationRepository } from '../../../Domain/IRepository/IOrganizationRepository';
import { GetOrganizationsByPageQuery } from './GetOrganizationsByPageQuery';
import { ShortOrganizationDto } from '../../DTOs/OrganizationDto';
import { OrganizationMapper } from '../../DTOs/Mappers/OrganizationMapper';
import { logError, logRequest } from '../../Services/Logger';
export class GetOrganizationsByPageQueryHandler {
constructor(private readonly orgRepo: IOrganizationRepository) {}
async execute(query: GetOrganizationsByPageQuery): Promise<{ organizations: ShortOrganizationDto[], totalCount: number }> {
try {
// Validate pagination parameters
if (query.from < 0 || query.to < query.from) {
throw new Error('Invalid pagination parameters');
}
const limit = query.to - query.from + 1;
if (limit > 100) {
throw new Error('Page size too large. Maximum 100 records per request');
}
logRequest('Get organizations by page query started', undefined, undefined, {
from: query.from,
to: query.to,
includeDeleted: query.includeDeleted || false
});
const result = query.includeDeleted
? await this.orgRepo.findByPageIncludingDeleted(query.from, query.to)
: await this.orgRepo.findByPage(query.from, query.to);
logRequest('Get organizations by page query completed', undefined, undefined, {
from: query.from,
to: query.to,
returned: result.organizations.length,
totalCount: result.totalCount,
includeDeleted: query.includeDeleted || false
});
return {
organizations: OrganizationMapper.toShortDtoList(result.organizations),
totalCount: result.totalCount
};
} catch (error) {
logError('GetOrganizationsByPageQueryHandler error', error instanceof Error ? error : new Error(String(error)));
// Handle database errors
if (error instanceof Error && error.message.includes('database')) {
throw new Error('Database connection error');
}
// Re-throw validation errors as-is
if (error instanceof Error && (error.message.includes('Invalid pagination') || error.message.includes('Page size'))) {
throw error;
}
throw new Error('Failed to retrieve organizations');
}
}
}
@@ -0,0 +1,144 @@
import { IUserRepository } from '../../Domain/IRepository/IUserRepository';
import { IOrganizationRepository } from '../../Domain/IRepository/IOrganizationRepository';
import { IDeckRepository } from '../../Domain/IRepository/IDeckRepository';
import { SearchQuery, SearchResult } from '../DTOs/SearchDto';
import { ShortUserDto, DetailUserDto } from '../DTOs/UserDto';
import { ShortOrganizationDto, DetailOrganizationDto } from '../DTOs/OrganizationDto';
import { ShortDeckDto, DetailDeckDto } from '../DTOs/DeckDto';
import { UserMapper } from '../DTOs/Mappers/UserMapper';
import { OrganizationMapper } from '../DTOs/Mappers/OrganizationMapper';
import { DeckMapper } from '../DTOs/Mappers/DeckMapper';
export type SearchType = 'users' | 'organizations' | 'decks';
export interface IGeneralSearchService {
searchUsers(searchQuery: SearchQuery): Promise<SearchResult<ShortUserDto>>;
searchOrganizations(searchQuery: SearchQuery): Promise<SearchResult<ShortOrganizationDto>>;
searchDecks(searchQuery: SearchQuery): Promise<SearchResult<ShortDeckDto>>;
searchByType(searchType: SearchType, searchQuery: SearchQuery): Promise<SearchResult<ShortUserDto | ShortOrganizationDto | ShortDeckDto>>;
}
export class GeneralSearchService implements IGeneralSearchService {
constructor(
private userRepo: IUserRepository,
private organizationRepo: IOrganizationRepository,
private deckRepo: IDeckRepository
) {}
static getSearchTypeFromUrl(url: string): SearchType {
if (url.includes('/users/') || url.includes('/api/users/')) {
return 'users';
} else if (url.includes('/organizations/') || url.includes('/api/organizations/')) {
return 'organizations';
} else if (url.includes('/decks/') || url.includes('/api/decks/')) {
return 'decks';
}
return 'users';
}
async searchUsers(searchQuery: SearchQuery): Promise<SearchResult<ShortUserDto>> {
const { query, limit = 20, offset = 0 } = searchQuery;
if (!query || query.trim().length === 0) {
return {
results: [],
totalCount: 0,
hasMore: false,
searchQuery: query,
searchType: 'users'
};
}
try {
const { users, totalCount } = await this.userRepo.search(query.trim(), limit, offset);
const results = users.map(user => UserMapper.toShortDto(user));
const hasMore = (offset + limit) < totalCount;
return {
results,
totalCount,
hasMore,
searchQuery: query,
searchType: 'users'
};
} catch (error) {
throw new Error('Failed to search users');
}
}
async searchOrganizations(searchQuery: SearchQuery): Promise<SearchResult<ShortOrganizationDto>> {
const { query, limit = 20, offset = 0 } = searchQuery;
if (!query || query.trim().length === 0) {
return {
results: [],
totalCount: 0,
hasMore: false,
searchQuery: query,
searchType: 'organizations'
};
}
const { organizations, totalCount } = await this.organizationRepo.search(query.trim(), limit, offset);
const results = organizations.map(org => OrganizationMapper.toShortDto(org));
const hasMore = (offset + limit) < totalCount;
return {
results,
totalCount,
hasMore,
searchQuery: query,
searchType: 'organizations'
};
}
async searchDecks(searchQuery: SearchQuery): Promise<SearchResult<ShortDeckDto>> {
const { query, limit = 20, offset = 0 } = searchQuery;
if (!query || query.trim().length === 0) {
return {
results: [],
totalCount: 0,
hasMore: false,
searchQuery: query,
searchType: 'decks'
};
}
const { decks, totalCount } = await this.deckRepo.search(query.trim(), limit, offset);
const results = decks.map(deck => DeckMapper.toShortDto(deck));
const hasMore = (offset + limit) < totalCount;
return {
results,
totalCount,
hasMore,
searchQuery: query,
searchType: 'decks'
};
}
async searchByType(
searchType: SearchType,
searchQuery: SearchQuery
): Promise<SearchResult<ShortUserDto | ShortOrganizationDto | ShortDeckDto>> {
switch (searchType) {
case 'users':
return await this.searchUsers(searchQuery) as SearchResult<ShortUserDto | ShortOrganizationDto | ShortDeckDto>;
case 'organizations':
return await this.searchOrganizations(searchQuery) as SearchResult<ShortUserDto | ShortOrganizationDto | ShortDeckDto>;
case 'decks':
return await this.searchDecks(searchQuery) as SearchResult<ShortUserDto | ShortOrganizationDto | ShortDeckDto>;
default:
throw new Error(`Unsupported search type: ${searchType}`);
}
}
async searchFromUrl(
url: string,
searchQuery: SearchQuery
): Promise<SearchResult<ShortUserDto | ShortOrganizationDto | ShortDeckDto>> {
const searchType = GeneralSearchService.getSearchTypeFromUrl(url);
return await this.searchByType(searchType, searchQuery);
}
}
@@ -0,0 +1,131 @@
import { UserState } from '../../Domain/User/UserAggregate';
import { logAuth } from './Logger';
import { Request, Response } from 'express';
/**
* Admin Bypass Service - Centralized admin privilege checking and logging
*/
export class AdminBypassService {
/**
* Check if user has admin privileges
* @param userState - User's current state
* @returns true if user is admin
*/
static isAdmin(userState: UserState): boolean {
return userState === UserState.ADMIN;
}
/**
* Check if user should bypass all restrictions
* @param userState - User's current state
* @returns true if restrictions should be bypassed
*/
static shouldBypassRestrictions(userState: UserState): boolean {
return this.isAdmin(userState);
}
/**
* Log admin bypass action for audit trail
* @param action - Description of the action being bypassed
* @param adminUserId - ID of the admin user
* @param targetId - ID of the target resource
* @param details - Additional details about the bypass
* @param req - Optional request object for context
* @param res - Optional response object for context
*/
static logAdminBypass(
action: string,
adminUserId: string,
targetId: string,
details?: any,
req?: Request,
res?: Response
): void {
logAuth(`ADMIN_BYPASS: ${action}`, adminUserId, {
targetId,
action,
bypassReason: 'Admin privileges',
timestamp: new Date().toISOString(),
...details
}, req, res);
}
}
/**
* Admin Audit Service - Enhanced logging for all admin actions
*/
export class AdminAuditService {
/**
* Log comprehensive admin action for audit trail
* @param action - Action being performed
* @param adminUserId - ID of the admin user
* @param details - Detailed information about the action
* @param req - Request object for context
* @param res - Response object for context
*/
static logAdminAction(
action: string,
adminUserId: string,
details: {
targetType: 'user' | 'organization' | 'deck' | 'contact' | 'chat';
targetId: string;
operation: 'create' | 'read' | 'update' | 'delete' | 'bypass' | 'export' | 'import';
changes?: any;
sensitive?: boolean;
metadata?: any;
},
req?: Request,
res?: Response
): void {
const auditData = {
timestamp: new Date().toISOString(),
adminUserId,
action,
...details,
ip: req?.ip,
userAgent: req?.get('User-Agent'),
endpoint: req?.path,
method: req?.method,
requestId: req?.headers['x-request-id'] || 'unknown'
};
// Enhanced logging for admin actions
logAuth(`ADMIN_AUDIT: ${action}`, adminUserId, auditData, req, res);
// Additional security logging for sensitive operations
if (details.sensitive) {
logAuth(`ADMIN_SENSITIVE: ${action}`, adminUserId, {
...auditData,
alertLevel: 'HIGH',
requiresReview: true
}, req, res);
}
}
/**
* Log bulk admin operations
* @param action - Bulk action being performed
* @param adminUserId - ID of the admin user
* @param affectedCount - Number of resources affected
* @param targetType - Type of resources affected
* @param req - Request object for context
* @param res - Response object for context
*/
static logBulkAdminAction(
action: string,
adminUserId: string,
affectedCount: number,
targetType: string,
req?: Request,
res?: Response
): void {
this.logAdminAction(`BULK_${action}`, adminUserId, {
targetType: targetType as any,
targetId: `bulk-${affectedCount}-items`,
operation: 'update' as any,
metadata: { affectedCount },
sensitive: affectedCount > 10 // Mark large bulk operations as sensitive
}, req, res);
}
}
@@ -0,0 +1,57 @@
import { Request, Response, NextFunction } from 'express';
import { JWTService } from './JWTService';
import { logAuth, logWarning } from './Logger';
export const jwtService = new JWTService();
export function authRequired(req: Request, res: Response, next: NextFunction) {
const payload = jwtService.verify(req);
if (!payload) {
logAuth('Authentication failed - No valid token', undefined, {
ip: req.ip,
userAgent: req.get ? req.get('User-Agent') : 'unknown',
path: req.path
}, req);
return res.status(401).json({ error: 'Unauthorized' });
}
logAuth('Authentication successful', payload.userId, {
authLevel: payload.authLevel,
orgId: payload.orgId
}, req);
const refreshed = jwtService.refreshIfNeeded(payload, res);
if (refreshed) {
logAuth('Token refreshed', payload.userId, undefined, req);
}
(req as any).user = payload;
next();
}
export function adminRequired(req: Request, res: Response, next: NextFunction) {
const payload = jwtService.verify(req);
if (!payload || payload.authLevel !== 1) {
logWarning('Admin access denied', {
hasPayload: !!payload,
authLevel: payload?.authLevel,
userId: payload?.userId,
ip: req.ip,
path: req.path
}, req);
return res.status(403).json({ error: 'Forbidden' });
}
logAuth('Admin authentication successful', payload.userId, {
authLevel: payload.authLevel,
orgId: payload.orgId
}, req);
const refreshed = jwtService.refreshIfNeeded(payload, res);
if (refreshed) {
logAuth('Admin token refreshed', payload.userId, undefined, req);
}
(req as any).user = payload;
next();
}
@@ -0,0 +1,134 @@
import { IContactRepository } from '../../Domain/IRepository/IContactRepository';
import { EmailService } from './EmailService';
import { ContactType } from '../../Domain/Contact/ContactAggregate';
import { logOther, logError } from './Logger';
import { EmailTemplateHelper, LocalizedSubjects } from './EmailTemplateHelper';
export interface EmailResponseData {
to: string;
message: string;
contactId: string;
adminUserId: string;
contactName: string;
contactType: ContactType;
originalMessage: string;
language?: 'en' | 'hu' | 'de'; // Default to 'en' if not specified
}
export class ContactEmailService {
private emailService: EmailService;
constructor(private readonly contactRepo: IContactRepository) {
this.emailService = new EmailService();
}
async sendResponse(responseData: EmailResponseData): Promise<void> {
try {
// First update the contact with the response
await this.contactRepo.update(responseData.contactId, {
adminResponse: responseData.message,
responseDate: new Date(),
respondedBy: responseData.adminUserId,
});
// Determine language and template
const language = responseData.language || 'en';
const templateName = language === 'en' ? 'contact-response' : `contact-response-${language}`;
// Prepare template data
const templateData = {
contactName: responseData.contactName,
contactTypeString: this.getContactTypeString(responseData.contactType, language),
contactTypeBadge: this.getContactTypeBadge(responseData.contactType),
originalMessage: responseData.originalMessage,
adminResponse: responseData.message,
companyName: 'SerpentRace',
supportEmail: 'support@serpentrace.com'
};
// Send email using EmailService with template
const emailSent = await this.emailService.sendEmail({
to: responseData.to,
subject: this.getLocalizedContactResponseSubject(language),
template: templateName,
templateData
});
if (emailSent) {
logOther('Contact response email sent successfully', {
to: responseData.to,
subject: this.getLocalizedContactResponseSubject(language),
contactId: responseData.contactId,
respondedBy: responseData.adminUserId,
language
});
} else {
throw new Error('Email service failed to send email');
}
} catch (error) {
logError('Failed to send contact response email', error instanceof Error ? error : new Error(String(error)));
throw new Error('Failed to send email response');
}
}
private getLocalizedContactResponseSubject(language: 'en' | 'hu' | 'de'): string {
const subjects: LocalizedSubjects = {
contactResponse: {
en: 'SerpentRace - Response to Your Message',
hu: 'SerpentRace - Válasz az üzenetére',
de: 'SerpentRace - Antwort auf Ihre Nachricht'
}
};
return EmailTemplateHelper.getLocalizedSubject('contactResponse', subjects, language);
}
private getContactTypeString(type: ContactType, language: 'en' | 'hu' | 'de' = 'en'): string {
const translations = {
[ContactType.BUG]: {
en: 'Bug Report',
hu: 'Hiba bejelentés',
de: 'Fehlerbericht'
},
[ContactType.PROBLEM]: {
en: 'Problem',
hu: 'Probléma',
de: 'Problem'
},
[ContactType.QUESTION]: {
en: 'Question',
hu: 'Kérdés',
de: 'Frage'
},
[ContactType.SALES]: {
en: 'Sales Inquiry',
hu: 'Értékesítési kérdés',
de: 'Verkaufsanfrage'
},
[ContactType.OTHER]: {
en: 'General Inquiry',
hu: 'Általános kérdés',
de: 'Allgemeine Anfrage'
}
};
return translations[type]?.[language] || translations[type]?.['en'] || 'Contact';
}
private getContactTypeBadge(type: ContactType): string {
switch (type) {
case ContactType.BUG:
return 'bug';
case ContactType.PROBLEM:
return 'problem';
case ContactType.QUESTION:
return 'question';
case ContactType.SALES:
return 'sales';
case ContactType.OTHER:
return 'other';
default:
return 'other';
}
}
}
@@ -0,0 +1,446 @@
// Repository Interfaces
import { IUserRepository } from '../../Domain/IRepository/IUserRepository';
import { IChatRepository } from '../../Domain/IRepository/IChatRepository';
import { IChatArchiveRepository } from '../../Domain/IRepository/IChatArchiveRepository';
import { IDeckRepository } from '../../Domain/IRepository/IDeckRepository';
import { IOrganizationRepository } from '../../Domain/IRepository/IOrganizationRepository';
import { IContactRepository } from '../../Domain/IRepository/IContactRepository';
// Repository Implementations
import { UserRepository } from '../../Infrastructure/Repository/UserRepository';
import { ChatRepository } from '../../Infrastructure/Repository/ChatRepository';
import { ChatArchiveRepository } from '../../Infrastructure/Repository/ChatArchiveRepository';
import { DeckRepository } from '../../Infrastructure/Repository/DeckRepository';
import { OrganizationRepository } from '../../Infrastructure/Repository/OrganizationRepository';
import { ContactRepository } from '../../Infrastructure/Repository/ContactRepository';
// Command Handlers
import { CreateUserCommandHandler } from '../User/commands/CreateUserCommandHandler';
import { LoginCommandHandler } from '../User/commands/LoginCommandHandler';
import { UpdateUserCommandHandler } from '../User/commands/UpdateUserCommandHandler';
import { DeactivateUserCommandHandler } from '../User/commands/DeactivateUserCommandHandler';
import { DeleteUserCommandHandler } from '../User/commands/DeleteUserCommandHandler';
import { VerifyEmailCommandHandler } from '../User/commands/VerifyEmailCommandHandler';
import { RequestPasswordResetCommandHandler } from '../User/commands/RequestPasswordResetCommandHandler';
import { ResetPasswordCommandHandler } from '../User/commands/ResetPasswordCommandHandler';
import { CreateChatCommandHandler } from '../Chat/commands/CreateChatCommandHandler';
import { SendMessageCommandHandler } from '../Chat/commands/SendMessageCommandHandler';
import { ArchiveChatCommandHandler, RestoreChatCommandHandler } from '../Chat/commands/ChatArchiveCommandHandlers';
import { CreateDeckCommandHandler } from '../Deck/commands/CreateDeckCommandHandler';
import { UpdateDeckCommandHandler } from '../Deck/commands/UpdateDeckCommandHandler';
import { DeleteDeckCommandHandler } from '../Deck/commands/DeleteDeckCommandHandler';
import { CreateOrganizationCommandHandler } from '../Organization/commands/CreateOrganizationCommandHandler';
import { UpdateOrganizationCommandHandler } from '../Organization/commands/UpdateOrganizationCommandHandler';
import { DeleteOrganizationCommandHandler } from '../Organization/commands/DeleteOrganizationCommandHandler';
import { ProcessOrgAuthCallbackCommandHandler } from '../Organization/commands/ProcessOrgAuthCallbackCommandHandler';
import { CreateContactCommandHandler } from '../Contact/commands/CreateContactCommandHandler';
import { UpdateContactCommandHandler } from '../Contact/commands/UpdateContactCommandHandler';
import { DeleteContactCommandHandler } from '../Contact/commands/DeleteContactCommandHandler';
// Query Handlers
import { GetUserByIdQueryHandler } from '../User/queries/GetUserByIdQueryHandler';
import { GetUsersByPageQueryHandler } from '../User/queries/GetUsersByPageQueryHandler';
import { GetUserChatsQueryHandler } from '../Chat/queries/GetUserChatsQueryHandler';
import { GetChatHistoryQueryHandler, GetArchivedChatsQueryHandler } from '../Chat/queries/ChatHistoryQueryHandlers';
import { GetChatsByPageQueryHandler } from '../Chat/queries/GetChatsByPageQueryHandler';
import { GetDeckByIdQueryHandler } from '../Deck/queries/GetDeckByIdQueryHandler';
import { GetDecksByPageQueryHandler } from '../Deck/queries/GetDecksByPageQueryHandler';
import { GetOrganizationByIdQueryHandler } from '../Organization/queries/GetOrganizationByIdQueryHandler';
import { GetOrganizationsByPageQueryHandler } from '../Organization/queries/GetOrganizationsByPageQueryHandler';
import { GetOrganizationLoginUrlQueryHandler } from '../Organization/queries/GetOrganizationLoginUrlQueryHandler';
import { GetContactByIdQueryHandler } from '../Contact/queries/GetContactByIdQueryHandler';
import { GetContactsByPageQueryHandler } from '../Contact/queries/GetContactsByPageQueryHandler';
// Services
import { JWTService } from './JWTService';
import { ContactEmailService } from './ContactEmailService';
import { DeckImportExportService } from './DeckImportExportService';
/**
* Central Dependency Injection Container
* Manages all repositories, command handlers, and query handlers as singletons
*/
export class DIContainer {
private static instance: DIContainer;
// Repositories - Using interfaces for better abstraction
private _userRepository: IUserRepository | null = null;
private _chatRepository: IChatRepository | null = null;
private _chatArchiveRepository: IChatArchiveRepository | null = null;
private _deckRepository: IDeckRepository | null = null;
private _organizationRepository: IOrganizationRepository | null = null;
private _contactRepository: IContactRepository | null = null;
// Services
private _jwtService: JWTService | null = null;
private _contactEmailService: ContactEmailService | null = null;
private _deckImportExportService: DeckImportExportService | null = null;
// Command Handlers
private _createUserCommandHandler: CreateUserCommandHandler | null = null;
private _loginCommandHandler: LoginCommandHandler | null = null;
private _updateUserCommandHandler: UpdateUserCommandHandler | null = null;
private _deactivateUserCommandHandler: DeactivateUserCommandHandler | null = null;
private _deleteUserCommandHandler: DeleteUserCommandHandler | null = null;
private _verifyEmailCommandHandler: VerifyEmailCommandHandler | null = null;
private _requestPasswordResetCommandHandler: RequestPasswordResetCommandHandler | null = null;
private _resetPasswordCommandHandler: ResetPasswordCommandHandler | null = null;
private _createChatCommandHandler: CreateChatCommandHandler | null = null;
private _sendMessageCommandHandler: SendMessageCommandHandler | null = null;
private _archiveChatCommandHandler: ArchiveChatCommandHandler | null = null;
private _restoreChatCommandHandler: RestoreChatCommandHandler | null = null;
private _createDeckCommandHandler: CreateDeckCommandHandler | null = null;
private _updateDeckCommandHandler: UpdateDeckCommandHandler | null = null;
private _deleteDeckCommandHandler: DeleteDeckCommandHandler | null = null;
private _createOrganizationCommandHandler: CreateOrganizationCommandHandler | null = null;
private _updateOrganizationCommandHandler: UpdateOrganizationCommandHandler | null = null;
private _deleteOrganizationCommandHandler: DeleteOrganizationCommandHandler | null = null;
private _processOrgAuthCallbackCommandHandler: ProcessOrgAuthCallbackCommandHandler | null = null;
private _createContactCommandHandler: CreateContactCommandHandler | null = null;
private _updateContactCommandHandler: UpdateContactCommandHandler | null = null;
private _deleteContactCommandHandler: DeleteContactCommandHandler | null = null;
// Query Handlers
private _getUserByIdQueryHandler: GetUserByIdQueryHandler | null = null;
private _getUsersByPageQueryHandler: GetUsersByPageQueryHandler | null = null;
private _getUserChatsQueryHandler: GetUserChatsQueryHandler | null = null;
private _getChatHistoryQueryHandler: GetChatHistoryQueryHandler | null = null;
private _getArchivedChatsQueryHandler: GetArchivedChatsQueryHandler | null = null;
private _getChatsByPageQueryHandler: GetChatsByPageQueryHandler | null = null;
private _getDeckByIdQueryHandler: GetDeckByIdQueryHandler | null = null;
private _getDecksByPageQueryHandler: GetDecksByPageQueryHandler | null = null;
private _getOrganizationByIdQueryHandler: GetOrganizationByIdQueryHandler | null = null;
private _getOrganizationsByPageQueryHandler: GetOrganizationsByPageQueryHandler | null = null;
private _getOrganizationLoginUrlQueryHandler: GetOrganizationLoginUrlQueryHandler | null = null;
private _getContactByIdQueryHandler: GetContactByIdQueryHandler | null = null;
private _getContactsByPageQueryHandler: GetContactsByPageQueryHandler | null = null;
private constructor() {}
public static getInstance(): DIContainer {
if (!DIContainer.instance) {
DIContainer.instance = new DIContainer();
}
return DIContainer.instance;
}
// Repository getters - Return interfaces for better abstraction
public get userRepository(): IUserRepository {
if (!this._userRepository) {
this._userRepository = new UserRepository();
}
return this._userRepository;
}
public get chatRepository(): IChatRepository {
if (!this._chatRepository) {
this._chatRepository = new ChatRepository();
}
return this._chatRepository;
}
public get chatArchiveRepository(): IChatArchiveRepository {
if (!this._chatArchiveRepository) {
this._chatArchiveRepository = new ChatArchiveRepository();
}
return this._chatArchiveRepository;
}
public get deckRepository(): IDeckRepository {
if (!this._deckRepository) {
this._deckRepository = new DeckRepository();
}
return this._deckRepository;
}
public get organizationRepository(): IOrganizationRepository {
if (!this._organizationRepository) {
this._organizationRepository = new OrganizationRepository();
}
return this._organizationRepository;
}
public get contactRepository(): IContactRepository {
if (!this._contactRepository) {
this._contactRepository = new ContactRepository();
}
return this._contactRepository;
}
// Services getters
public get jwtService(): JWTService {
if (!this._jwtService) {
this._jwtService = new JWTService();
}
return this._jwtService;
}
public get contactEmailService(): ContactEmailService {
if (!this._contactEmailService) {
this._contactEmailService = new ContactEmailService(this.contactRepository);
}
return this._contactEmailService;
}
public get deckImportExportService(): DeckImportExportService {
if (!this._deckImportExportService) {
this._deckImportExportService = new DeckImportExportService(this.deckRepository);
}
return this._deckImportExportService;
}
// Command Handler getters
public get createUserCommandHandler(): CreateUserCommandHandler {
if (!this._createUserCommandHandler) {
this._createUserCommandHandler = new CreateUserCommandHandler(this.userRepository);
}
return this._createUserCommandHandler;
}
public get loginCommandHandler(): LoginCommandHandler {
if (!this._loginCommandHandler) {
this._loginCommandHandler = new LoginCommandHandler(this.userRepository, this.jwtService, this.organizationRepository);
}
return this._loginCommandHandler;
}
public get updateUserCommandHandler(): UpdateUserCommandHandler {
if (!this._updateUserCommandHandler) {
this._updateUserCommandHandler = new UpdateUserCommandHandler(this.userRepository);
}
return this._updateUserCommandHandler;
}
public get deactivateUserCommandHandler(): DeactivateUserCommandHandler {
if (!this._deactivateUserCommandHandler) {
this._deactivateUserCommandHandler = new DeactivateUserCommandHandler(this.userRepository);
}
return this._deactivateUserCommandHandler;
}
public get deleteUserCommandHandler(): DeleteUserCommandHandler {
if (!this._deleteUserCommandHandler) {
this._deleteUserCommandHandler = new DeleteUserCommandHandler(this.userRepository);
}
return this._deleteUserCommandHandler;
}
public get verifyEmailCommandHandler(): VerifyEmailCommandHandler {
if (!this._verifyEmailCommandHandler) {
this._verifyEmailCommandHandler = new VerifyEmailCommandHandler(this.userRepository);
}
return this._verifyEmailCommandHandler;
}
public get requestPasswordResetCommandHandler(): RequestPasswordResetCommandHandler {
if (!this._requestPasswordResetCommandHandler) {
this._requestPasswordResetCommandHandler = new RequestPasswordResetCommandHandler(this.userRepository);
}
return this._requestPasswordResetCommandHandler;
}
public get resetPasswordCommandHandler(): ResetPasswordCommandHandler {
if (!this._resetPasswordCommandHandler) {
this._resetPasswordCommandHandler = new ResetPasswordCommandHandler(this.userRepository);
}
return this._resetPasswordCommandHandler;
}
public get createChatCommandHandler(): CreateChatCommandHandler {
if (!this._createChatCommandHandler) {
this._createChatCommandHandler = new CreateChatCommandHandler(this.chatRepository, this.userRepository);
}
return this._createChatCommandHandler;
}
public get sendMessageCommandHandler(): SendMessageCommandHandler {
if (!this._sendMessageCommandHandler) {
this._sendMessageCommandHandler = new SendMessageCommandHandler(this.chatRepository);
}
return this._sendMessageCommandHandler;
}
public get archiveChatCommandHandler(): ArchiveChatCommandHandler {
if (!this._archiveChatCommandHandler) {
this._archiveChatCommandHandler = new ArchiveChatCommandHandler(this.chatRepository);
}
return this._archiveChatCommandHandler;
}
public get restoreChatCommandHandler(): RestoreChatCommandHandler {
if (!this._restoreChatCommandHandler) {
this._restoreChatCommandHandler = new RestoreChatCommandHandler(this.chatRepository);
}
return this._restoreChatCommandHandler;
}
public get createDeckCommandHandler(): CreateDeckCommandHandler {
if (!this._createDeckCommandHandler) {
this._createDeckCommandHandler = new CreateDeckCommandHandler(
this.deckRepository,
this.userRepository,
this.organizationRepository
);
}
return this._createDeckCommandHandler;
}
public get updateDeckCommandHandler(): UpdateDeckCommandHandler {
if (!this._updateDeckCommandHandler) {
this._updateDeckCommandHandler = new UpdateDeckCommandHandler(this.deckRepository);
}
return this._updateDeckCommandHandler;
}
public get deleteDeckCommandHandler(): DeleteDeckCommandHandler {
if (!this._deleteDeckCommandHandler) {
this._deleteDeckCommandHandler = new DeleteDeckCommandHandler(this.deckRepository);
}
return this._deleteDeckCommandHandler;
}
public get createOrganizationCommandHandler(): CreateOrganizationCommandHandler {
if (!this._createOrganizationCommandHandler) {
this._createOrganizationCommandHandler = new CreateOrganizationCommandHandler(this.organizationRepository);
}
return this._createOrganizationCommandHandler;
}
public get updateOrganizationCommandHandler(): UpdateOrganizationCommandHandler {
if (!this._updateOrganizationCommandHandler) {
this._updateOrganizationCommandHandler = new UpdateOrganizationCommandHandler(this.organizationRepository);
}
return this._updateOrganizationCommandHandler;
}
public get deleteOrganizationCommandHandler(): DeleteOrganizationCommandHandler {
if (!this._deleteOrganizationCommandHandler) {
this._deleteOrganizationCommandHandler = new DeleteOrganizationCommandHandler(this.organizationRepository);
}
return this._deleteOrganizationCommandHandler;
}
public get processOrgAuthCallbackCommandHandler(): ProcessOrgAuthCallbackCommandHandler {
if (!this._processOrgAuthCallbackCommandHandler) {
this._processOrgAuthCallbackCommandHandler = new ProcessOrgAuthCallbackCommandHandler(this.userRepository, this.organizationRepository);
}
return this._processOrgAuthCallbackCommandHandler;
}
public get createContactCommandHandler(): CreateContactCommandHandler {
if (!this._createContactCommandHandler) {
this._createContactCommandHandler = new CreateContactCommandHandler(this.contactRepository);
}
return this._createContactCommandHandler;
}
public get updateContactCommandHandler(): UpdateContactCommandHandler {
if (!this._updateContactCommandHandler) {
this._updateContactCommandHandler = new UpdateContactCommandHandler(this.contactRepository);
}
return this._updateContactCommandHandler;
}
public get deleteContactCommandHandler(): DeleteContactCommandHandler {
if (!this._deleteContactCommandHandler) {
this._deleteContactCommandHandler = new DeleteContactCommandHandler(this.contactRepository);
}
return this._deleteContactCommandHandler;
}
// Query Handler getters
public get getUserByIdQueryHandler(): GetUserByIdQueryHandler {
if (!this._getUserByIdQueryHandler) {
this._getUserByIdQueryHandler = new GetUserByIdQueryHandler(this.userRepository);
}
return this._getUserByIdQueryHandler;
}
public get getUserChatsQueryHandler(): GetUserChatsQueryHandler {
if (!this._getUserChatsQueryHandler) {
this._getUserChatsQueryHandler = new GetUserChatsQueryHandler(this.chatRepository, this.chatArchiveRepository);
}
return this._getUserChatsQueryHandler;
}
public get getChatHistoryQueryHandler(): GetChatHistoryQueryHandler {
if (!this._getChatHistoryQueryHandler) {
this._getChatHistoryQueryHandler = new GetChatHistoryQueryHandler(this.chatRepository, this.chatArchiveRepository);
}
return this._getChatHistoryQueryHandler;
}
public get getArchivedChatsQueryHandler(): GetArchivedChatsQueryHandler {
if (!this._getArchivedChatsQueryHandler) {
this._getArchivedChatsQueryHandler = new GetArchivedChatsQueryHandler(this.chatArchiveRepository);
}
return this._getArchivedChatsQueryHandler;
}
public get getDeckByIdQueryHandler(): GetDeckByIdQueryHandler {
if (!this._getDeckByIdQueryHandler) {
this._getDeckByIdQueryHandler = new GetDeckByIdQueryHandler(this.deckRepository);
}
return this._getDeckByIdQueryHandler;
}
public get getOrganizationByIdQueryHandler(): GetOrganizationByIdQueryHandler {
if (!this._getOrganizationByIdQueryHandler) {
this._getOrganizationByIdQueryHandler = new GetOrganizationByIdQueryHandler(this.organizationRepository);
}
return this._getOrganizationByIdQueryHandler;
}
public get getOrganizationLoginUrlQueryHandler(): GetOrganizationLoginUrlQueryHandler {
if (!this._getOrganizationLoginUrlQueryHandler) {
this._getOrganizationLoginUrlQueryHandler = new GetOrganizationLoginUrlQueryHandler(this.organizationRepository);
}
return this._getOrganizationLoginUrlQueryHandler;
}
public get getContactByIdQueryHandler(): GetContactByIdQueryHandler {
if (!this._getContactByIdQueryHandler) {
this._getContactByIdQueryHandler = new GetContactByIdQueryHandler(this.contactRepository);
}
return this._getContactByIdQueryHandler;
}
public get getContactsByPageQueryHandler(): GetContactsByPageQueryHandler {
if (!this._getContactsByPageQueryHandler) {
this._getContactsByPageQueryHandler = new GetContactsByPageQueryHandler(this.contactRepository);
}
return this._getContactsByPageQueryHandler;
}
// New paginated query handlers
public get getUsersByPageQueryHandler(): GetUsersByPageQueryHandler {
if (!this._getUsersByPageQueryHandler) {
this._getUsersByPageQueryHandler = new GetUsersByPageQueryHandler(this.userRepository);
}
return this._getUsersByPageQueryHandler;
}
public get getDecksByPageQueryHandler(): GetDecksByPageQueryHandler {
if (!this._getDecksByPageQueryHandler) {
this._getDecksByPageQueryHandler = new GetDecksByPageQueryHandler(this.deckRepository);
}
return this._getDecksByPageQueryHandler;
}
public get getOrganizationsByPageQueryHandler(): GetOrganizationsByPageQueryHandler {
if (!this._getOrganizationsByPageQueryHandler) {
this._getOrganizationsByPageQueryHandler = new GetOrganizationsByPageQueryHandler(this.organizationRepository);
}
return this._getOrganizationsByPageQueryHandler;
}
public get getChatsByPageQueryHandler(): GetChatsByPageQueryHandler {
if (!this._getChatsByPageQueryHandler) {
this._getChatsByPageQueryHandler = new GetChatsByPageQueryHandler(this.chatRepository);
}
return this._getChatsByPageQueryHandler;
}
}
// Export singleton instance
export const container = DIContainer.getInstance();
@@ -0,0 +1,208 @@
import * as crypto from 'crypto';
import * as fs from 'fs';
import { DeckAggregate, State, CType } from '../../Domain/Deck/DeckAggregate';
import { IDeckRepository } from '../../Domain/IRepository/IDeckRepository';
import { logError, logAuth } from './Logger';
export interface SprDeckData {
name: string;
type: number;
cards: any[];
ctype: number;
exportDate: string;
version: string;
}
export interface ImportDeckCommand {
name: string;
type: number;
cards: any[];
ctype?: number;
userid: string;
}
export class DeckImportExportService {
private readonly encryptionKey: string;
private readonly algorithm = 'aes-256-gcm';
constructor(private readonly deckRepo: IDeckRepository) {
this.encryptionKey = process.env.DECK_ENCRYPTION_KEY || 'your-32-byte-encryption-key-here!!';
if (this.encryptionKey.length !== 32) {
throw new Error('DECK_ENCRYPTION_KEY must be exactly 32 characters long');
}
}
async exportDeckToSpr(deckId: string, userId: string): Promise<Buffer> {
try {
const deck = await this.deckRepo.findByIdIncludingDeleted(deckId);
if (!deck) {
throw new Error('Deck not found');
}
if (deck.userid !== userId) {
throw new Error('Unauthorized: You can only export your own decks');
}
const deckData: SprDeckData = {
name: deck.name,
type: deck.type,
cards: deck.cards,
ctype: deck.ctype,
exportDate: new Date().toISOString(),
version: '1.0'
};
const jsonString = JSON.stringify(deckData);
const encrypted = this.encrypt(jsonString);
logAuth('Deck exported to SPR format', userId, {
deckId: deck.id,
deckName: deck.name,
cardCount: deck.cards.length
});
return encrypted;
} catch (error) {
logError('Failed to export deck to SPR', error as Error);
throw error;
}
}
async importDeckFromSpr(sprData: Buffer, userId: string): Promise<DeckAggregate> {
try {
const decrypted = this.decrypt(sprData);
const deckData: SprDeckData = JSON.parse(decrypted);
// Validate required fields
if (!deckData.name || !deckData.cards || deckData.type === undefined) {
throw new Error('Invalid SPR file format: missing required fields');
}
// Create new deck
const newDeck = new DeckAggregate();
newDeck.name = deckData.name;
newDeck.type = deckData.type;
newDeck.userid = userId;
newDeck.cards = deckData.cards;
newDeck.ctype = deckData.ctype || CType.PUBLIC;
newDeck.state = State.ACTIVE;
const createdDeck = await this.deckRepo.create(newDeck);
logAuth('Deck imported from SPR format', userId, {
deckId: createdDeck.id,
deckName: createdDeck.name,
cardCount: createdDeck.cards.length,
originalExportDate: deckData.exportDate
});
return createdDeck;
} catch (error) {
logError('Failed to import deck from SPR', error as Error);
throw error;
}
}
async importDeckFromJson(jsonData: any, userId: string): Promise<DeckAggregate> {
try {
// Validate required fields
if (!jsonData.name || !jsonData.cards || jsonData.type === undefined) {
throw new Error('Invalid JSON format: missing required fields (name, cards, type)');
}
// Create new deck
const newDeck = new DeckAggregate();
newDeck.name = jsonData.name;
newDeck.type = jsonData.type;
newDeck.userid = userId;
newDeck.cards = jsonData.cards;
newDeck.ctype = jsonData.ctype || CType.PUBLIC;
newDeck.state = State.ACTIVE;
const createdDeck = await this.deckRepo.create(newDeck);
logAuth('Deck imported from JSON format', userId, {
deckId: createdDeck.id,
deckName: createdDeck.name,
cardCount: createdDeck.cards.length
});
return createdDeck;
} catch (error) {
logError('Failed to import deck from JSON', error as Error);
throw error;
}
}
// Admin-only function to import JSON without encryption
async adminImportFromJson(jsonData: any, targetUserId: string, adminUserId: string): Promise<DeckAggregate> {
try {
if (!jsonData.name || !jsonData.cards || jsonData.type === undefined) {
throw new Error('Invalid JSON format: missing required fields (name, cards, type)');
}
const newDeck = new DeckAggregate();
newDeck.name = jsonData.name;
newDeck.type = jsonData.type;
newDeck.userid = targetUserId;
newDeck.cards = jsonData.cards;
newDeck.ctype = jsonData.ctype || CType.PUBLIC;
newDeck.state = jsonData.state || State.ACTIVE;
const createdDeck = await this.deckRepo.create(newDeck);
logAuth('Deck imported by admin from JSON', adminUserId, {
deckId: createdDeck.id,
deckName: createdDeck.name,
cardCount: createdDeck.cards.length,
targetUserId: targetUserId
});
return createdDeck;
} catch (error) {
logError('Failed to admin import deck from JSON', error as Error);
throw error;
}
}
private encrypt(text: string): Buffer {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(this.algorithm, this.encryptionKey, iv);
cipher.setAAD(Buffer.from('SerpentRace-Deck', 'utf8'));
let encrypted = cipher.update(text, 'utf8');
encrypted = Buffer.concat([encrypted, cipher.final()]);
const authTag = cipher.getAuthTag();
return Buffer.concat([iv, authTag, encrypted]);
}
private decrypt(encryptedData: Buffer): string {
if (encryptedData.length < 32) {
throw new Error('Invalid SPR file: file too short');
}
const iv = encryptedData.slice(0, 16);
const authTag = encryptedData.slice(16, 32);
const encrypted = encryptedData.slice(32);
const decipher = crypto.createDecipheriv(this.algorithm, this.encryptionKey, iv);
decipher.setAAD(Buffer.from('SerpentRace-Deck', 'utf8'));
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted, undefined, 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
generateFilename(deckName: string): string {
// Sanitize deck name for filename
const sanitized = deckName.replace(/[^a-zA-Z0-9\-_]/g, '_');
const timestamp = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
return `${sanitized}_${timestamp}.spr`;
}
}
@@ -0,0 +1,264 @@
import * as nodemailer from 'nodemailer';
import * as fs from 'fs';
import * as path from 'path';
import { logError, logAuth, logStartup } from './Logger';
import { EmailTemplateHelper, LocalizedSubjects } from './EmailTemplateHelper';
export interface EmailOptions {
to: string;
subject: string;
html?: string;
text?: string;
template?: string;
templateData?: any;
}
export interface EmailConfig {
host: string;
port: number;
secure: boolean;
auth: {
user: string;
pass: string;
};
from: string;
}
export class EmailService {
private transporter!: nodemailer.Transporter;
private config: EmailConfig;
private templatesPath: string;
constructor() {
this.templatesPath = path.join(__dirname, '../../Templates');
this.config = {
host: process.env.EMAIL_HOST || 'smtp.gmail.com',
port: parseInt(process.env.EMAIL_PORT || '587'),
secure: process.env.EMAIL_SECURE === 'true',
auth: {
user: process.env.EMAIL_USER || '',
pass: process.env.EMAIL_PASS || ''
},
from: process.env.EMAIL_FROM || 'noreply@serpentrace.com'
};
this.initializeTransporter();
}
private initializeTransporter(): void {
try {
this.transporter = nodemailer.createTransport({
host: this.config.host,
port: this.config.port,
secure: this.config.secure,
auth: {
user: this.config.auth.user,
pass: this.config.auth.pass
}
});
} catch (error) {
logError('EmailService initialization failed', error instanceof Error ? error : new Error(String(error)));
throw new Error('Failed to initialize email service');
}
}
/**
* Send email with template
* @param options - Email options including template and data
*/
async sendEmail(options: EmailOptions): Promise<boolean> {
try {
let htmlContent = options.html;
let textContent = options.text;
if (options.template) {
const templateResult = await this.loadTemplate(options.template, options.templateData || {});
htmlContent = templateResult.html;
textContent = templateResult.text;
}
const mailOptions = {
from: this.config.from,
to: options.to,
subject: options.subject,
html: htmlContent,
text: textContent
};
const result = await this.transporter.sendMail(mailOptions);
logAuth('Email sent successfully', undefined, {
messageId: result.messageId,
to: options.to,
subject: options.subject
});
return true;
} catch (error) {
logError('Email sending failed', error instanceof Error ? error : new Error(String(error)));
return false;
}
}
/**
* Send verification email to user
* @param userEmail - User's email address
* @param userName - User's name
* @param verificationToken - Verification token
* @param verificationUrl - Complete verification URL
* @param language - Language code ('en', 'hu', 'de')
*/
async sendVerificationEmail(
userEmail: string,
userName: string,
verificationToken: string,
verificationUrl: string,
language: 'en' | 'hu' | 'de' = 'en'
): Promise<boolean> {
try {
const templateName = language === 'en' ? 'verification' : `verification-${language}`;
const subject = this.getLocalizedVerificationSubject(language);
return await this.sendEmail({
to: userEmail,
subject,
template: templateName,
templateData: {
userName,
verificationToken,
verificationUrl,
companyName: 'SerpentRace',
supportEmail: 'support@serpentrace.com'
}
});
} catch (error) {
logError('Verification email sending failed', error instanceof Error ? error : new Error(String(error)));
return false;
}
}
/**
* Send password reset email
* @param userEmail - User's email address
* @param userName - User's name
* @param resetToken - Password reset token
* @param resetUrl - Complete password reset URL
* @param language - Language code ('en', 'hu', 'de')
*/
async sendPasswordResetEmail(
userEmail: string,
userName: string,
resetToken: string,
resetUrl: string,
language: 'en' | 'hu' | 'de' = 'en'
): Promise<boolean> {
try {
const templateName = language === 'en' ? 'password-reset' : `password-reset-${language}`;
const subject = this.getLocalizedPasswordResetSubject(language);
return await this.sendEmail({
to: userEmail,
subject,
template: templateName,
templateData: {
userName,
resetToken,
resetUrl,
companyName: 'SerpentRace',
supportEmail: 'support@serpentrace.com'
}
});
} catch (error) {
logError('Password reset email sending failed', error instanceof Error ? error : new Error(String(error)));
return false;
}
}
/**
* Load and compile email template with language support
* @param templateName - Name of the template file (with or without language suffix)
* @param data - Data to replace placeholders in the template
*/
private async loadTemplate(templateName: string, data: any): Promise<{ html: string; text: string }> {
try {
// Try the specified template first
let htmlTemplatePath = path.join(this.templatesPath, `${templateName}.html`);
let textTemplatePath = path.join(this.templatesPath, `${templateName}.txt`);
let htmlTemplate = '';
let textTemplate = '';
// Load HTML template if it exists
if (fs.existsSync(htmlTemplatePath)) {
htmlTemplate = fs.readFileSync(htmlTemplatePath, 'utf8');
} else {
// If language-specific template doesn't exist, try fallback to English
const baseName = templateName.replace(/-[a-z]{2}$/, ''); // Remove language suffix
const fallbackHtmlPath = path.join(this.templatesPath, `${baseName}.html`);
if (fs.existsSync(fallbackHtmlPath)) {
htmlTemplate = fs.readFileSync(fallbackHtmlPath, 'utf8');
}
}
// Load text template if it exists
if (fs.existsSync(textTemplatePath)) {
textTemplate = fs.readFileSync(textTemplatePath, 'utf8');
} else {
// If language-specific template doesn't exist, try fallback to English
const baseName = templateName.replace(/-[a-z]{2}$/, ''); // Remove language suffix
const fallbackTextPath = path.join(this.templatesPath, `${baseName}.txt`);
if (fs.existsSync(fallbackTextPath)) {
textTemplate = fs.readFileSync(fallbackTextPath, 'utf8');
}
}
// If no templates found, throw error
if (!htmlTemplate && !textTemplate) {
throw new Error(`Template '${templateName}' not found`);
}
// Replace placeholders in templates
const processedTemplate = EmailTemplateHelper.processTemplate(
{ html: htmlTemplate, text: textTemplate },
data
);
return {
html: processedTemplate.html,
text: processedTemplate.text
};
} catch (error) {
logError('Email template loading failed', error instanceof Error ? error : new Error(String(error)));
throw new Error(`Failed to load email template: ${templateName}`);
}
}
/**
* Get localized verification email subject
* @param language - Language code ('en', 'hu', 'de')
*/
private getLocalizedVerificationSubject(language: 'en' | 'hu' | 'de'): string {
const subjects: LocalizedSubjects = {
verification: {
en: 'SerpentRace - Verify Your Account',
hu: 'SerpentRace - Fiók megerősítése',
de: 'SerpentRace - Konto verifizieren'
}
};
return EmailTemplateHelper.getLocalizedSubject('verification', subjects, language);
}
/**
* Get localized password reset email subject
* @param language - Language code ('en', 'hu', 'de')
*/
private getLocalizedPasswordResetSubject(language: 'en' | 'hu' | 'de'): string {
const subjects: LocalizedSubjects = {
passwordReset: {
en: 'SerpentRace - Password Reset Request',
hu: 'SerpentRace - Jelszó visszaállítás kérése',
de: 'SerpentRace - Passwort zurücksetzen'
}
};
return EmailTemplateHelper.getLocalizedSubject('passwordReset', subjects, language);
}
}
@@ -0,0 +1,39 @@
export interface LocalizedSubjects {
[key: string]: {
en: string;
hu: string;
de: string;
};
}
export interface TemplateData {
[key: string]: any;
}
export interface EmailTemplate {
html: string;
text: string;
}
export class EmailTemplateHelper {
public static getLocalizedSubject(
subjectKey: string,
subjects: LocalizedSubjects,
language: 'en' | 'hu' | 'de'
): string {
return subjects[subjectKey]?.[language] || subjects[subjectKey]?.['en'] || 'SerpentRace';
}
public static replaceTemplatePlaceholders(template: string, data: TemplateData): string {
return template.replace(/\{\{(\w+)\}\}/g, (match, key) => {
return data[key] !== undefined ? String(data[key]) : match;
});
}
public static processTemplate(templateContent: EmailTemplate, data: TemplateData): EmailTemplate {
return {
html: this.replaceTemplatePlaceholders(templateContent.html, data),
text: this.replaceTemplatePlaceholders(templateContent.text, data)
};
}
}
@@ -0,0 +1,35 @@
import { Response } from 'express';
export class ErrorResponseService {
static sendError(res: Response, statusCode: number, message: string, details?: any): Response {
const errorResponse: any = { error: message };
if (details) {
errorResponse.details = details;
}
return res.status(statusCode).json(errorResponse);
}
static sendInternalServerError(res: Response): Response {
return this.sendError(res, 500, 'Internal server error');
}
static sendBadRequest(res: Response, message: string = 'Bad request', details?: any): Response {
return this.sendError(res, 400, message, details);
}
static sendUnauthorized(res: Response, message: string = 'Unauthorized'): Response {
return this.sendError(res, 401, message);
}
static sendForbidden(res: Response, message: string = 'Forbidden'): Response {
return this.sendError(res, 403, message);
}
static sendNotFound(res: Response, message: string = 'Not found'): Response {
return this.sendError(res, 404, message);
}
static sendConflict(res: Response, message: string = 'Conflict'): Response {
return this.sendError(res, 409, message);
}
}
@@ -0,0 +1,124 @@
import jwt, { SignOptions } from 'jsonwebtoken';
import { Request, Response } from 'express';
import { UserState } from '../../Domain/User/UserAggregate';
export interface TokenPayload {
userId: string;
authLevel: 0 | 1;
userStatus: UserState;
orgId: string;
iat?: number;
exp?: number;
}
export class JWTService {
private readonly secretKey: string;
private readonly tokenExpiry: number;
private readonly cookieName: string;
constructor() {
this.secretKey = process.env.JWT_SECRET || 'your-secret-key';
let expiry = 86400;
if (process.env.JWT_EXPIRY) {
expiry = parseInt(process.env.JWT_EXPIRY);
} else if (process.env.JWT_EXPIRATION) {
expiry = this.parseDuration(process.env.JWT_EXPIRATION);
}
this.tokenExpiry = expiry;
this.cookieName = 'auth_token';
if (process.env.NODE_ENV === 'production' && (!process.env.JWT_SECRET || process.env.JWT_SECRET === 'your-secret-key')) {
throw new Error('JWT_SECRET environment variable must be set in production');
}
}
create(payload: TokenPayload, res: Response): string {
const now = Math.floor(Date.now() / 1000);
const payloadWithTimestamps: TokenPayload = {
...payload,
iat: now,
exp: now + this.tokenExpiry
};
// Don't use expiresIn option since we're manually setting exp in payload
const options: SignOptions = {};
const token = jwt.sign(payloadWithTimestamps, this.secretKey, options);
res.cookie(this.cookieName, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: this.tokenExpiry * 1000, // Convert to milliseconds
});
return token;
}
verify(req: Request): TokenPayload | null {
try {
const token = req.cookies[this.cookieName];
if (!token) return null;
const decoded = jwt.verify(token, this.secretKey) as TokenPayload;
return decoded;
} catch (error) {
return null;
}
}
// Check if token needs refresh (within 25% of expiry time)
shouldRefreshToken(payload: TokenPayload): boolean {
if (!payload.exp || !payload.iat) return false;
const now = Math.floor(Date.now() / 1000);
const tokenAge = now - payload.iat;
const tokenLifetime = payload.exp - payload.iat;
const refreshThreshold = tokenLifetime * 0.75; // Refresh when 75% of lifetime has passed
return tokenAge >= refreshThreshold;
}
// Conditionally refresh token only if needed
refreshIfNeeded(payload: TokenPayload, res: Response): boolean {
if (this.shouldRefreshToken(payload)) {
// Create new token with fresh timestamps, but same user data
const freshPayload: Omit<TokenPayload, 'iat' | 'exp'> = {
userId: payload.userId,
authLevel: payload.authLevel,
userStatus: payload.userStatus,
orgId: payload.orgId
};
this.create(freshPayload, res);
return true;
}
return false;
}
/**
* Parse duration string to seconds (e.g., "24h", "7d", "30m")
* @param duration Duration string
* @returns Duration in seconds
*/
private parseDuration(duration: string): number {
const match = duration.match(/^(\d+)([smhd])$/);
if (!match) {
throw new Error(`Invalid duration format: ${duration}. Use format like '24h', '7d', '30m'`);
}
const [, value, unit] = match;
const num = parseInt(value);
switch (unit) {
case 's': return num; // seconds
case 'm': return num * 60; // minutes
case 'h': return num * 60 * 60; // hours
case 'd': return num * 60 * 60 * 24; // days
default:
throw new Error(`Unsupported duration unit: ${unit}`);
}
}
}
@@ -0,0 +1,61 @@
import { LoggingService, LogLevel } from './LoggingService';
import { Request, Response } from 'express';
// Singleton instance
const logger = LoggingService.getInstance();
// Convenience functions for each log level
export const logRequest = (message: string, req?: Request, res?: Response, metadata?: any) => {
logger.log(LogLevel.REQUEST, message, metadata, req, res);
};
export const logError = (message: string, error?: Error, req?: Request, res?: Response) => {
const metadata = error ? {
name: error.name,
message: error.message,
stack: error.stack
} : undefined;
logger.log(LogLevel.ERROR, message, metadata, req, res);
};
export const logWarning = (message: string, metadata?: any, req?: Request, res?: Response) => {
logger.log(LogLevel.WARNING, message, metadata, req, res);
};
export const logAuth = (message: string, userId?: string, metadata?: any, req?: Request, res?: Response) => {
const authMetadata = {
userId,
...metadata
};
logger.log(LogLevel.AUTH, message, authMetadata, req, res);
};
export const logDatabase = (message: string, query?: string, executionTime?: number, metadata?: any) => {
const dbMetadata = {
query: query ? query.substring(0, 200) : undefined,
executionTime,
...metadata
};
logger.log(LogLevel.DATABASE, message, dbMetadata);
};
export const logStartup = (message: string, metadata?: any) => {
logger.log(LogLevel.STARTUP, message, metadata);
};
export const logConnection = (message: string, type: string, status: 'success' | 'failure' | 'attempt', metadata?: any) => {
const connectionMetadata = {
connectionType: type,
status,
...metadata
};
logger.log(LogLevel.CONNECTION, message, connectionMetadata);
};
export const logOther = (message: string, metadata?: any, req?: Request, res?: Response) => {
logger.log(LogLevel.OTHER, message, metadata, req, res);
};
// Export the main service
export { LoggingService, LogLevel };
export default logger;
@@ -0,0 +1,394 @@
import fs from 'fs';
import path from 'path';
import { Request, Response, NextFunction } from 'express';
import * as Minio from 'minio';
export enum LogLevel {
REQUEST = 'REQUEST',
ERROR = 'ERROR',
WARNING = 'WARNING',
AUTH = 'AUTH',
DATABASE = 'DATABASE',
STARTUP = 'STARTUP',
CONNECTION = 'CONNECTION',
OTHER = 'OTHER'
}
export interface LogEntry {
timestamp: string;
level: LogLevel;
message: string;
metadata?: any;
requestId?: string;
userId?: string;
ip?: string;
userAgent?: string;
method?: string;
url?: string;
statusCode?: number;
responseTime?: number;
}
export class LoggingService {
private static instance: LoggingService;
private minioClient: Minio.Client | null = null;
private logBuffer: LogEntry[] = [];
private currentLogFile: string | null = null;
private logCount = 0;
private readonly maxLogsPerFile = parseInt(process.env.MAX_LOGS_PER_FILE || '10000');
private readonly logsDir = path.join(process.cwd(), 'logs');
private readonly bucketName = process.env.MINIO_BUCKET_NAME || 'serpentrace-logs';
private uploadInterval: NodeJS.Timeout | null = null;
private constructor() {
this.initializeLogsDirectory();
this.initializeMinioClient();
this.createNewLogFile();
if (process.env.NODE_ENV !== 'test') {
this.startPeriodicUpload();
}
process.on('SIGTERM', () => this.shutdown());
process.on('SIGINT', () => this.shutdown());
process.on('beforeExit', () => this.shutdown());
}
static getInstance(): LoggingService {
if (!LoggingService.instance) {
LoggingService.instance = new LoggingService();
}
return LoggingService.instance;
}
private initializeLogsDirectory(): void {
try {
if (!fs.existsSync(this.logsDir)) {
fs.mkdirSync(this.logsDir, { recursive: true });
}
// Create monthly subdirectory
const monthlyDir = this.getMonthlyDirectory();
if (!fs.existsSync(monthlyDir)) {
fs.mkdirSync(monthlyDir, { recursive: true });
}
} catch (error) {
console.error('Failed to initialize logs directory:', error);
}
}
private initializeMinioClient(): void {
try {
// Check if in production or development
if (process.env.NODE_ENV === 'production') {
if (process.env.MINIO_ENDPOINT && process.env.MINIO_ACCESS_KEY && process.env.MINIO_SECRET_KEY) {
this.minioClient = new Minio.Client({
endPoint: process.env.MINIO_ENDPOINT,
port: parseInt(process.env.MINIO_PORT || '9000'),
useSSL: process.env.MINIO_USE_SSL === 'true',
accessKey: process.env.MINIO_ACCESS_KEY,
secretKey: process.env.MINIO_SECRET_KEY
});
this.ensureBucketExists();
} else {
console.warn('Minio configuration not found. Logs will only be stored locally and in console.');
}
} else {
// Development-specific Minio configuration
this.minioClient = new Minio.Client({
endPoint: 'localhost',
port: 9000,
useSSL: false,
accessKey: 'serpentrace',
secretKey: 'serpentrace123!'
});
this.ensureBucketExists();
}
} catch (error) {
console.error('Failed to initialize Minio client:', error);
this.minioClient = null;
}
}
private async ensureBucketExists(): Promise<void> {
if (!this.minioClient) return;
try {
const exists = await this.minioClient.bucketExists(this.bucketName);
if (!exists) {
await this.minioClient.makeBucket(this.bucketName);
this.log(LogLevel.STARTUP, `Created Minio bucket: ${this.bucketName}`);
}
} catch (error) {
console.error('Failed to ensure bucket exists:', error);
}
}
private startPeriodicUpload(): void {
// Upload current log file to Minio every 2 minutes
this.uploadInterval = setInterval(async () => {
if (this.currentLogFile && this.minioClient) {
await this.uploadToMinio(this.currentLogFile);
}
}, 2 * 60 * 1000); // 2 minutes
}
private getMonthlyDirectory(): string {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
return path.join(this.logsDir, `${year}-${month}`);
}
private getMonthlyMinioPrefix(): string {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
return `${year}-${month}/`;
}
private createNewLogFile(): void {
const now = new Date();
const timestamp = now.toISOString().replace(/[:.]/g, '-');
const fileName = `serpentrace-${timestamp}.log`;
this.currentLogFile = path.join(this.getMonthlyDirectory(), fileName);
this.logCount = 0;
// Write log file header
const header = `# SerpentRace Backend Logs\n# Started: ${now.toISOString()}\n# Max entries per file: ${this.maxLogsPerFile}\n\n`;
try {
fs.writeFileSync(this.currentLogFile, header);
} catch (error) {
console.error('Failed to create log file:', error);
}
}
private formatLogEntry(entry: LogEntry): string {
const parts = [
entry.timestamp,
`[${entry.level}]`,
entry.message
];
if (entry.requestId) parts.push(`ReqId:${entry.requestId}`);
if (entry.userId) parts.push(`UserId:${entry.userId}`);
if (entry.ip) parts.push(`IP:${entry.ip}`);
if (entry.method && entry.url) parts.push(`${entry.method} ${entry.url}`);
if (entry.statusCode) parts.push(`Status:${entry.statusCode}`);
if (entry.responseTime) parts.push(`Time:${entry.responseTime}ms`);
if (entry.userAgent) parts.push(`UA:${entry.userAgent.substring(0, 50)}`);
if (entry.metadata) parts.push(`Meta:${JSON.stringify(entry.metadata)}`);
return parts.join(' | ');
}
private async writeToLocalFile(entry: LogEntry): Promise<void> {
if (!this.currentLogFile) return;
try {
const logLine = this.formatLogEntry(entry) + '\n';
fs.appendFileSync(this.currentLogFile, logLine);
this.logCount++;
// Check if we need to rotate the log file
if (this.logCount >= this.maxLogsPerFile) {
await this.rotateLogFile();
}
} catch (error) {
console.error('Failed to write to log file:', error);
}
}
private async rotateLogFile(): Promise<void> {
if (!this.currentLogFile) return;
try {
// Upload current file to Minio before rotating
await this.uploadToMinio(this.currentLogFile);
// Create new log file
this.createNewLogFile();
this.log(LogLevel.OTHER, 'Log file rotated due to size limit');
} catch (error) {
console.error('Failed to rotate log file:', error);
}
}
private async uploadToMinio(filePath: string): Promise<void> {
if (!this.minioClient) {
console.warn('Minio client not initialized, skipping upload');
return;
}
if (!fs.existsSync(filePath)) {
console.warn(`Log file does not exist: ${filePath}`);
return;
}
try {
const fileName = path.basename(filePath);
const objectName = this.getMonthlyMinioPrefix() + fileName;
console.log(`Attempting to upload log file to Minio: ${objectName}`);
await this.minioClient.fPutObject(this.bucketName, objectName, filePath);
console.log(`Successfully uploaded log file to Minio: ${objectName}`);
} catch (error) {
console.error('Failed to upload to Minio:', error);
console.error('Minio config:', {
endpoint: this.minioClient ? 'configured' : 'not configured',
bucket: this.bucketName
});
}
}
private logToConsole(entry: LogEntry): void {
const formattedEntry = this.formatLogEntry(entry);
switch (entry.level) {
case LogLevel.ERROR:
console.error(formattedEntry);
break;
case LogLevel.WARNING:
console.warn(formattedEntry);
break;
case LogLevel.REQUEST:
case LogLevel.AUTH:
case LogLevel.DATABASE:
case LogLevel.CONNECTION:
console.info(formattedEntry);
break;
case LogLevel.STARTUP:
console.log(formattedEntry);
break;
default:
console.log(formattedEntry);
}
}
public log(
level: LogLevel,
message: string,
metadata?: any,
req?: Request,
res?: Response,
responseTime?: number
): void {
const entry: LogEntry = {
timestamp: new Date().toISOString(),
level,
message,
metadata
};
// Add request context if available
if (req) {
entry.requestId = (req as any).requestId || this.generateRequestId();
entry.userId = (req as any).user?.userId;
entry.ip = req.ip || req.socket?.remoteAddress || 'unknown';
entry.userAgent = req.get ? req.get('User-Agent') : 'unknown';
entry.method = req.method;
entry.url = req.originalUrl || req.url;
}
if (res) {
entry.statusCode = res.statusCode;
}
if (responseTime !== undefined) {
entry.responseTime = responseTime;
}
// Log to all three destinations
this.logToConsole(entry);
this.writeToLocalFile(entry);
// Add to buffer for potential batch processing
this.logBuffer.push(entry);
// Limit buffer size
if (this.logBuffer.length > 1000) {
this.logBuffer = this.logBuffer.slice(-500);
}
}
private generateRequestId(): string {
return Math.random().toString(36).substr(2, 9);
}
public async shutdown(): Promise<void> {
try {
// Clear the upload interval
if (this.uploadInterval) {
clearInterval(this.uploadInterval);
this.uploadInterval = null;
}
// Upload current log file to Minio
if (this.currentLogFile) {
await this.uploadToMinio(this.currentLogFile);
}
this.log(LogLevel.STARTUP, 'Logging service shutting down gracefully');
// Give time for final logs to be written
await new Promise(resolve => setTimeout(resolve, 1000));
} catch (error) {
console.error('Error during logging service shutdown:', error);
}
}
// Middleware factory methods
public requestLoggingMiddleware() {
return (req: Request, res: Response, next: NextFunction) => {
const startTime = Date.now();
// Generate request ID
(req as any).requestId = this.generateRequestId();
// Log request start
this.log(LogLevel.REQUEST, `Incoming request`, undefined, req);
// Override res.end to log response
const originalEnd = res.end.bind(res);
res.end = (...args: any[]): Response => {
const responseTime = Date.now() - startTime;
LoggingService.getInstance().log(
LogLevel.REQUEST,
`Request completed`,
undefined,
req,
res,
responseTime
);
return originalEnd(...args);
};
next();
};
}
public errorLoggingMiddleware() {
return (error: Error, req: Request, res: Response, next: NextFunction) => {
this.log(
LogLevel.ERROR,
`Unhandled error: ${error.message}`,
{
stack: error.stack,
name: error.name
},
req,
res
);
next(error);
};
}
}
export default LoggingService;
@@ -0,0 +1,99 @@
import * as bcrypt from 'bcrypt';
import { logError } from './Logger';
export class PasswordService {
private static readonly SALT_ROUNDS = 12;
/**
* Hashes a plain text password using bcrypt
* @param password - The plain text password to hash
* @returns Promise<string> - The hashed password
*/
static async hashPassword(password: string): Promise<string> {
try {
if (!password || typeof password !== 'string') {
throw new Error('Password must be a non-empty string');
}
return await bcrypt.hash(password, this.SALT_ROUNDS);
} catch (error) {
logError('PasswordService.hashPassword error', error instanceof Error ? error : new Error(String(error)));
if (error instanceof Error && error.message === 'Password must be a non-empty string') {
throw error; // Re-throw validation errors as-is
}
throw new Error('Failed to hash password');
}
}
/**
* Verifies a plain text password against a hashed password
* @param password - The plain text password to verify
* @param hashedPassword - The hashed password to compare against
* @returns Promise<boolean> - True if password matches, false otherwise
*/
static async verifyPassword(password: string, hashedPassword: string): Promise<boolean> {
try {
if (!password || typeof password !== 'string') {
return false; // Invalid input should return false, not throw
}
if (!hashedPassword || typeof hashedPassword !== 'string') {
return false; // Invalid input should return false, not throw
}
return await bcrypt.compare(password, hashedPassword);
} catch (error) {
logError('PasswordService.verifyPassword error', error instanceof Error ? error : new Error(String(error)));
return false; // Return false on error instead of throwing
}
}
/**
* Validates password strength requirements
* @param password - The password to validate
* @returns object - Object containing isValid boolean and error messages
*/
static validatePasswordStrength(password: string): { isValid: boolean; errors: string[] } {
try {
const errors: string[] = [];
if (!password || typeof password !== 'string') {
errors.push('Password must be provided as a string');
return { isValid: false, errors };
}
if (password.length < 8) {
errors.push('Password must be at least 8 characters long');
}
if (!/[A-Z]/.test(password)) {
errors.push('Password must contain at least one uppercase letter');
}
if (!/[a-z]/.test(password)) {
errors.push('Password must contain at least one lowercase letter');
}
if (!/\d/.test(password)) {
errors.push('Password must contain at least one number');
}
if (!/[!@#$%^&*(),.?":{}|<>]/.test(password)) {
errors.push('Password must contain at least one special character');
}
return {
isValid: errors.length === 0,
errors
};
} catch (error) {
logError('PasswordService.validatePasswordStrength error', error instanceof Error ? error : new Error(String(error)));
return {
isValid: false,
errors: ['Password validation failed due to internal error']
};
}
}
}
@@ -0,0 +1,306 @@
import { createClient, RedisClientType } from 'redis';
import { logError, logStartup, logWarning } from './Logger';
export interface ActiveChatData {
chatId: string;
participants: string[];
lastActivity: Date;
messageCount: number;
chatType: 'direct' | 'group' | 'game';
gameId?: string;
name?: string;
}
export interface ActiveUserData {
userId: string;
activeChatIds: string[];
lastActivity: Date;
isOnline: boolean;
}
export class RedisService {
private static instance: RedisService;
private client: RedisClientType;
private isConnected: boolean = false;
private constructor() {
const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379';
this.client = createClient({
url: redisUrl,
socket: {
reconnectStrategy: (retries) => Math.min(retries * 50, 500)
}
});
this.client.on('error', (err) => {
logError('Redis connection error', err);
this.isConnected = false;
});
this.client.on('connect', () => {
logStartup('Redis client connected successfully');
this.isConnected = true;
});
this.client.on('disconnect', () => {
logWarning('Redis client disconnected');
this.isConnected = false;
});
}
public static getInstance(): RedisService {
if (!RedisService.instance) {
RedisService.instance = new RedisService();
}
return RedisService.instance;
}
public async connect(): Promise<void> {
try {
if (!this.isConnected) {
await this.client.connect();
}
} catch (error) {
logError('Failed to connect to Redis', error as Error);
throw error;
}
}
public async disconnect(): Promise<void> {
try {
if (this.isConnected) {
await this.client.disconnect();
}
} catch (error) {
logError('Failed to disconnect from Redis', error as Error);
}
}
public async setActiveChat(chatId: string, chatData: ActiveChatData): Promise<void> {
try {
const key = `active_chat:${chatId}`;
await this.client.hSet(key, {
chatId: chatData.chatId,
participants: JSON.stringify(chatData.participants),
lastActivity: chatData.lastActivity.toISOString(),
messageCount: chatData.messageCount.toString(),
chatType: chatData.chatType,
gameId: chatData.gameId || '',
name: chatData.name || ''
});
// Set expiration for 1 hour of inactivity
await this.client.expire(key, 3600);
} catch (error) {
logError(`Failed to set active chat ${chatId}`, error as Error);
}
}
public async getActiveChat(chatId: string): Promise<ActiveChatData | null> {
try {
const key = `active_chat:${chatId}`;
const data = await this.client.hGetAll(key);
if (!data.chatId) {
return null;
}
return {
chatId: data.chatId,
participants: JSON.parse(data.participants),
lastActivity: new Date(data.lastActivity),
messageCount: parseInt(data.messageCount, 10),
chatType: data.chatType as 'direct' | 'group' | 'game',
gameId: data.gameId || undefined,
name: data.name || undefined
};
} catch (error) {
logError(`Failed to get active chat ${chatId}`, error as Error);
return null;
}
}
public async removeActiveChat(chatId: string): Promise<void> {
try {
const key = `active_chat:${chatId}`;
await this.client.del(key);
} catch (error) {
logError(`Failed to remove active chat ${chatId}`, error as Error);
}
}
public async getAllActiveChats(): Promise<ActiveChatData[]> {
try {
const pattern = 'active_chat:*';
const keys = await this.client.keys(pattern);
const chats: ActiveChatData[] = [];
for (const key of keys) {
const data = await this.client.hGetAll(key);
if (data.chatId) {
chats.push({
chatId: data.chatId,
participants: JSON.parse(data.participants),
lastActivity: new Date(data.lastActivity),
messageCount: parseInt(data.messageCount, 10),
chatType: data.chatType as 'direct' | 'group' | 'game',
gameId: data.gameId || undefined,
name: data.name || undefined
});
}
}
return chats;
} catch (error) {
logError('Failed to get all active chats', error as Error);
return [];
}
}
public async setActiveUser(userId: string, userData: ActiveUserData): Promise<void> {
try {
const key = `active_user:${userId}`;
await this.client.hSet(key, {
userId: userData.userId,
activeChatIds: JSON.stringify(userData.activeChatIds),
lastActivity: userData.lastActivity.toISOString(),
isOnline: userData.isOnline.toString()
});
// Set expiration for 2 hours
await this.client.expire(key, 7200);
} catch (error) {
logError(`Failed to set active user ${userId}`, error as Error);
}
}
public async getActiveUser(userId: string): Promise<ActiveUserData | null> {
try {
const key = `active_user:${userId}`;
const data = await this.client.hGetAll(key);
if (!data.userId) {
return null;
}
return {
userId: data.userId,
activeChatIds: JSON.parse(data.activeChatIds),
lastActivity: new Date(data.lastActivity),
isOnline: data.isOnline === 'true'
};
} catch (error) {
logError(`Failed to get active user ${userId}`, error as Error);
return null;
}
}
public async removeActiveUser(userId: string): Promise<void> {
try {
const key = `active_user:${userId}`;
await this.client.del(key);
} catch (error) {
logError(`Failed to remove active user ${userId}`, error as Error);
}
}
public async addUserToChat(userId: string, chatId: string): Promise<void> {
try {
const userData = await this.getActiveUser(userId) || {
userId,
activeChatIds: [],
lastActivity: new Date(),
isOnline: true
};
if (!userData.activeChatIds.includes(chatId)) {
userData.activeChatIds.push(chatId);
userData.lastActivity = new Date();
await this.setActiveUser(userId, userData);
}
} catch (error) {
logError(`Failed to add user ${userId} to chat ${chatId}`, error as Error);
}
}
public async removeUserFromChat(userId: string, chatId: string): Promise<void> {
try {
const userData = await this.getActiveUser(userId);
if (userData) {
userData.activeChatIds = userData.activeChatIds.filter(id => id !== chatId);
userData.lastActivity = new Date();
await this.setActiveUser(userId, userData);
}
} catch (error) {
logError(`Failed to remove user ${userId} from chat ${chatId}`, error as Error);
}
}
public async getUserActiveChats(userId: string): Promise<string[]> {
try {
const userData = await this.getActiveUser(userId);
return userData?.activeChatIds || [];
} catch (error) {
logError(`Failed to get active chats for user ${userId}`, error as Error);
return [];
}
}
public async updateChatActivity(chatId: string, messageCount?: number): Promise<void> {
try {
const chatData = await this.getActiveChat(chatId);
if (chatData) {
chatData.lastActivity = new Date();
if (messageCount !== undefined) {
chatData.messageCount = messageCount;
}
await this.setActiveChat(chatId, chatData);
}
} catch (error) {
logError(`Failed to update chat activity ${chatId}`, error as Error);
}
}
public async getInactiveChats(inactivityMinutes: number): Promise<string[]> {
try {
const cutoffTime = new Date(Date.now() - inactivityMinutes * 60 * 1000);
const allChats = await this.getAllActiveChats();
return allChats
.filter(chat => chat.lastActivity < cutoffTime)
.map(chat => chat.chatId);
} catch (error) {
logError('Failed to get inactive chats', error as Error);
return [];
}
}
public async cleanupInactiveChats(inactivityMinutes: number): Promise<string[]> {
try {
const inactiveChats = await this.getInactiveChats(inactivityMinutes);
for (const chatId of inactiveChats) {
await this.removeActiveChat(chatId);
}
return inactiveChats;
} catch (error) {
logError('Failed to cleanup inactive chats', error as Error);
return [];
}
}
public async ping(): Promise<boolean> {
try {
const result = await this.client.ping();
return result === 'PONG';
} catch (error) {
logError('Redis ping failed', error as Error);
return false;
}
}
public isRedisConnected(): boolean {
return this.isConnected;
}
}
@@ -0,0 +1,229 @@
import * as crypto from 'crypto';
import { logError } from './Logger';
export interface VerificationToken {
token: string;
expiresAt: Date;
createdAt: Date;
}
export interface PasswordResetToken {
token: string;
expiresAt: Date;
createdAt: Date;
}
export class TokenService {
private static readonly VERIFICATION_TOKEN_EXPIRES_HOURS = 24;
private static readonly PASSWORD_RESET_TOKEN_EXPIRES_HOURS = 1;
private static readonly TOKEN_LENGTH = 32;
/**
* Generate a secure random token
* @param length - Length of the token in bytes (default: 32)
* @returns Hexadecimal string token
*/
static generateSecureToken(length: number = TokenService.TOKEN_LENGTH): string {
try {
return crypto.randomBytes(length).toString('hex');
} catch (error) {
logError('TokenService.generateSecureToken error', error instanceof Error ? error : new Error(String(error)));
throw new Error('Failed to generate secure token');
}
}
/**
* Generate email verification token with expiration
* @returns VerificationToken object with token and expiration info
*/
static generateVerificationToken(): VerificationToken {
try {
const token = this.generateSecureToken();
const createdAt = new Date();
const expiresAt = new Date(createdAt.getTime() + (this.VERIFICATION_TOKEN_EXPIRES_HOURS * 60 * 60 * 1000));
return {
token,
createdAt,
expiresAt
};
} catch (error) {
logError('TokenService.generateVerificationToken error', error instanceof Error ? error : new Error(String(error)));
throw new Error('Failed to generate verification token');
}
}
/**
* Generate password reset token with expiration
* @returns PasswordResetToken object with token and expiration info
*/
static generatePasswordResetToken(): PasswordResetToken {
try {
const token = this.generateSecureToken();
const createdAt = new Date();
const expiresAt = new Date(createdAt.getTime() + (this.PASSWORD_RESET_TOKEN_EXPIRES_HOURS * 60 * 60 * 1000));
return {
token,
createdAt,
expiresAt
};
} catch (error) {
logError('TokenService.generatePasswordResetToken error', error instanceof Error ? error : new Error(String(error)));
throw new Error('Failed to generate password reset token');
}
}
/**
* Check if a token has expired
* @param expiresAt - Expiration date of the token
* @returns True if token has expired, false otherwise
*/
static isTokenExpired(expiresAt: Date): boolean {
try {
return new Date() > expiresAt;
} catch (error) {
logError('TokenService.isTokenExpired error', error instanceof Error ? error : new Error(String(error)));
return true; // Assume expired on error for security
}
}
/**
* Validate token format (basic validation)
* @param token - Token to validate
* @returns True if token format is valid, false otherwise
*/
static isValidTokenFormat(token: string): boolean {
try {
if (!token || typeof token !== 'string') {
return false;
}
// Check if token is hexadecimal and has expected length
const hexRegex = /^[a-f0-9]+$/i;
const expectedLength = this.TOKEN_LENGTH * 2; // Each byte becomes 2 hex characters
return hexRegex.test(token) && token.length === expectedLength;
} catch (error) {
logError('TokenService.isValidTokenFormat error', error instanceof Error ? error : new Error(String(error)));
return false;
}
}
/**
* Generate a verification URL with token
* @param baseUrl - Base URL of the application
* @param token - Verification token
* @returns Complete verification URL
*/
static generateVerificationUrl(baseUrl: string, token: string): string {
try {
// Remove trailing slash from baseUrl if present
const cleanBaseUrl = baseUrl.replace(/\/$/, '');
return `${cleanBaseUrl}/api/auth/verify-email?token=${encodeURIComponent(token)}`;
} catch (error) {
logError('TokenService.generateVerificationUrl error', error instanceof Error ? error : new Error(String(error)));
throw new Error('Failed to generate verification URL');
}
}
/**
* Generate a password reset URL with token
* @param baseUrl - Base URL of the application
* @param token - Password reset token
* @returns Complete password reset URL
*/
static generatePasswordResetUrl(baseUrl: string, token: string): string {
try {
// Remove trailing slash from baseUrl if present
const cleanBaseUrl = baseUrl.replace(/\/$/, '');
return `${cleanBaseUrl}/api/auth/reset-password?token=${encodeURIComponent(token)}`;
} catch (error) {
logError('TokenService.generatePasswordResetUrl error', error instanceof Error ? error : new Error(String(error)));
throw new Error('Failed to generate password reset URL');
}
}
/**
* Hash a token for secure storage in database
* @param token - Plain text token to hash
* @returns Hashed token
*/
static async hashToken(token: string): Promise<string> {
try {
if (!token || typeof token !== 'string') {
throw new Error('Token must be a non-empty string');
}
return crypto.createHash('sha256').update(token).digest('hex');
} catch (error) {
logError('TokenService.hashToken error', error instanceof Error ? error : new Error(String(error)));
throw new Error('Failed to hash token');
}
}
/**
* Verify a plain text token against a hashed token
* @param plainToken - Plain text token to verify
* @param hashedToken - Hashed token to compare against
* @returns True if tokens match, false otherwise
*/
static async verifyToken(plainToken: string, hashedToken: string): Promise<boolean> {
try {
if (!plainToken || !hashedToken) {
return false;
}
const hashedPlainToken = await this.hashToken(plainToken);
return hashedPlainToken === hashedToken;
} catch (error) {
logError('TokenService.verifyToken error', error instanceof Error ? error : new Error(String(error)));
return false;
}
}
/**
* Get token expiration info in human-readable format
* @param expiresAt - Expiration date
* @returns Human-readable expiration info
*/
static getExpirationInfo(expiresAt: Date): { expired: boolean; timeLeft: string } {
try {
const now = new Date();
const expired = now > expiresAt;
if (expired) {
const timeAgo = Math.floor((now.getTime() - expiresAt.getTime()) / (1000 * 60));
return {
expired: true,
timeLeft: `Expired ${timeAgo} minute(s) ago`
};
}
const timeLeft = Math.floor((expiresAt.getTime() - now.getTime()) / (1000 * 60));
const hours = Math.floor(timeLeft / 60);
const minutes = timeLeft % 60;
let timeString = '';
if (hours > 0) {
timeString = `${hours} hour(s)`;
if (minutes > 0) {
timeString += ` and ${minutes} minute(s)`;
}
} else {
timeString = `${minutes} minute(s)`;
}
return {
expired: false,
timeLeft: `Expires in ${timeString}`
};
} catch (error) {
logError('TokenService.getExpirationInfo error', error instanceof Error ? error : new Error(String(error)));
return {
expired: true,
timeLeft: 'Unable to determine expiration'
};
}
}
}
@@ -0,0 +1,341 @@
import { Request, Response, NextFunction } from 'express';
import { ErrorResponseService } from './ErrorResponseService';
import { logError, logWarning } from './Logger';
/**
* Common validation middleware functions for request validation
*/
export class ValidationMiddleware {
/**
* Validates required fields in request body
* @param requiredFields Array of required field names
*/
static validateRequiredFields(requiredFields: string[]) {
return (req: Request, res: Response, next: NextFunction) => {
const missingFields: string[] = [];
for (const field of requiredFields) {
if (!req.body || req.body[field] === undefined || req.body[field] === null || req.body[field] === '') {
missingFields.push(field);
}
}
if (missingFields.length > 0) {
logWarning('Validation failed - missing required fields', {
missingFields,
endpoint: req.path
}, req, res);
return ErrorResponseService.sendBadRequest(
res,
'Missing required fields',
{ missingFields }
);
}
next();
};
}
/**
* Validates field types in request body
* @param fieldTypes Object mapping field names to expected types
*/
static validateFieldTypes(fieldTypes: Record<string, 'string' | 'number' | 'boolean' | 'array' | 'object'>) {
return (req: Request, res: Response, next: NextFunction) => {
const typeErrors: string[] = [];
for (const [field, expectedType] of Object.entries(fieldTypes)) {
if (req.body && req.body[field] !== undefined) {
const actualType = Array.isArray(req.body[field]) ? 'array' : typeof req.body[field];
if (actualType !== expectedType) {
typeErrors.push(`Field '${field}' should be ${expectedType}, got ${actualType}`);
}
}
}
if (typeErrors.length > 0) {
logWarning('Validation failed - invalid field types', {
typeErrors,
endpoint: req.path
}, req, res);
return ErrorResponseService.sendBadRequest(
res,
'Invalid field types',
{ errors: typeErrors }
);
}
next();
};
}
/**
* Validates string field length constraints
* @param constraints Object mapping field names to min/max length
*/
static validateStringLength(constraints: Record<string, { min?: number; max?: number }>) {
return (req: Request, res: Response, next: NextFunction) => {
const lengthErrors: string[] = [];
for (const [field, constraint] of Object.entries(constraints)) {
if (req.body && typeof req.body[field] === 'string') {
const value = req.body[field];
if (constraint.min !== undefined && value.length < constraint.min) {
lengthErrors.push(`Field '${field}' must be at least ${constraint.min} characters`);
}
if (constraint.max !== undefined && value.length > constraint.max) {
lengthErrors.push(`Field '${field}' must not exceed ${constraint.max} characters`);
}
}
}
if (lengthErrors.length > 0) {
logWarning('Validation failed - string length constraints', {
lengthErrors,
endpoint: req.path
}, req, res);
return ErrorResponseService.sendBadRequest(
res,
'String length validation failed',
{ errors: lengthErrors }
);
}
next();
};
}
/**
* Validates email format
* @param emailFields Array of field names that should contain valid emails
*/
static validateEmailFormat(emailFields: string[]) {
return (req: Request, res: Response, next: NextFunction) => {
const emailErrors: string[] = [];
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
for (const field of emailFields) {
if (req.body && req.body[field] && typeof req.body[field] === 'string') {
if (!emailRegex.test(req.body[field])) {
emailErrors.push(`Field '${field}' must contain a valid email address`);
}
}
}
if (emailErrors.length > 0) {
logWarning('Validation failed - invalid email format', {
emailErrors,
endpoint: req.path
}, req, res);
return ErrorResponseService.sendBadRequest(
res,
'Email format validation failed',
{ errors: emailErrors }
);
}
next();
};
}
/**
* Validates UUIDs format
* @param uuidFields Array of field names that should contain valid UUIDs
*/
static validateUUIDFormat(uuidFields: string[]) {
return (req: Request, res: Response, next: NextFunction) => {
const uuidErrors: string[] = [];
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
for (const field of uuidFields) {
const value = field.includes('.')
? this.getNestedValue(req, field)
: req.body?.[field] || req.params?.[field] || req.query?.[field];
if (value && typeof value === 'string') {
if (!uuidRegex.test(value)) {
uuidErrors.push(`Field '${field}' must contain a valid UUID`);
}
}
}
if (uuidErrors.length > 0) {
logWarning('Validation failed - invalid UUID format', {
uuidErrors,
endpoint: req.path
}, req, res);
return ErrorResponseService.sendBadRequest(
res,
'UUID format validation failed',
{ errors: uuidErrors }
);
}
next();
};
}
/**
* Validates numeric constraints
* @param constraints Object mapping field names to min/max values
*/
static validateNumericConstraints(constraints: Record<string, { min?: number; max?: number }>) {
return (req: Request, res: Response, next: NextFunction) => {
const numericErrors: string[] = [];
for (const [field, constraint] of Object.entries(constraints)) {
if (req.body && typeof req.body[field] === 'number') {
const value = req.body[field];
if (constraint.min !== undefined && value < constraint.min) {
numericErrors.push(`Field '${field}' must be at least ${constraint.min}`);
}
if (constraint.max !== undefined && value > constraint.max) {
numericErrors.push(`Field '${field}' must not exceed ${constraint.max}`);
}
}
}
if (numericErrors.length > 0) {
logWarning('Validation failed - numeric constraints', {
numericErrors,
endpoint: req.path
}, req, res);
return ErrorResponseService.sendBadRequest(
res,
'Numeric validation failed',
{ errors: numericErrors }
);
}
next();
};
}
/**
* Validates that arrays are not empty
* @param arrayFields Array of field names that should contain non-empty arrays
*/
static validateNonEmptyArrays(arrayFields: string[]) {
return (req: Request, res: Response, next: NextFunction) => {
const arrayErrors: string[] = [];
for (const field of arrayFields) {
if (req.body && Array.isArray(req.body[field])) {
if (req.body[field].length === 0) {
arrayErrors.push(`Field '${field}' must not be empty`);
}
} else if (req.body && req.body[field] !== undefined) {
arrayErrors.push(`Field '${field}' must be an array`);
}
}
if (arrayErrors.length > 0) {
logWarning('Validation failed - empty arrays', {
arrayErrors,
endpoint: req.path
}, req, res);
return ErrorResponseService.sendBadRequest(
res,
'Array validation failed',
{ errors: arrayErrors }
);
}
next();
};
}
/**
* Validates allowed values for enum-like fields
* @param allowedValues Object mapping field names to arrays of allowed values
*/
static validateAllowedValues(allowedValues: Record<string, any[]>) {
return (req: Request, res: Response, next: NextFunction) => {
const valueErrors: string[] = [];
for (const [field, allowed] of Object.entries(allowedValues)) {
if (req.body && req.body[field] !== undefined) {
if (!allowed.includes(req.body[field])) {
valueErrors.push(`Field '${field}' must be one of: ${allowed.join(', ')}`);
}
}
}
if (valueErrors.length > 0) {
logWarning('Validation failed - disallowed values', {
valueErrors,
endpoint: req.path
}, req, res);
return ErrorResponseService.sendBadRequest(
res,
'Value validation failed',
{ errors: valueErrors }
);
}
next();
};
}
/**
* Combines multiple validation middlewares
* @param validations Array of validation middleware functions
*/
static combine(validations: Array<(req: Request, res: Response, next: NextFunction) => void>) {
return async (req: Request, res: Response, next: NextFunction) => {
let currentIndex = 0;
const runNext = (error?: any) => {
if (error) {
return next(error);
}
if (currentIndex >= validations.length) {
return next();
}
const currentValidation = validations[currentIndex++];
try {
currentValidation(req, res, (err?: any) => {
if (res.headersSent) {
return; // Response already sent, don't continue
}
runNext(err);
});
} catch (error) {
logError('Validation middleware error', error as Error, req, res);
ErrorResponseService.sendInternalServerError(res);
}
};
runNext();
};
}
/**
* Helper method to get nested values from request
* @param req Request object
* @param path Dot-notation path like 'body.user.id'
*/
private static getNestedValue(req: Request, path: string): any {
const parts = path.split('.');
let current: any = req;
for (const part of parts) {
if (current && typeof current === 'object') {
current = current[part];
} else {
return undefined;
}
}
return current;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,11 @@
export interface CreateUserCommand {
username: string;
password: string;
email: string;
fname: string;
lname: string;
code?: string;
orgid?: string;
type: string;
phone?: string;
}
@@ -0,0 +1,89 @@
import { IUserRepository } from '../../../Domain/IRepository/IUserRepository';
import { CreateUserCommand } from './CreateUserCommand';
import { ShortUserDto } from '../../DTOs/UserDto';
import { UserAggregate, UserState } from '../../../Domain/User/UserAggregate';
import { UserMapper } from '../../DTOs/Mappers/UserMapper';
import { PasswordService } from '../../Services/PasswordService';
import { EmailService } from '../../Services/EmailService';
import { TokenService } from '../../Services/TokenService';
import { logDatabase, logError, logAuth, logWarning } from '../../Services/Logger';
export class CreateUserCommandHandler {
private emailService: EmailService;
constructor(private readonly userRepo: IUserRepository) {
this.emailService = new EmailService();
}
async execute(cmd: CreateUserCommand): Promise<ShortUserDto> {
try {
// Validate password strength
const passwordValidation = PasswordService.validatePasswordStrength(cmd.password);
if (!passwordValidation.isValid) {
throw new Error(`Password validation failed: ${passwordValidation.errors.join(', ')}`);
}
const user = new UserAggregate();
user.username = cmd.username;
// Hash the password before storing
user.password = await PasswordService.hashPassword(cmd.password);
// Generate verification token
const verificationTokenData = TokenService.generateVerificationToken();
user.token = await TokenService.hashToken(verificationTokenData.token);
user.TokenExpires = verificationTokenData.expiresAt;
user.email = cmd.email;
user.fname = cmd.fname;
user.lname = cmd.lname;
user.orgid = cmd.orgid || null;
user.token = cmd.code || null;
user.type = cmd.type;
user.phone = cmd.phone || null;
user.state = UserState.REGISTERED_NOT_VERIFIED;
const created = await this.userRepo.create(user);
// Send verification email
try {
const baseUrl = process.env.APP_BASE_URL || 'http://localhost:3000';
const verificationUrl = TokenService.generateVerificationUrl(baseUrl, verificationTokenData.token);
const emailSent = await this.emailService.sendVerificationEmail(
created.email,
`${created.fname} ${created.lname}`,
verificationTokenData.token,
verificationUrl
);
if (!emailSent) {
logWarning('Failed to send verification email', { email: created.email, userId: created.id });
// Don't throw error - user creation should still succeed even if email fails
} else {
logAuth('Verification email sent successfully', created.id, { email: created.email });
}
} catch (emailError) {
logError('Error sending verification email', emailError as Error);
// Don't throw error - user creation should still succeed even if email fails
}
return UserMapper.toShortDto(created);
} catch (error) {
logError('CreateUserCommandHandler error', error as Error);
// Re-throw validation errors as-is
if (error instanceof Error && error.message.includes('Password validation failed')) {
throw error;
}
// Handle database constraint errors
if (error instanceof Error && (error.message.includes('duplicate') || error.message.includes('unique'))) {
throw new Error('User with this username or email already exists');
}
// Generic error for other cases
throw new Error('Failed to create user');
}
}
}
@@ -0,0 +1,3 @@
export interface DeactivateUserCommand {
id: string;
}
@@ -0,0 +1,12 @@
import { IUserRepository } from '../../../Domain/IRepository/IUserRepository';
import { DeactivateUserCommand } from './DeactivateUserCommand';
export class DeactivateUserCommandHandler {
constructor(private readonly userRepo: IUserRepository) {}
async execute(cmd: DeactivateUserCommand): Promise<boolean> {
await this.userRepo.deactivate(cmd.id);
return true;
}
}
@@ -0,0 +1,4 @@
export interface DeleteUserCommand {
id: string;
soft?: boolean;
}
@@ -0,0 +1,16 @@
import { IUserRepository } from '../../../Domain/IRepository/IUserRepository';
import { DeleteUserCommand } from './DeleteUserCommand';
export class DeleteUserCommandHandler {
constructor(private readonly userRepo: IUserRepository) {}
async execute(cmd: DeleteUserCommand): Promise<boolean> {
if (cmd.soft) {
await this.userRepo.softDelete(cmd.id);
} else {
await this.userRepo.delete(cmd.id);
}
return true;
}
}
@@ -0,0 +1,4 @@
export interface LoginCommand {
username: string;
password: string;
}
@@ -0,0 +1,188 @@
import { IUserRepository } from '../../../Domain/IRepository/IUserRepository';
import { IOrganizationRepository } from '../../../Domain/IRepository/IOrganizationRepository';
import { LoginCommand } from './LoginCommand';
import { ShortUserDto } from '../../DTOs/UserDto';
import { UserMapper } from '../../DTOs/Mappers/UserMapper';
import { PasswordService } from '../../Services/PasswordService';
import { JWTService } from '../../Services/JWTService';
import { UserState } from '../../../Domain/User/UserAggregate';
import { logAuth, logDatabase, logError, logWarning } from '../../Services/Logger';
import { Response } from 'express';
export interface LoginResponse {
user: ShortUserDto;
token: string;
requiresOrgReauth?: boolean;
orgLoginUrl?: string;
organizationName?: string;
}
export class LoginCommandHandler {
constructor(
private readonly userRepo: IUserRepository,
private readonly jwtService: JWTService,
private readonly orgRepo: IOrganizationRepository
) {}
async execute(cmd: LoginCommand, res?: Response): Promise<LoginResponse | null> {
const startTime = Date.now();
try {
logAuth('Login attempt', undefined, { username: cmd.username });
const user = await this.userRepo.findByUsername(cmd.username) ||
await this.userRepo.findByEmail(cmd.username);
logDatabase('User lookup completed', undefined, Date.now() - startTime, {
found: !!user,
searchBy: cmd.username.includes('@') ? 'email' : 'username'
});
if (!user) {
logAuth('Login failed - User not found', undefined, { username: cmd.username });
return null;
}
// Check if user account state allows login
const restrictedStates = [
UserState.REGISTERED_NOT_VERIFIED,
UserState.SOFT_DELETE,
UserState.DEACTIVATED
];
if (restrictedStates.includes(user.state)) {
let stateDescription = '';
switch (user.state) {
case UserState.REGISTERED_NOT_VERIFIED:
stateDescription = 'Email not verified';
break;
case UserState.SOFT_DELETE:
stateDescription = 'Account deleted';
break;
case UserState.DEACTIVATED:
stateDescription = 'Account deactivated';
break;
}
logAuth('Login failed - Account state restriction', user.id, {
username: cmd.username,
userState: user.state,
stateDescription
});
return null;
}
try {
const passwordStartTime = Date.now();
const isPasswordValid = await PasswordService.verifyPassword(cmd.password, user.password);
logAuth('Password verification completed', user.id, {
valid: isPasswordValid,
verificationTime: Date.now() - passwordStartTime
});
if (!isPasswordValid) {
logWarning('Login failed - Invalid password', {
userId: user.id,
username: cmd.username
});
return null;
}
} catch (error) {
logError('Password verification error', error as Error);
return null;
}
const mockRes = {
cookie: () => {}
} as any;
const tokenPayload = {
userId: user.id,
authLevel: (user.state === UserState.ADMIN ? 1 : 0) as 0 | 1,
userStatus: user.state,
orgId: user.orgid || ''
};
try {
// Use the real response object if provided, otherwise use mock
const responseObj = res || mockRes;
const token = this.jwtService.create(tokenPayload, responseObj);
// Check if user belongs to an organization and needs reauthentication
let requiresOrgReauth = false;
let orgLoginUrl: string | undefined;
let organizationName: string | undefined;
if (user.orgid) {
const organization = await this.orgRepo.findById(user.orgid);
if (organization) {
organizationName = organization.name;
// Check if user has logged in to organization within the last month
const oneMonthAgo = new Date();
oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
const needsReauth = !user.Orglogindate || user.Orglogindate < oneMonthAgo;
if (needsReauth && organization.url) {
requiresOrgReauth = true;
orgLoginUrl = organization.url;
logAuth('User requires organization reauthentication', user.id, {
organizationId: user.orgid,
organizationName: organization.name,
lastOrgLogin: user.Orglogindate?.toISOString() || 'never',
orgLoginUrl: organization.url
});
}
}
}
logAuth('Login successful', user.id, {
authLevel: tokenPayload.authLevel,
userStatus: tokenPayload.userStatus,
orgId: tokenPayload.orgId,
requiresOrgReauth,
organizationName,
totalLoginTime: Date.now() - startTime
});
const response: LoginResponse = {
user: UserMapper.toShortDto(user),
token
};
if (requiresOrgReauth) {
response.requiresOrgReauth = true;
response.orgLoginUrl = orgLoginUrl;
response.organizationName = organizationName;
}
return response;
} catch (error) {
logError('Token creation failed during login', error as Error);
throw new Error('Login failed due to internal error');
}
} catch (error) {
if (error instanceof Error) {
logError('Login handler error', error);
// Handle database connection errors
if (error.message.includes('database connection')) {
logDatabase('Database connection error during login', undefined, Date.now() - startTime);
throw new Error('Database connection error');
}
// If it's already a properly formatted error, re-throw it
if (error.message === 'Login failed due to internal error' ||
error.message === 'Database connection error') {
throw error;
}
}
// Default database error handling
logDatabase('Unexpected database error during login', undefined, Date.now() - startTime);
throw new Error('Database connection error');
}
}
}
@@ -0,0 +1,3 @@
export interface RequestPasswordResetCommand {
email: string;
}
@@ -0,0 +1,69 @@
import { RequestPasswordResetCommand } from './RequestPasswordResetCommand';
import { IUserRepository } from '../../../Domain/IRepository/IUserRepository';
import { EmailService } from '../../Services/EmailService';
import { TokenService } from '../../Services/TokenService';
import { logAuth, logWarning, logError } from '../../Services/Logger';
export class RequestPasswordResetCommandHandler {
private emailService: EmailService;
constructor(private userRepo: IUserRepository) {
this.emailService = new EmailService();
}
async execute(cmd: RequestPasswordResetCommand): Promise<boolean> {
try {
if (!cmd.email) {
throw new Error('Email is required');
}
// Find user by email
const user = await this.userRepo.findByEmail(cmd.email);
if (!user) {
// Don't reveal if user exists or not for security reasons
// Still return true but don't send email
logAuth(`Password reset requested for non-existent email: ${cmd.email}`);
return true;
}
// Generate password reset token
const resetTokenData = TokenService.generatePasswordResetToken();
// Update user with reset token
user.token = await TokenService.hashToken(resetTokenData.token);
user.TokenExpires = resetTokenData.expiresAt;
await this.userRepo.update(user.id, user);
// Send password reset email
try {
const baseUrl = process.env.APP_BASE_URL || 'http://localhost:3000';
const resetUrl = TokenService.generatePasswordResetUrl(baseUrl, resetTokenData.token);
const emailSent = await this.emailService.sendPasswordResetEmail(
user.email,
`${user.fname} ${user.lname}`,
resetTokenData.token,
resetUrl
);
if (!emailSent) {
logWarning(`Failed to send password reset email to ${user.email}`);
// Don't throw error - request should still succeed even if email fails
} else {
logAuth(`Password reset email sent successfully to ${user.email}`);
}
} catch (emailError) {
logError('Error sending password reset email', emailError instanceof Error ? emailError : new Error(String(emailError)));
// Don't throw error - request should still succeed even if email fails
}
return true;
} catch (error) {
logError('Password reset request error', error instanceof Error ? error : new Error(String(error)));
throw error;
}
}
}
@@ -0,0 +1,4 @@
export interface ResetPasswordCommand {
token: string;
newPassword: string;
}
@@ -0,0 +1,58 @@
import { ResetPasswordCommand } from './ResetPasswordCommand';
import { IUserRepository } from '../../../Domain/IRepository/IUserRepository';
import { TokenService } from '../../Services/TokenService';
import { PasswordService } from '../../Services/PasswordService';
import { logError } from '../../Services/Logger';
export class ResetPasswordCommandHandler {
constructor(private userRepo: IUserRepository) {}
async execute(cmd: ResetPasswordCommand): Promise<boolean> {
try {
if (!cmd.token) {
throw new Error('Reset token is required');
}
if (!cmd.newPassword) {
throw new Error('New password is required');
}
// Validate password strength
const validation = PasswordService.validatePasswordStrength(cmd.newPassword);
if (!validation.isValid) {
throw new Error(`Password validation failed: ${validation.errors.join(', ')}`);
}
// Hash the token to compare with stored value
const hashedToken = await TokenService.hashToken(cmd.token);
// Find user with this password reset token
const user = await this.userRepo.findByToken(hashedToken);
if (!user) {
throw new Error('Invalid or expired reset token');
}
// Check if token is expired
if (user.TokenExpires && user.TokenExpires < new Date()) {
throw new Error('Reset token has expired');
}
// Hash the new password
const hashedPassword = await PasswordService.hashPassword(cmd.newPassword);
// Update user password and clear reset token
user.password = hashedPassword;
user.token = null;
user.TokenExpires = null;
await this.userRepo.update(user.id, user);
return true;
} catch (error) {
logError('Password reset error', error instanceof Error ? error : new Error(String(error)));
throw error;
}
}
}
@@ -0,0 +1,13 @@
export interface UpdateUserCommand {
id: string;
orgid?: string;
username?: string;
password?: string;
email?: string;
fname?: string;
lname?: string;
code?: string;
type?: string;
phone?: string;
state?: number;
}
@@ -0,0 +1,29 @@
import { IUserRepository } from '../../../Domain/IRepository/IUserRepository';
import { UpdateUserCommand } from './UpdateUserCommand';
import { ShortUserDto } from '../../DTOs/UserDto';
import { UserMapper } from '../../DTOs/Mappers/UserMapper';
import { PasswordService } from '../../Services/PasswordService';
export class UpdateUserCommandHandler {
constructor(private readonly userRepo: IUserRepository) {}
async execute(cmd: UpdateUserCommand): Promise<ShortUserDto | null> {
const updateData = { ...cmd };
// Hash the password if it's being updated
if (cmd.password) {
// Validate password strength
const passwordValidation = PasswordService.validatePasswordStrength(cmd.password);
if (!passwordValidation.isValid) {
throw new Error(`Password validation failed: ${passwordValidation.errors.join(', ')}`);
}
updateData.password = await PasswordService.hashPassword(cmd.password);
}
const updated = await this.userRepo.update(cmd.id, updateData);
if (!updated) return null;
return UserMapper.toShortDto(updated);
}
}
@@ -0,0 +1,3 @@
export interface VerifyEmailCommand {
token: string;
}
@@ -0,0 +1,45 @@
import { VerifyEmailCommand } from './VerifyEmailCommand';
import { IUserRepository } from '../../../Domain/IRepository/IUserRepository';
import { TokenService } from '../../Services/TokenService';
import { UserState } from '../../../Domain/User/UserAggregate';
import { logError } from '../../Services/Logger';
export class VerifyEmailCommandHandler {
constructor(private userRepo: IUserRepository) {}
async execute(cmd: VerifyEmailCommand): Promise<boolean> {
try {
if (!cmd.token) {
throw new Error('Verification token is required');
}
// Hash the token to compare with stored value
const hashedToken = await TokenService.hashToken(cmd.token);
// Find user with this verification token
const user = await this.userRepo.findByToken(hashedToken);
if (!user) {
throw new Error('Invalid or expired verification token');
}
// Check if token is expired
if (user.TokenExpires && user.TokenExpires < new Date()) {
throw new Error('Verification token has expired');
}
// Update user verification status
user.token = null;
user.TokenExpires = null;
user.state = UserState.VERIFIED_REGULAR;
await this.userRepo.update(user.id, user);
return true;
} catch (error) {
logError('Email verification error', error instanceof Error ? error : new Error(String(error)));
throw error;
}
}
}

Some files were not shown because too many files have changed in this diff Show More