fel kesz game backend

This commit is contained in:
2025-09-15 19:00:35 +02:00
parent 7963f28021
commit 3af8de2797
267 changed files with 15655 additions and 347 deletions
+2
View File
@@ -10,6 +10,7 @@ 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';
@@ -131,6 +132,7 @@ 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());
@@ -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();
}
};
@@ -107,38 +107,6 @@ router.get('/users/page/:from/:to', adminRequired, async (req: Request, res: Res
}
});
// Get users by page (admin only) - RECOMMENDED
router.get('/users/page/:from/:to', adminRequired, async (req: Request, res: Response) => {
try {
const from = parseInt(req.params.from);
const to = parseInt(req.params.to);
const includeDeleted = req.query.includeDeleted === 'true';
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('Admin get users by page endpoint accessed', req, res, { from, to, includeDeleted });
const result = includeDeleted
? await container.userRepository.findByPageIncludingDeleted(from, to)
: await container.userRepository.findByPage(from, to);
logRequest('Admin users page retrieved successfully', req, res, {
from,
to,
count: result.users.length,
total: result.totalCount,
includeDeleted
});
res.json(result);
} catch (error) {
logError('Admin get users by page endpoint error', error as Error, req, res);
res.status(500).json({ error: 'Internal server error' });
}
});
// Get user by ID including soft-deleted ones
router.get('/users/:userId',
adminRequired,
@@ -173,32 +141,32 @@ router.get('/users/:userId',
});
// Search users including soft-deleted ones
router.get('/users/search/:searchTerm',
adminRequired,
ValidationMiddleware.validateStringLength({ searchTerm: { min: 2, max: 100 } }),
async (req: Request, res: Response) => {
try {
const { searchTerm } = req.params;
const includeDeleted = req.query.includeDeleted === 'true';
// router.get('/users/search/:searchTerm',
// adminRequired,
// ValidationMiddleware.validateStringLength({ searchTerm: { min: 2, max: 100 } }),
// async (req: Request, res: Response) => {
// try {
// const { searchTerm } = req.params;
// const includeDeleted = req.query.includeDeleted === 'true';
logRequest('Admin search users endpoint accessed', req, res, { searchTerm, includeDeleted });
// logRequest('Admin search users endpoint accessed', req, res, { searchTerm, includeDeleted });
const users = includeDeleted
? await container.userRepository.searchIncludingDeleted(searchTerm)
: await container.userRepository.search(searchTerm);
// const users = includeDeleted
// ? await container.userRepository.searchIncludingDeleted(searchTerm)
// : await container.userRepository.search(searchTerm);
logRequest('Admin user search completed', req, res, {
searchTerm,
resultCount: Array.isArray(users) ? users.length : (users.totalCount || 0),
includeDeleted
});
// logRequest('Admin user search completed', req, res, {
// searchTerm,
// resultCount: Array.isArray(users) ? users.length : (users.totalCount || 0),
// includeDeleted
// });
res.json(users);
} catch (error) {
logError('Admin search users endpoint error', error as Error, req, res);
res.status(500).json({ error: 'Internal server error' });
}
});
// res.json(users);
// } catch (error) {
// logError('Admin search users endpoint error', error as Error, req, res);
// res.status(500).json({ error: 'Internal server error' });
// }
// });
// Update any user (admin only)
router.patch('/users/:userId',
@@ -390,6 +358,30 @@ router.get('/decks/search/:searchTerm', adminRequired, async (req: Request, res:
}
});
//modify deck (admin only)
router.patch('/decks/:id', adminRequired, async (req: Request, res: Response) => {
try {
const deckId = req.params.id;
const adminUserId = (req as any).user.userId;
logRequest('Admin update deck endpoint accessed', req, res, { deckId, adminUserId, updateFields: Object.keys(req.body) });
const result = await container.updateDeckCommandHandler.execute({ id: deckId, userstate: 1 , ...req.body});
logRequest('Deck updated successfully by admin', req, res, { deckId, adminUserId });
res.json(result);
} catch (error) {
logError('Admin update deck endpoint error', error as Error, req, res);
if (error instanceof Error && error.message.includes('not found')) {
return res.status(404).json({ error: `Deck not found` });
}
if (error instanceof Error && (error.message.includes('duplicate') || error.message.includes('unique constraint'))) {
return res.status(409).json({ error: 'Deck with this name already exists' });
}
if (error instanceof Error && error.message.includes('validation')) {
return res.status(400).json({ error: 'Invalid input data', details: error.message });
}
res.status(500).json({ error: 'Internal server error' });
}
});
// Hard delete deck (admin only)
router.delete('/decks/:id/hard', adminRequired, async (req: Request, res: Response) => {
try {
@@ -60,7 +60,7 @@ 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 });
@@ -140,7 +140,7 @@ deckRouter.get('/:id', authRequired, async (req, res) => {
}
});
deckRouter.put('/:id', authRequired, async (req, res) => {
deckRouter.patch('/:id', authRequired, async (req, res) => {
try {
const deckId = req.params.id;
const userId = (req as any).user.userId;
@@ -164,6 +164,10 @@ deckRouter.put('/:id', authRequired, async (req, res) => {
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' });
}
@@ -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;
@@ -32,7 +32,7 @@ userRouter.post('/login',
logAuth('User login successful', result.user.id, { username: result.user.username }, req, res);
res.json(result);
} else {
return ErrorResponseService.sendUnauthorized(res, 'Invalid username or password');
throw new Error(`Login failed: ${result}`);
}
} catch (error) {
@@ -48,6 +48,9 @@ userRouter.post('/login',
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');
}
@@ -84,7 +87,8 @@ userRouter.post('/create',
res.status(201).json(result);
} catch (error) {
logError('Create user endpoint error', error as Error, req, res);
// 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')) {
@@ -93,6 +97,10 @@ userRouter.post('/create',
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);
@@ -165,4 +173,29 @@ userRouter.patch('/profile', authRequired, async (req, 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);
}
});
export default userRouter;
@@ -1,4 +1,5 @@
import swaggerJSDoc from 'swagger-jsdoc';
import path from 'path';
export const swaggerOptions = {
definition: {
@@ -18,9 +19,13 @@ export const swaggerOptions = {
},
servers: [
{
url: 'http://localhost:3000',
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'
@@ -61,11 +66,35 @@ export const swaggerOptions = {
{
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/swaggerDefinitions.ts'
'./src/Api/swagger/swaggerDefinitionsFixed.ts'
],
};
@@ -1,11 +1,6 @@
/**
* @swagger
* components:
* securitySchemes:
* bearerAuth:
* type: http
* scheme: bearer
* bearerFormat: JWT
* schemas:
* User:
* type: object
@@ -299,6 +294,34 @@
* chatId:
* type: string
*
* Game:
* type: object
* properties:
* id:
* type: string
* format: uuid
* gamecode:
* type: string
* maxplayers:
* type: integer
* logintype:
* type: integer
* gamedecks:
* type: array
* players:
* type: array
* items:
* type: string
* started:
* type: boolean
* finished:
* type: boolean
* state:
* type: integer
* createdate:
* type: string
* format: date-time
*
* Error:
* type: object
* properties:
@@ -309,32 +332,35 @@
* format: date-time
* details:
* type: string
*
* paths:
* /api/users/login:
* post:
* tags: [Users]
* summary: User login
* description: Authenticate user and return JWT token
* requestBody:
* required: true
*/
/**
* @swagger
*
* /api/users/login:
* post:
* tags: [Users]
* summary: User login
* description: Authenticate user and return JWT token
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/LoginRequest'
* responses:
* 200:
* description: Login successful
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/LoginRequest'
* responses:
* 200:
* description: Login successful
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/LoginResponse'
* 401:
* description: Invalid credentials
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* $ref: '#/components/schemas/LoginResponse'
* 401:
* description: Invalid credentials
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*
*
* /api/users/create:
* post:
@@ -1397,6 +1423,163 @@
* application/json:
* schema:
* $ref: '#/components/schemas/Contact'
*
* /api/games/start:
* post:
* summary: Start a new game
* tags: [Games]
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - deckids
* - maxplayers
* - logintype
* properties:
* deckids:
* type: array
* items:
* type: string
* description: Array of deck IDs (must include all 3 types LUCK, JOKER, QUESTION)
* maxplayers:
* type: integer
* minimum: 2
* maximum: 8
* description: Maximum number of players allowed in the game
* logintype:
* type: integer
* enum: [0, 1, 2]
* description: How players can join (PUBLIC=0, PRIVATE=1, ORGANIZATION=2)
* responses:
* 200:
* description: Game started successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* id:
* type: string
* description: Game UUID
* gamecode:
* type: string
* description: 6-character game code for joining
* maxplayers:
* type: integer
* logintype:
* type: integer
* gamedecks:
* type: array
* description: Shuffled game decks
* players:
* type: array
* items:
* type: string
* started:
* type: boolean
* finished:
* type: boolean
* state:
* type: integer
* description: Game state (WAITING=0, ACTIVE=1, FINISHED=2, CANCELLED=3)
* createdate:
* type: string
* format: date-time
* 400:
* description: Invalid input parameters
* 401:
* description: Authentication required
* 500:
* description: Internal server error
*
* /api/games/join:
* post:
* summary: Join a game (automatically detects game type)
* description: Join any game by providing the game code. The system automatically determines if authentication is required based on the game type.
* tags: [Games]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - gameCode
* properties:
* gameCode:
* type: string
* description: 6-character game code
* example: "ABC123"
* playerName:
* type: string
* description: Display name for the player (required for public games, optional for authenticated games)
* example: "John Doe"
* responses:
* 200:
* description: Successfully joined the game
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Game'
* 400:
* description: Invalid input or missing required fields
* 401:
* description: Authentication required for this game type
* 403:
* description: Organization membership required
* 404:
* description: Game not found
* 409:
* description: Game is full or not accepting players
* 500:
* description: Internal server error
*
* /api/games/{gameId}/start:
* post:
* summary: Start gameplay for an existing game
* description: Initialize gameplay by setting all player positions to 0 and assigning random turn order. This is separate from game creation.
* tags: [Games]
* parameters:
* - in: path
* name: gameId
* required: true
* schema:
* type: string
* description: The ID of the game to start
* responses:
* 200:
* description: Game started successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* message:
* type: string
* example: "Game started successfully"
* gameId:
* type: string
* example: "game123"
* playerCount:
* type: number
* example: 4
* 400:
* description: Invalid input or game cannot be started
* 401:
* description: Authentication required
* 403:
* description: Only game master can start the game
* 404:
* description: Game not found
* 409:
* description: Game already started or not ready to start
* 500:
* description: Internal server error
*/
export {};
File diff suppressed because it is too large Load Diff
@@ -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;
}
@@ -21,7 +21,6 @@ export class UserMapper {
fname: user.fname,
lname: user.lname,
code: user.token,
type: user.type,
phone: user.phone,
state: user.state,
};
@@ -24,7 +24,6 @@ export interface DetailUserDto {
fname: string;
lname: string;
code: string | null;
type: string;
phone: string | null;
state: number;
}
@@ -1,5 +1,8 @@
import { n } from "framer-motion/dist/types.d-D0HXPxHm";
export interface UpdateDeckCommand {
id: string;
userstate?: number;
name?: string;
type?: number;
userid?: string;
@@ -2,13 +2,49 @@ 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> {
const updated = await this.deckRepo.update(cmd.id, { ...cmd });
if (!updated) return null;
return DeckMapper.toShortDto(updated);
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;
}
}
}
@@ -1,14 +1,14 @@
import { IDeckRepository } from '../../../Domain/IRepository/IDeckRepository';
import { GetDeckByIdQuery } from './GetDeckByIdQuery';
import { ShortDeckDto } from '../../DTOs/DeckDto';
import { DetailDeckDto } from '../../DTOs/DeckDto';
import { DeckMapper } from '../../DTOs/Mappers/DeckMapper';
export class GetDeckByIdQueryHandler {
constructor(private readonly deckRepo: IDeckRepository) {}
async execute(query: GetDeckByIdQuery): Promise<ShortDeckDto | null> {
async execute(query: GetDeckByIdQuery): Promise<DetailDeckDto | null> {
const deck = await this.deckRepo.findById(query.id);
if (!deck) return null;
return DeckMapper.toShortDto(deck);
return DeckMapper.toDetailDto(deck);
}
}
@@ -0,0 +1,494 @@
import { GameField, BoardData } from '../../Domain/Game/GameAggregate';
import { logOther, logError } from '../Services/Logger';
interface TargetField {
fieldNumber: number;
distance: number;
}
interface SpecialFieldInfo {
position: number;
type: 'positive' | 'negative' | 'luck';
}
export class BoardGenerationService {
private readonly MAX_GENERATION_TIME = parseInt(process.env.MAX_GENERATION_TIME_SECONDS || '20') * 1000;
private readonly ERROR_TOLERANCE = parseInt(process.env.GENERATION_ERROR_TOLERANCE || '15');
async generateBoard(
positiveFieldCount: number,
negativeFieldCount: number,
luckFieldCount: number
): Promise<BoardData> {
const startTime = Date.now();
let bestAttempt: BoardData | null = null;
let attemptCount = 0;
while (Date.now() - startTime < this.MAX_GENERATION_TIME) {
attemptCount++;
try {
const attempt = this.generateSingleAttempt(positiveFieldCount, negativeFieldCount, luckFieldCount);
if (attempt.totalErrorRate <= this.ERROR_TOLERANCE) {
logOther(`Board generation successful on attempt ${attemptCount}. Error rate: ${attempt.totalErrorRate}%`);
return attempt;
}
if (!bestAttempt || attempt.totalErrorRate < bestAttempt.totalErrorRate) {
bestAttempt = attempt;
}
logOther(`Attempt ${attemptCount}: Error rate ${attempt.totalErrorRate}% (target: ${this.ERROR_TOLERANCE}%)`);
} catch (error) {
logError(`Board generation attempt ${attemptCount} failed:`, error as Error);
}
}
logOther(`Using best attempt with error rate: ${bestAttempt?.totalErrorRate || 100}%`);
return bestAttempt || this.generateFallbackBoard(positiveFieldCount, negativeFieldCount, luckFieldCount);
}
private generateSingleAttempt(
positiveFieldCount: number,
negativeFieldCount: number,
luckFieldCount: number
): BoardData {
// Step 1: Choose special field positions
const specialFieldPositions = this.chooseSpecialFieldPositions(
positiveFieldCount,
negativeFieldCount,
luckFieldCount
);
// Step 2: Select target fields for each special field (6 targets per field for dice 1-6)
const targetFieldsMap = this.selectTargetFields(specialFieldPositions);
// Step 3: Create border with strategic placement
const border = this.createStrategicBorder(targetFieldsMap);
// Step 4: Calculate step values based on border positions
const fields = this.calculateStepValues(specialFieldPositions, targetFieldsMap, border);
// Step 5: Validate against 20-30 rule and calculate error rate
const validationResults = this.validateBoardGeneration(fields, border);
// Log generation statistics
logOther('Board generation attempt completed', {
totalFields: fields.length,
specialFields: fields.filter(f => f.type !== 'regular').length,
positiveFields: fields.filter(f => f.type === 'positive').length,
negativeFields: fields.filter(f => f.type === 'negative').length,
luckFields: fields.filter(f => f.type === 'luck').length,
errorRate: validationResults.errorRate,
targetCount: Array.from(targetFieldsMap.values()).reduce((sum, targets) => sum + targets.length, 0)
});
return {
fields,
border,
validationResults: validationResults.validationResults,
totalErrorRate: validationResults.errorRate
};
}
private chooseSpecialFieldPositions(
positiveFieldCount: number,
negativeFieldCount: number,
luckFieldCount: number
): SpecialFieldInfo[] {
const totalSpecial = positiveFieldCount + negativeFieldCount + luckFieldCount;
const positions: number[] = [];
const specialFields: SpecialFieldInfo[] = [];
// Random placement with retry for good distribution
let attempts = 0;
while (positions.length < totalSpecial && attempts < 100) {
const position = Math.floor(Math.random() * 100) + 1; // 1-100
if (!positions.includes(position)) {
// Check minimum distance from existing positions
const tooClose = positions.some(existingPos => Math.abs(existingPos - position) < 3);
if (!tooClose || attempts > 50) { // Relax distance requirement after many attempts
positions.push(position);
}
}
attempts++;
}
// Sort positions and assign types
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]];
}
positions.forEach((position, index) => {
specialFields.push({
position,
type: types[index] || 'positive'
});
});
return specialFields;
}
private selectTargetFields(specialFields: SpecialFieldInfo[]): Map<number, TargetField[]> {
const targetFieldsMap = new Map<number, TargetField[]>();
specialFields.forEach(field => {
if (field.type === 'luck') {
// Luck fields don't need target calculations
targetFieldsMap.set(field.position, []);
return;
}
const targets: TargetField[] = [];
const usedTargets = new Set<number>();
// Generate 6 different target fields (for dice 1-6) with 20-30 rule compliance
for (let i = 0; i < 6; i++) {
let targetField: number;
let distance: number;
let attempts = 0;
do {
// Determine max distance based on field position (20-30 rule)
let maxDistance: number;
let maxBackward: number;
if (field.position <= 85) {
maxDistance = 20;
maxBackward = 20;
} else {
maxDistance = 20; // forward
maxBackward = 30; // backward
}
// Create variety in distances within the allowed range
const distanceType = Math.random();
if (distanceType < 0.5) {
// Close distance (50% chance) - 1 to 1/3 of max
distance = Math.floor(Math.random() * Math.floor(maxDistance / 3)) + 1;
} else {
// Far distance (50% chance) - 1/3 to max
distance = Math.floor(Math.random() * (maxDistance - Math.floor(maxDistance / 3))) + Math.floor(maxDistance / 3);
}
// Randomly choose forward or backward
if (Math.random() < 0.5) {
distance = -Math.min(distance, maxBackward);
} else {
distance = Math.min(distance, maxDistance);
}
targetField = field.position + distance;
// Ensure target is within valid range
if (targetField < 1) targetField = 1;
if (targetField > 100) targetField = 100;
// Recalculate actual distance after clamping
distance = Math.abs(targetField - field.position);
attempts++;
} while (usedTargets.has(targetField) && attempts < 30);
if (!usedTargets.has(targetField)) {
usedTargets.add(targetField);
targets.push({
fieldNumber: targetField,
distance: Math.abs(targetField - field.position)
});
} else {
// Fallback: use a nearby valid target
let fallbackTarget = field.position + (i - 3); // Create some variety around current position
if (fallbackTarget < 1) fallbackTarget = 1;
if (fallbackTarget > 100) fallbackTarget = 100;
targets.push({
fieldNumber: fallbackTarget,
distance: Math.abs(fallbackTarget - field.position)
});
}
}
targetFieldsMap.set(field.position, targets);
});
return targetFieldsMap;
}
private createStrategicBorder(targetFieldsMap: Map<number, TargetField[]>): number[] {
// Collect all target field numbers
const targetNumbers = new Set<number>();
targetFieldsMap.forEach(targets => {
targets.forEach(target => targetNumbers.add(target.fieldNumber));
});
// Create array of all numbers 1-100
const allNumbers = Array.from({ length: 100 }, (_, i) => i + 1);
// Separate target numbers from remaining numbers
const remainingNumbers = allNumbers.filter(num => !targetNumbers.has(num));
// Shuffle remaining numbers
for (let i = remainingNumbers.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[remainingNumbers[i], remainingNumbers[j]] = [remainingNumbers[j], remainingNumbers[i]];
}
// Create border with strategic placement
const border: number[] = [];
const targetArray = Array.from(targetNumbers);
// Encourage overlap by placing target numbers first, then fill with random
let targetIndex = 0;
let remainingIndex = 0;
for (let i = 0; i < 100; i++) {
// Alternate between target numbers and remaining numbers, but favor targets when available
if (targetIndex < targetArray.length && (remainingIndex >= remainingNumbers.length || Math.random() < 0.6)) {
border.push(targetArray[targetIndex]);
targetIndex++;
} else if (remainingIndex < remainingNumbers.length) {
border.push(remainingNumbers[remainingIndex]);
remainingIndex++;
} else {
// Fallback - should not happen if logic is correct
border.push((i % 100) + 1);
}
}
return border;
}
private calculateStepValues(
specialFields: SpecialFieldInfo[],
targetFieldsMap: Map<number, TargetField[]>,
border: number[]
): 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 calculated 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;
}
const targets = targetFieldsMap.get(specialField.position) || [];
if (targets.length === 0) return;
// NEW APPROACH: Calculate step value that will land on first target with dice=1
// This ensures we have a baseline that works, then dice 2-6 will hit other targets
const firstTarget = targets[0];
const targetIndexInBorder = border.indexOf(firstTarget.fieldNumber);
if (targetIndexInBorder !== -1) {
// Start from field position in border (field position = border index + 1, but we want 0-based)
const startBorderIndex = (specialField.position - 1) % border.length;
// Calculate step value needed to reach target with dice=1
let stepValue: number;
if (specialField.type === 'positive') {
// For positive: move right to target, then +1 more for dice=1
stepValue = targetIndexInBorder - startBorderIndex - 1; // -1 for dice offset
// Handle wrap-around
if (stepValue < 0) {
stepValue += border.length;
}
} else {
// For negative: move left to target, then -1 more for dice=1
stepValue = startBorderIndex - targetIndexInBorder + 1; // +1 for dice offset
// Handle wrap-around
if (stepValue > border.length) {
stepValue -= border.length;
}
// Make negative for negative fields
stepValue = -stepValue;
}
fields[fieldIndex].stepValue = stepValue;
// Debug logging for step value calculation
logOther(`Calculated step value for ${specialField.type} field at position ${specialField.position}`, {
targetField: firstTarget.fieldNumber,
targetIndexInBorder,
startBorderIndex,
calculatedStepValue: stepValue,
fieldType: specialField.type
});
} else {
// Fallback if target not found in border (shouldn't happen)
fields[fieldIndex].stepValue = specialField.type === 'positive' ? 1 : -1;
}
});
return fields;
}
private validateBoardGeneration(fields: GameField[], border: number[]): {
validationResults: { [fieldIndex: number]: number[] };
errorRate: number;
} {
const validationResults: { [fieldIndex: number]: number[] } = {};
let totalCombinations = 0;
let invalidCombinations = 0;
fields.forEach((field, fieldIndex) => {
if (field.type !== 'positive' && field.type !== 'negative') {
return; // Skip non-special fields
}
const diceOutcomes: number[] = [];
for (let diceValue = 1; diceValue <= 6; diceValue++) {
totalCombinations++;
try {
const result = this.calculateBorderMovement(
field.position,
field.stepValue || 0,
diceValue,
border,
field.type === 'positive'
);
// Validate 20-30 rule
const distance = Math.abs(result - field.position);
const isValid = this.validate20_30Rule(field.position, result, distance);
if (isValid) {
diceOutcomes.push(result);
} else {
diceOutcomes.push(-1); // Mark as invalid
invalidCombinations++;
}
} catch (error) {
diceOutcomes.push(-1); // Mark as invalid
invalidCombinations++;
}
}
validationResults[fieldIndex] = diceOutcomes;
});
const errorRate = totalCombinations > 0 ? (invalidCombinations / totalCombinations) * 100 : 0;
return {
validationResults,
errorRate: Math.round(errorRate * 100) / 100 // Round to 2 decimal places
};
}
private calculateBorderMovement(
currentPosition: number,
stepValue: number,
diceValue: number,
border: number[],
isPositive: boolean
): number {
// Step 1: Find border index for current field (field position corresponds to border index)
let borderIndex = (currentPosition - 1) % border.length; // Convert to 0-based, handle wraparound
// Step 2: Apply field step value (handle negative step values for negative fields)
if (isPositive) {
borderIndex = (borderIndex + Math.abs(stepValue)) % border.length;
} else {
// For negative fields, stepValue is already negative, so we subtract it (which adds its absolute value)
borderIndex = (borderIndex - stepValue + border.length) % border.length;
}
// Step 3: Apply dice value
if (isPositive) {
borderIndex = (borderIndex + diceValue) % border.length;
} else {
borderIndex = (borderIndex - diceValue + border.length) % border.length;
}
// Step 4: Return the field number at final border position
return border[borderIndex];
}
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;
}
private generateFallbackBoard(
positiveFieldCount: number,
negativeFieldCount: number,
luckFieldCount: number
): BoardData {
// Simple fallback: create basic board with minimal special fields
const fields: GameField[] = Array.from({ length: 100 }, (_, i) => ({
position: i + 1,
type: 'regular' as const
}));
// Add a few special fields with safe step values
let specialCount = 0;
for (let i = 10; i < 90 && specialCount < positiveFieldCount + negativeFieldCount; i += 10) {
if (specialCount < positiveFieldCount) {
fields[i].type = 'positive';
fields[i].stepValue = 1;
} else {
fields[i].type = 'negative';
fields[i].stepValue = -1;
}
specialCount++;
}
// Simple border: shuffled 1-100
const border = Array.from({ length: 100 }, (_, i) => i + 1);
for (let i = border.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[border[i], border[j]] = [border[j], border[i]];
}
return {
fields,
border,
validationResults: {},
totalErrorRate: 100 // Mark as fallback
};
}
}
@@ -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,66 @@
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. Error rate: ${boardData.totalErrorRate}%`);
} 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: [],
border: [],
validationResults: {},
totalErrorRate: 100,
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,440 @@
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 and border
}
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,
borderLength: boardData.border ? boardData.border.length : 0,
totalErrorRate: boardData.totalErrorRate
});
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}`, {
errorRate: boardData.totalErrorRate,
fieldCount: boardData.fields.length,
borderLength: boardData.border.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
}
}
}
@@ -49,10 +49,14 @@ export class GeneralSearchService implements IGeneralSearchService {
};
}
// 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(), limit, offset);
const { users, totalCount } = await this.userRepo.search(query.trim(), effectiveLimit, effectiveOffset);
const results = users.map(user => UserMapper.toShortDto(user));
const hasMore = (offset + limit) < totalCount;
const hasMore = (effectiveOffset + effectiveLimit) < totalCount;
return {
results,
@@ -105,17 +109,25 @@ export class GeneralSearchService implements IGeneralSearchService {
};
}
const { decks, totalCount } = await this.deckRepo.search(query.trim(), limit, offset);
const results = decks.map(deck => DeckMapper.toShortDto(deck));
const hasMore = (offset + limit) < totalCount;
// Ensure limit is at least 1 to prevent database issues
const effectiveLimit = Math.max(limit || 20, 1);
const effectiveOffset = Math.max(offset || 0, 0);
return {
results,
totalCount,
hasMore,
searchQuery: query,
searchType: 'decks'
};
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(
@@ -1,57 +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();
export function authRequired(req: Request, res: Response, next: NextFunction) {
const payload = jwtService.verify(req);
if (!payload) {
logAuth('Authentication failed - No valid token', undefined, {
ip: req.ip,
userAgent: req.get ? req.get('User-Agent') : 'unknown',
path: req.path
}, req);
return res.status(401).json({ error: 'Unauthorized' });
/**
* 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;
}
logAuth('Authentication successful', payload.userId, {
authLevel: payload.authLevel,
orgId: payload.orgId
}, req);
const refreshed = jwtService.refreshIfNeeded(payload, res);
if (refreshed) {
logAuth('Token refreshed', payload.userId, undefined, req);
}
(req as any).user = payload;
next();
}
export function adminRequired(req: Request, res: Response, next: NextFunction) {
const payload = jwtService.verify(req);
if (!payload || payload.authLevel !== 1) {
logWarning('Admin access denied', {
hasPayload: !!payload,
authLevel: payload?.authLevel,
userId: payload?.userId,
ip: req.ip,
path: req.path
/**
* 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);
return res.status(403).json({ error: 'Forbidden' });
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' });
}
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);
}
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' });
}
(req as any).user = payload;
next();
}
@@ -5,6 +5,7 @@ import { IChatArchiveRepository } from '../../Domain/IRepository/IChatArchiveRep
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';
@@ -13,10 +14,12 @@ import { ChatArchiveRepository } from '../../Infrastructure/Repository/ChatArchi
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';
@@ -55,6 +58,10 @@ import { GetContactsByPageQueryHandler } from '../Contact/queries/GetContactsByP
import { JWTService } from './JWTService';
import { ContactEmailService } from './ContactEmailService';
import { DeckImportExportService } from './DeckImportExportService';
import { RedisService } from './RedisService';
import { GameService } from '../Game/GameService';
import { BoardGenerationService } from '../Game/BoardGenerationService';
import { GenerateBoardCommandHandler } from '../Game/commands/GenerateBoardCommandHandler';
/**
* Central Dependency Injection Container
@@ -70,15 +77,19 @@ export class DIContainer {
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 _contactEmailService: ContactEmailService | null = null;
private _deckImportExportService: DeckImportExportService | 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;
@@ -99,6 +110,7 @@ export class DIContainer {
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;
@@ -167,6 +179,13 @@ export class DIContainer {
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) {
@@ -189,6 +208,20 @@ export class DIContainer {
return this._deckImportExportService;
}
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) {
@@ -204,6 +237,13 @@ export class DIContainer {
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);
@@ -348,6 +388,13 @@ export class DIContainer {
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) {
@@ -95,16 +95,21 @@ export class LoggingService {
console.warn('Minio configuration not found. Logs will only be stored locally and in console.');
}
} else {
// Development-specific Minio configuration
this.minioClient = new Minio.Client({
endPoint: 'localhost',
port: 9000,
useSSL: false,
accessKey: 'serpentrace',
secretKey: 'serpentrace123!'
});
// Development mode - only use MinIO if explicitly configured
if (process.env.MINIO_ENDPOINT || process.env.ENABLE_MINIO === 'true') {
this.minioClient = new Minio.Client({
endPoint: process.env.MINIO_ENDPOINT || 'localhost',
port: parseInt(process.env.MINIO_PORT || '9000'),
useSSL: false,
accessKey: process.env.MINIO_ACCESS_KEY || 'serpentrace',
secretKey: process.env.MINIO_SECRET_KEY || 'serpentrace123!'
});
this.ensureBucketExists();
this.ensureBucketExists();
} else {
console.log('Development mode: MinIO disabled. Set ENABLE_MINIO=true to enable MinIO logging.');
this.minioClient = null;
}
}
@@ -124,7 +129,9 @@ export class LoggingService {
this.log(LogLevel.STARTUP, `Created Minio bucket: ${this.bucketName}`);
}
} catch (error) {
console.error('Failed to ensure bucket exists:', error);
console.warn('MinIO connection failed - disabling MinIO logging:', (error as Error).message);
// Disable MinIO client if connection fails
this.minioClient = null;
}
}
@@ -303,4 +303,73 @@ export class RedisService {
public isRedisConnected(): boolean {
return this.isConnected;
}
// Generic Redis methods for game data
public async get(key: string): Promise<string | null> {
try {
return await this.client.get(key);
} catch (error) {
logError(`Failed to get key ${key}`, error as Error);
return null;
}
}
public async set(key: string, value: string): Promise<void> {
try {
await this.client.set(key, value);
} catch (error) {
logError(`Failed to set key ${key}`, error as Error);
}
}
public async setWithExpiry(key: string, value: string, expirySeconds: number): Promise<void> {
try {
await this.client.setEx(key, expirySeconds, value);
} catch (error) {
logError(`Failed to set key ${key} with expiry`, error as Error);
}
}
public async del(key: string): Promise<void> {
try {
await this.client.del(key);
} catch (error) {
logError(`Failed to delete key ${key}`, error as Error);
}
}
public async setAdd(key: string, member: string): Promise<void> {
try {
await this.client.sAdd(key, member);
} catch (error) {
logError(`Failed to add member to set ${key}`, error as Error);
}
}
public async setRemove(key: string, member: string): Promise<void> {
try {
await this.client.sRem(key, member);
} catch (error) {
logError(`Failed to remove member from set ${key}`, error as Error);
}
}
public async setMembers(key: string): Promise<string[]> {
try {
return await this.client.sMembers(key);
} catch (error) {
logError(`Failed to get members of set ${key}`, error as Error);
return [];
}
}
public async exists(key: string): Promise<boolean> {
try {
const result = await this.client.exists(key);
return result === 1;
} catch (error) {
logError(`Failed to check existence of key ${key}`, error as Error);
return false;
}
}
}
@@ -54,6 +54,31 @@ interface DeleteMessageData {
messageId: string;
}
// Game-related WebSocket interfaces (prepared for future implementation)
interface JoinGameRoomData {
gameCode: string;
}
interface LeaveGameRoomData {
gameCode: string;
}
interface GameStateUpdateData {
gameId: string;
gameCode: string;
players: string[];
state: string;
currentTurn?: string;
}
interface GameActionData {
gameId: string;
gameCode: string;
playerId: string;
action: 'pick_card' | 'play_card' | 'end_turn' | 'leave_game';
data?: any;
}
export class WebSocketService {
private io: SocketIOServer;
private jwtService: JWTService;
@@ -236,6 +261,12 @@ export class WebSocketService {
socket.on('chat:delete', (data: DeleteChatData) => this.handleDeleteChat(socket, data));
socket.on('chat:archive:delete', (data: DeleteChatArchiveData) => this.handleDeleteChatArchive(socket, data));
socket.on('message:delete', (data: DeleteMessageData) => this.handleDeleteMessage(socket, data));
// Game event handlers (prepared for future implementation)
socket.on('game:join', (data: JoinGameRoomData) => this.handleJoinGameRoom(socket, data));
socket.on('game:leave', (data: LeaveGameRoomData) => this.handleLeaveGameRoom(socket, data));
socket.on('game:action', (data: GameActionData) => this.handleGameAction(socket, data));
socket.on('disconnect', () => this.handleDisconnection(socket));
}
@@ -1172,4 +1203,211 @@ export class WebSocketService {
}
}
}
// Game-related WebSocket handlers (prepared for future implementation)
/**
* Handle player joining a game room for real-time updates
* @param socket The authenticated socket
* @param data Game room data containing game code
*/
private async handleJoinGameRoom(socket: AuthenticatedSocket, data: JoinGameRoomData) {
try {
const userId = socket.userId!;
const gameRoom = `game_${data.gameCode}`;
logAuth('Player joining game room', userId, {
gameCode: data.gameCode,
gameRoom,
socketId: socket.id
});
// Join the WebSocket room for this game
await socket.join(gameRoom);
// Emit confirmation to the player
socket.emit('game:joined', {
gameCode: data.gameCode,
room: gameRoom,
message: 'Successfully joined game room'
});
// Notify other players in the game room
socket.to(gameRoom).emit('game:player_joined', {
playerId: userId,
gameCode: data.gameCode,
timestamp: new Date().toISOString()
});
logAuth('Player joined game room successfully', userId, {
gameCode: data.gameCode,
gameRoom
});
} catch (error) {
logError('Error joining game room', error as Error);
socket.emit('game:error', {
message: 'Failed to join game room',
gameCode: data.gameCode
});
}
}
/**
* Handle player leaving a game room
* @param socket The authenticated socket
* @param data Game room data containing game code
*/
private async handleLeaveGameRoom(socket: AuthenticatedSocket, data: LeaveGameRoomData) {
try {
const userId = socket.userId!;
const gameRoom = `game_${data.gameCode}`;
logAuth('Player leaving game room', userId, {
gameCode: data.gameCode,
gameRoom,
socketId: socket.id
});
// Leave the WebSocket room
await socket.leave(gameRoom);
// Notify other players in the game room
socket.to(gameRoom).emit('game:player_left', {
playerId: userId,
gameCode: data.gameCode,
timestamp: new Date().toISOString()
});
// Confirm to the leaving player
socket.emit('game:left', {
gameCode: data.gameCode,
message: 'Successfully left game room'
});
logAuth('Player left game room successfully', userId, {
gameCode: data.gameCode,
gameRoom
});
} catch (error) {
logError('Error leaving game room', error as Error);
socket.emit('game:error', {
message: 'Failed to leave game room',
gameCode: data.gameCode
});
}
}
/**
* Handle game actions (cards, turns, etc.) - prepared for future implementation
* @param socket The authenticated socket
* @param data Game action data
*/
private async handleGameAction(socket: AuthenticatedSocket, data: GameActionData) {
try {
const userId = socket.userId!;
const gameRoom = `game_${data.gameCode}`;
logAuth('Game action received', userId, {
gameId: data.gameId,
gameCode: data.gameCode,
action: data.action,
socketId: socket.id
});
// Validate that the player is authorized to perform this action
if (data.playerId !== userId) {
socket.emit('game:error', {
message: 'Unauthorized action',
gameCode: data.gameCode
});
return;
}
// TODO: Implement specific game logic here
// This will be implemented when the game flow is discussed
// For now, just broadcast the action to other players
socket.to(gameRoom).emit('game:action_performed', {
playerId: userId,
gameCode: data.gameCode,
action: data.action,
data: data.data,
timestamp: new Date().toISOString()
});
// Confirm action to the acting player
socket.emit('game:action_confirmed', {
gameCode: data.gameCode,
action: data.action,
message: 'Action processed successfully'
});
logAuth('Game action processed', userId, {
gameId: data.gameId,
gameCode: data.gameCode,
action: data.action
});
} catch (error) {
logError('Error processing game action', error as Error);
socket.emit('game:error', {
message: 'Failed to process game action',
gameCode: data.gameCode,
action: data.action
});
}
}
/**
* Broadcast game state updates to all players in a game
* @param gameCode The game code
* @param gameState The updated game state
*/
public broadcastGameStateUpdate(gameCode: string, gameState: GameStateUpdateData): void {
try {
const gameRoom = `game_${gameCode}`;
this.io.to(gameRoom).emit('game:state_updated', {
...gameState,
timestamp: new Date().toISOString()
});
logRequest('Game state broadcasted', undefined, undefined, {
gameCode,
gameRoom,
playerCount: gameState.players.length
});
} catch (error) {
logError('Error broadcasting game state', error as Error);
}
}
/**
* Notify players when a game starts
* @param gameCode The game code
* @param players Array of player IDs
*/
public notifyGameStart(gameCode: string, players: string[]): void {
try {
const gameRoom = `game_${gameCode}`;
this.io.to(gameRoom).emit('game:started', {
gameCode,
players,
message: 'Game has started!',
timestamp: new Date().toISOString()
});
logRequest('Game start notification sent', undefined, undefined, {
gameCode,
playerCount: players.length
});
} catch (error) {
logError('Error notifying game start', error as Error);
}
}
}
@@ -38,52 +38,55 @@ export class CreateUserCommandHandler {
user.fname = cmd.fname;
user.lname = cmd.lname;
user.orgid = cmd.orgid || null;
user.token = cmd.code || null;
user.type = cmd.type;
user.phone = cmd.phone || null;
user.state = UserState.REGISTERED_NOT_VERIFIED;
const created = await this.userRepo.create(user);
// Send verification email
try {
const baseUrl = process.env.APP_BASE_URL || 'http://localhost:3000';
const verificationUrl = TokenService.generateVerificationUrl(baseUrl, verificationTokenData.token);
const emailSent = await this.emailService.sendVerificationEmail(
created.email,
`${created.fname} ${created.lname}`,
verificationTokenData.token,
verificationUrl
);
if (!emailSent) {
logWarning('Failed to send verification email', { email: created.email, userId: created.id });
// Don't throw error - user creation should still succeed even if email fails
} else {
logAuth('Verification email sent successfully', created.id, { email: created.email });
}
} catch (emailError) {
logError('Error sending verification email', emailError as Error);
// Don't throw error - user creation should still succeed even if email fails
}
// Send verification email (non-blocking)
this.sendVerificationEmailAsync(created, verificationTokenData.token);
return UserMapper.toShortDto(created);
} catch (error) {
logError('CreateUserCommandHandler error', error as Error);
// Only log the error once here, don't log again in router
const errorMessage = (error as Error).message;
// Re-throw validation errors as-is
if (error instanceof Error && error.message.includes('Password validation failed')) {
// Re-throw validation errors as-is (don't log as these are user input errors)
if (errorMessage.includes('Password validation failed')) {
throw error;
}
// Handle database constraint errors
if (error instanceof Error && (error.message.includes('duplicate') || error.message.includes('unique'))) {
if (errorMessage.includes('duplicate') || errorMessage.includes('unique') ||
errorMessage.includes('UNIQUE constraint') || errorMessage.includes('already exists')) {
throw new Error('User with this username or email already exists');
}
// Generic error for other cases
// Log database/system errors but throw user-friendly message
logError('CreateUserCommandHandler error', error as Error);
throw new Error('Failed to create user');
}
}
private async sendVerificationEmailAsync(user: UserAggregate, token: string): Promise<void> {
try {
const baseUrl = process.env.APP_BASE_URL || 'http://localhost:3000';
const verificationUrl = TokenService.generateVerificationUrl(baseUrl, token);
const emailSent = await this.emailService.sendVerificationEmail(
user.email,
`${user.fname} ${user.lname}`,
token,
verificationUrl
);
if (!emailSent) {
logWarning('Failed to send verification email', { email: user.email, userId: user.id });
} else {
logAuth('Verification email sent successfully', user.id, { email: user.email });
}
} catch (emailError) {
logError('Error sending verification email', emailError as Error);
}
}
}
@@ -40,7 +40,7 @@ export class LoginCommandHandler {
if (!user) {
logAuth('Login failed - User not found', undefined, { username: cmd.username });
return null;
throw new Error('Invalid username');
}
// Check if user account state allows login
@@ -52,15 +52,19 @@ export class LoginCommandHandler {
if (restrictedStates.includes(user.state)) {
let stateDescription = '';
let errorMessage = '';
switch (user.state) {
case UserState.REGISTERED_NOT_VERIFIED:
stateDescription = 'Email not verified';
errorMessage = 'User account not verified';
break;
case UserState.SOFT_DELETE:
stateDescription = 'Account deleted';
errorMessage = 'User account deactivated';
break;
case UserState.DEACTIVATED:
stateDescription = 'Account deactivated';
errorMessage = 'User account deactivated';
break;
}
@@ -69,7 +73,7 @@ export class LoginCommandHandler {
userState: user.state,
stateDescription
});
return null;
throw new Error(errorMessage);
}
try {
@@ -86,11 +90,11 @@ export class LoginCommandHandler {
userId: user.id,
username: cmd.username
});
return null;
throw new Error('Invalid password');
}
} catch (error) {
logError('Password verification error', error as Error);
return null;
throw new Error('Invalid password');
}
const mockRes = {
@@ -174,8 +178,12 @@ export class LoginCommandHandler {
throw new Error('Database connection error');
}
// If it's already a properly formatted error, re-throw it
if (error.message === 'Login failed due to internal error' ||
// Re-throw authentication/validation errors as-is
if (error.message.includes('Invalid username') ||
error.message.includes('Invalid password') ||
error.message.includes('not verified') ||
error.message.includes('deactivated') ||
error.message === 'Login failed due to internal error' ||
error.message === 'Database connection error') {
throw error;
}
@@ -0,0 +1,145 @@
import { Request, Response } from 'express';
import { logAuth, logError, logWarning } from '../../Services/Logger';
import { IUserRepository } from '../../../Domain/IRepository/IUserRepository';
import { JWTService } from '../../Services/JWTService';
import { RedisService } from '../../Services/RedisService';
export class LogoutCommandHandler {
private jwtService: JWTService;
private redisService: RedisService;
constructor(private readonly userRepo: IUserRepository) {
this.jwtService = new JWTService();
this.redisService = RedisService.getInstance();
}
async execute(userId: string, res: Response, req?: Request): Promise<boolean> {
try {
logAuth('Logout process started', userId);
// 1. Get token from request to blacklist it
let tokenToBlacklist: string | null = null;
if (req) {
// Extract token from cookie
tokenToBlacklist = req.cookies['auth_token'];
// Also check Authorization header as fallback
if (!tokenToBlacklist && req.headers.authorization) {
const authHeader = req.headers.authorization;
if (authHeader.startsWith('Bearer ')) {
tokenToBlacklist = authHeader.substring(7);
}
}
}
// 2. Blacklist the current JWT token in Redis (if available)
if (tokenToBlacklist && req) {
try {
// Store token in blacklist with expiration matching token expiry
const decoded = this.jwtService.verify(req);
if (decoded && decoded.exp) {
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
if (ttl > 0) {
await this.redisService.setWithExpiry(`blacklist:${tokenToBlacklist}`, 'true', ttl);
logAuth('JWT token blacklisted', userId, { tokenExpiry: ttl });
}
}
} catch (error) {
logWarning('Failed to blacklist token', { userId, error: (error as Error).message });
}
}
// 3. Clear authentication cookie
res.clearCookie('auth_token', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/'
});
// 4. Remove user from active sessions in Redis
try {
await this.redisService.removeActiveUser(userId);
logAuth('User removed from active sessions', userId);
} catch (error) {
logWarning('Failed to remove user from active sessions', { userId, error: (error as Error).message });
// Continue even if this fails
}
// 5. Update user's last logout timestamp in database
try {
const updateResult = await this.userRepo.update(userId, { updatedate: new Date() });
if (updateResult) {
logAuth('User last logout timestamp updated', userId);
}
} catch (error) {
logWarning('Failed to update user logout timestamp', { userId, error: (error as Error).message });
// Continue even if this fails
}
// 6. Clear any user-specific cache entries
try {
// Clear user session data
await this.redisService.del(`user:${userId}:session`);
await this.redisService.del(`user:${userId}:active_chats`);
logAuth('User cache cleared', userId);
} catch (error) {
logWarning('Failed to clear user cache', { userId, error: (error as Error).message });
// Continue even if this fails
}
logAuth('User logout completed successfully', userId);
return true;
} catch (error) {
logError('LogoutCommandHandler error', error as Error);
return false;
}
}
/**
* Check if a token is blacklisted
*/
async isTokenBlacklisted(token: string): Promise<boolean> {
try {
const result = await this.redisService.get(`blacklist:${token}`);
return result === 'true';
} catch (error) {
logError('Error checking token blacklist', error as Error);
return false;
}
}
/**
* Logout user from all devices by blacklisting all their active tokens
* This is a simplified version - in a real implementation you'd track active tokens per user
*/
async logoutFromAllDevices(userId: string): Promise<boolean> {
try {
// Clear all user-related Redis keys
const userKeys = [
`user:${userId}:session`,
`user:${userId}:active_chats`,
`user:${userId}:active_tokens`,
`user:${userId}:websocket_connections`
];
for (const key of userKeys) {
try {
await this.redisService.del(key);
} catch (error) {
logWarning(`Failed to delete Redis key: ${key}`, { userId, error: (error as Error).message });
}
}
// Update user logout timestamp
await this.userRepo.update(userId, { updatedate: new Date() });
logAuth('User logged out from all devices', userId);
return true;
} catch (error) {
logError('Error logging out user from all devices', error as Error);
return false;
}
}
}
@@ -1,17 +1,17 @@
import { IUserRepository } from '../../../Domain/IRepository/IUserRepository';
import { GetUserByIdQuery } from './GetUserByIdQuery';
import { ShortUserDto } from '../../DTOs/UserDto';
import { DetailUserDto } from '../../DTOs/UserDto';
import { UserMapper } from '../../DTOs/Mappers/UserMapper';
import { logError } from '../../Services/Logger';
export class GetUserByIdQueryHandler {
constructor(private readonly userRepo: IUserRepository) {}
async execute(query: GetUserByIdQuery): Promise<ShortUserDto | null> {
async execute(query: GetUserByIdQuery): Promise<DetailUserDto | null> {
try {
const user = await this.userRepo.findById(query.id);
if (!user) return null;
return UserMapper.toShortDto(user);
return UserMapper.toDetailDto(user);
} catch (error) {
logError('GetUserByIdQueryHandler error', error instanceof Error ? error : new Error(String(error)));
@@ -26,10 +26,26 @@ export enum CardType {
CLOSER = 4
}
export enum ConsequenceType {
MOVE_FORWARD = 0,
MOVE_BACKWARD = 1,
LOSE_TURN = 2,
EXTRA_TURN = 3,
SWAP_POSITION = 4,
GO_TO_START = 5,
TURN_AGAIN = 6
}
export interface Consequence {
type: ConsequenceType;
value?: number;
}
export interface Card {
text: string;
type?: CardType;
answer?: string | null;
consequence?: Consequence | null;
}
@Entity('Decks')
@@ -0,0 +1,105 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
import { Consequence } from '../Deck/DeckAggregate';
export enum GameState {
WAITING = 0,
ACTIVE = 1,
FINISHED = 2,
CANCELLED = 3
}
export enum LoginType {
PUBLIC = 0,
PRIVATE = 1,
ORGANIZATION = 2
}
export enum DeckType {
JOCKER = 0,
LUCK = 1,
QUEST = 2
}
export interface GameCard {
cardid: string;
question?: string;
answer?: string;
consequence?: Consequence | null;
played?: boolean;
playerid?: string;
}
export interface GameDeck {
deckid: string;
decktype: DeckType;
cards: GameCard[];
}
@Entity('Games')
export class GameAggregate {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 10, unique: true })
gamecode!: string;
@Column({ type: 'int' })
maxplayers!: number;
@Column({ type: 'int', default: LoginType.PUBLIC })
logintype!: LoginType;
@Column({ type: 'varchar', length: 255, nullable: true })
createdby!: string | null;
@Column({ type: 'varchar', length: 255, nullable: true })
orgid!: string | null;
@Column({ type: 'json' })
gamedecks!: GameDeck[];
@Column({ type: 'json', default: () => "'[]'" })
players!: string[];
@Column({ type: 'boolean', default: false })
started!: boolean;
@Column({ type: 'boolean', default: false })
finished!: boolean;
@Column({ type: 'varchar', length: 255, nullable: true })
winner!: string | null;
@Column({ type: 'int', default: GameState.WAITING })
state!: GameState;
@CreateDateColumn({ name: 'create_date' })
createdate!: Date;
@Column({ type: 'timestamp', nullable: true, name: 'start_date' })
startdate!: Date | null;
@Column({ type: 'timestamp', nullable: true, name: 'end_date' })
enddate!: Date | null;
@UpdateDateColumn({ name: 'update_date' })
updatedate!: Date;
}
// Board Generation Types
export interface GameField {
position: number;
type: 'regular' | 'positive' | 'negative' | 'luck';
stepValue?: number;
}
export interface BoardData {
gameId?: string;
fields: GameField[];
border: number[];
validationResults: { [fieldIndex: number]: number[] };
totalErrorRate: number;
generationComplete?: boolean;
generatedAt?: Date;
error?: string;
}
@@ -0,0 +1,24 @@
import { GameAggregate } from '../Game/GameAggregate';
export interface IGameRepository {
create(game: Partial<GameAggregate>): Promise<GameAggregate>;
findByPage(from: number, to: number): Promise<{ games: GameAggregate[], totalCount: number }>;
findByPageIncludingDeleted(from: number, to: number): Promise<{ games: GameAggregate[], totalCount: number }>;
findById(id: string): Promise<GameAggregate | null>;
findByIdIncludingDeleted(id: string): Promise<GameAggregate | null>;
findByGameCode(gamecode: string): Promise<GameAggregate | null>;
search(query: string, limit?: number, offset?: number): Promise<{ games: GameAggregate[], totalCount: number }>;
searchIncludingDeleted(query: string, limit?: number, offset?: number): Promise<{ games: GameAggregate[], totalCount: number }>;
update(id: string, update: Partial<GameAggregate>): Promise<GameAggregate | null>;
delete(id: string): Promise<any>;
softDelete(id: string): Promise<GameAggregate | null>;
// Game-specific methods
findActiveGames(): Promise<GameAggregate[]>;
findGamesByPlayer(playerId: string): Promise<GameAggregate[]>;
findWaitingGames(): Promise<GameAggregate[]>;
findFinishedGames(from?: number, to?: number): Promise<{ games: GameAggregate[], totalCount: number }>;
addPlayerToGame(gameId: string, playerId: string): Promise<GameAggregate | null>;
removePlayerFromGame(gameId: string, playerId: string): Promise<GameAggregate | null>;
updateGameState(gameId: string, started: boolean, finished?: boolean, winner?: string): Promise<GameAggregate | null>;
}
@@ -38,9 +38,6 @@ export class UserAggregate {
@Column({ type: 'timestamp', nullable: true })
TokenExpires!: Date | null;
@Column({ type: 'varchar', length: 50 })
type!: string;
@Column({ type: 'varchar', length: 20, nullable: true })
phone!: string | null;
@@ -1,22 +0,0 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class Test1755691733404 implements MigrationInterface {
name = 'Test1755691733404'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "Users" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "orgid" uuid, "username" character varying(100) NOT NULL, "password" character varying(255) NOT NULL, "email" character varying(255) NOT NULL, "fname" character varying(100) NOT NULL, "lname" character varying(100) NOT NULL, "code" character varying(50), "type" character varying(50) NOT NULL, "phone" character varying(20), "state" integer NOT NULL DEFAULT '0', "regdate" TIMESTAMP NOT NULL DEFAULT now(), "updatedate" TIMESTAMP NOT NULL DEFAULT now(), "Orglogindate" TIMESTAMP, CONSTRAINT "UQ_ffc81a3b97dcbf8e320d5106c0d" UNIQUE ("username"), CONSTRAINT "UQ_3c3ab3f49a87e6ddb607f3c4945" UNIQUE ("email"), CONSTRAINT "PK_16d4f7d636df336db11d87413e3" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE TABLE "Organizations" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying(255) NOT NULL, "contactfname" character varying(100) NOT NULL, "contactlname" character varying(100) NOT NULL, "contactphone" character varying(20) NOT NULL, "contactemail" character varying(255) NOT NULL, "state" integer NOT NULL DEFAULT '0', "regdate" TIMESTAMP NOT NULL DEFAULT now(), "updatedate" TIMESTAMP NOT NULL DEFAULT now(), "url" character varying(500), "userinorg" integer NOT NULL DEFAULT '0', CONSTRAINT "PK_e0690a31419f6666194423526f2" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE TABLE "Decks" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying(255) NOT NULL, "type" integer NOT NULL, "user_id" uuid NOT NULL, "creation_date" TIMESTAMP NOT NULL DEFAULT now(), "cards" json NOT NULL, "played_number" integer NOT NULL DEFAULT '0', "ctype" integer NOT NULL DEFAULT '0', "update_date" TIMESTAMP NOT NULL DEFAULT now(), "state" integer NOT NULL DEFAULT '0', "organization_id" uuid, CONSTRAINT "PK_001f26cb3ec39c1f25269943473" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE TABLE "Chats" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "users" uuid array NOT NULL, "messages" json NOT NULL, "updateDate" TIMESTAMP NOT NULL DEFAULT now(), "state" integer NOT NULL DEFAULT '0', CONSTRAINT "PK_64c36c2b8d86a0d5de4cf64de8d" PRIMARY KEY ("id"))`);
await queryRunner.query(`ALTER TABLE "Decks" ADD CONSTRAINT "FK_06ee28f90d68543a03b14aebe13" FOREIGN KEY ("organization_id") REFERENCES "Organizations"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "Decks" DROP CONSTRAINT "FK_06ee28f90d68543a03b14aebe13"`);
await queryRunner.query(`DROP TABLE "Chats"`);
await queryRunner.query(`DROP TABLE "Decks"`);
await queryRunner.query(`DROP TABLE "Organizations"`);
await queryRunner.query(`DROP TABLE "Users"`);
}
}
@@ -1,18 +0,0 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddEmailVerificationFields1755706019351 implements MigrationInterface {
name = 'AddEmailVerificationFields1755706019351'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "Users" DROP COLUMN "code"`);
await queryRunner.query(`ALTER TABLE "Users" ADD "token" character varying(255)`);
await queryRunner.query(`ALTER TABLE "Users" ADD "TokenExpires" TIMESTAMP`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "Users" DROP COLUMN "TokenExpires"`);
await queryRunner.query(`ALTER TABLE "Users" DROP COLUMN "token"`);
await queryRunner.query(`ALTER TABLE "Users" ADD "code" character varying(50)`);
}
}
@@ -1,30 +0,0 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddChatMessagingSystem1755817306222 implements MigrationInterface {
name = 'AddChatMessagingSystem1755817306222'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "ChatArchives" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "chatId" uuid NOT NULL, "archivedMessages" json NOT NULL, "archivedAt" TIMESTAMP NOT NULL, "createDate" TIMESTAMP NOT NULL DEFAULT now(), "chatType" character varying(50) NOT NULL, "chatName" character varying(255), "gameId" uuid, "participants" uuid array NOT NULL, CONSTRAINT "PK_fe62979fc2061d7afe278d3f14e" PRIMARY KEY ("id"))`);
await queryRunner.query(`ALTER TABLE "Chats" ADD "type" character varying(50) NOT NULL DEFAULT 'direct'`);
await queryRunner.query(`ALTER TABLE "Chats" ADD "name" character varying(255)`);
await queryRunner.query(`ALTER TABLE "Chats" ADD "gameId" uuid`);
await queryRunner.query(`ALTER TABLE "Chats" ADD "createdBy" uuid`);
await queryRunner.query(`ALTER TABLE "Chats" ADD "lastActivity" TIMESTAMP`);
await queryRunner.query(`ALTER TABLE "Chats" ADD "createDate" TIMESTAMP NOT NULL DEFAULT now()`);
await queryRunner.query(`ALTER TABLE "Chats" ADD "archiveDate" TIMESTAMP`);
await queryRunner.query(`ALTER TABLE "Chats" ALTER COLUMN "messages" SET DEFAULT '[]'`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "Chats" ALTER COLUMN "messages" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE "Chats" DROP COLUMN "archiveDate"`);
await queryRunner.query(`ALTER TABLE "Chats" DROP COLUMN "createDate"`);
await queryRunner.query(`ALTER TABLE "Chats" DROP COLUMN "lastActivity"`);
await queryRunner.query(`ALTER TABLE "Chats" DROP COLUMN "createdBy"`);
await queryRunner.query(`ALTER TABLE "Chats" DROP COLUMN "gameId"`);
await queryRunner.query(`ALTER TABLE "Chats" DROP COLUMN "name"`);
await queryRunner.query(`ALTER TABLE "Chats" DROP COLUMN "type"`);
await queryRunner.query(`DROP TABLE "ChatArchives"`);
}
}
@@ -1,14 +0,0 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class CreateContactTable1755855028839 implements MigrationInterface {
name = 'CreateContactTable1755855028839'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "Contacts" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying(255) NOT NULL, "email" character varying(255) NOT NULL, "userid" uuid, "type" integer NOT NULL, "txt" text NOT NULL, "state" integer NOT NULL DEFAULT '0', "createDate" TIMESTAMP NOT NULL DEFAULT now(), "updateDate" TIMESTAMP NOT NULL DEFAULT now(), "adminResponse" text, "responseDate" TIMESTAMP, "respondedBy" uuid, CONSTRAINT "PK_68782cec65c8eef577c62958273" PRIMARY KEY ("id"))`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "Contacts"`);
}
}
@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class Full1757939815984 implements MigrationInterface {
name = 'Full1757939815984'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "Chats" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "type" character varying(50) NOT NULL DEFAULT 'direct', "name" character varying(255), "gameId" uuid, "createdBy" uuid, "users" uuid array NOT NULL, "messages" json NOT NULL DEFAULT '[]', "lastActivity" TIMESTAMP, "createDate" TIMESTAMP NOT NULL DEFAULT now(), "updateDate" TIMESTAMP NOT NULL DEFAULT now(), "state" integer NOT NULL DEFAULT '0', "archiveDate" TIMESTAMP, CONSTRAINT "PK_64c36c2b8d86a0d5de4cf64de8d" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE TABLE "Users" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "orgid" uuid, "username" character varying(100) NOT NULL, "password" character varying(255) NOT NULL, "email" character varying(255) NOT NULL, "fname" character varying(100) NOT NULL, "lname" character varying(100) NOT NULL, "token" character varying(255), "TokenExpires" TIMESTAMP, "phone" character varying(20), "state" integer NOT NULL DEFAULT '0', "regdate" TIMESTAMP NOT NULL DEFAULT now(), "updatedate" TIMESTAMP NOT NULL DEFAULT now(), "Orglogindate" TIMESTAMP, CONSTRAINT "UQ_ffc81a3b97dcbf8e320d5106c0d" UNIQUE ("username"), CONSTRAINT "UQ_3c3ab3f49a87e6ddb607f3c4945" UNIQUE ("email"), CONSTRAINT "PK_16d4f7d636df336db11d87413e3" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE TABLE "Contacts" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying(255) NOT NULL, "email" character varying(255) NOT NULL, "userid" uuid, "type" integer NOT NULL, "txt" text NOT NULL, "state" integer NOT NULL DEFAULT '0', "createDate" TIMESTAMP NOT NULL DEFAULT now(), "updateDate" TIMESTAMP NOT NULL DEFAULT now(), "adminResponse" text, "responseDate" TIMESTAMP, "respondedBy" uuid, CONSTRAINT "PK_68782cec65c8eef577c62958273" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE TABLE "ChatArchives" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "chatId" uuid NOT NULL, "archivedMessages" json NOT NULL, "archivedAt" TIMESTAMP NOT NULL, "createDate" TIMESTAMP NOT NULL DEFAULT now(), "chatType" character varying(50) NOT NULL, "chatName" character varying(255), "gameId" uuid, "participants" uuid array NOT NULL, CONSTRAINT "PK_fe62979fc2061d7afe278d3f14e" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE TABLE "Games" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "gamecode" character varying(10) NOT NULL, "maxplayers" integer NOT NULL, "logintype" integer NOT NULL DEFAULT '0', "createdby" character varying(255), "orgid" character varying(255), "gamedecks" json NOT NULL, "players" json NOT NULL DEFAULT '[]', "started" boolean NOT NULL DEFAULT false, "finished" boolean NOT NULL DEFAULT false, "winner" character varying(255), "state" integer NOT NULL DEFAULT '0', "create_date" TIMESTAMP NOT NULL DEFAULT now(), "start_date" TIMESTAMP, "end_date" TIMESTAMP, "update_date" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_9d52c646079cbe6f242a85c5c41" UNIQUE ("gamecode"), CONSTRAINT "PK_1950492f583d31609c5e9fbbe12" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE TABLE "Organizations" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying(255) NOT NULL, "contactfname" character varying(100) NOT NULL, "contactlname" character varying(100) NOT NULL, "contactphone" character varying(20) NOT NULL, "contactemail" character varying(255) NOT NULL, "state" integer NOT NULL DEFAULT '0', "regdate" TIMESTAMP NOT NULL DEFAULT now(), "updatedate" TIMESTAMP NOT NULL DEFAULT now(), "url" character varying(500), "userinorg" integer NOT NULL DEFAULT '0', "maxOrganizationalDecks" integer, CONSTRAINT "PK_e0690a31419f6666194423526f2" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE TABLE "Decks" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying(255) NOT NULL, "type" integer NOT NULL, "user_id" uuid NOT NULL, "creation_date" TIMESTAMP NOT NULL DEFAULT now(), "cards" json NOT NULL, "played_number" integer NOT NULL DEFAULT '0', "ctype" integer NOT NULL DEFAULT '0', "update_date" TIMESTAMP NOT NULL DEFAULT now(), "state" integer NOT NULL DEFAULT '0', "organization_id" uuid, CONSTRAINT "PK_001f26cb3ec39c1f25269943473" PRIMARY KEY ("id"))`);
await queryRunner.query(`ALTER TABLE "Decks" ADD CONSTRAINT "FK_06ee28f90d68543a03b14aebe13" FOREIGN KEY ("organization_id") REFERENCES "Organizations"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "Decks" DROP CONSTRAINT "FK_06ee28f90d68543a03b14aebe13"`);
await queryRunner.query(`DROP TABLE "Decks"`);
await queryRunner.query(`DROP TABLE "Organizations"`);
await queryRunner.query(`DROP TABLE "Games"`);
await queryRunner.query(`DROP TABLE "ChatArchives"`);
await queryRunner.query(`DROP TABLE "Contacts"`);
await queryRunner.query(`DROP TABLE "Users"`);
await queryRunner.query(`DROP TABLE "Chats"`);
}
}
@@ -1,28 +0,0 @@
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
export class AddMaxOrganizationalDecksToOrganization1692712800000 implements MigrationInterface {
name = 'AddMaxOrganizationalDecksToOrganization1692712800000';
public async up(queryRunner: QueryRunner): Promise<void> {
// Add maxOrganizationalDecks column to Organizations table
await queryRunner.addColumn('Organizations', new TableColumn({
name: 'maxOrganizationalDecks',
type: 'int',
isNullable: true, // No default - set by admin
comment: 'Maximum number of organizational decks a premium user can create in this organization'
}));
// Add performance indexes for deck filtering queries
await queryRunner.query(`CREATE INDEX "IDX_DECK_USER_STATE_CTYPE" ON "Decks" ("user_id", "state", "ctype")`);
await queryRunner.query(`CREATE INDEX "IDX_DECK_ORG_CTYPE_STATE" ON "Decks" ("organization_id", "ctype", "state")`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Drop indexes
await queryRunner.query(`DROP INDEX "IDX_DECK_ORG_CTYPE_STATE"`);
await queryRunner.query(`DROP INDEX "IDX_DECK_USER_STATE_CTYPE"`);
// Remove maxOrganizationalDecks column
await queryRunner.dropColumn('Organizations', 'maxOrganizationalDecks');
}
}
@@ -1,11 +0,0 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddEmailVerificationFields1755706017175 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
}
public async down(queryRunner: QueryRunner): Promise<void> {
}
}
@@ -1,11 +0,0 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class FixEmailVerificationFields1755706055220 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
}
public async down(queryRunner: QueryRunner): Promise<void> {
}
}
@@ -1,6 +1,6 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class Test1755691732089 implements MigrationInterface {
export class Full1757939815062 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
}
@@ -89,7 +89,7 @@ export class DeckRepository implements IDeckRepository {
async update(id: string, update: Partial<DeckAggregate>) {
await this.repo.update(id, update);
return this.findById(id);
return this.findByIdIncludingDeleted(id);
}
async delete(id: string) {
@@ -0,0 +1,419 @@
import { Repository, Not, In } from 'typeorm';
import { AppDataSource } from '../ormconfig';
import { GameAggregate, GameState } from '../../Domain/Game/GameAggregate';
import { IGameRepository } from '../../Domain/IRepository/IGameRepository';
import { logDatabase, logError } from '../../Application/Services/Logger';
export class GameRepository implements IGameRepository {
private repo: Repository<GameAggregate>;
constructor() {
this.repo = AppDataSource.getRepository(GameAggregate);
}
async create(game: Partial<GameAggregate>): Promise<GameAggregate> {
const startTime = performance.now();
try {
const result = await this.repo.save(game);
const endTime = performance.now();
logDatabase('Game created', `executionTime: ${Math.round(endTime - startTime)}ms, gameId: ${result.id}, gameCode: ${result.gamecode}`);
return result;
} catch (error) {
const endTime = performance.now();
logDatabase('Game creation failed', `executionTime: ${Math.round(endTime - startTime)}ms`);
logError('GameRepository.create error', error instanceof Error ? error : new Error(String(error)));
throw new Error('Failed to create game in database');
}
}
async findByPage(from: number, to: number): Promise<{ games: GameAggregate[], totalCount: number }> {
const startTime = performance.now();
try {
const limit = to - from + 1;
const offset = from;
// Get total count for pagination
const totalCount = await this.repo.count({
where: { state: Not(GameState.CANCELLED) }
});
// Get paginated results
const games = await this.repo.find({
where: { state: Not(GameState.CANCELLED) },
order: { updatedate: 'DESC' },
take: limit,
skip: offset
});
const endTime = performance.now();
logDatabase('Game page query completed', `executionTime: ${Math.round(endTime - startTime)}ms, found: ${games.length}, total: ${totalCount}, from: ${from}, to: ${to}`);
return { games, totalCount };
} catch (error) {
const endTime = performance.now();
logDatabase('Game page query failed', `executionTime: ${Math.round(endTime - startTime)}ms, from: ${from}, to: ${to}`);
logError('GameRepository.findByPage error', error instanceof Error ? error : new Error(String(error)));
throw new Error('Failed to get games page from database');
}
}
async findByPageIncludingDeleted(from: number, to: number): Promise<{ games: GameAggregate[], totalCount: number }> {
const startTime = performance.now();
try {
const limit = to - from + 1;
const offset = from;
// Get total count for pagination (including deleted)
const totalCount = await this.repo.count();
// Get paginated results (including deleted)
const games = await this.repo.find({
order: { updatedate: 'DESC' },
take: limit,
skip: offset
});
const endTime = performance.now();
logDatabase('Game page query (including deleted) completed', `executionTime: ${Math.round(endTime - startTime)}ms, found: ${games.length}, total: ${totalCount}, from: ${from}, to: ${to}`);
return { games, totalCount };
} catch (error) {
const endTime = performance.now();
logDatabase('Game page query (including deleted) failed', `executionTime: ${Math.round(endTime - startTime)}ms, from: ${from}, to: ${to}`);
logError('GameRepository.findByPageIncludingDeleted error', error instanceof Error ? error : new Error(String(error)));
throw new Error('Failed to get games page (including deleted) from database');
}
}
async findById(id: string): Promise<GameAggregate | null> {
const startTime = performance.now();
try {
const result = await this.repo.findOne({
where: { id, state: Not(GameState.CANCELLED) }
});
const endTime = performance.now();
logDatabase('Game findById completed', `executionTime: ${Math.round(endTime - startTime)}ms, gameId: ${id}, found: ${!!result}`);
return result;
} catch (error) {
const endTime = performance.now();
logDatabase('Game findById failed', `executionTime: ${Math.round(endTime - startTime)}ms, gameId: ${id}`);
logError('GameRepository.findById error', error instanceof Error ? error : new Error(String(error)));
throw new Error('Failed to find game by id in database');
}
}
async findByIdIncludingDeleted(id: string): Promise<GameAggregate | null> {
const startTime = performance.now();
try {
const result = await this.repo.findOne({
where: { id }
});
const endTime = performance.now();
logDatabase('Game findByIdIncludingDeleted completed', `executionTime: ${Math.round(endTime - startTime)}ms, gameId: ${id}, found: ${!!result}`);
return result;
} catch (error) {
const endTime = performance.now();
logDatabase('Game findByIdIncludingDeleted failed', `executionTime: ${Math.round(endTime - startTime)}ms, gameId: ${id}`);
logError('GameRepository.findByIdIncludingDeleted error', error instanceof Error ? error : new Error(String(error)));
throw new Error('Failed to find game by id (including deleted) in database');
}
}
async findByGameCode(gamecode: string): Promise<GameAggregate | null> {
const startTime = performance.now();
try {
const result = await this.repo.findOne({
where: { gamecode, state: Not(GameState.CANCELLED) }
});
const endTime = performance.now();
logDatabase('Game findByGameCode completed', `executionTime: ${Math.round(endTime - startTime)}ms, gameCode: ${gamecode}, found: ${!!result}`);
return result;
} catch (error) {
const endTime = performance.now();
logDatabase('Game findByGameCode failed', `executionTime: ${Math.round(endTime - startTime)}ms, gameCode: ${gamecode}`);
logError('GameRepository.findByGameCode error', error instanceof Error ? error : new Error(String(error)));
throw new Error('Failed to find game by game code in database');
}
}
async search(query: string, limit?: number, offset?: number): Promise<{ games: GameAggregate[], totalCount: number }> {
const startTime = performance.now();
try {
const queryBuilder = this.repo.createQueryBuilder('game')
.where('game.state != :cancelledState', { cancelledState: GameState.CANCELLED })
.andWhere('(game.gamecode ILIKE :query)', { query: `%${query}%` });
// Get total count
const totalCount = await queryBuilder.getCount();
// Apply pagination if provided
if (limit !== undefined) {
queryBuilder.take(limit);
}
if (offset !== undefined) {
queryBuilder.skip(offset);
}
const games = await queryBuilder.orderBy('game.updatedate', 'DESC').getMany();
const endTime = performance.now();
logDatabase('Game search completed', `executionTime: ${Math.round(endTime - startTime)}ms, query: ${query}, found: ${games.length}, total: ${totalCount}`);
return { games, totalCount };
} catch (error) {
const endTime = performance.now();
logDatabase('Game search failed', `executionTime: ${Math.round(endTime - startTime)}ms, query: ${query}`);
logError('GameRepository.search error', error instanceof Error ? error : new Error(String(error)));
throw new Error('Failed to search games in database');
}
}
async searchIncludingDeleted(query: string, limit?: number, offset?: number): Promise<{ games: GameAggregate[], totalCount: number }> {
const startTime = performance.now();
try {
const queryBuilder = this.repo.createQueryBuilder('game')
.where('(game.gamecode ILIKE :query)', { query: `%${query}%` });
// Get total count
const totalCount = await queryBuilder.getCount();
// Apply pagination if provided
if (limit !== undefined) {
queryBuilder.take(limit);
}
if (offset !== undefined) {
queryBuilder.skip(offset);
}
const games = await queryBuilder.orderBy('game.updatedate', 'DESC').getMany();
const endTime = performance.now();
logDatabase('Game search (including deleted) completed', `executionTime: ${Math.round(endTime - startTime)}ms, query: ${query}, found: ${games.length}, total: ${totalCount}`);
return { games, totalCount };
} catch (error) {
const endTime = performance.now();
logDatabase('Game search (including deleted) failed', `executionTime: ${Math.round(endTime - startTime)}ms, query: ${query}`);
logError('GameRepository.searchIncludingDeleted error', error instanceof Error ? error : new Error(String(error)));
throw new Error('Failed to search games (including deleted) in database');
}
}
async update(id: string, update: Partial<GameAggregate>): Promise<GameAggregate | null> {
const startTime = performance.now();
try {
await this.repo.update(id, update);
const result = await this.findById(id);
const endTime = performance.now();
logDatabase('Game update completed', `executionTime: ${Math.round(endTime - startTime)}ms, gameId: ${id}, updated: ${!!result}`);
return result;
} catch (error) {
const endTime = performance.now();
logDatabase('Game update failed', `executionTime: ${Math.round(endTime - startTime)}ms, gameId: ${id}`);
logError('GameRepository.update error', error instanceof Error ? error : new Error(String(error)));
throw new Error('Failed to update game in database');
}
}
async delete(id: string): Promise<any> {
const startTime = performance.now();
try {
const result = await this.repo.delete(id);
const endTime = performance.now();
logDatabase('Game delete completed', `executionTime: ${Math.round(endTime - startTime)}ms, gameId: ${id}, affected: ${result.affected}`);
return result;
} catch (error) {
const endTime = performance.now();
logDatabase('Game delete failed', `executionTime: ${Math.round(endTime - startTime)}ms, gameId: ${id}`);
logError('GameRepository.delete error', error instanceof Error ? error : new Error(String(error)));
throw new Error('Failed to delete game from database');
}
}
async softDelete(id: string): Promise<GameAggregate | null> {
const startTime = performance.now();
try {
await this.repo.update(id, { state: GameState.CANCELLED });
const result = await this.findByIdIncludingDeleted(id);
const endTime = performance.now();
logDatabase('Game soft delete completed', `executionTime: ${Math.round(endTime - startTime)}ms, gameId: ${id}, updated: ${!!result}`);
return result;
} catch (error) {
const endTime = performance.now();
logDatabase('Game soft delete failed', `executionTime: ${Math.round(endTime - startTime)}ms, gameId: ${id}`);
logError('GameRepository.softDelete error', error instanceof Error ? error : new Error(String(error)));
throw new Error('Failed to soft delete game in database');
}
}
// Game-specific methods
async findActiveGames(): Promise<GameAggregate[]> {
const startTime = performance.now();
try {
const games = await this.repo.find({
where: { state: GameState.ACTIVE },
order: { updatedate: 'DESC' }
});
const endTime = performance.now();
logDatabase('Active games query completed', `executionTime: ${Math.round(endTime - startTime)}ms, found: ${games.length}`);
return games;
} catch (error) {
const endTime = performance.now();
logDatabase('Active games query failed', `executionTime: ${Math.round(endTime - startTime)}ms`);
logError('GameRepository.findActiveGames error', error instanceof Error ? error : new Error(String(error)));
throw new Error('Failed to find active games in database');
}
}
async findGamesByPlayer(playerId: string): Promise<GameAggregate[]> {
const startTime = performance.now();
try {
const queryBuilder = this.repo.createQueryBuilder('game')
.where('game.state != :cancelledState', { cancelledState: GameState.CANCELLED })
.andWhere('JSON_CONTAINS(game.players, :playerId)', { playerId: `"${playerId}"` })
.orderBy('game.updatedate', 'DESC');
const games = await queryBuilder.getMany();
const endTime = performance.now();
logDatabase('Games by player query completed', `executionTime: ${Math.round(endTime - startTime)}ms, playerId: ${playerId}, found: ${games.length}`);
return games;
} catch (error) {
const endTime = performance.now();
logDatabase('Games by player query failed', `executionTime: ${Math.round(endTime - startTime)}ms, playerId: ${playerId}`);
logError('GameRepository.findGamesByPlayer error', error instanceof Error ? error : new Error(String(error)));
throw new Error('Failed to find games by player in database');
}
}
async findWaitingGames(): Promise<GameAggregate[]> {
const startTime = performance.now();
try {
const games = await this.repo.find({
where: { state: GameState.WAITING },
order: { createdate: 'ASC' }
});
const endTime = performance.now();
logDatabase('Waiting games query completed', `executionTime: ${Math.round(endTime - startTime)}ms, found: ${games.length}`);
return games;
} catch (error) {
const endTime = performance.now();
logDatabase('Waiting games query failed', `executionTime: ${Math.round(endTime - startTime)}ms`);
logError('GameRepository.findWaitingGames error', error instanceof Error ? error : new Error(String(error)));
throw new Error('Failed to find waiting games in database');
}
}
async findFinishedGames(from?: number, to?: number): Promise<{ games: GameAggregate[], totalCount: number }> {
const startTime = performance.now();
try {
const queryBuilder = this.repo.createQueryBuilder('game')
.where('game.state = :finishedState', { finishedState: GameState.FINISHED })
.orderBy('game.enddate', 'DESC');
// Get total count
const totalCount = await queryBuilder.getCount();
// Apply pagination if provided
if (from !== undefined && to !== undefined) {
const limit = to - from + 1;
const offset = from;
queryBuilder.take(limit).skip(offset);
}
const games = await queryBuilder.getMany();
const endTime = performance.now();
logDatabase('Finished games query completed', `executionTime: ${Math.round(endTime - startTime)}ms, found: ${games.length}, total: ${totalCount}`);
return { games, totalCount };
} catch (error) {
const endTime = performance.now();
logDatabase('Finished games query failed', `executionTime: ${Math.round(endTime - startTime)}ms`);
logError('GameRepository.findFinishedGames error', error instanceof Error ? error : new Error(String(error)));
throw new Error('Failed to find finished games in database');
}
}
async addPlayerToGame(gameId: string, playerId: string): Promise<GameAggregate | null> {
const startTime = performance.now();
try {
const game = await this.findById(gameId);
if (!game) {
return null;
}
// Check if player is already in the game
if (game.players.includes(playerId)) {
return game;
}
// Check if game is full
if (game.players.length >= game.maxplayers) {
throw new Error('Game is full');
}
const updatedPlayers = [...game.players, playerId];
const result = await this.update(gameId, { players: updatedPlayers });
const endTime = performance.now();
logDatabase('Player added to game', `executionTime: ${Math.round(endTime - startTime)}ms, gameId: ${gameId}, playerId: ${playerId}`);
return result;
} catch (error) {
const endTime = performance.now();
logDatabase('Add player to game failed', `executionTime: ${Math.round(endTime - startTime)}ms, gameId: ${gameId}, playerId: ${playerId}`);
logError('GameRepository.addPlayerToGame error', error instanceof Error ? error : new Error(String(error)));
throw new Error('Failed to add player to game in database');
}
}
async removePlayerFromGame(gameId: string, playerId: string): Promise<GameAggregate | null> {
const startTime = performance.now();
try {
const game = await this.findById(gameId);
if (!game) {
return null;
}
const updatedPlayers = game.players.filter(id => id !== playerId);
const result = await this.update(gameId, { players: updatedPlayers });
const endTime = performance.now();
logDatabase('Player removed from game', `executionTime: ${Math.round(endTime - startTime)}ms, gameId: ${gameId}, playerId: ${playerId}`);
return result;
} catch (error) {
const endTime = performance.now();
logDatabase('Remove player from game failed', `executionTime: ${Math.round(endTime - startTime)}ms, gameId: ${gameId}, playerId: ${playerId}`);
logError('GameRepository.removePlayerFromGame error', error instanceof Error ? error : new Error(String(error)));
throw new Error('Failed to remove player from game in database');
}
}
async updateGameState(gameId: string, started: boolean, finished?: boolean, winner?: string): Promise<GameAggregate | null> {
const startTime = performance.now();
try {
const updateData: Partial<GameAggregate> = { started };
if (started && !finished) {
updateData.state = GameState.ACTIVE;
updateData.startdate = new Date();
}
if (finished) {
updateData.finished = true;
updateData.state = GameState.FINISHED;
updateData.enddate = new Date();
if (winner) {
updateData.winner = winner;
}
}
const result = await this.update(gameId, updateData);
const endTime = performance.now();
logDatabase('Game state updated', `executionTime: ${Math.round(endTime - startTime)}ms, gameId: ${gameId}, started: ${started}, finished: ${finished}, winner: ${winner}`);
return result;
} catch (error) {
const endTime = performance.now();
logDatabase('Game state update failed', `executionTime: ${Math.round(endTime - startTime)}ms, gameId: ${gameId}`);
logError('GameRepository.updateGameState error', error instanceof Error ? error : new Error(String(error)));
throw new Error('Failed to update game state in database');
}
}
}
@@ -6,7 +6,7 @@ export const AppDataSource = new DataSource({
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432'),
username: process.env.DB_USERNAME || 'postgres',
password: process.env.DB_PASSWORD || 'password',
password: process.env.DB_PASSWORD || 'postgres',
database: process.env.DB_NAME || 'serpentrace',
synchronize: false, // Set to false when using migrations
logging: process.env.NODE_ENV === 'development',