Backend Complete: Interface Refactoring & Service Container Enhancements

Repository Interface Optimization:
- Created IBaseRepository.ts and IPaginatedRepository.ts
- Refactored all 7 repository interfaces to extend base interfaces
- Eliminated ~200 lines of redundant code (70% reduction)
- Improved type safety and maintainability

 Dependency Injection Improvements:
- Added EmailService and GameTokenService to DIContainer
- Updated CreateUserCommandHandler constructor for DI
- Updated RequestPasswordResetCommandHandler constructor for DI
- Enhanced testability and service consistency

 Environment Configuration:
- Created comprehensive .env.example with 40+ variables
- Organized into 12 logical sections (Database, Security, Email, etc.)
- Added security guidelines and best practices
- Documented all backend environment requirements

 Documentation:
- Added comprehensive codebase review
- Created refactoring summary report
- Added frontend implementation guide

Impact: Improved code quality, reduced maintenance overhead, enhanced developer experience
This commit is contained in:
2025-09-21 03:27:57 +02:00
parent 5b7c3ba4b2
commit 86211923db
306 changed files with 52956 additions and 0 deletions
+252
View File
@@ -0,0 +1,252 @@
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 gameRouter from './routers/gameRouter';
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);
app.use('/api/games', gameRouter);
// 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 };
@@ -0,0 +1,67 @@
import { Request, Response, NextFunction } from 'express';
import { JWTService } from '../../Application/Services/JWTService';
import { UserState } from '../../Domain/User/UserAggregate';
import { logAuth, logWarning } from '../../Application/Services/Logger';
interface AuthenticatedRequest extends Request {
user?: {
userId: string;
authLevel: 0 | 1;
userStatus: UserState;
orgId: string | null;
};
}
/**
* Optional authentication middleware - extracts JWT data if present but doesn't require authentication
* Used for endpoints that work for both authenticated and anonymous users
*/
export const optionalAuth = (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
const jwtService = new JWTService();
try {
// Try to extract token from Authorization header or cookies
const authHeader = req.headers.authorization;
const token = authHeader?.startsWith('Bearer ')
? authHeader.substring(7)
: req.cookies?.auth_token;
if (token) {
// If token exists, try to verify it
const payload = jwtService.verify(req);
if (payload) {
req.user = {
userId: payload.userId,
authLevel: payload.authLevel,
userStatus: payload.userStatus,
orgId: payload.orgId || null
};
logAuth('Optional auth - user authenticated', payload.userId, {
authLevel: payload.authLevel,
userStatus: payload.userStatus,
orgId: payload.orgId
});
} else {
logWarning('Optional auth - invalid token provided', {
hasToken: true,
tokenLength: token.length
});
}
}
// Continue regardless of authentication status
next();
} catch (error) {
// Log the error but continue without authentication
logWarning('Optional auth - error processing token', {
error: error instanceof Error ? error.message : String(error),
hasAuthHeader: !!req.headers.authorization,
hasCookie: !!req.cookies?.auth_token
});
next();
}
};
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,197 @@
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 });
req.body.userid = userId; // Set userId in request body
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.patch('/: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 });
}
if (error instanceof Error && error.message.includes('admin')) {
return res.status(403).json({ error: 'Forbidden: ' + 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,308 @@
import { Router } from 'express';
import { authRequired } from '../../Application/Services/AuthMiddleware';
import { optionalAuth } from '../middleware/optionalAuth';
import { container } from '../../Application/Services/DIContainer';
import { ErrorResponseService } from '../../Application/Services/ErrorResponseService';
import { ValidationMiddleware } from '../../Application/Services/ValidationMiddleware';
import { logRequest, logError, logWarning } from '../../Application/Services/Logger';
import { LoginType } from '../../Domain/Game/GameAggregate';
const gameRouter = Router();
gameRouter.post('/start', authRequired, async (req, res) => {
try {
const userId = (req as any).user.userId;
const orgId = (req as any).user.orgId;
const { deckids, maxplayers, logintype } = req.body;
logRequest('Start game endpoint accessed', req, res, {
userId,
orgId,
deckCount: deckids?.length,
maxplayers,
logintype
});
// Validate required fields
if (!deckids || !Array.isArray(deckids) || deckids.length === 0) {
return res.status(400).json({ error: 'deckids is required and must be a non-empty array' });
}
if (!maxplayers || typeof maxplayers !== 'number') {
return res.status(400).json({ error: 'maxplayers is required and must be a number' });
}
if (logintype === undefined || typeof logintype !== 'number') {
return res.status(400).json({ error: 'logintype is required and must be a number (0=PUBLIC, 1=PRIVATE, 2=ORGANIZATION)' });
}
// Start the game using the GameService
const game = await container.gameService.startGame(
deckids,
maxplayers,
logintype as LoginType,
userId,
orgId
);
logRequest('Game started successfully', req, res, {
userId,
gameId: game.id,
gameCode: game.gamecode,
deckCount: game.gamedecks.length,
totalCards: game.gamedecks.reduce((sum, deck) => sum + deck.cards.length, 0)
});
res.json(game);
} catch (error) {
logError('Start game endpoint error', error as Error, req, res);
if (error instanceof Error) {
if (error.message.includes('not found')) {
return res.status(404).json({ error: error.message });
}
if (error.message.includes('validation') ||
error.message.includes('must be') ||
error.message.includes('required') ||
error.message.includes('Invalid')) {
return res.status(400).json({ error: error.message });
}
}
res.status(500).json({ error: 'Internal server error' });
}
});
gameRouter.post('/join', optionalAuth, async (req, res) => {
try {
const user = (req as any).user;
const { gameCode, playerName } = req.body;
logRequest('Join game endpoint accessed', req, res, {
gameCode,
playerName,
hasAuth: !!user,
userId: user?.userId,
orgId: user?.orgId
});
// Validate required fields
if (!gameCode || typeof gameCode !== 'string') {
return res.status(400).json({ error: 'gameCode is required and must be a string' });
}
if (gameCode.length !== 6) {
return res.status(400).json({ error: 'gameCode must be exactly 6 characters long' });
}
// First, we need to find the game to determine its type
const gameRepository = container.gameRepository;
const gameToJoin = await gameRepository.findByGameCode(gameCode);
if (!gameToJoin) {
return res.status(404).json({ error: 'Game not found' });
}
// Determine join requirements based on game login type
let actualPlayerId: string | undefined;
let actualPlayerName: string | undefined;
let actualOrgId: string | null = null;
switch (gameToJoin.logintype) {
case LoginType.PUBLIC:
// Public games: playerName required, authentication optional
// If user is logged in and no playerName provided, use their username
if (!playerName || typeof playerName !== 'string' || !playerName.trim()) {
if (user && user.userId) {
// User is logged in, fetch their username to use as playerName
try {
const userDetails = await container.getUserByIdQueryHandler.execute({ id: user.userId });
if (userDetails && userDetails.username) {
actualPlayerName = userDetails.username;
logRequest('Using logged-in user\'s username as playerName', req, res, {
userId: user.userId,
username: userDetails.username
});
} else {
return res.status(400).json({
error: 'playerName is required for public games'
});
}
} catch (error) {
logError('Failed to fetch user details for playerName', error as Error, req, res);
return res.status(400).json({
error: 'playerName is required for public games'
});
}
} else {
// User is not logged in, playerName is required
return res.status(400).json({
error: 'playerName is required for public games'
});
}
} else {
// playerName was provided, use it
actualPlayerName = playerName.trim();
}
actualPlayerId = user?.userId; // Use authenticated user ID if available, otherwise undefined
break;
case LoginType.PRIVATE:
// Private games: authentication required
if (!user || !user.userId) {
return res.status(401).json({
error: 'Authentication required to join private games'
});
}
actualPlayerId = user.userId;
actualPlayerName = playerName;
break;
case LoginType.ORGANIZATION:
// Organization games: authentication + organization membership required
if (!user || !user.userId) {
return res.status(401).json({
error: 'Authentication required to join organization games'
});
}
if (!user.orgId) {
return res.status(403).json({
error: 'Organization membership required to join organization games'
});
}
if (gameToJoin.orgid && user.orgId !== gameToJoin.orgid) {
return res.status(403).json({
error: 'You must be a member of the same organization to join this game'
});
}
actualPlayerId = user.userId;
actualPlayerName = playerName;
actualOrgId = user.orgId;
break;
default:
return res.status(400).json({ error: 'Invalid game type' });
}
// Join the game using the GameService with determined parameters
const game = await container.gameService.joinGame(
gameCode,
actualPlayerId,
actualPlayerName,
actualOrgId,
gameToJoin.logintype
);
logRequest('Player joined game successfully', req, res, {
userId: actualPlayerId || 'anonymous',
gameId: game.id,
gameCode: game.gamecode,
gameType: LoginType[gameToJoin.logintype],
playerCount: game.players.length,
maxPlayers: game.maxplayers,
playerName: actualPlayerName
});
res.json(game);
} catch (error) {
logError('Join game endpoint error', error as Error, req, res);
if (error instanceof Error) {
if (error.message.includes('not found')) {
return res.status(404).json({ error: error.message });
}
if (error.message.includes('Authentication required')) {
return res.status(401).json({ error: error.message });
}
if (error.message.includes('Organization') || error.message.includes('organization')) {
return res.status(403).json({ error: error.message });
}
if (error.message.includes('full') ||
error.message.includes('already in') ||
error.message.includes('not accepting')) {
return res.status(409).json({ error: error.message });
}
if (error.message.includes('validation') ||
error.message.includes('must be') ||
error.message.includes('required') ||
error.message.includes('Invalid')) {
return res.status(400).json({ error: error.message });
}
}
res.status(500).json({ error: 'Internal server error' });
}
});
gameRouter.post('/:gameId/start', authRequired, async (req, res) => {
try {
const userId = (req as any).user.userId;
const { gameId } = req.params;
logRequest('Start gameplay endpoint accessed', req, res, {
userId,
gameId
});
// Validate required fields
if (!gameId || typeof gameId !== 'string') {
return res.status(400).json({ error: 'gameId is required and must be a string' });
}
// Start the gameplay using the GameService
const result = await container.gameService.startGamePlay(gameId, userId);
logRequest('Game gameplay started successfully', req, res, {
userId,
gameId,
playerCount: result.game.players.length
});
res.json({
message: 'Game started successfully',
gameId: gameId,
playerCount: result.game.players.length,
game: result.game,
boardData: result.boardData
});
} catch (error) {
logError('Start gameplay endpoint error', error as Error, req, res);
if (error instanceof Error) {
if (error.message.includes('not found')) {
return res.status(404).json({ error: error.message });
}
if (error.message.includes('Only') || error.message.includes('master')) {
return res.status(403).json({ error: error.message });
}
if (error.message.includes('already started') ||
error.message.includes('not ready') ||
error.message.includes('minimum players') ||
error.message.includes('not in waiting state') ||
error.message.includes('cannot be started')) {
return res.status(409).json({ error: error.message });
}
if (error.message.includes('validation') ||
error.message.includes('must be') ||
error.message.includes('required') ||
error.message.includes('Invalid')) {
return res.status(400).json({ error: error.message });
}
// Board generation specific errors
if (error.message.includes('Board generation') ||
error.message.includes('board not found') ||
error.message.includes('BoardGenerationService') ||
error.message.includes('Failed to wait for board generation') ||
error.message.includes('board generation timeout')) {
return res.status(500).json({ error: error.message });
}
}
res.status(500).json({ error: 'Internal server error' });
}
});
export default gameRouter;
@@ -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,66 @@
import e, { Router } from 'express';
import { container, DIContainer } from '../../Application/Services/DIContainer';
import { ErrorResponseService } from '../../Application/Services/ErrorResponseService';
import { logRequest, logError, logAuth, logWarning, logOther } from '../../Application/Services/Logger';
import { GenerateBoardCommand } from '../../Application/Game/commands/GenerateBoardCommand';
const router = Router();
//function to test the search service
async function triggerAsyncBoardGeneration(gameId: string): Promise<boolean> {
try {
// Calculate default field counts based on game configuration
// For now, use reasonable defaults - this should be configurable by host in the future
const maxSpecialFieldsPercentage = parseInt(process.env.MAX_SPECIAL_FIELDS_PERCENTAGE || '67');
const maxSpecialFields = Math.floor((100 * maxSpecialFieldsPercentage) / 100);
// Default distribution: 60% positive, 25% negative, 15% luck
const positiveFieldCount = Math.floor(maxSpecialFields * 0.6);
const negativeFieldCount = Math.floor(maxSpecialFields * 0.25);
const luckFieldCount = Math.floor(maxSpecialFields * 0.15);
const command: GenerateBoardCommand = {
gameId,
positiveFieldCount,
negativeFieldCount,
luckFieldCount
};
logOther(`Triggering async board generation for game ${gameId}`, {
positiveFieldCount,
negativeFieldCount,
luckFieldCount,
totalSpecialFields: positiveFieldCount + negativeFieldCount + luckFieldCount
});
// Execute board generation in background
await DIContainer.getInstance().generateBoardCommandHandler.execute(command);
return true;
} catch (error) {
logError(`Async board generation failed for game ${gameId}`, error as Error);
// Don't propagate error - board generation failure shouldn't affect game creation
return false;
}
}
// Game board generation endpoint
router.post('/gameBoardGeneration', async (req, res) => {
try {
logRequest('Game board generation endpoint accessed', req, res);
const result = await triggerAsyncBoardGeneration("######-#####-#####-######");
if (result) {
logOther('Game board generation triggered successfully', result);
return res.json({ message: 'Game board generation triggered successfully' });
} else {
throw new Error('Game board generation failed to trigger');
}
} catch (error : any) {
logError('Error in game board generation endpoint', error);
return ErrorResponseService.sendInternalServerError(res);
}
});
export default router;
@@ -0,0 +1,313 @@
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 {
throw new Error(`Login failed: ${result}`);
}
} 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('restriction')) {
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) {
// Don't log here since CreateUserCommandHandler already logs system errors
// Only log validation/user input errors at router level
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);
}
// Log unexpected errors that weren't handled by the command handler
if (!error.message.includes('Failed to create user')) {
logError('Unexpected create user endpoint error', error as Error, req, res);
}
}
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);
}
});
//Soft delete user (current user)
userRouter.delete('/profile', authRequired, async (req, res) => {
try {
const userId = (req as any).user.userId;
const result = await container.deleteUserCommandHandler.execute({ id: userId, soft: true });
logRequest('User soft deleted successfully', req, res, { userId });
res.json({ success: result });
} catch (error) {
logError('Soft delete user endpoint error', error as Error, req, res);
return ErrorResponseService.sendInternalServerError(res);
}
});
//logout user (current user)
userRouter.post('/logout', authRequired, async (req, res) => {
try {
const userId = (req as any).user.userId;
await container.logoutCommandHandler.execute(userId, res, req);
logRequest('User logged out successfully', req, res, { userId });
res.json({ success: true });
} catch (error) {
logError('Logout user endpoint error', error as Error, req, res);
return ErrorResponseService.sendInternalServerError(res);
}
});
// Email verification endpoint
userRouter.get('/verify-email/:token', async (req, res) => {
try {
const { token } = req.params;
logRequest('Email verification endpoint accessed', req, res, {
tokenPrefix: token.substring(0, 8) + '...'
});
if (!token) {
return ErrorResponseService.sendBadRequest(res, 'Verification token is required');
}
const result = await container.verifyEmailCommandHandler.execute({ token });
if (result) {
logAuth('Email verification successful', undefined, { tokenPrefix: token.substring(0, 8) + '...' }, req, res);
res.json({ success: true, message: 'Email verified successfully' });
} else {
throw new Error('Email verification failed');
}
} catch (error) {
logError('Email verification endpoint error', error as Error, req, res);
if (error instanceof Error) {
if (error.message.includes('Invalid') || error.message.includes('expired')) {
return ErrorResponseService.sendBadRequest(res, 'Invalid or expired verification token');
}
}
return ErrorResponseService.sendInternalServerError(res);
}
});
// Forgot password request endpoint
userRouter.post('/forgot-password',
ValidationMiddleware.combine([
ValidationMiddleware.validateRequiredFields(['email']),
ValidationMiddleware.validateEmailFormat(['email'])
]),
async (req, res) => {
try {
const { email } = req.body;
logRequest('Forgot password endpoint accessed', req, res, { email });
const result = await container.requestPasswordResetCommandHandler.execute({ email });
if (result) {
logAuth('Password reset request successful', undefined, { email }, req, res);
res.json({
success: true,
message: 'If an account with this email exists, a password reset link has been sent'
});
} else {
throw new Error('Password reset request failed');
}
} catch (error) {
logError('Forgot password endpoint error', error as Error, req, res);
// Always return success for security (don't reveal if email exists)
res.json({
success: true,
message: 'If an account with this email exists, a password reset link has been sent'
});
}
});
// Reset password endpoint
userRouter.post('/reset-password',
ValidationMiddleware.combine([
ValidationMiddleware.validateRequiredFields(['token', 'newPassword']),
ValidationMiddleware.validateStringLength({
newPassword: { min: 6, max: 100 }
})
]),
async (req, res) => {
try {
const { token, newPassword } = req.body;
logRequest('Reset password endpoint accessed', req, res, {
tokenPrefix: token.substring(0, 8) + '...'
});
const result = await container.resetPasswordCommandHandler.execute({ token, newPassword });
if (result) {
logAuth('Password reset successful', undefined, { tokenPrefix: token.substring(0, 8) + '...' }, req, res);
res.json({ success: true, message: 'Password reset successfully' });
} else {
throw new Error('Password reset failed');
}
} catch (error) {
logError('Reset password endpoint error', error as Error, req, res);
if (error instanceof Error) {
if (error.message.includes('Invalid') || error.message.includes('expired')) {
return ErrorResponseService.sendBadRequest(res, 'Invalid or expired reset token');
}
if (error.message.includes('Password validation')) {
return ErrorResponseService.sendBadRequest(res, error.message);
}
}
return ErrorResponseService.sendInternalServerError(res);
}
});
export default userRouter;
@@ -0,0 +1,101 @@
import swaggerJSDoc from 'swagger-jsdoc';
import path from 'path';
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:3001',
description: 'Local development server'
},
{
url: 'http://localhost:3000',
description: 'Local development server (alt)'
},
{
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'
},
{
name: 'Games',
description: 'Game management and gameplay'
},
{
name: 'Admin - Users',
description: 'Admin user management operations'
},
{
name: 'Admin - Decks',
description: 'Admin deck management operations'
},
{
name: 'Admin - Organizations',
description: 'Admin organization management operations'
},
{
name: 'Admin - Chats',
description: 'Admin chat management operations'
},
{
name: 'Admin - Contacts',
description: 'Admin contact management operations'
}
]
},
apis: [
'./src/Api/swagger/swaggerDefinitionsFixed.ts'
],
};
export const swaggerSpec = swaggerJSDoc(swaggerOptions);
File diff suppressed because it is too large Load Diff
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,46 @@
import * as DeckAggregate from "../../Domain/Deck/DeckAggregate";
export interface GameStartDto {
gameid: string;
maxplayers: number;
logintype: number;
gamecode: string;
deck: gamedeck[];
}
enum decktype {
JOCKER = 0,
LUCK = 1,
QUEST = 2
}
export interface cards {
cardid: string;
question?: string;
answer?: string;
consequence?: DeckAggregate.Consequence | null;
played?: boolean;
playerid?: string;
}
export interface gamedeck {
deckid: string;
decktype: decktype;
cards: cards[];
}
export interface GameDataDto {
id: string;
gamecode: string;
maxplayers: number;
logintype: number;
gamedecks: gamedeck[];
players: string[];
started: boolean;
finished: boolean;
winner?: string;
currentplayer?: string;
createdate: Date;
startdate?: Date;
enddate?: Date;
}
@@ -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,32 @@
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,
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,29 @@
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;
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,10 @@
export interface UpdateDeckCommand {
id: string;
userstate?: number;
name?: string;
type?: number;
userid?: string;
cards?: any[];
ctype?: number;
state?: number;
}
@@ -0,0 +1,50 @@
import { IDeckRepository } from '../../../Domain/IRepository/IDeckRepository';
import { UpdateDeckCommand } from './UpdateDeckCommand';
import { ShortDeckDto } from '../../DTOs/DeckDto';
import { DeckMapper } from '../../DTOs/Mappers/DeckMapper';
import { DeckAggregate } from '../../../Domain/Deck/DeckAggregate';
import { logError } from '../../Services/Logger';
export class UpdateDeckCommandHandler {
constructor(private readonly deckRepo: IDeckRepository) {}
async execute(cmd: UpdateDeckCommand): Promise<ShortDeckDto | null> {
if(cmd.state !== undefined && cmd.userstate!==1) {
throw new Error('Only admin users can change deck state');
}
try {
let existingDeck: DeckAggregate | null = null;
if (cmd.userstate === 1) {
existingDeck = await this.deckRepo.findByIdIncludingDeleted(cmd.id);
} else {
existingDeck = await this.deckRepo.findById(cmd.id);
}
if (!existingDeck) {
logError(`Deck not found with ID: ${cmd.id}`);
throw new Error('Deck not found');
}
const for_update: Partial<DeckAggregate> = {};
if(cmd.name !== undefined) for_update.name = cmd.name;
if(cmd.type !== undefined) for_update.type = cmd.type;
if(cmd.cards !== undefined) for_update.cards = cmd.cards;
if(cmd.ctype !== undefined) for_update.ctype = cmd.ctype;
if(cmd.state !== undefined) for_update.state = cmd.state;
// Ensure we have something to update
if (Object.keys(for_update).length === 0) {
throw new Error('No fields provided for update');
}
const deck = await this.deckRepo.update(cmd.id, { ...for_update });
if(!deck) {
logError(`Deck update failed for ID: ${cmd.id}. Update returned null.`);
throw new Error('Failed to update deck');
}
return DeckMapper.toShortDto(deck);
} catch (error: any) {
logError(`Error updating deck: ${cmd.id}`, error);
throw error;
}
}
}
@@ -0,0 +1,3 @@
export interface GetDeckByIdQuery {
id: string;
}
@@ -0,0 +1,14 @@
import { IDeckRepository } from '../../../Domain/IRepository/IDeckRepository';
import { GetDeckByIdQuery } from './GetDeckByIdQuery';
import { DetailDeckDto } from '../../DTOs/DeckDto';
import { DeckMapper } from '../../DTOs/Mappers/DeckMapper';
export class GetDeckByIdQueryHandler {
constructor(private readonly deckRepo: IDeckRepository) {}
async execute(query: GetDeckByIdQuery): Promise<DetailDeckDto | null> {
const deck = await this.deckRepo.findById(query.id);
if (!deck) return null;
return DeckMapper.toDetailDto(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,199 @@
import { GameField, BoardData } from '../../Domain/Game/GameAggregate';
import { logOther, logError } from '../Services/Logger';
interface SpecialFieldInfo {
position: number;
type: 'positive' | 'negative' | 'luck';
}
export class BoardGenerationService {
async generateBoard(
positiveFieldCount: number,
negativeFieldCount: number,
luckFieldCount: number
): Promise<BoardData> {
// Pattern-based approach has 100% success rate, no retry needed
const result = this.generateSingleAttempt(positiveFieldCount, negativeFieldCount, luckFieldCount);
logOther('Pattern-based board generation completed', {
totalFields: result.fields.length,
specialFields: result.fields.filter((f: GameField) => f.type !== 'regular').length,
positiveFields: result.fields.filter((f: GameField) => f.type === 'positive').length,
negativeFields: result.fields.filter((f: GameField) => f.type === 'negative').length,
luckFields: result.fields.filter((f: GameField) => f.type === 'luck').length
});
return result;
}
private generateSingleAttempt(
positiveFieldCount: number,
negativeFieldCount: number,
luckFieldCount: number
): BoardData {
// Step 1: Choose special field positions
const specialFieldPositions = this.chooseSpecialFieldPositions(
positiveFieldCount,
negativeFieldCount,
luckFieldCount
);
// Step 2: Calculate step values using pattern-based approach
const fields = this.calculatePatternBasedStepValues(specialFieldPositions);
return {
fields
};
}
private chooseSpecialFieldPositions(
positiveFieldCount: number,
negativeFieldCount: number,
luckFieldCount: number
): SpecialFieldInfo[] {
const totalSpecial = positiveFieldCount + negativeFieldCount + luckFieldCount;
const specialFields: SpecialFieldInfo[] = [];
// Generate unique random positions
const positions = new Set<number>();
while (positions.size < totalSpecial) {
const position = Math.floor(Math.random() * 100) + 1; // 1-100
positions.add(position);
}
// Convert to sorted array
const sortedPositions = Array.from(positions).sort((a, b) => a - b);
// Distribute types randomly
const types: ('positive' | 'negative' | 'luck')[] = [
...Array(positiveFieldCount).fill('positive'),
...Array(negativeFieldCount).fill('negative'),
...Array(luckFieldCount).fill('luck')
];
// Shuffle types
for (let i = types.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[types[i], types[j]] = [types[j], types[i]];
}
sortedPositions.forEach((position, index) => {
specialFields.push({
position,
type: types[index] || 'positive'
});
});
return specialFields;
}
private calculatePatternBasedStepValues(specialFields: SpecialFieldInfo[]): GameField[] {
// Initialize all fields as regular
const fields: GameField[] = Array.from({ length: 100 }, (_, i) => ({
position: i + 1,
type: 'regular' as const
}));
// Update special fields with pattern-based step values
specialFields.forEach(specialField => {
const fieldIndex = specialField.position - 1; // Convert to 0-based index
fields[fieldIndex].type = specialField.type;
if (specialField.type === 'luck') {
// Luck fields don't need step values
return;
}
// Calculate step values based on position rules
let maxStepValue: number;
let minStepValue: number;
if (specialField.position <= 80) {
// Positions 1-80: step values can be ±20
maxStepValue = 20;
minStepValue = -20;
} else {
// Positions 81-100: step values can be -30 to +10
maxStepValue = 10;
minStepValue = -30;
}
// Generate appropriate step value for field type
if (specialField.type === 'positive') {
// Positive fields: use positive step values (3-8 range for good gameplay)
const stepValue = Math.floor(Math.random() * 6) + 3; // 3-8
fields[fieldIndex].stepValue = Math.min(stepValue, maxStepValue);
} else {
// Negative fields: use negative step values (-3 to -8 range)
const stepValue = -(Math.floor(Math.random() * 6) + 3); // -3 to -8
fields[fieldIndex].stepValue = Math.max(stepValue, minStepValue);
}
});
return fields;
}
// This method can be used by FieldEffectService for movement calculations
public calculatePatternBasedMovement(
currentPosition: number,
stepValue: number,
diceValue: number
): number {
// Calculate pattern modifier based on current position
const patternModifier = this.getPatternModifier(currentPosition);
// Calculate final position: currentPosition + (stepValue × dice) + patternModifier
const movement = stepValue * diceValue;
let finalPosition = currentPosition + movement + patternModifier;
// Ensure position stays within board bounds (1-100)
if (finalPosition < 1) {
finalPosition = 1;
} else if (finalPosition > 100) {
finalPosition = 100;
}
return finalPosition;
}
private getPatternModifier(position: number): number {
// Pattern modifiers for strategic complexity:
// - Positions ending in 0 (10, 20, 30...): No modifier
// - Positions ending in 5 (15, 25, 35...): ±3 modifier
// - Positions divisible by 3 (9, 12, 21...): ±2 modifier
// - Odd positions (1, 7, 11...): ±1 modifier
// - Other even positions: No modifier
if (position % 10 === 0) {
return 0; // Positions ending in 0
} else if (position % 10 === 5) {
return Math.random() < 0.5 ? 3 : -3; // Positions ending in 5
} else if (position % 3 === 0) {
return Math.random() < 0.5 ? 2 : -2; // Divisible by 3
} else if (position % 2 === 1) {
return Math.random() < 0.5 ? 1 : -1; // Odd positions
} else {
return 0; // Other even positions
}
}
private validate20_30Rule(currentPosition: number, targetPosition: number, distance: number): boolean {
// Fields 1-85: max 20 fields in any direction
if (currentPosition <= 85) {
return distance <= 20;
}
// Fields 86-100: max 30 fields backward, max 20 fields forward
if (currentPosition > 85) {
if (targetPosition > currentPosition) {
// Moving forward: max 20 fields
return distance <= 20;
} else {
// Moving backward: max 30 fields
return distance <= 30;
}
}
return false;
}
}
@@ -0,0 +1,303 @@
import { StartGameCommand } from './commands/StartGameCommand';
import { StartGameCommandHandler } from './commands/StartGameCommandHandler';
import { JoinGameCommand } from './commands/JoinGameCommand';
import { JoinGameCommandHandler } from './commands/JoinGameCommandHandler';
import { StartGamePlayCommand } from './commands/StartGamePlayCommand';
import { StartGamePlayCommandHandler, GameStartResult } from './commands/StartGamePlayCommandHandler';
import { GameAggregate, LoginType } from '../../Domain/Game/GameAggregate';
import { logOther, logError } from '../Services/Logger';
export class GameService {
private startGameHandler: StartGameCommandHandler;
private joinGameHandler: JoinGameCommandHandler;
private startGamePlayHandler: StartGamePlayCommandHandler;
constructor() {
this.startGameHandler = new StartGameCommandHandler();
this.joinGameHandler = new JoinGameCommandHandler();
this.startGamePlayHandler = new StartGamePlayCommandHandler();
}
/**
* Starts a new game with the provided deck IDs
* @param deckids Array of deck IDs (should contain 3 types: LUCK, JOKER, QUESTION)
* @param maxplayers Maximum number of players allowed in the game
* @param logintype How players can join the game (PUBLIC, PRIVATE, ORGANIZATION)
* @param userid Optional ID of the user creating the game
* @returns Promise<GameAggregate> The created game
*/
async startGame(
deckids: string[],
maxplayers: number,
logintype: LoginType,
userid?: string,
orgid?: string | null
): Promise<GameAggregate> {
const startTime = performance.now();
try {
logOther('GameService.startGame called', {
deckCount: deckids.length,
maxplayers,
logintype,
userid,
orgid
});
// Validate input parameters
this.validateStartGameInput(deckids, maxplayers, logintype);
// Create and execute the command
const command: StartGameCommand = {
deckids,
maxplayers,
logintype,
userid,
orgid
};
const game = await this.startGameHandler.handle(command);
const endTime = performance.now();
logOther('Game started successfully', {
gameId: game.id,
gameCode: game.gamecode,
deckCount: game.gamedecks.length,
totalCards: game.gamedecks.reduce((sum, deck) => sum + deck.cards.length, 0),
executionTime: Math.round(endTime - startTime)
});
return game;
} catch (error) {
const endTime = performance.now();
logError('GameService.startGame failed', error instanceof Error ? error : new Error(String(error)));
logOther('Game start failed', {
executionTime: Math.round(endTime - startTime),
error: error instanceof Error ? error.message : String(error)
});
throw error;
}
}
/**
* Join an existing game using game code
* @param gameCode 6-character game code
* @param playerId ID of the player joining (optional for public games)
* @param playerName Display name for the player
* @param orgId Organization ID (for organization games)
* @param loginType Type of join being attempted
* @returns Promise<GameAggregate> The updated game with new player
*/
async joinGame(
gameCode: string,
playerId?: string,
playerName?: string,
orgId?: string | null,
loginType?: LoginType
): Promise<GameAggregate> {
const startTime = performance.now();
try {
logOther('GameService.joinGame called', {
gameCode,
playerId: playerId || 'anonymous',
playerName,
orgId,
loginType
});
// Validate input parameters
this.validateJoinGameInput(gameCode, playerId, loginType);
// Create and execute the command
const command: JoinGameCommand = {
gameCode,
playerId,
playerName,
orgId,
loginType: loginType || LoginType.PUBLIC
};
const game = await this.joinGameHandler.handle(command);
const endTime = performance.now();
logOther('Player joined game successfully', {
gameId: game.id,
gameCode: game.gamecode,
playerId,
playerCount: game.players.length,
maxPlayers: game.maxplayers,
executionTime: Math.round(endTime - startTime)
});
return game;
} catch (error) {
const endTime = performance.now();
logError('GameService.joinGame failed', error instanceof Error ? error : new Error(String(error)));
logOther('Game join failed', {
gameCode,
playerId,
executionTime: Math.round(endTime - startTime),
error: error instanceof Error ? error.message : String(error)
});
throw error;
}
}
/**
* Start an existing game (move from WAITING to ACTIVE)
* Initializes all player positions to 0 and assigns random turn order
* @param gameId Game ID to start
* @param userId User ID of the game master (optional for public games)
* @returns Promise<GameAggregate> The updated game
*/
async startGamePlay(
gameId: string,
userId?: string
): Promise<GameStartResult> {
const startTime = performance.now();
try {
logOther('GameService.startGamePlay called', {
gameId,
userId: userId || 'system'
});
// Validate input parameters
this.validateStartGamePlayInput(gameId);
// Create and execute the command
const command: StartGamePlayCommand = {
gameId,
userId
};
const result = await this.startGamePlayHandler.handle(command);
const endTime = performance.now();
logOther('Game play started successfully', {
gameId: result.game.id,
gameCode: result.game.gamecode,
playerCount: result.game.players.length,
gameState: result.game.state,
executionTime: Math.round(endTime - startTime)
});
return result;
} catch (error) {
const endTime = performance.now();
logError('GameService.startGamePlay failed', error instanceof Error ? error : new Error(String(error)));
logOther('Game play start failed', {
gameId,
userId,
executionTime: Math.round(endTime - startTime),
error: error instanceof Error ? error.message : String(error)
});
throw error;
}
}
private validateStartGamePlayInput(gameId: string): void {
// Validate game ID
if (!gameId || typeof gameId !== 'string') {
throw new Error('Game ID is required and must be a string');
}
logOther('Start game play input validation passed', {
gameId
});
}
private validateJoinGameInput(gameCode: string, playerId?: string, loginType?: LoginType): void {
// Validate game code
if (!gameCode || typeof gameCode !== 'string') {
throw new Error('Game code is required and must be a string');
}
if (gameCode.length !== 6) {
throw new Error('Game code must be exactly 6 characters long');
}
// Validate login type specific requirements
if (loginType === LoginType.PRIVATE || loginType === LoginType.ORGANIZATION) {
if (!playerId || typeof playerId !== 'string') {
throw new Error(`Player ID is required for ${LoginType[loginType]} games`);
}
}
logOther('Join game input validation passed', {
gameCode,
playerId: playerId || 'anonymous',
loginType
});
}
private validateStartGameInput(deckids: string[], maxplayers: number, logintype: LoginType): void {
// Validate deck IDs
if (!deckids || deckids.length === 0) {
throw new Error('At least one deck ID must be provided');
}
if (deckids.length < 3) {
throw new Error('At least 3 decks are required to start a game (one for each type: LUCK, JOKER, QUESTION)');
}
// Validate max players
if (!maxplayers || maxplayers < 2) {
throw new Error('Maximum players must be at least 2');
}
if (maxplayers > 8) {
throw new Error('Maximum players cannot exceed 8');
}
// Validate login type
if (logintype < 0 || logintype > 2) {
throw new Error('Invalid login type. Must be PUBLIC (0), PRIVATE (1), or ORGANIZATION (2)');
}
// Check for duplicate deck IDs
const uniqueIds = new Set(deckids);
if (uniqueIds.size !== deckids.length) {
throw new Error('Duplicate deck IDs are not allowed');
}
logOther('Start game input validation passed', {
deckCount: deckids.length,
maxplayers,
logintype
});
}
/**
* Game flow explanation (to be implemented later):
*
* 1. START GAME (implemented above):
* - Input: deckids, maxplayers, logintype, gamecode
* - Process: Fetch decks, validate types, shuffle cards, create game
* - Output: Game with shuffled deck objects
*
* 2. JOIN GAME (to be implemented):
* - Input: gamecode, playerid
* - Process: Find game, validate capacity, add player
* - Output: Updated game with new player
*
* 3. GAME ROUNDS (to be implemented):
* - Input: gameid, current player
* - Process: Manage turn order, track game state
* - Output: Current player information
*
* 4. PICK CARD (to be implemented):
* - Input: gameid, playerid, deck type
* - Process: Draw card from specific deck, apply consequence
* - Output: Card details and consequence effects
*
* 5. END GAME (to be implemented):
* - Input: gameid, winner
* - Process: Set game as finished, record winner
* - Output: Final game state
*/
}
@@ -0,0 +1,6 @@
export interface GenerateBoardCommand {
gameId: string;
positiveFieldCount: number;
negativeFieldCount: number;
luckFieldCount: number;
}
@@ -0,0 +1,63 @@
import { GenerateBoardCommand } from './GenerateBoardCommand';
import { BoardGenerationService } from '../BoardGenerationService';
import { RedisService } from '../../Services/RedisService';
import { logOther, logError } from '../../Services/Logger';
import { BoardData } from '../../../Domain/Game/GameAggregate';
export class GenerateBoardCommandHandler {
constructor(
private readonly boardGenerationService: BoardGenerationService,
private readonly redisService: RedisService
) {}
async execute(cmd: GenerateBoardCommand): Promise<void> {
try {
logOther(`Starting board generation for game ${cmd.gameId}`);
const startTime = Date.now();
// Generate board with 20-30 rule validation
const boardData = await this.boardGenerationService.generateBoard(
cmd.positiveFieldCount,
cmd.negativeFieldCount,
cmd.luckFieldCount
);
// Store in Redis
const boardDataWithMetadata: BoardData = {
...boardData,
gameId: cmd.gameId,
generatedAt: new Date(),
generationComplete: true
};
await this.redisService.setWithExpiry(
`game_board_${cmd.gameId}`,
JSON.stringify(boardDataWithMetadata),
24 * 60 * 60 // 24 hours
);
const executionTime = Date.now() - startTime;
logOther(`Board generation completed for game ${cmd.gameId} in ${executionTime}ms using pattern-based approach`);
} catch (error) {
logError(`Board generation failed for game ${cmd.gameId}:`, error as Error);
// Store error state in Redis
const errorData: BoardData = {
gameId: cmd.gameId,
fields: [],
generationComplete: false,
error: error instanceof Error ? error.message : 'Unknown error',
generatedAt: new Date()
};
await this.redisService.setWithExpiry(
`game_board_${cmd.gameId}`,
JSON.stringify(errorData),
24 * 60 * 60
);
throw error;
}
}
}
@@ -0,0 +1,9 @@
import { LoginType } from '../../../Domain/Game/GameAggregate';
export interface JoinGameCommand {
gameCode: string; // 6-character game code
playerId?: string; // User ID of the player joining (optional for public games)
playerName?: string; // Display name for the player (required for public games)
orgId?: string | null; // Organization ID (for organization games)
loginType: LoginType; // Type of join being attempted
}
@@ -0,0 +1,213 @@
import { JoinGameCommand } from './JoinGameCommand';
import { GameAggregate, GameState, LoginType } from '../../../Domain/Game/GameAggregate';
import { IGameRepository } from '../../../Domain/IRepository/IGameRepository';
import { DIContainer } from '../../Services/DIContainer';
import { RedisService } from '../../Services/RedisService';
import { logOther, logError } from '../../Services/Logger';
import { v4 as uuidv4 } from 'uuid';
export interface GamePlayerData {
playerId: string;
playerName?: string;
joinedAt: Date;
isOnline: boolean;
position?: number; // For game board position (to be used later)
}
export interface ActiveGameData {
gameId: string;
gameCode: string;
hostId?: string;
maxPlayers: number;
currentPlayers: GamePlayerData[];
state: GameState;
createdAt: Date;
startedAt?: Date;
currentTurn?: string; // Player ID whose turn it is
websocketRoom: string; // WebSocket room name for real-time updates
}
export class JoinGameCommandHandler {
private gameRepository: IGameRepository;
private redisService: RedisService;
constructor() {
this.gameRepository = DIContainer.getInstance().gameRepository;
this.redisService = RedisService.getInstance();
}
async handle(command: JoinGameCommand): Promise<GameAggregate> {
const startTime = performance.now();
try {
logOther('Joining game', `gameCode: ${command.gameCode}, playerId: ${command.playerId || 'anonymous'}, loginType: ${command.loginType}`);
// Find the game by game code
const game = await this.gameRepository.findByGameCode(command.gameCode);
if (!game) {
throw new Error(`Game with code ${command.gameCode} not found`);
}
// Generate player ID for public games or use provided one
const actualPlayerId = command.playerId || uuidv4();
// Validate game joinability (authentication/org checks done in router)
this.validateGameJoinability(game, actualPlayerId, command);
// Add player to database
const updatedGame = await this.gameRepository.addPlayerToGame(game.id, actualPlayerId);
if (!updatedGame) {
throw new Error('Failed to add player to game');
}
// Update Redis with the new player
await this.updateGameInRedis(updatedGame, { ...command, playerId: actualPlayerId });
const endTime = performance.now();
logOther('Player joined game successfully', {
gameId: game.id,
gameCode: game.gamecode,
playerId: actualPlayerId,
playerCount: updatedGame.players.length,
maxPlayers: updatedGame.maxplayers,
loginType: game.logintype,
executionTime: Math.round(endTime - startTime)
});
return updatedGame;
} catch (error) {
const endTime = performance.now();
logError('Failed to join game', error instanceof Error ? error : new Error(String(error)));
logOther('Game join failed', {
gameCode: command.gameCode,
playerId: command.playerId || 'anonymous',
loginType: command.loginType,
executionTime: Math.round(endTime - startTime)
});
throw error;
}
}
private validateGameJoinability(game: GameAggregate, playerId: string, command: JoinGameCommand): void {
// Check if game is in waiting state
if (game.state !== GameState.WAITING) {
throw new Error('Game is not accepting new players');
}
// Check if player is already in the game
if (game.players.includes(playerId)) {
throw new Error('Player is already in this game');
}
// Check if game is full
if (game.players.length >= game.maxplayers) {
throw new Error('Game is full');
}
// Note: Login type validation is now handled in the router before reaching this handler
// This ensures proper authentication and organization membership checks are done first
logOther('Game join validation passed', {
gameId: game.id,
gameCode: game.gamecode,
currentPlayers: game.players.length,
maxPlayers: game.maxplayers,
gameState: game.state,
loginType: game.logintype,
playerId: playerId,
isAuthenticated: !!command.playerId
});
}
private async updateGameInRedis(game: GameAggregate, command: JoinGameCommand & { playerId: string }): Promise<void> {
try {
const redisKey = `game:${game.id}`;
// Get existing game data from Redis or create new
let gameData: ActiveGameData;
const existingData = await this.redisService.get(redisKey);
if (existingData) {
gameData = JSON.parse(existingData) as ActiveGameData;
} else {
// Create new game data structure
gameData = {
gameId: game.id,
gameCode: game.gamecode,
maxPlayers: game.maxplayers,
currentPlayers: [],
state: game.state,
createdAt: game.createdate,
websocketRoom: `game_${game.gamecode}`
};
}
// Add the new player
const newPlayer: GamePlayerData = {
playerId: command.playerId,
playerName: command.playerName,
joinedAt: new Date(),
isOnline: true
};
// Update players list (remove if exists, then add)
gameData.currentPlayers = gameData.currentPlayers.filter(p => p.playerId !== command.playerId);
gameData.currentPlayers.push(newPlayer);
// Update game state and player count
gameData.state = game.state;
// Store updated data in Redis with TTL (24 hours)
await this.redisService.setWithExpiry(redisKey, JSON.stringify(gameData), 24 * 60 * 60);
// Add player to active players set
await this.redisService.setAdd(`active_players:${game.id}`, command.playerId);
logOther('Game data updated in Redis', {
gameId: game.id,
gameCode: game.gamecode,
redisKey,
playerCount: gameData.currentPlayers.length,
websocketRoom: gameData.websocketRoom,
playerId: command.playerId
});
} catch (error) {
logError('Failed to update game in Redis', error instanceof Error ? error : new Error(String(error)));
// Don't throw error here - Redis failure shouldn't prevent game join
logOther('Game join completed despite Redis error', {
gameId: game.id,
playerId: command.playerId
});
}
}
async getGameFromRedis(gameId: string): Promise<ActiveGameData | null> {
try {
const redisKey = `game:${gameId}`;
const data = await this.redisService.get(redisKey);
return data ? JSON.parse(data) as ActiveGameData : null;
} catch (error) {
logError('Failed to get game from Redis', error instanceof Error ? error : new Error(String(error)));
return null;
}
}
async removePlayerFromRedis(gameId: string, playerId: string): Promise<void> {
try {
const redisKey = `game:${gameId}`;
const existingData = await this.redisService.get(redisKey);
if (existingData) {
const gameData = JSON.parse(existingData) as ActiveGameData;
gameData.currentPlayers = gameData.currentPlayers.filter(p => p.playerId !== playerId);
await this.redisService.setWithExpiry(redisKey, JSON.stringify(gameData), 24 * 60 * 60);
await this.redisService.setRemove(`active_players:${gameId}`, playerId);
}
} catch (error) {
logError('Failed to remove player from Redis', error instanceof Error ? error : new Error(String(error)));
}
}
}
@@ -0,0 +1,9 @@
import { LoginType } from '../../../Domain/Game/GameAggregate';
export interface StartGameCommand {
deckids: string[]; // Array of deck IDs (3 types, multiple decks per type)
maxplayers: number; // Maximum number of players
logintype: LoginType; // How players can join the game
userid?: string; // Optional user who created the game (becomes game master)
orgid?: string | null; // Organization ID (for organization games)
}
@@ -0,0 +1,290 @@
import { StartGameCommand } from './StartGameCommand';
import { GameAggregate, GameDeck, GameCard, DeckType, GameState } from '../../../Domain/Game/GameAggregate';
import { DeckAggregate } from '../../../Domain/Deck/DeckAggregate';
import { IGameRepository } from '../../../Domain/IRepository/IGameRepository';
import { IDeckRepository } from '../../../Domain/IRepository/IDeckRepository';
import { DIContainer } from '../../Services/DIContainer';
import { RedisService } from '../../Services/RedisService';
import { logOther, logError } from '../../Services/Logger';
import { randomBytes } from 'crypto';
import { GenerateBoardCommand } from './GenerateBoardCommand';
export interface ActiveGameData {
gameId: string;
gameCode: string;
hostId?: string;
maxPlayers: number;
currentPlayers: GamePlayerData[];
state: GameState;
createdAt: Date;
startedAt?: Date;
currentTurn?: string;
websocketRoom: string;
}
export interface GamePlayerData {
playerId: string;
playerName?: string;
joinedAt: Date;
isOnline: boolean;
position?: number;
}
export class StartGameCommandHandler {
private gameRepository: IGameRepository;
private deckRepository: IDeckRepository;
private redisService: RedisService;
constructor() {
this.gameRepository = DIContainer.getInstance().gameRepository;
this.deckRepository = DIContainer.getInstance().deckRepository;
this.redisService = RedisService.getInstance();
}
async handle(command: StartGameCommand): Promise<GameAggregate> {
const startTime = performance.now();
try {
logOther('Starting game creation', `deckCount: ${command.deckids.length}, maxPlayers: ${command.maxplayers}, loginType: ${command.logintype}`);
// Generate unique game code
const gamecode = this.generateGameCode();
// Fetch all decks by IDs
const decks = await this.fetchDecks(command.deckids);
// Validate we have 3 deck types
this.validateDeckTypes(decks);
// Group decks by type and shuffle cards within each type
const gamedecks = await this.createShuffledGameDecks(decks);
// Create the game aggregate
const gameData: Partial<GameAggregate> = {
gamecode,
maxplayers: command.maxplayers,
logintype: command.logintype,
createdby: command.userid || null,
orgid: command.orgid || null,
gamedecks,
players: [],
started: false,
finished: false,
winner: null,
state: GameState.WAITING,
startdate: null,
enddate: null
};
// Save the game to database
const savedGame = await this.gameRepository.create(gameData);
// Create Redis object for real-time game management
await this.createGameInRedis(savedGame, command.userid);
// Trigger async board generation (don't block game creation)
this.triggerAsyncBoardGeneration(savedGame.id).catch((error: Error) => {
logError('Async board generation failed', error);
});
const endTime = performance.now();
logOther('Game created successfully', `gameId: ${savedGame.id}, gameCode: ${savedGame.gamecode}, executionTime: ${Math.round(endTime - startTime)}ms`);
return savedGame;
} catch (error) {
const endTime = performance.now();
logError('Failed to create game', error instanceof Error ? error : new Error(String(error)));
logOther('Game creation failed', `executionTime: ${Math.round(endTime - startTime)}ms`);
throw new Error('Failed to start game: ' + (error instanceof Error ? error.message : String(error)));
}
}
private generateGameCode(): string {
// Generate a 6-character alphanumeric game code
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let result = '';
const randomBytesArray = randomBytes(6);
for (let i = 0; i < 6; i++) {
result += chars[randomBytesArray[i] % chars.length];
}
return result;
}
private async fetchDecks(deckIds: string[]): Promise<DeckAggregate[]> {
const decks: DeckAggregate[] = [];
for (const deckId of deckIds) {
const deck = await this.deckRepository.findById(deckId);
if (!deck) {
throw new Error(`Deck with ID ${deckId} not found`);
}
decks.push(deck);
}
return decks;
}
private validateDeckTypes(decks: DeckAggregate[]): void {
const deckTypes = new Set(decks.map(deck => deck.type));
// Check if we have all 3 required deck types (LUCK=0, JOKER=1, QUESTION=2)
const requiredTypes = [0, 1, 2]; // Based on Type enum in DeckAggregate
const missingTypes = requiredTypes.filter(type => !deckTypes.has(type));
if (missingTypes.length > 0) {
throw new Error(`Missing required deck types: ${missingTypes.join(', ')}. Game requires LUCK, JOKER, and QUESTION deck types.`);
}
logOther('Deck types validation passed', `foundTypes: [${Array.from(deckTypes).join(', ')}]`);
}
private async createShuffledGameDecks(decks: DeckAggregate[]): Promise<GameDeck[]> {
// Group decks by type
const decksByType = new Map<number, DeckAggregate[]>();
decks.forEach(deck => {
if (!decksByType.has(deck.type)) {
decksByType.set(deck.type, []);
}
decksByType.get(deck.type)!.push(deck);
});
const gamedecks: GameDeck[] = [];
// Process each deck type
for (const [deckType, typeDecks] of decksByType) {
// Collect all cards from decks of this type
const allCards: GameCard[] = [];
typeDecks.forEach(deck => {
deck.cards.forEach(card => {
const gameCard: GameCard = {
cardid: this.generateCardId(),
question: card.text,
answer: card.answer || undefined,
consequence: card.consequence || null,
played: false,
playerid: undefined
};
allCards.push(gameCard);
});
});
// Shuffle all cards of this type
const shuffledCards = this.shuffleArray(allCards);
// Create game deck for this type
const gameDeck: GameDeck = {
deckid: typeDecks[0].id, // Use first deck ID as representative
decktype: this.mapDeckTypeToGameDeckType(deckType),
cards: shuffledCards
};
gamedecks.push(gameDeck);
logOther('Created shuffled game deck', `type: ${deckType}, cardCount: ${shuffledCards.length}, sourceDecks: ${typeDecks.length}`);
}
return gamedecks;
}
private mapDeckTypeToGameDeckType(deckType: number): DeckType {
// Map DeckAggregate.Type to GameAggregate.DeckType
switch (deckType) {
case 0: return DeckType.LUCK; // LUCK = 0
case 1: return DeckType.JOCKER; // JOKER = 1
case 2: return DeckType.QUEST; // QUESTION = 2
default: throw new Error(`Unknown deck type: ${deckType}`);
}
}
private shuffleArray<T>(array: T[]): T[] {
const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}
private generateCardId(): string {
return randomBytes(8).toString('hex');
}
private async createGameInRedis(game: GameAggregate, hostId?: string): Promise<void> {
try {
const redisKey = `game:${game.id}`;
const gameData: ActiveGameData = {
gameId: game.id,
gameCode: game.gamecode,
hostId: hostId,
maxPlayers: game.maxplayers,
currentPlayers: [],
state: game.state,
createdAt: game.createdate,
websocketRoom: `game_${game.gamecode}`
};
// Store game data in Redis with TTL (24 hours)
await this.redisService.setWithExpiry(redisKey, JSON.stringify(gameData), 24 * 60 * 60);
// Create game room for WebSocket connections
await this.redisService.set(`game_room:${game.gamecode}`, game.id);
logOther('Game created in Redis', {
gameId: game.id,
gameCode: game.gamecode,
hostId: hostId,
websocketRoom: gameData.websocketRoom,
redisKey
});
} catch (error) {
logError('Failed to create game in Redis', error instanceof Error ? error : new Error(String(error)));
// Don't throw error here - Redis failure shouldn't prevent game creation
logOther('Game created successfully despite Redis error', {
gameId: game.id,
gameCode: game.gamecode
});
}
}
private async triggerAsyncBoardGeneration(gameId: string): Promise<void> {
try {
// Calculate default field counts based on game configuration
// For now, use reasonable defaults - this should be configurable by host in the future
const maxSpecialFieldsPercentage = parseInt(process.env.MAX_SPECIAL_FIELDS_PERCENTAGE || '67');
const maxSpecialFields = Math.floor((100 * maxSpecialFieldsPercentage) / 100);
// Default distribution: 60% positive, 25% negative, 15% luck
const positiveFieldCount = Math.floor(maxSpecialFields * 0.6);
const negativeFieldCount = Math.floor(maxSpecialFields * 0.25);
const luckFieldCount = Math.floor(maxSpecialFields * 0.15);
const command: GenerateBoardCommand = {
gameId,
positiveFieldCount,
negativeFieldCount,
luckFieldCount
};
logOther(`Triggering async board generation for game ${gameId}`, {
positiveFieldCount,
negativeFieldCount,
luckFieldCount,
totalSpecialFields: positiveFieldCount + negativeFieldCount + luckFieldCount
});
// Execute board generation in background
await DIContainer.getInstance().generateBoardCommandHandler.execute(command);
} catch (error) {
logError(`Async board generation failed for game ${gameId}`, error as Error);
// Don't propagate error - board generation failure shouldn't affect game creation
}
}
}
@@ -0,0 +1,4 @@
export interface StartGamePlayCommand {
gameId: string; // Game ID to start
userId?: string; // User who is starting the game (should be game master)
}
@@ -0,0 +1,436 @@
import { StartGamePlayCommand } from './StartGamePlayCommand';
import { GameAggregate, GameState, BoardData, GameField } from '../../../Domain/Game/GameAggregate';
import { IGameRepository } from '../../../Domain/IRepository/IGameRepository';
import { DIContainer } from '../../Services/DIContainer';
import { RedisService } from '../../Services/RedisService';
import { WebSocketService } from '../../Services/WebSocketService';
import { logOther, logError } from '../../Services/Logger';
export interface GamePlayerPosition {
playerId: string;
playerName?: string;
position: number; // Board position (starts at 0)
turnOrder: number; // Random number to determine turn sequence
isOnline: boolean;
joinedAt: Date;
}
export interface ActiveGamePlayData {
gameId: string;
gameCode: string;
hostId?: string;
maxPlayers: number;
players: GamePlayerPosition[];
state: GameState;
createdAt: Date;
startedAt: Date;
currentTurn: number; // Index of current player in turn order
turnSequence: string[]; // Ordered array of player IDs based on turnOrder
websocketRoom: string;
gamePhase: 'starting' | 'playing' | 'paused' | 'finished';
boardData: BoardData; // Generated board with fields
}
export interface GameStartResult {
game: GameAggregate;
boardData: BoardData;
}
export class StartGamePlayCommandHandler {
private gameRepository: IGameRepository;
private redisService: RedisService;
constructor() {
this.gameRepository = DIContainer.getInstance().gameRepository;
this.redisService = RedisService.getInstance();
}
async handle(command: StartGamePlayCommand): Promise<GameStartResult> {
const startTime = performance.now();
try {
logOther('Starting game play', `gameId: ${command.gameId}, userId: ${command.userId || 'system'}`);
// Find the game
const game = await this.gameRepository.findById(command.gameId);
if (!game) {
throw new Error(`Game with ID ${command.gameId} not found`);
}
// Validate game can be started
this.validateGameCanStart(game, command.userId);
// Wait for board generation to complete (max 20 seconds)
const boardData = await this.waitForBoardGeneration(game.id);
// Update game state in database
const updatedGame = await this.gameRepository.update(game.id, {
started: true,
state: GameState.ACTIVE,
startdate: new Date()
});
if (!updatedGame) {
throw new Error('Failed to update game state');
}
// Initialize game play in Redis with board data
await this.initializeGamePlayInRedis(updatedGame, boardData);
// Notify all players via WebSocket
await this.notifyGameStart(updatedGame);
const endTime = performance.now();
logOther('Game play started successfully', {
gameId: updatedGame.id,
gameCode: updatedGame.gamecode,
playerCount: updatedGame.players.length,
executionTime: Math.round(endTime - startTime)
});
return {
game: updatedGame,
boardData: boardData
};
} catch (error) {
const endTime = performance.now();
logError('Failed to start game play', error instanceof Error ? error : new Error(String(error)));
logOther('Game start failed', {
gameId: command.gameId,
userId: command.userId,
executionTime: Math.round(endTime - startTime)
});
throw error;
}
}
private validateGameCanStart(game: GameAggregate, userId?: string): void {
// Check if game is in waiting state
if (game.state !== GameState.WAITING) {
throw new Error('Game is not in waiting state and cannot be started');
}
// Check if game is already started
if (game.started) {
throw new Error('Game has already been started');
}
// Check if there are enough players (at least 2)
if (game.players.length < 2) {
throw new Error('Game needs at least 2 players to start');
}
// For private and organization games, check if user is game master
if (game.createdby && userId && game.createdby !== userId) {
throw new Error('Only the game master can start this game');
}
logOther('Game start validation passed', {
gameId: game.id,
gameCode: game.gamecode,
playerCount: game.players.length,
gameState: game.state,
isGameMaster: !game.createdby || (userId && game.createdby === userId)
});
}
private async initializeGamePlayInRedis(game: GameAggregate, boardData: BoardData): Promise<void> {
try {
const redisKey = `gameplay:${game.id}`;
// Generate random turn orders for all players
const playersWithPositions = this.initializePlayerPositions(game.players);
// Sort by turn order to create turn sequence
const turnSequence = [...playersWithPositions]
.sort((a, b) => a.turnOrder - b.turnOrder)
.map(p => p.playerId);
const gamePlayData: ActiveGamePlayData = {
gameId: game.id,
gameCode: game.gamecode,
hostId: game.createdby || undefined,
maxPlayers: game.maxplayers,
players: playersWithPositions,
state: GameState.ACTIVE,
createdAt: game.createdate,
startedAt: new Date(),
currentTurn: 0, // Start with first player in sequence
turnSequence,
websocketRoom: `game_${game.gamecode}`,
gamePhase: 'starting',
boardData
};
// Store game play data in Redis with TTL (24 hours)
await this.redisService.setWithExpiry(redisKey, JSON.stringify(gamePlayData), 24 * 60 * 60);
// Create turn sequence mapping for quick lookups
await this.redisService.setWithExpiry(
`game_turns:${game.id}`,
JSON.stringify(turnSequence),
24 * 60 * 60
);
logOther('Game play initialized in Redis', {
gameId: game.id,
gameCode: game.gamecode,
playerCount: playersWithPositions.length,
turnSequence,
currentPlayer: turnSequence[0],
redisKey
});
} catch (error) {
logError('Failed to initialize game play in Redis', error instanceof Error ? error : new Error(String(error)));
throw new Error('Failed to initialize game session');
}
}
private initializePlayerPositions(playerIds: string[]): GamePlayerPosition[] {
const players: GamePlayerPosition[] = [];
// Generate random turn orders (1 to playerCount)
const turnOrders = this.generateRandomTurnOrders(playerIds.length);
playerIds.forEach((playerId, index) => {
players.push({
playerId,
position: 0, // All players start at position 0
turnOrder: turnOrders[index],
isOnline: true, // Assume online when game starts
joinedAt: new Date()
});
});
logOther('Player positions initialized', {
playerCount: players.length,
turnOrders: turnOrders,
playersData: players.map(p => ({
playerId: p.playerId,
position: p.position,
turnOrder: p.turnOrder
}))
});
return players;
}
private generateRandomTurnOrders(playerCount: number): number[] {
// Create array [1, 2, 3, ..., playerCount]
const orders = Array.from({ length: playerCount }, (_, i) => i + 1);
// Fisher-Yates shuffle
for (let i = orders.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[orders[i], orders[j]] = [orders[j], orders[i]];
}
return orders;
}
private async notifyGameStart(game: GameAggregate): Promise<void> {
try {
// Note: WebSocket notifications will be handled when WebSocket service is available
// For now, just log the game start
logOther('Game start notifications prepared', {
gameId: game.id,
gameCode: game.gamecode,
playerCount: game.players.length,
websocketRoom: `game_${game.gamecode}`
});
// TODO: Implement WebSocket notifications when service is properly integrated
// wsService.notifyGameStart(game.gamecode, game.players);
// wsService.broadcastGameStateUpdate(game.gamecode, gameStateData);
} catch (error) {
logError('Failed to prepare game start notifications', error instanceof Error ? error : new Error(String(error)));
// Don't throw error here - notification failure shouldn't prevent game start
}
}
async getGamePlayFromRedis(gameId: string): Promise<ActiveGamePlayData | null> {
try {
const redisKey = `gameplay:${gameId}`;
const data = await this.redisService.get(redisKey);
return data ? JSON.parse(data) as ActiveGamePlayData : null;
} catch (error) {
logError('Failed to get game play from Redis', error instanceof Error ? error : new Error(String(error)));
return null;
}
}
async updatePlayerPosition(gameId: string, playerId: string, newPosition: number): Promise<void> {
try {
const gameData = await this.getGamePlayFromRedis(gameId);
if (!gameData) {
throw new Error('Game session not found');
}
// Update player position
const player = gameData.players.find(p => p.playerId === playerId);
if (player) {
player.position = newPosition;
// Save back to Redis
const redisKey = `gameplay:${gameId}`;
await this.redisService.setWithExpiry(redisKey, JSON.stringify(gameData), 24 * 60 * 60);
logOther('Player position updated', {
gameId,
playerId,
newPosition
});
}
} catch (error) {
logError('Failed to update player position', error instanceof Error ? error : new Error(String(error)));
throw error;
}
}
async getNextPlayer(gameId: string): Promise<string | null> {
try {
const gameData = await this.getGamePlayFromRedis(gameId);
if (!gameData) {
return null;
}
const nextTurnIndex = (gameData.currentTurn + 1) % gameData.turnSequence.length;
return gameData.turnSequence[nextTurnIndex];
} catch (error) {
logError('Failed to get next player', error instanceof Error ? error : new Error(String(error)));
return null;
}
}
async advanceTurn(gameId: string): Promise<string | null> {
try {
const gameData = await this.getGamePlayFromRedis(gameId);
if (!gameData) {
return null;
}
// Advance to next player
gameData.currentTurn = (gameData.currentTurn + 1) % gameData.turnSequence.length;
const currentPlayer = gameData.turnSequence[gameData.currentTurn];
// Save back to Redis
const redisKey = `gameplay:${gameId}`;
await this.redisService.setWithExpiry(redisKey, JSON.stringify(gameData), 24 * 60 * 60);
logOther('Turn advanced', {
gameId,
currentTurn: gameData.currentTurn,
currentPlayer
});
return currentPlayer;
} catch (error) {
logError('Failed to advance turn', error instanceof Error ? error : new Error(String(error)));
return null;
}
}
private async waitForBoardGeneration(gameId: string): Promise<BoardData> {
const maxWaitTime = parseInt(process.env.MAX_GENERATION_TIME_SECONDS || '20') * 1000;
const pollInterval = 500; // Check every 500ms
const startTime = Date.now();
logOther(`Waiting for board generation for game ${gameId}`, {
maxWaitTime: maxWaitTime / 1000,
pollInterval,
redisKey: `game_board_${gameId}`
});
while (Date.now() - startTime < maxWaitTime) {
try {
const redisKey = `game_board_${gameId}`;
const boardDataStr = await this.redisService.get(redisKey);
logOther(`Board generation check for game ${gameId}`, {
attempt: Math.floor((Date.now() - startTime) / pollInterval) + 1,
hasData: !!boardDataStr,
dataLength: boardDataStr ? boardDataStr.length : 0,
waitTime: Date.now() - startTime
});
if (boardDataStr) {
const boardData: BoardData = JSON.parse(boardDataStr);
logOther(`Board data found for game ${gameId}`, {
generationComplete: boardData.generationComplete,
hasError: !!boardData.error,
fieldsCount: boardData.fields ? boardData.fields.length : 0
});
if (boardData.generationComplete) {
if (boardData.error) {
logError(`Board generation failed for game ${gameId}`, new Error(boardData.error));
throw new Error(`Board generation failed: ${boardData.error}`);
}
logOther(`Board generation completed for game ${gameId}`, {
fieldCount: boardData.fields.length,
waitTime: Date.now() - startTime
});
return boardData;
}
} else {
// No board data found yet - check if we need to trigger generation
logOther(`No board data found yet for game ${gameId}, checking if generation was triggered...`, {
waitTime: Date.now() - startTime,
redisKey
});
// If we've waited for 2 seconds and still no data, try to trigger generation manually
if (Date.now() - startTime > 2000) {
await this.ensureBoardGenerationTriggered(gameId);
}
}
// Wait before next poll
await new Promise(resolve => setTimeout(resolve, pollInterval));
} catch (error) {
logError(`Error checking board generation status for game ${gameId}`, error as Error);
throw new Error(`Failed to retrieve board data: ${error instanceof Error ? error.message : String(error)}`);
}
}
// Timeout reached
logError(`Board generation timeout for game ${gameId}`, new Error(`Generation took longer than ${maxWaitTime / 1000} seconds`));
throw new Error(`Board generation timeout. Game ${gameId} is not ready to start. Please try again later.`);
}
private async ensureBoardGenerationTriggered(gameId: string): Promise<void> {
try {
logOther(`Ensuring board generation is triggered for game ${gameId}`);
// Check if generation was already triggered by looking for any board data
const redisKey = `game_board_${gameId}`;
const existingData = await this.redisService.get(redisKey);
if (!existingData) {
// No data at all - trigger generation manually
logOther(`No board generation found for game ${gameId}, triggering manually`);
// Use DIContainer to trigger board generation
const generateBoardCommand = {
gameId,
positiveFieldCount: Math.floor(67 * 0.6), // Default: 60% positive
negativeFieldCount: Math.floor(67 * 0.25), // Default: 25% negative
luckFieldCount: Math.floor(67 * 0.15) // Default: 15% luck
};
await DIContainer.getInstance().generateBoardCommandHandler.execute(generateBoardCommand);
logOther(`Board generation manually triggered for game ${gameId}`);
}
} catch (error) {
logError(`Failed to ensure board generation for game ${gameId}`, error as Error);
// Don't throw here - let the main wait loop handle the timeout
}
}
}
@@ -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,156 @@
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'
};
}
// Ensure limit is at least 1 to prevent database issues
const effectiveLimit = Math.max(limit || 20, 1);
const effectiveOffset = Math.max(offset || 0, 0);
try {
const { users, totalCount } = await this.userRepo.search(query.trim(), effectiveLimit, effectiveOffset);
const results = users.map(user => UserMapper.toShortDto(user));
const hasMore = (effectiveOffset + effectiveLimit) < 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'
};
}
// Ensure limit is at least 1 to prevent database issues
const effectiveLimit = Math.max(limit || 20, 1);
const effectiveOffset = Math.max(offset || 0, 0);
try {
const { decks, totalCount } = await this.deckRepo.search(query.trim(), effectiveLimit, effectiveOffset);
const results = decks.map(deck => DeckMapper.toShortDto(deck));
const hasMore = (effectiveOffset + effectiveLimit) < totalCount;
return {
results,
totalCount,
hasMore,
searchQuery: query,
searchType: 'decks'
};
} catch (error) {
throw new Error('Failed to search 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,146 @@
import { Request, Response, NextFunction } from 'express';
import { JWTService } from './JWTService';
import { RedisService } from './RedisService';
import { logAuth, logWarning } from './Logger';
export const jwtService = new JWTService();
const redisService = RedisService.getInstance();
/**
* Check if a token is blacklisted
*/
async function isTokenBlacklisted(token: string): Promise<boolean> {
try {
const result = await redisService.get(`blacklist:${token}`);
return result === 'true';
} catch (error) {
// If Redis is down, allow the request to proceed (fail open)
logWarning('Failed to check token blacklist - allowing request', { error: (error as Error).message });
return false;
}
}
/**
* Extract token from request (cookie or Authorization header)
*/
function extractToken(req: Request): string | null {
// First try to get token from cookie
const cookieToken = req.cookies['auth_token'];
if (cookieToken) {
return cookieToken;
}
// Fallback to Authorization header
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
return authHeader.substring(7);
}
return null;
}
export async function authRequired(req: Request, res: Response, next: NextFunction) {
try {
// Extract token from request
const token = extractToken(req);
if (!token) {
logAuth('Authentication failed - No token provided', undefined, {
ip: req.ip,
userAgent: req.get ? req.get('User-Agent') : 'unknown',
path: req.path
}, req);
return res.status(401).json({ error: 'Unauthorized' });
}
// Check if token is blacklisted
const isBlacklisted = await isTokenBlacklisted(token);
if (isBlacklisted) {
logAuth('Authentication failed - Token blacklisted', undefined, {
ip: req.ip,
userAgent: req.get ? req.get('User-Agent') : 'unknown',
path: req.path
}, req);
return res.status(401).json({ error: 'Token has been invalidated' });
}
// Verify token
const payload = jwtService.verify(req);
if (!payload) {
logAuth('Authentication failed - Invalid 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();
} catch (error) {
logWarning('Authentication middleware error', { error: (error as Error).message }, req);
return res.status(500).json({ error: 'Internal server error' });
}
}
export async function adminRequired(req: Request, res: Response, next: NextFunction) {
try {
// Extract token from request
const token = extractToken(req);
if (!token) {
logWarning('Admin access denied - No token provided', {
ip: req.ip,
path: req.path
}, req);
return res.status(401).json({ error: 'Unauthorized' });
}
// Check if token is blacklisted
const isBlacklisted = await isTokenBlacklisted(token);
if (isBlacklisted) {
logWarning('Admin access denied - Token blacklisted', {
ip: req.ip,
path: req.path
}, req);
return res.status(401).json({ error: 'Token has been invalidated' });
}
// Verify token and check admin privileges
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();
} catch (error) {
logWarning('Admin authentication middleware error', { error: (error as Error).message }, req);
return res.status(500).json({ error: 'Internal server error' });
}
}
@@ -0,0 +1,386 @@
import { GameAggregate, GameCard, DeckType, GameDeck } from '../../Domain/Game/GameAggregate';
import { ConsequenceType } from '../../Domain/Deck/DeckAggregate';
import { CardProcessingService, CardClientData, CardValidationResult } from './CardProcessingService';
export interface CardDrawResult {
success: boolean;
card?: GameCard;
clientData?: CardClientData; // Prepared data for client
error?: string;
}
export interface CardAnswerResult {
correct: boolean;
consequence: ConsequenceType;
description: string;
validationDetails?: CardValidationResult; // Detailed validation info
}
export interface PendingCardAnswer {
gameId: string;
playerId: string;
card: GameCard;
timeoutId: NodeJS.Timeout;
startTime: Date;
}
/**
* Service responsible for handling card drawing mechanics during special field landings
* Integrates with existing GameCard interface and DeckType enum
*/
export class CardDrawingService {
private pendingAnswers: Map<string, PendingCardAnswer> = new Map();
private readonly ANSWER_TIMEOUT_MS = 60000; // 1 minute
private cardProcessingService: CardProcessingService;
constructor() {
this.cardProcessingService = new CardProcessingService();
}
/**
* Draw a card from the appropriate deck based on field type
* @param game Game aggregate containing the deck information
* @param fieldType Type of field the player landed on
* @param playerId ID of the player who needs to draw the card
* @returns Card draw result with the drawn card or error
*/
drawCard(game: GameAggregate, fieldType: 'positive' | 'negative' | 'luck', playerId: string): CardDrawResult {
try {
// Determine which deck type to use based on field type
const deckType = this.getRequiredDeckType(fieldType);
// Find the appropriate deck in the game
const gameDecks: GameDeck[] = typeof game.gamedecks === 'string'
? JSON.parse(game.gamedecks)
: game.gamedecks;
const targetDeck = gameDecks.find((deck: GameDeck) => deck.decktype === deckType);
if (!targetDeck) {
return {
success: false,
error: `No ${this.getDeckTypeName(deckType)} deck found in game`
};
}
// Filter available cards (not played by this player yet)
const availableCards = targetDeck.cards.filter((card: GameCard) => !card.played || card.playerid !== playerId);
if (availableCards.length === 0) {
return {
success: false,
error: `No more cards available in ${this.getDeckTypeName(deckType)} deck`
};
}
// Randomly select a card
const randomIndex = Math.floor(Math.random() * availableCards.length);
const drawnCard = availableCards[randomIndex];
// Mark card as drawn by this player
drawnCard.played = true;
drawnCard.playerid = playerId;
// Prepare client data based on card type
let clientData: CardClientData | undefined;
try {
if (drawnCard.type !== undefined) {
clientData = this.cardProcessingService.prepareCardForClient(drawnCard);
}
} catch (error) {
// If client data preparation fails, still return the card but log the error
console.warn(`Failed to prepare client data for card ${drawnCard.cardid}:`, error);
}
return {
success: true,
card: drawnCard,
clientData: clientData
};
} catch (error) {
return {
success: false,
error: `Failed to draw card: ${error instanceof Error ? error.message : 'Unknown error'}`
};
}
}
/**
* Draw a joker card for secondary landings on special fields
* @param game Game aggregate containing the deck information
* @param playerId ID of the player who needs to draw the joker card
* @returns Card draw result with the joker card or error
*/
drawJokerCard(game: GameAggregate, playerId: string): CardDrawResult {
try {
const gameDecks: GameDeck[] = typeof game.gamedecks === 'string'
? JSON.parse(game.gamedecks)
: game.gamedecks;
const jokerDeck = gameDecks.find((deck: GameDeck) => deck.decktype === DeckType.JOCKER);
if (!jokerDeck) {
return {
success: false,
error: 'No joker deck found in game'
};
}
// Filter available joker cards
const availableCards = jokerDeck.cards.filter((card: GameCard) => !card.played || card.playerid !== playerId);
if (availableCards.length === 0) {
return {
success: false,
error: 'No more joker cards available'
};
}
// Randomly select a joker card
const randomIndex = Math.floor(Math.random() * availableCards.length);
const drawnCard = availableCards[randomIndex];
// Mark card as drawn by this player
drawnCard.played = true;
drawnCard.playerid = playerId;
return {
success: true,
card: drawnCard
};
} catch (error) {
return {
success: false,
error: `Failed to draw joker card: ${error instanceof Error ? error.message : 'Unknown error'}`
};
}
}
/**
* Start the answer timeout for a question card
* @param gameId Game ID
* @param playerId Player ID who needs to answer
* @param card The card with the question
* @param onTimeout Callback function when timeout occurs
* @returns Unique key for tracking this pending answer
*/
startAnswerTimeout(
gameId: string,
playerId: string,
card: GameCard,
onTimeout: (gameId: string, playerId: string, card: GameCard) => void
): string {
const key = `${gameId}:${playerId}`;
// Clear any existing timeout for this player
this.clearAnswerTimeout(key);
// Set new timeout
const timeoutId = setTimeout(() => {
onTimeout(gameId, playerId, card);
this.pendingAnswers.delete(key);
}, this.ANSWER_TIMEOUT_MS);
// Store pending answer
this.pendingAnswers.set(key, {
gameId,
playerId,
card,
timeoutId,
startTime: new Date()
});
return key;
}
/**
* Clear an answer timeout
* @param key The key returned from startAnswerTimeout
*/
clearAnswerTimeout(key: string): void {
const pending = this.pendingAnswers.get(key);
if (pending) {
clearTimeout(pending.timeoutId);
this.pendingAnswers.delete(key);
}
}
/**
* Process player's answer to a question card
* @param card The question card
* @param playerAnswer Player's submitted answer
* @returns Result indicating if answer was correct and consequence to apply
*/
processAnswer(card: GameCard, playerAnswer: any): CardAnswerResult {
if (!card.answer) {
throw new Error('Card has no answer to compare against');
}
let validationResult: CardValidationResult;
try {
// Use CardProcessingService for type-specific validation
validationResult = this.cardProcessingService.validateAnswer(card, playerAnswer);
} catch (error) {
// Fallback to simple string comparison if type-specific validation fails
console.warn(`Card validation failed, using fallback: ${error}`);
validationResult = this.fallbackValidation(card, playerAnswer);
}
// For question cards, the consequence is applied only if the answer is correct
// If wrong, we apply a default negative consequence
const consequence = validationResult.isCorrect
? (card.consequence?.type || ConsequenceType.EXTRA_TURN)
: ConsequenceType.LOSE_TURN; // Default penalty for wrong answer
return {
correct: validationResult.isCorrect,
consequence: consequence,
description: validationResult.explanation || (validationResult.isCorrect
? '✅ Correct!'
: '❌ Wrong answer!'),
validationDetails: validationResult
};
}
/**
* Process automatic wrong answer (timeout occurred)
* @param card The question card that timed out
* @returns Result with wrong consequence applied
*/
processTimeoutAnswer(card: GameCard): CardAnswerResult {
if (!card.answer) {
throw new Error('Card has no answer to compare against');
}
const consequence = ConsequenceType.LOSE_TURN; // Default penalty for timeout
return {
correct: false,
consequence: consequence,
description: `⏰ Time's up! The correct answer was "${card.answer}". ${this.getConsequenceDescription(consequence, false)}`
};
}
/**
* Process luck card effect (no answer required)
* @param card The luck card
* @returns Result with the luck consequence to apply
*/
processLuckCard(card: GameCard): CardAnswerResult {
const consequence = card.consequence?.type || ConsequenceType.EXTRA_TURN;
return {
correct: true, // Luck cards are always "correct" since no answer is needed
consequence: consequence,
description: `🍀 ${this.getConsequenceDescription(consequence, true)}`
};
}
/**
* Get the required deck type based on field type
*/
private getRequiredDeckType(fieldType: 'positive' | 'negative' | 'luck'): DeckType {
switch (fieldType) {
case 'positive':
case 'negative':
return DeckType.QUEST; // Question cards for positive/negative fields
case 'luck':
return DeckType.LUCK; // Luck cards for luck fields
default:
throw new Error(`Unsupported field type: ${fieldType}`);
}
}
/**
* Get human-readable deck type name
*/
private getDeckTypeName(deckType: DeckType): string {
switch (deckType) {
case DeckType.QUEST:
return 'question';
case DeckType.LUCK:
return 'luck';
case DeckType.JOCKER:
return 'joker';
default:
return 'unknown';
}
}
/**
* Get human-readable consequence description
*/
private getConsequenceDescription(consequence: ConsequenceType, isPositive: boolean): string {
switch (consequence) {
case ConsequenceType.MOVE_FORWARD:
return isPositive ? 'Move forward!' : 'Move forward anyway!';
case ConsequenceType.MOVE_BACKWARD:
return 'Move backward!';
case ConsequenceType.LOSE_TURN:
return 'Lose your next turn!';
case ConsequenceType.EXTRA_TURN:
return 'Get an extra turn!';
case ConsequenceType.GO_TO_START:
return 'Go back to start!';
default:
return 'Unknown effect!';
}
}
/**
* Get remaining time for a pending answer
* @param key The key for the pending answer
* @returns Remaining time in seconds, or -1 if not found
*/
getRemainingTime(key: string): number {
const pending = this.pendingAnswers.get(key);
if (!pending) {
return -1;
}
const elapsed = Date.now() - pending.startTime.getTime();
const remaining = Math.max(0, this.ANSWER_TIMEOUT_MS - elapsed);
return Math.ceil(remaining / 1000); // Return in seconds
}
/**
* Check if a player has a pending answer
* @param gameId Game ID
* @param playerId Player ID
* @returns True if player has a pending answer
*/
hasPendingAnswer(gameId: string, playerId: string): boolean {
const key = `${gameId}:${playerId}`;
return this.pendingAnswers.has(key);
}
/**
* Fallback validation for cards without proper type information
* @param card The card to validate
* @param playerAnswer Player's answer
* @returns Basic validation result
*/
private fallbackValidation(card: GameCard, playerAnswer: any): CardValidationResult {
if (typeof card.answer !== 'string' || typeof playerAnswer !== 'string') {
return {
isCorrect: false,
submittedAnswer: playerAnswer,
explanation: 'Cannot validate non-string answers without card type information'
};
}
const cleanPlayerAnswer = playerAnswer.toLowerCase().trim();
const cleanCorrectAnswer = card.answer.toLowerCase().trim();
const isCorrect = cleanPlayerAnswer === cleanCorrectAnswer;
return {
isCorrect,
submittedAnswer: playerAnswer,
correctAnswer: card.answer,
explanation: isCorrect
? '✅ Correct!'
: `❌ Wrong! The correct answer was "${card.answer}".`
};
}
}
@@ -0,0 +1,323 @@
import { GameCard } from '../../Domain/Game/GameAggregate';
import { CardType } from '../../Domain/Deck/DeckAggregate';
// Type-specific answer structures
export interface QuizOption {
answer: string; // A, B, C, D
text: string;
correct: boolean;
}
export interface CloserAnswer {
correct: number;
percent: number;
}
export interface CardClientData {
cardid: string;
question: string;
type: CardType;
// Type-specific client data
options?: QuizOption[]; // For QUIZ
words?: string[]; // For SENTENCE_PAIRING (scrambled)
acceptableAnswers?: string[]; // For OWN_ANSWER (not sent to client)
// CLOSER and TRUE_FALSE send only question
}
export interface CardValidationResult {
isCorrect: boolean;
submittedAnswer: any;
correctAnswer?: any;
explanation?: string;
}
/**
* Service responsible for handling type-specific card processing
* Prepares cards for clients and validates answers based on CardType
*/
export class CardProcessingService {
/**
* Prepare card data for client based on card type
* @param card The game card to prepare
* @returns Client-safe card data with type-specific information
*/
prepareCardForClient(card: GameCard): CardClientData {
if (!card.question || card.type === undefined) {
throw new Error('Card must have question and type defined');
}
const baseData: CardClientData = {
cardid: card.cardid,
question: card.question,
type: card.type
};
switch (card.type) {
case CardType.QUIZ:
return this.prepareQuizCard(card, baseData);
case CardType.SENTENCE_PAIRING:
return this.prepareSentencePairingCard(card, baseData);
case CardType.OWN_ANSWER:
return this.prepareOwnAnswerCard(card, baseData);
case CardType.TRUE_FALSE:
return this.prepareTrueFalseCard(card, baseData);
case CardType.CLOSER:
return this.prepareCloserCard(card, baseData);
default:
throw new Error(`Unsupported card type: ${card.type}`);
}
}
/**
* Validate player's answer based on card type
* @param card The game card
* @param playerAnswer Player's submitted answer
* @returns Validation result with correctness and explanation
*/
validateAnswer(card: GameCard, playerAnswer: any): CardValidationResult {
if (card.type === undefined) {
throw new Error('Card type is required for validation');
}
switch (card.type) {
case CardType.QUIZ:
return this.validateQuizAnswer(card, playerAnswer);
case CardType.SENTENCE_PAIRING:
return this.validateSentencePairingAnswer(card, playerAnswer);
case CardType.OWN_ANSWER:
return this.validateOwnAnswerAnswer(card, playerAnswer);
case CardType.TRUE_FALSE:
return this.validateTrueFalseAnswer(card, playerAnswer);
case CardType.CLOSER:
return this.validateCloserAnswer(card, playerAnswer);
default:
throw new Error(`Unsupported card type for validation: ${card.type}`);
}
}
/**
* Prepare QUIZ card with multiple choice options
*/
private prepareQuizCard(card: GameCard, baseData: CardClientData): CardClientData {
if (!Array.isArray(card.answer)) {
throw new Error('Quiz card answer must be an array of options');
}
return {
...baseData,
options: card.answer as QuizOption[]
};
}
/**
* Prepare SENTENCE_PAIRING card with scrambled words
*/
private prepareSentencePairingCard(card: GameCard, baseData: CardClientData): CardClientData {
if (typeof card.answer !== 'string') {
throw new Error('Sentence pairing card answer must be a string');
}
const words = card.answer.split(' ').filter(word => word.trim() !== '');
const scrambledWords = this.scrambleArray([...words]);
return {
...baseData,
words: scrambledWords
};
}
/**
* Prepare OWN_ANSWER card (only question, acceptable answers hidden)
*/
private prepareOwnAnswerCard(card: GameCard, baseData: CardClientData): CardClientData {
// Don't send acceptable answers to client
return baseData;
}
/**
* Prepare TRUE_FALSE card (only question)
*/
private prepareTrueFalseCard(card: GameCard, baseData: CardClientData): CardClientData {
return baseData;
}
/**
* Prepare CLOSER card (only question)
*/
private prepareCloserCard(card: GameCard, baseData: CardClientData): CardClientData {
return baseData;
}
/**
* Validate QUIZ answer (A, B, C, D)
*/
private validateQuizAnswer(card: GameCard, playerAnswer: string): CardValidationResult {
if (!Array.isArray(card.answer)) {
throw new Error('Quiz card answer must be an array');
}
const options = card.answer as QuizOption[];
const correctOption = options.find(opt => opt.correct);
if (!correctOption) {
throw new Error('Quiz card must have one correct answer');
}
const isCorrect = playerAnswer.toUpperCase() === correctOption.answer.toUpperCase();
return {
isCorrect,
submittedAnswer: playerAnswer,
correctAnswer: correctOption.answer,
explanation: isCorrect
? `✅ Correct! ${correctOption.text}`
: `❌ Wrong! Correct answer was ${correctOption.answer}: ${correctOption.text}`
};
}
/**
* Validate SENTENCE_PAIRING answer (reconstructed sentence)
*/
private validateSentencePairingAnswer(card: GameCard, playerAnswer: string[] | string): CardValidationResult {
if (typeof card.answer !== 'string') {
throw new Error('Sentence pairing card answer must be a string');
}
// Handle both array of words and joined string
const reconstructed = Array.isArray(playerAnswer)
? playerAnswer.join(' ').toLowerCase().trim()
: playerAnswer.toLowerCase().trim();
const correctSentence = card.answer.toLowerCase().trim();
const isCorrect = reconstructed === correctSentence;
return {
isCorrect,
submittedAnswer: reconstructed,
correctAnswer: card.answer,
explanation: isCorrect
? '✅ Perfect! You arranged the sentence correctly!'
: `❌ Wrong order! Correct sentence: "${card.answer}"`
};
}
/**
* Validate OWN_ANSWER (check against acceptable answers array)
*/
private validateOwnAnswerAnswer(card: GameCard, playerAnswer: string): CardValidationResult {
if (!Array.isArray(card.answer)) {
throw new Error('Own answer card must have array of acceptable answers');
}
const acceptableAnswers = card.answer as string[];
const cleanPlayerAnswer = playerAnswer.toLowerCase().trim();
const isCorrect = acceptableAnswers.some(acceptable =>
acceptable.toLowerCase().trim() === cleanPlayerAnswer
);
return {
isCorrect,
submittedAnswer: playerAnswer,
correctAnswer: acceptableAnswers,
explanation: isCorrect
? '✅ Correct! Your answer is acceptable.'
: `❌ Your answer doesn't match any acceptable responses.`
};
}
/**
* Validate TRUE_FALSE answer
*/
private validateTrueFalseAnswer(card: GameCard, playerAnswer: string): CardValidationResult {
if (typeof card.answer !== 'boolean' && typeof card.answer !== 'string') {
throw new Error('True/false card answer must be boolean or string');
}
// Convert player answer to boolean
const playerBool = this.convertToBoolean(playerAnswer);
const correctBool = typeof card.answer === 'boolean'
? card.answer
: this.convertToBoolean(card.answer);
const isCorrect = playerBool === correctBool;
return {
isCorrect,
submittedAnswer: playerAnswer,
correctAnswer: correctBool ? 'True' : 'False',
explanation: isCorrect
? '✅ Correct!'
: `❌ Wrong! The correct answer is ${correctBool ? 'True' : 'False'}.`
};
}
/**
* Validate CLOSER answer (numerical proximity)
*/
private validateCloserAnswer(card: GameCard, playerAnswer: string | number): CardValidationResult {
if (typeof card.answer !== 'object' || !card.answer.correct || !card.answer.percent) {
throw new Error('Closer card answer must have correct and percent fields');
}
const closerAnswer = card.answer as CloserAnswer;
const playerNumber = typeof playerAnswer === 'number'
? playerAnswer
: parseFloat(playerAnswer.toString());
if (isNaN(playerNumber)) {
return {
isCorrect: false,
submittedAnswer: playerAnswer,
correctAnswer: closerAnswer.correct,
explanation: '❌ Invalid number! Please enter a valid numeric answer.'
};
}
const tolerance = closerAnswer.correct * (closerAnswer.percent / 100);
const minValue = closerAnswer.correct - tolerance;
const maxValue = closerAnswer.correct + tolerance;
const isCorrect = playerNumber >= minValue && playerNumber <= maxValue;
return {
isCorrect,
submittedAnswer: playerNumber,
correctAnswer: closerAnswer.correct,
explanation: isCorrect
? `✅ Close enough! Correct answer: ${closerAnswer.correct}`
: `❌ Not close enough! Correct answer: ${closerAnswer.correct}${closerAnswer.percent}%)`
};
}
/**
* Convert string to boolean for TRUE_FALSE validation
*/
private convertToBoolean(value: string): boolean {
const lowerValue = value.toLowerCase().trim();
return ['true', 'yes', '1', 'correct', 'right'].includes(lowerValue);
}
/**
* Scramble array elements randomly
*/
private scrambleArray<T>(array: T[]): T[] {
const scrambled = [...array];
for (let i = scrambled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[scrambled[i], scrambled[j]] = [scrambled[j], scrambled[i]];
}
return scrambled;
}
}
@@ -0,0 +1,133 @@
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 {
constructor(
private readonly contactRepo: IContactRepository,
private readonly emailService: 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,541 @@
// 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';
import { IGameRepository } from '../../Domain/IRepository/IGameRepository';
// 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';
import { GameRepository } from '../../Infrastructure/Repository/GameRepository';
// Command Handlers
import { CreateUserCommandHandler } from '../User/commands/CreateUserCommandHandler';
import { LoginCommandHandler } from '../User/commands/LoginCommandHandler';
import { LogoutCommandHandler } from '../User/commands/LogoutCommandHandler';
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 { EmailService } from './EmailService';
import { GameTokenService } from './GameTokenService';
import { ContactEmailService } from './ContactEmailService';
import { DeckImportExportService } from './DeckImportExportService';
import { FieldEffectService } from './FieldEffectService';
import { CardDrawingService } from './CardDrawingService';
import { GamemasterService } from './GamemasterService';
import { RedisService } from './RedisService';
import { GameService } from '../Game/GameService';
import { BoardGenerationService } from '../Game/BoardGenerationService';
import { GenerateBoardCommandHandler } from '../Game/commands/GenerateBoardCommandHandler';
/**
* 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;
private _gameRepository: IGameRepository | null = null;
// Services
private _jwtService: JWTService | null = null;
private _emailService: EmailService | null = null;
private _gameTokenService: GameTokenService | null = null;
private _contactEmailService: ContactEmailService | null = null;
private _deckImportExportService: DeckImportExportService | null = null;
private _cardDrawingService: CardDrawingService | null = null;
private _gamemasterService: GamemasterService | null = null;
private _fieldEffectService: FieldEffectService | null = null;
private _gameService: GameService | null = null;
private _boardGenerationService: BoardGenerationService | null = null;
// Command Handlers
private _createUserCommandHandler: CreateUserCommandHandler | null = null;
private _loginCommandHandler: LoginCommandHandler | null = null;
private _logoutCommandHandler: LogoutCommandHandler | 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;
private _generateBoardCommandHandler: GenerateBoardCommandHandler | 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;
}
public get gameRepository(): IGameRepository {
if (!this._gameRepository) {
this._gameRepository = new GameRepository();
}
return this._gameRepository;
}
// Services getters
public get jwtService(): JWTService {
if (!this._jwtService) {
this._jwtService = new JWTService();
}
return this._jwtService;
}
public get emailService(): EmailService {
if (!this._emailService) {
this._emailService = new EmailService();
}
return this._emailService;
}
public get gameTokenService(): GameTokenService {
if (!this._gameTokenService) {
this._gameTokenService = new GameTokenService();
}
return this._gameTokenService;
}
public get contactEmailService(): ContactEmailService {
if (!this._contactEmailService) {
this._contactEmailService = new ContactEmailService(this.contactRepository, this.emailService);
}
return this._contactEmailService;
}
public get deckImportExportService(): DeckImportExportService {
if (!this._deckImportExportService) {
this._deckImportExportService = new DeckImportExportService(this.deckRepository);
}
return this._deckImportExportService;
}
public get cardDrawingService(): CardDrawingService {
if (!this._cardDrawingService) {
this._cardDrawingService = new CardDrawingService();
}
return this._cardDrawingService;
}
public get gamemasterService(): GamemasterService {
if (!this._gamemasterService) {
this._gamemasterService = new GamemasterService();
}
return this._gamemasterService;
}
public get fieldEffectService(): FieldEffectService {
if (!this._fieldEffectService) {
this._fieldEffectService = new FieldEffectService(
this.boardGenerationService,
this.gamemasterService
);
}
return this._fieldEffectService;
}
public get gameService(): GameService {
if (!this._gameService) {
this._gameService = new GameService();
}
return this._gameService;
}
public get boardGenerationService(): BoardGenerationService {
if (!this._boardGenerationService) {
this._boardGenerationService = new BoardGenerationService();
}
return this._boardGenerationService;
}
// Command Handler getters
public get createUserCommandHandler(): CreateUserCommandHandler {
if (!this._createUserCommandHandler) {
this._createUserCommandHandler = new CreateUserCommandHandler(this.userRepository, this.emailService);
}
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 logoutCommandHandler(): LogoutCommandHandler {
if (!this._logoutCommandHandler) {
this._logoutCommandHandler = new LogoutCommandHandler(this.userRepository);
}
return this._logoutCommandHandler;
}
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, this.emailService);
}
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;
}
public get generateBoardCommandHandler(): GenerateBoardCommandHandler {
if (!this._generateBoardCommandHandler) {
this._generateBoardCommandHandler = new GenerateBoardCommandHandler(this.boardGenerationService, RedisService.getInstance());
}
return this._generateBoardCommandHandler;
}
// 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,437 @@
import { GameCard, GameField } from '../../Domain/Game/GameAggregate';
import { Consequence } from '../../Domain/Deck/DeckAggregate';
import { BoardGenerationService } from '../Game/BoardGenerationService';
import { GamemasterService, GamemasterDecisionResult } from './GamemasterService';
import { FieldEffectRequest, FieldEffectResult } from './Interfaces/GameInterfaces';
// Interfaces for different card processing results
export interface GuessResult {
guessedPosition: number;
actualPosition: number;
isCorrect: boolean;
penaltyApplied: boolean; // true if moved back 2 fields
description: string;
}
export interface TurnEffect {
type: 'LOSE_TURN' | 'EXTRA_TURN';
playerId: string;
value: number; // Number of turns to lose/gain
}
export interface CardProcessingResult {
finalPosition: number;
stepValue: number;
dice: number;
patternModifier: number;
consequenceModifier: number;
guessResult?: GuessResult;
gamemasterResult?: GamemasterDecisionResult;
turnEffect?: TurnEffect; // Turn-based consequences that need game state changes
description: string;
effects: string[]; // Array of all effects applied
}
/**
* Service responsible for processing card-based field effects with step calculations
* Integrates pattern-based movement with test/guess mechanism and gamemaster decisions
*/
export class FieldEffectService {
constructor(
private boardGenerationService: BoardGenerationService,
private gamemasterService: GamemasterService
) {}
/**
* Process a card-based field effect with complete movement calculation
* @param request The field effect request containing all necessary data
* @returns Promise with complete processing result
*/
async processFieldEffect(request: FieldEffectRequest): Promise<CardProcessingResult> {
const { currentPosition, card, field, dice } = request;
// Ensure stepValue is defined
const stepValue = field.stepValue || 1;
// Calculate base movement using pattern-based system
const finalPosition = this.boardGenerationService.calculatePatternBasedMovement(
currentPosition,
stepValue,
dice
);
// Calculate pattern modifier manually for tracking
const patternModifier = this.getPatternModifier(currentPosition);
let result: CardProcessingResult = {
finalPosition,
stepValue,
dice,
patternModifier,
consequenceModifier: 0,
description: `Moved from position ${currentPosition} to ${finalPosition}`,
effects: []
};
// Process card based on type
if (this.isQuestionCard(card.type)) {
result = await this.processQuestionCard(request, result);
} else if (this.isJokerCard(card.type)) {
result = await this.processJokerCard(request, result);
} else if (this.isLuckCard(card.type)) {
result = await this.processLuckCard(request, result);
}
return result;
}
/**
* Get pattern modifier based on position (duplicated from BoardGenerationService)
* @param position Current position
* @returns Pattern modifier value
*/
private getPatternModifier(position: number): number {
// Pattern modifiers for strategic complexity:
// Positions 1-20: +2 bonus (easier start)
// Positions 21-40: -1 penalty (early game challenge)
// Positions 41-60: +1 bonus (mid-game boost)
// Positions 61-80: -2 penalty (late game challenge)
// Positions 81-100: +3 bonus (final stretch boost)
if (position <= 20) {
return 2;
} else if (position <= 40) {
return -1;
} else if (position <= 60) {
return 1;
} else if (position <= 80) {
return -2;
} else {
return 3;
}
}
/**
* Check if card is a question card (types 0-4)
* @param cardType Card type
* @returns True if question card
*/
private isQuestionCard(cardType?: number): boolean {
return cardType !== undefined && cardType >= 0 && cardType <= 4;
}
/**
* Check if card is a joker card
* @param cardType Card type
* @returns True if joker card
*/
private isJokerCard(cardType?: number): boolean {
return cardType === 5; // Assuming joker cards have type 5
}
/**
* Check if card is a luck card
* @param cardType Card type
* @returns True if luck card
*/
private isLuckCard(cardType?: number): boolean {
return cardType === 6; // Assuming luck cards have type 6
}
/**
* Process question card with test/guess mechanism
* @param request The field effect request
* @param baseResult The base movement calculation result
* @returns Updated result with guess processing
*/
private async processQuestionCard(
request: FieldEffectRequest,
baseResult: CardProcessingResult
): Promise<CardProcessingResult> {
const { guessedPosition } = request;
if (guessedPosition === undefined) {
throw new Error('Question cards require a position guess');
}
// Apply test/guess mechanism
const guessResult = this.processGuess(
guessedPosition,
baseResult.finalPosition,
baseResult.finalPosition
);
let finalPosition = baseResult.finalPosition;
let effects = [...baseResult.effects];
if (!guessResult.isCorrect) {
// Apply guess penalty: move back exactly 2 fields
finalPosition = Math.max(1, baseResult.finalPosition - 2);
effects.push(`Wrong guess penalty: moved back 2 fields`);
} else {
effects.push(`Correct guess: no penalty`);
}
return {
...baseResult,
finalPosition,
guessResult,
effects,
description: `Question card: ${guessResult.description}`
};
}
/**
* Process joker card with same guess mechanism as question cards + gamemaster decision
* @param request The field effect request
* @param baseResult The base movement calculation result
* @returns Updated result with guess processing and gamemaster decision
*/
private async processJokerCard(
request: FieldEffectRequest,
baseResult: CardProcessingResult
): Promise<CardProcessingResult> {
const { guessedPosition, gameId, playerId, playerName, card } = request;
if (guessedPosition === undefined) {
throw new Error('Joker cards require a position guess');
}
// Joker cards always use dice = 6, recalculate with correct dice value
const jokerDice = 6;
const correctBasePosition = this.boardGenerationService.calculatePatternBasedMovement(
request.currentPosition,
baseResult.stepValue,
jokerDice
);
let finalPosition = correctBasePosition;
let effects = [`Joker card: dice counted as 6`];
// Step 1: Process guess penalty (same as question cards)
const guessResult = this.processGuess(
guessedPosition,
correctBasePosition,
correctBasePosition
);
if (!guessResult.isCorrect) {
// Apply guess penalty: move back exactly 2 fields
finalPosition = Math.max(1, correctBasePosition - 2);
effects.push(`Wrong guess penalty: moved back 2 fields`);
} else {
effects.push(`Correct guess: no penalty`);
}
// Step 2: Process gamemaster decision (replaces player answer effect)
const gamemasterResult = await this.requestGamemasterDecision(
gameId,
playerId,
playerName,
card
);
let consequenceModifier = 0;
let turnEffect: TurnEffect | undefined;
if (gamemasterResult.consequence && card.consequence) {
// Apply consequence based on gamemaster decision using new processing method
const consequenceResult = this.processConsequence(playerId, finalPosition, card.consequence);
finalPosition = consequenceResult.newPosition;
consequenceModifier = consequenceResult.positionChange;
turnEffect = consequenceResult.turnEffect;
effects.push(`Gamemaster decision: ${gamemasterResult.description}`);
effects.push(`Consequence applied: ${this.getConsequenceDescription(card.consequence)}`);
} else {
effects.push(`No consequence applied: ${gamemasterResult.description}`);
}
return {
...baseResult,
finalPosition,
dice: jokerDice, // Update to show dice was 6
consequenceModifier,
turnEffect,
guessResult,
gamemasterResult,
effects,
description: `Joker card: ${guessResult.description} | ${gamemasterResult.description}`
};
}
/**
* Process luck card with immediate effects
* @param request The field effect request
* @param baseResult The base movement calculation result
* @returns Updated result with luck card effects
*/
private async processLuckCard(
request: FieldEffectRequest,
baseResult: CardProcessingResult
): Promise<CardProcessingResult> {
const { card, playerId } = request;
let finalPosition = baseResult.finalPosition;
let consequenceModifier = 0;
let turnEffect: TurnEffect | undefined;
let effects = [...baseResult.effects];
if (card.consequence) {
// Apply immediate consequence using new processing method
const consequenceResult = this.processConsequence(playerId, finalPosition, card.consequence);
finalPosition = consequenceResult.newPosition;
consequenceModifier = consequenceResult.positionChange;
turnEffect = consequenceResult.turnEffect;
effects.push(`Luck effect: ${this.getConsequenceDescription(card.consequence)}`);
}
return {
...baseResult,
finalPosition,
consequenceModifier,
turnEffect,
effects,
description: `Luck card: immediate effect applied`
};
}
/**
* Process position guess and determine if penalty should be applied
* @param guessedPosition Player's position guess
* @param actualPosition The calculated final position
* @param basePosition The position before guess penalty
* @returns Guess processing result
*/
private processGuess(
guessedPosition: number,
actualPosition: number,
basePosition: number
): GuessResult {
// Validate guess range
if (guessedPosition < 1 || guessedPosition > 100) {
throw new Error('Position guess must be between 1 and 100');
}
const isCorrect = guessedPosition === actualPosition;
const penaltyApplied = !isCorrect;
return {
guessedPosition,
actualPosition,
isCorrect,
penaltyApplied,
description: isCorrect
? `Correct guess (${guessedPosition})!`
: `Wrong guess (${guessedPosition}${actualPosition})`
};
}
/**
* Request gamemaster decision for joker card
* @param gameId Game ID
* @param playerId Player ID
* @param playerName Player name
* @param card Joker card
* @returns Promise with gamemaster decision result
*/
private async requestGamemasterDecision(
gameId: string,
playerId: string,
playerName: string,
card: GameCard
): Promise<GamemasterDecisionResult> {
// For now, return a default decision - this will be replaced with actual async gamemaster interaction
// TODO: Implement proper WebSocket-based gamemaster decision flow
return {
decision: 'reject' as any,
consequence: false,
description: '🎭 Gamemaster decision pending...'
};
}
/**
* Process consequence and separate position changes from turn effects
* @param playerId Player ID who drew the card
* @param currentPosition Current position before consequence
* @param consequence Card consequence
* @returns Object with position changes and turn effects
*/
private processConsequence(playerId: string, currentPosition: number, consequence: Consequence): {
newPosition: number;
positionChange: number;
turnEffect?: TurnEffect;
} {
// Handle position-affecting consequences
if (consequence.type === 0 || consequence.type === 1 || consequence.type === 5) {
const newPosition = this.applyConsequenceToPosition(currentPosition, consequence);
return {
newPosition,
positionChange: newPosition - currentPosition
};
}
// Handle turn-based consequences
if (consequence.type === 2 || consequence.type === 3) {
const turnEffect: TurnEffect = {
type: consequence.type === 2 ? 'LOSE_TURN' : 'EXTRA_TURN',
playerId,
value: consequence.value || 1
};
return {
newPosition: currentPosition, // No position change
positionChange: 0,
turnEffect
};
}
// Unknown consequence type
return {
newPosition: currentPosition,
positionChange: 0
};
}
/**
* Apply consequence to position with proper boundary handling
* @param currentPosition Current position
* @param consequence Card consequence
* @returns New position after consequence
*/
private applyConsequenceToPosition(currentPosition: number, consequence: Consequence): number {
switch (consequence.type) {
case 0: // MOVE_FORWARD
return Math.min(100, currentPosition + (consequence.value || 1));
case 1: // MOVE_BACKWARD
return Math.max(1, currentPosition - (consequence.value || 1));
case 5: // GO_TO_START
return 1;
default:
return currentPosition; // Other consequences don't change position
}
}
/**
* Get human-readable description for consequence
* @param consequence Card consequence
* @returns Description string
*/
private getConsequenceDescription(consequence: Consequence): string {
switch (consequence.type) {
case 0: // MOVE_FORWARD
return `Move forward ${consequence.value || 1} steps`;
case 1: // MOVE_BACKWARD
return `Move backward ${consequence.value || 1} steps`;
case 2: // LOSE_TURN
const lostTurns = consequence.value || 1;
return lostTurns === 1 ? 'Lose next turn' : `Lose next ${lostTurns} turns`;
case 3: // EXTRA_TURN
const extraTurns = consequence.value || 1;
return extraTurns === 1 ? 'Get extra turn' : `Get ${extraTurns} extra turns`;
case 5: // GO_TO_START
return 'Go back to start';
default:
return 'Unknown effect';
}
}
}
@@ -0,0 +1,205 @@
import jwt, { SignOptions } from 'jsonwebtoken';
import { Request } from 'express';
export interface GameTokenPayload {
gameId: string;
gameCode: string;
playerName: string;
isAuthenticated: boolean;
userId?: string; // Optional - only for authenticated players
tokenType: 'game_session';
iat?: number;
exp?: number;
}
export class GameTokenService {
private readonly secretKey: string;
private readonly gameTokenExpiry: number;
constructor() {
this.secretKey = process.env.JWT_SECRET || 'your-secret-key';
// Game tokens expire after 24 hours (or configured duration)
// This should be longer than typical game duration
this.gameTokenExpiry = parseInt(process.env.GAME_TOKEN_EXPIRY || '86400'); // 24 hours default
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 a game session token for a player
* @param gameId - The database ID of the game
* @param gameCode - The public game code (e.g., ABC123)
* @param playerName - The player's name in the game
* @param userId - Optional user ID for authenticated players
* @returns Game session JWT token
*/
createGameToken(gameId: string, gameCode: string, playerName: string, userId?: string): string {
const now = Math.floor(Date.now() / 1000);
const payload: GameTokenPayload = {
gameId,
gameCode,
playerName,
isAuthenticated: !!userId,
userId,
tokenType: 'game_session',
iat: now,
exp: now + this.gameTokenExpiry
};
const options: SignOptions = {};
const token = jwt.sign(payload, this.secretKey, options);
return token;
}
/**
* Verify and decode a game session token
* @param token - The game session JWT token
* @returns Decoded payload or null if invalid
*/
verifyGameToken(token: string): GameTokenPayload | null {
try {
const decoded = jwt.verify(token, this.secretKey) as GameTokenPayload;
// Verify it's actually a game token
if (decoded.tokenType !== 'game_session') {
return null;
}
return decoded;
} catch (error) {
return null;
}
}
/**
* Extract game token from request headers or query params
* @param req - Express request object
* @returns Game token string or null
*/
extractGameTokenFromRequest(req: Request): string | null {
// Check Authorization header
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
return authHeader.substring(7);
}
// Check query parameter (for WebSocket handshake)
if (req.query && req.query.gameToken && typeof req.query.gameToken === 'string') {
return req.query.gameToken;
}
// Check game_token cookie
if (req.cookies && req.cookies.game_token) {
return req.cookies.game_token;
}
return null;
}
/**
* Verify game token from request and return payload
* @param req - Express request object
* @returns Decoded game token payload or null
*/
verifyGameTokenFromRequest(req: Request): GameTokenPayload | null {
const token = this.extractGameTokenFromRequest(req);
if (!token) {
return null;
}
return this.verifyGameToken(token);
}
/**
* Check if a game token is valid for a specific game
* @param token - The game session token
* @param gameCode - The game code to validate against
* @param playerName - Optional player name to validate
* @returns True if token is valid for the game
*/
isValidForGame(token: string, gameCode: string, playerName?: string): boolean {
const payload = this.verifyGameToken(token);
if (!payload) {
return false;
}
// Check game code matches
if (payload.gameCode !== gameCode) {
return false;
}
// Check player name if provided
if (playerName && payload.playerName !== playerName) {
return false;
}
return true;
}
/**
* Refresh a game token (extend expiry)
* @param currentToken - The current game token
* @returns New token with extended expiry or null if invalid
*/
refreshGameToken(currentToken: string): string | null {
const payload = this.verifyGameToken(currentToken);
if (!payload) {
return null;
}
// Create new token with same data but fresh expiry
return this.createGameToken(
payload.gameId,
payload.gameCode,
payload.playerName,
payload.userId
);
}
/**
* Get remaining time before token expires
* @param token - The game session token
* @returns Seconds until expiry or -1 if invalid/expired
*/
getTimeUntilExpiry(token: string): number {
const payload = this.verifyGameToken(token);
if (!payload || !payload.exp) {
return -1;
}
const now = Math.floor(Date.now() / 1000);
const remaining = payload.exp - now;
return remaining > 0 ? remaining : -1;
}
/**
* Create a game token response object for API responses
* @param gameId - The database ID of the game
* @param gameCode - The public game code
* @param playerName - The player's name
* @param userId - Optional user ID for authenticated players
* @returns Object with token and metadata
*/
createGameTokenResponse(gameId: string, gameCode: string, playerName: string, userId?: string) {
const token = this.createGameToken(gameId, gameCode, playerName, userId);
const expiresIn = this.gameTokenExpiry;
return {
gameToken: token,
gameCode,
playerName,
isAuthenticated: !!userId,
expiresIn,
expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString(),
tokenType: 'game_session'
};
}
}
export default GameTokenService;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,284 @@
import { GameAggregate, GameCard } from '../../Domain/Game/GameAggregate';
export interface GamemasterDecisionRequest {
gameId: string;
playerId: string;
playerName: string;
card: GameCard;
requestId: string;
timeoutId: NodeJS.Timeout;
startTime: Date;
}
export enum GamemasterDecision {
APPROVE = 'approve',
REJECT = 'reject'
}
export interface GamemasterDecisionResult {
decision: GamemasterDecision;
consequence: boolean; // true = apply consequence, false = don't apply
description: string;
}
/**
* Service responsible for handling gamemaster decisions on joker cards
* Integrates with existing gamemaster role identification system
*/
export class GamemasterService {
private pendingDecisions: Map<string, GamemasterDecisionRequest> = new Map();
private readonly DECISION_TIMEOUT_MS = 120000; // 2 minutes for gamemaster to decide
/**
* Request gamemaster decision for a joker card
* @param gameId Game ID
* @param playerId Player ID who drew the joker card
* @param playerName Player name for display
* @param card The joker card that needs decision
* @param onTimeout Callback when gamemaster doesn't respond in time
* @returns Request ID for tracking this decision
*/
requestGamemasterDecision(
gameId: string,
playerId: string,
playerName: string,
card: GameCard,
onTimeout: (requestId: string) => void
): string {
const requestId = `${gameId}:${playerId}:${Date.now()}`;
// Clear any existing decision for this player
this.clearExistingDecision(gameId, playerId);
// Set timeout for gamemaster decision
const timeoutId = setTimeout(() => {
onTimeout(requestId);
this.pendingDecisions.delete(requestId);
}, this.DECISION_TIMEOUT_MS);
// Store pending decision
this.pendingDecisions.set(requestId, {
gameId,
playerId,
playerName,
card,
requestId,
timeoutId,
startTime: new Date()
});
return requestId;
}
/**
* Process gamemaster's decision on a joker card
* @param requestId The request ID returned from requestGamemasterDecision
* @param decision The gamemaster's decision
* @returns Result with consequence application info
*/
processGamemasterDecision(requestId: string, decision: GamemasterDecision): GamemasterDecisionResult | null {
const pendingRequest = this.pendingDecisions.get(requestId);
if (!pendingRequest) {
return null; // Request not found or already processed
}
// Clear the timeout since decision was made
clearTimeout(pendingRequest.timeoutId);
this.pendingDecisions.delete(requestId);
// Determine if consequence should be applied based on its nature and decision
const consequence = pendingRequest.card.consequence;
const isNegativeConsequence = this.isNegativeConsequence(consequence?.type);
let applyConsequence: boolean;
if (isNegativeConsequence) {
// Negative consequences applied when gamemaster REJECTS
applyConsequence = decision === GamemasterDecision.REJECT;
} else {
// Positive consequences applied when gamemaster APPROVES
applyConsequence = decision === GamemasterDecision.APPROVE;
}
return {
decision,
consequence: applyConsequence,
description: this.getDecisionDescription(decision, applyConsequence, pendingRequest.card, isNegativeConsequence)
};
}
/**
* Process automatic decision when gamemaster times out
* @param requestId The request ID that timed out
* @returns Result with default rejection applied
*/
processTimeoutDecision(requestId: string): GamemasterDecisionResult | null {
const pendingRequest = this.pendingDecisions.get(requestId);
if (!pendingRequest) {
return null;
}
this.pendingDecisions.delete(requestId);
return {
decision: GamemasterDecision.REJECT,
consequence: false,
description: `🎭 Gamemaster didn't respond in time. No effect applied.`
};
}
/**
* Get pending decision by request ID
* @param requestId The request ID
* @returns Pending decision request or undefined
*/
getPendingDecision(requestId: string): GamemasterDecisionRequest | undefined {
return this.pendingDecisions.get(requestId);
}
/**
* Get all pending decisions for a game
* @param gameId Game ID
* @returns Array of pending decisions for the game
*/
getPendingDecisionsForGame(gameId: string): GamemasterDecisionRequest[] {
return Array.from(this.pendingDecisions.values())
.filter(request => request.gameId === gameId);
}
/**
* Check if gamemaster is the correct user for a game
* @param game Game aggregate
* @param userId User ID to check
* @returns True if user is the gamemaster
*/
isGamemaster(game: GameAggregate, userId: string): boolean {
return game.createdby === userId;
}
/**
* Cancel a pending decision (e.g., if player leaves game)
* @param requestId Request ID to cancel
* @returns True if decision was cancelled
*/
cancelDecision(requestId: string): boolean {
const pendingRequest = this.pendingDecisions.get(requestId);
if (!pendingRequest) {
return false;
}
clearTimeout(pendingRequest.timeoutId);
this.pendingDecisions.delete(requestId);
return true;
}
/**
* Clear any existing pending decision for a player in a game
* @param gameId Game ID
* @param playerId Player ID
*/
private clearExistingDecision(gameId: string, playerId: string): void {
for (const [requestId, request] of this.pendingDecisions.entries()) {
if (request.gameId === gameId && request.playerId === playerId) {
clearTimeout(request.timeoutId);
this.pendingDecisions.delete(requestId);
break;
}
}
}
/**
* Get human-readable description for joker card effect
* @param card The joker card
* @param applied Whether the effect will be applied
* @returns Description string
*/
private getJokerDescription(card: GameCard, applied: boolean): string {
if (!applied) {
return 'No effect applied.';
}
if (!card.consequence) {
return 'Apply joker effect!';
}
switch (card.consequence.type) {
case 0: // MOVE_FORWARD
return `Move forward ${card.consequence.value || 1} steps!`;
case 1: // MOVE_BACKWARD
return `Move backward ${card.consequence.value || 1} steps!`;
case 2: // LOSE_TURN
return 'Lose your next turn!';
case 3: // EXTRA_TURN
return 'Get an extra turn!';
case 5: // GO_TO_START
return 'Go back to start!';
default:
return 'Apply joker effect!';
}
}
/**
* Get remaining time for a pending decision
* @param requestId Request ID
* @returns Remaining time in seconds, or -1 if not found
*/
getRemainingTime(requestId: string): number {
const pending = this.pendingDecisions.get(requestId);
if (!pending) {
return -1;
}
const elapsed = Date.now() - pending.startTime.getTime();
const remaining = Math.max(0, this.DECISION_TIMEOUT_MS - elapsed);
return Math.ceil(remaining / 1000); // Return in seconds
}
/**
* Get count of pending decisions for a game
* @param gameId Game ID
* @returns Number of pending decisions
*/
getPendingDecisionCount(gameId: string): number {
return Array.from(this.pendingDecisions.values())
.filter(request => request.gameId === gameId).length;
}
/**
* Determine if a consequence type is negative
* @param consequenceType The consequence type to check
* @returns True if consequence is negative
*/
private isNegativeConsequence(consequenceType?: number): boolean {
if (consequenceType === undefined) return false;
// Negative consequences: MOVE_BACKWARD, LOSE_TURN, GO_TO_START
return [1, 2, 5].includes(consequenceType); // MOVE_BACKWARD=1, LOSE_TURN=2, GO_TO_START=5
}
/**
* Get description for gamemaster decision result
* @param decision Gamemaster's decision
* @param applyConsequence Whether consequence will be applied
* @param card The joker card
* @param isNegative Whether the consequence is negative
* @returns Description string
*/
private getDecisionDescription(decision: GamemasterDecision, applyConsequence: boolean, card: GameCard, isNegative: boolean): string {
if (decision === GamemasterDecision.APPROVE) {
if (isNegative) {
return '🎭 Gamemaster approved - no penalty applied!';
} else {
return `🎭 Gamemaster approved! ${this.getJokerDescription(card, true)}`;
}
} else {
if (isNegative) {
return `🎭 Gamemaster rejected! ${this.getJokerDescription(card, true)}`;
} else {
return '🎭 Gamemaster rejected - no bonus applied.';
}
}
}
}
@@ -0,0 +1,132 @@
/**
* Shared interfaces for game-related WebSocket communications
* Used by both WebSocketService and GameWebSocketService
*/
export interface JoinGameRoomData {
gameCode: string;
}
export interface LeaveGameRoomData {
gameCode: string;
}
export interface GameStateUpdateData {
gameId: string;
gameCode: string;
players: string[];
state: string;
currentTurn?: string;
}
export interface GameActionData {
gameId: string;
gameCode: string;
playerId: string;
action: 'pick_card' | 'play_card' | 'end_turn' | 'leave_game' | 'roll-dice' | 'move' | 'use-field';
data?: any;
}
// Field Effect Service WebSocket interfaces
export interface FieldEffectCalculationData {
gameId: string;
gameCode: string;
playerId: string;
currentPosition: number;
card: any; // GameCard
field: any; // GameField
dice: number;
guessedPosition?: number;
}
export interface FieldEffectResultData {
gameId: string;
gameCode: string;
playerId: string;
result: {
finalPosition: number;
stepValue: number;
dice: number;
patternModifier: number;
consequenceModifier: number;
guessResult?: any;
gamemasterResult?: any;
description: string;
effects: string[];
turnEffect?: {
type: 'LOSE_TURN' | 'EXTRA_TURN';
value: number;
playerId: string;
};
};
}
export interface GamemasterDecisionRequestData {
gameId: string;
gameCode: string;
requestId: string;
playerId: string;
playerName: string;
card: any; // GameCard
timeRemaining: number;
}
export interface GamemasterDecisionResponseData {
requestId: string;
decision: 'approve' | 'reject';
}
// Game-specific interfaces for GameWebSocketService
export interface JoinGameData {
gameToken: string; // Required game session token
}
export interface LeaveGameData {
gameCode: string;
}
export interface DiceRollData {
gameCode: string;
diceValue: number; // Value from frontend (1-6)
}
export interface PlayerPosition {
playerId: string;
playerName: string;
boardPosition: number;
turnOrder: number;
}
export interface GameChatData {
gameCode: string;
message: string;
}
// Field Effect related types
export interface FieldEffectRequest {
gameId: string;
playerId: string;
playerName: string;
currentPosition: number;
card: any;
field: any;
dice: number;
guessedPosition?: number;
}
export interface FieldEffectResult {
finalPosition: number;
stepValue: number;
dice: number;
patternModifier: number;
consequenceModifier: number;
guessResult?: any;
gamemasterResult?: any;
description: string;
effects: string[];
turnEffect?: {
type: 'LOSE_TURN' | 'EXTRA_TURN';
value: number;
playerId: string;
};
}
@@ -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;

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