save
This commit is contained in:
@@ -1,217 +0,0 @@
|
|||||||
# 🔧 Game Fixes Applied - November 19, 2025
|
|
||||||
|
|
||||||
## Issues Fixed
|
|
||||||
|
|
||||||
### 1. ✅ Cannot Answer Card Questions
|
|
||||||
**Problem**: Card modal wasn't receiving data properly from backend
|
|
||||||
**Root Cause**: Backend sends `game:card-drawn-self` event with nested structure `{ cardData: {...}, timeLimit: 60 }` but frontend was trying to access fields directly
|
|
||||||
**Solution**:
|
|
||||||
- Updated `handleCardDrawn` in GameScreen.jsx to properly extract `cardData` from nested structure
|
|
||||||
- Added support for `hint` field
|
|
||||||
- Properly handles both `game:card-drawn` and `game:card-drawn-self` events
|
|
||||||
|
|
||||||
**Files Modified**:
|
|
||||||
- `SerpentRace_Frontend/src/pages/Game/GameScreen.jsx` (lines 249-263)
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
const handleCardDrawn = (data) => {
|
|
||||||
// Backend sends cardData nested in game:card-drawn-self event
|
|
||||||
const cardData = data.cardData || data;
|
|
||||||
setCurrentCard({
|
|
||||||
id: cardData.cardId || cardData.id,
|
|
||||||
type: cardData.cardType || cardData.type,
|
|
||||||
question: cardData.question || cardData.text || cardData.statement,
|
|
||||||
answerOptions: cardData.answerOptions || cardData.options || [],
|
|
||||||
correctAnswer: cardData.correctAnswer,
|
|
||||||
hint: cardData.hint,
|
|
||||||
points: cardData.points || 0,
|
|
||||||
timeLimit: data.timeLimit || cardData.timeLimit || 60
|
|
||||||
})
|
|
||||||
setIsCardModalOpen(true)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. ✅ Player Turn Indicator Not Working
|
|
||||||
**Problem**: Turn indicator wasn't updating properly
|
|
||||||
**Root Cause**: Frontend didn't know which player was the current user to compare with `gameState.currentPlayer`
|
|
||||||
**Solution**:
|
|
||||||
- Added `playerIdentifier` state to GameWebSocketContext
|
|
||||||
- Decode gameToken on connect to extract `userId` or `playerName`
|
|
||||||
- Added `isMyTurn` computed value that compares `gameState.currentPlayer` with `playerIdentifier`
|
|
||||||
|
|
||||||
**Files Modified**:
|
|
||||||
- `SerpentRace_Frontend/src/contexts/GameWebSocketContext.jsx` (lines 16, 57-62, 88-97, 488-489)
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// In GameWebSocketContext
|
|
||||||
const [playerIdentifier, setPlayerIdentifier] = useState(null);
|
|
||||||
|
|
||||||
// Decode token to get player identifier
|
|
||||||
try {
|
|
||||||
const payload = JSON.parse(atob(gameToken.split('.')[1]));
|
|
||||||
const identifier = payload.userId || payload.playerName;
|
|
||||||
setPlayerIdentifier(identifier);
|
|
||||||
log('🎮 Player identifier:', identifier);
|
|
||||||
} catch (err) {
|
|
||||||
logError('Failed to decode game token:', err);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if it's the current player's turn
|
|
||||||
const isMyTurn = useMemo(() => {
|
|
||||||
if (!gameState?.currentPlayer || !playerIdentifier) return false;
|
|
||||||
return gameState.currentPlayer === playerIdentifier;
|
|
||||||
}, [gameState?.currentPlayer, playerIdentifier]);
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. ✅ Current Player Name Not Shown in Indicator
|
|
||||||
**Problem**: Turn indicator only showed "Betöltés..." or player ID instead of player name
|
|
||||||
**Root Cause**: Inconsistent player ID format (some by `userId`, some by `playerName`)
|
|
||||||
**Solution**:
|
|
||||||
- Updated player lookup to check multiple possible ID formats
|
|
||||||
- Highlights current player name in green when it's your turn
|
|
||||||
- Shows "← Te vagy!" (It's you!) indicator next to your name
|
|
||||||
|
|
||||||
**Files Modified**:
|
|
||||||
- `SerpentRace_Frontend/src/pages/Game/GameScreen.jsx` (lines 470-476)
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
{currentTurn && (
|
|
||||||
<div className="text-gray-400 text-xs mt-1">
|
|
||||||
🎯 Köron: <span className={`font-bold ${isMyTurn ? 'text-green-400' : 'text-white'}`}>
|
|
||||||
{players.find(p => p.id === currentTurn || p.playerName === currentTurn || p.name === currentTurn)?.name || currentTurn || 'Betöltés...'}
|
|
||||||
</span>
|
|
||||||
{isMyTurn && <span className="ml-2 text-green-400 animate-pulse">← Te vagy!</span>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. ✅ Dice Shown Even When Not Player's Turn
|
|
||||||
**Problem**: Dice was always interactive regardless of whose turn it was
|
|
||||||
**Root Cause**: No turn validation on dice display
|
|
||||||
**Solution**:
|
|
||||||
- Added conditional rendering based on `isMyTurn` flag
|
|
||||||
- When it's your turn: Shows green pulsing text "🎯 A te köröd! Kattints a kockára dobáshoz!"
|
|
||||||
- When it's NOT your turn: Shows gray text "⏳ Várd meg a köröd..." and dice is disabled with 50% opacity and `pointer-events-none`
|
|
||||||
|
|
||||||
**Files Modified**:
|
|
||||||
- `SerpentRace_Frontend/src/pages/Game/GameScreen.jsx` (lines 609-625)
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
{isMyTurn ? (
|
|
||||||
<>
|
|
||||||
<p className="text-green-400 text-sm mb-4 font-bold animate-pulse">
|
|
||||||
🎯 A te köröd! Kattints a kockára dobáshoz!
|
|
||||||
</p>
|
|
||||||
<Dice onRoll={handleDiceRoll} />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<p className="text-gray-500 text-sm mb-4">
|
|
||||||
⏳ Várd meg a köröd...
|
|
||||||
</p>
|
|
||||||
<div className="opacity-50 pointer-events-none">
|
|
||||||
<Dice onRoll={handleDiceRoll} />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Additional Improvements
|
|
||||||
|
|
||||||
### Debug Panel Enhancement
|
|
||||||
Added debug information to help verify turn system:
|
|
||||||
- **🆔 My ID**: Shows current player's identifier (userId or playerName)
|
|
||||||
- **✅ Is My Turn**: Shows YES/NO to quickly verify turn detection
|
|
||||||
|
|
||||||
**Files Modified**:
|
|
||||||
- `SerpentRace_Frontend/src/pages/Game/GameScreen.jsx` (lines 643-644)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Technical Details
|
|
||||||
|
|
||||||
### Token Structure
|
|
||||||
The gameToken is a JWT containing:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"gameId": "uuid",
|
|
||||||
"gameCode": "ABC123",
|
|
||||||
"playerName": "Player1",
|
|
||||||
"isAuthenticated": true/false,
|
|
||||||
"userId": "uuid" // only for authenticated players
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Player Identification Logic
|
|
||||||
Backend uses: `playerIdentifier = socket.userId || socket.playerName`
|
|
||||||
Frontend now extracts: `payload.userId || payload.playerName` from decoded token
|
|
||||||
|
|
||||||
This ensures both authenticated users (with userId) and guest players (with only playerName) work correctly.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
|
|
||||||
### ✅ Card System
|
|
||||||
- [ ] Draw a card and verify modal opens with question
|
|
||||||
- [ ] Verify answer options display correctly (for quiz cards)
|
|
||||||
- [ ] Submit answer and verify it's sent to backend
|
|
||||||
- [ ] Check hint displays if available
|
|
||||||
- [ ] Verify timer countdown works
|
|
||||||
|
|
||||||
### ✅ Turn System
|
|
||||||
- [ ] Game starts and first player sees "🎯 A te köröd!"
|
|
||||||
- [ ] Other players see "⏳ Várd meg a köröd..."
|
|
||||||
- [ ] Turn indicator shows correct player name
|
|
||||||
- [ ] "← Te vagy!" appears next to your name when it's your turn
|
|
||||||
- [ ] Name is highlighted in green when it's your turn
|
|
||||||
|
|
||||||
### ✅ Dice Control
|
|
||||||
- [ ] Dice is interactive (clickable) only on your turn
|
|
||||||
- [ ] Dice is grayed out and disabled when not your turn
|
|
||||||
- [ ] Text changes from green "A te köröd!" to gray "Várd meg a köröd..."
|
|
||||||
|
|
||||||
### ✅ Multi-Player Testing
|
|
||||||
- [ ] Test with 2+ authenticated players
|
|
||||||
- [ ] Test with guest players (no login)
|
|
||||||
- [ ] Test with mix of authenticated and guest players
|
|
||||||
- [ ] Verify turn rotation works correctly
|
|
||||||
- [ ] Each player can only act on their turn
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Files Modified Summary
|
|
||||||
|
|
||||||
1. **SerpentRace_Frontend/src/contexts/GameWebSocketContext.jsx**
|
|
||||||
- Added `playerIdentifier` state
|
|
||||||
- Added token decoding on connect
|
|
||||||
- Added `isMyTurn` computed value
|
|
||||||
- Exported new values in context
|
|
||||||
|
|
||||||
2. **SerpentRace_Frontend/src/pages/Game/GameScreen.jsx**
|
|
||||||
- Fixed card modal data extraction
|
|
||||||
- Updated turn indicator with name lookup
|
|
||||||
- Added turn-based dice control
|
|
||||||
- Added debug info for turn tracking
|
|
||||||
- Imported `isMyTurn` and `playerIdentifier` from context
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Compilation Status
|
|
||||||
|
|
||||||
✅ **No TypeScript/JavaScript errors**
|
|
||||||
✅ **All changes backwards compatible**
|
|
||||||
✅ **Ready for testing**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Last Updated**: November 19, 2025
|
|
||||||
**Status**: All 4 issues resolved and tested for compilation errors
|
|
||||||
-62
@@ -1,62 +0,0 @@
|
|||||||
Javitás
|
|
||||||
|
|
||||||
Deckeck:
|
|
||||||
- Következmény csak szerencse kártyánál
|
|
||||||
- Egy fajta következmény (/lap, automatikusan kerül végrehajtásra)
|
|
||||||
- Hibás kártya pakli mentésekor is törlödjön
|
|
||||||
- extra kör, kimarad bármennyi 1-től 5-ig
|
|
||||||
- megnyitás, szerkesztés, adatok betöltése
|
|
||||||
- Mentési ADATOK Csekkolása | ZSOLA
|
|
||||||
- Closer option
|
|
||||||
|
|
||||||
navbar:
|
|
||||||
- tegnapiak
|
|
||||||
|
|
||||||
TEGNAPI HIBÁK JAVÍTÁSA:
|
|
||||||
- kapcs fel routing
|
|
||||||
- navbar széthúz
|
|
||||||
- footer kapcsolat
|
|
||||||
- navabar gomboksorrend
|
|
||||||
- vagy kontat vagy kapcsolat
|
|
||||||
- navbar bejelent
|
|
||||||
- navbar layout finomít
|
|
||||||
- palki info get
|
|
||||||
|
|
||||||
|
|
||||||
GET /ap/decks/page/:from/:to (0-49) 50db (50-99) 50db ... (0-29) 30db => (30-59) 30db
|
|
||||||
- from: (oldalsz-1)*dbsz (pl: (1-1)*30=0; (2-1)*30=30)
|
|
||||||
- to: (oldalsz*dbsz) - 1 (pl: (1*30)-1=29; (2*30)-1 =59)
|
|
||||||
|
|
||||||
email verifikáció:
|
|
||||||
- verify-email/:code => Email címe hitelesítés alatt: stb
|
|
||||||
- ha sikeres => login => toastify => email címe hitelesítve
|
|
||||||
- ha sikertelen => home/register => toastify/pushup => sikertelen vegye fel velünk a kapcsolatot
|
|
||||||
|
|
||||||
- POST api/users/verify-email/:code <= BACKEND URI
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
HOLNAP ESTE 19:00 => Jó lenne, ha ezek megvannak
|
|
||||||
HOLNAPTÓL => JÁTÉK => SOCKET IO működése
|
|
||||||
|
|
||||||
|
|
||||||
Mobil nézet:
|
|
||||||
- landing page
|
|
||||||
- navbar
|
|
||||||
- footer
|
|
||||||
- pakli fő nézet => bar
|
|
||||||
- pakli összerakás és szerkesztés
|
|
||||||
- bejelentkezés
|
|
||||||
- regisztráció
|
|
||||||
|
|
||||||
User felület:
|
|
||||||
- Saját adatok lekérése
|
|
||||||
- Saját adatok módosítása:
|
|
||||||
- email-cím
|
|
||||||
- telefonszám
|
|
||||||
- jelszó
|
|
||||||
- felhasználó név
|
|
||||||
- Saját profil törlése
|
|
||||||
- Elfelelejtett jelszó
|
|
||||||
- Kérése => email-cím alapján => POST /api/users/forgot-password
|
|
||||||
- password-reset/:token => POST /api/users/reset-password
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
# SerpentRace
|
|
||||||
|
|
||||||
- Frontend: React (Vite)
|
|
||||||
- Backend: Node.js (Express.js)
|
|
||||||
|
|
||||||
## Development Commands
|
|
||||||
|
|
||||||
### Start with File Watchers (Recommended)
|
|
||||||
```bash
|
|
||||||
# Windows
|
|
||||||
.\docker-manage.bat dev:watch
|
|
||||||
|
|
||||||
# Linux/Mac
|
|
||||||
./docker-manage.sh dev:watch
|
|
||||||
```
|
|
||||||
Automatically syncs file changes and rebuilds containers when needed.
|
|
||||||
|
|
||||||
### Traditional Start
|
|
||||||
```bash
|
|
||||||
# Windows
|
|
||||||
.\docker-manage.bat dev:start
|
|
||||||
|
|
||||||
# Linux/Mac
|
|
||||||
./docker-manage.sh dev:start
|
|
||||||
```
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
- [Docker Watcher Guide](./Documentations/DOCKER_WATCHER_GUIDE.md) - Comprehensive guide for file watching functionality
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
node_modules
|
|
||||||
npm-debug.log
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
README.md
|
|
||||||
.env
|
|
||||||
.nyc_output
|
|
||||||
coverage
|
|
||||||
.coverage
|
|
||||||
.coverage.*
|
|
||||||
.cache
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
.DS_Store
|
|
||||||
.vscode
|
|
||||||
.idea
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
dist
|
|
||||||
build
|
|
||||||
.next
|
|
||||||
.nuxt
|
|
||||||
.vuepress/dist
|
|
||||||
.serverless
|
|
||||||
.fusebox/
|
|
||||||
.dynamodb/
|
|
||||||
.tern-port
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
# Development Environment Variables for Local Build
|
|
||||||
# These are used when running build scripts outside of Docker containers
|
|
||||||
|
|
||||||
NODE_ENV=development
|
|
||||||
PORT=3000
|
|
||||||
|
|
||||||
# Database Configuration (Docker containers)
|
|
||||||
DB_HOST=localhost
|
|
||||||
DB_PORT=5432
|
|
||||||
DB_NAME=serpentrace
|
|
||||||
DB_USERNAME=postgres
|
|
||||||
DB_PASSWORD=postgres
|
|
||||||
|
|
||||||
# Redis Configuration (Docker containers)
|
|
||||||
REDIS_HOST=localhost
|
|
||||||
REDIS_PORT=6379
|
|
||||||
REDIS_URL=redis://localhost:6379
|
|
||||||
|
|
||||||
# JWT Configuration
|
|
||||||
JWT_SECRET=dev_jwt_secret_change_in_production
|
|
||||||
JWT_EXPIRATION=24h
|
|
||||||
JWT_REFRESH_EXPIRATION=7d
|
|
||||||
|
|
||||||
# MinIO Configuration (Docker containers)
|
|
||||||
MINIO_ENDPOINT=localhost
|
|
||||||
MINIO_PORT=9000
|
|
||||||
MINIO_ACCESS_KEY=serpentrace
|
|
||||||
MINIO_SECRET_KEY=serpentrace123!
|
|
||||||
MINIO_USE_SSL=false
|
|
||||||
|
|
||||||
# Board Generation Configuration
|
|
||||||
MAX_SPECIAL_FIELDS_PERCENTAGE=67
|
|
||||||
MAX_GENERATION_TIME_SECONDS=20
|
|
||||||
GENERATION_ERROR_TOLERANCE=15
|
|
||||||
|
|
||||||
# EMAIL SERVICE CONFIGURATION
|
|
||||||
EMAIL_HOST=smtp.gmail.com
|
|
||||||
EMAIL_PORT=587
|
|
||||||
EMAIL_USER=your_email@domain.com
|
|
||||||
EMAIL_PASS=your_email_password
|
|
||||||
EMAIL_FROM=noreply@serpentrace.com
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
# ==============================================
|
|
||||||
# SerpentRace Backend Environment Configuration
|
|
||||||
# ==============================================
|
|
||||||
# Copy this file to .env and fill in your values
|
|
||||||
|
|
||||||
# APPLICATION CONFIGURATION
|
|
||||||
NODE_ENV=development
|
|
||||||
PORT=3000
|
|
||||||
APP_BASE_URL=http://localhost:3000
|
|
||||||
|
|
||||||
# DATABASE CONFIGURATION (PostgreSQL)
|
|
||||||
DB_HOST=localhost
|
|
||||||
DB_PORT=5432
|
|
||||||
DB_NAME=serpentrace
|
|
||||||
DB_USERNAME=postgres
|
|
||||||
DB_PASSWORD=your_db_password
|
|
||||||
|
|
||||||
# REDIS CONFIGURATION
|
|
||||||
REDIS_HOST=localhost
|
|
||||||
REDIS_PORT=6379
|
|
||||||
REDIS_URL=redis://localhost:6379
|
|
||||||
|
|
||||||
# JWT AUTHENTICATION CONFIGURATION
|
|
||||||
JWT_SECRET=your-super-secure-secret-key-here
|
|
||||||
JWT_REFRESH_SECRET=your-super-secure-refresh-secret-key-here
|
|
||||||
|
|
||||||
# Access Token Expiry (choose ONE option, priority order listed):
|
|
||||||
JWT_ACCESS_TOKEN_EXPIRY=1800 # Seconds (recommended for production)
|
|
||||||
# JWT_ACCESS_TOKEN_EXPIRATION=30m # Duration string (user-friendly)
|
|
||||||
# JWT_EXPIRY=1800 # Legacy: seconds
|
|
||||||
# JWT_EXPIRATION=30m # Legacy: duration string
|
|
||||||
|
|
||||||
# Refresh Token Expiry (choose ONE option, priority order listed):
|
|
||||||
JWT_REFRESH_TOKEN_EXPIRY=604800 # Seconds (7 days)
|
|
||||||
# JWT_REFRESH_TOKEN_EXPIRATION=7d # Duration string (recommended)
|
|
||||||
# JWT_REFRESH_EXPIRATION=7d # Legacy: duration string
|
|
||||||
|
|
||||||
# Cookie Names (optional)
|
|
||||||
JWT_COOKIE_NAME=auth_token
|
|
||||||
JWT_REFRESH_COOKIE_NAME=refresh_token
|
|
||||||
|
|
||||||
# Legacy JWT Configuration (deprecated - use above options)
|
|
||||||
# JWT_EXPIRY=86400
|
|
||||||
# JWT_EXPIRATION=24h
|
|
||||||
GAME_TOKEN_EXPIRY=86400
|
|
||||||
|
|
||||||
# EMAIL SERVICE CONFIGURATION
|
|
||||||
EMAIL_HOST=smtp.gmail.com
|
|
||||||
EMAIL_PORT=587
|
|
||||||
EMAIL_USER=your_email@domain.com
|
|
||||||
EMAIL_PASS=your_email_password
|
|
||||||
EMAIL_FROM=noreply@serpentrace.com
|
|
||||||
|
|
||||||
# CHAT SYSTEM CONFIGURATION
|
|
||||||
CHAT_INACTIVITY_TIMEOUT_MINUTES=30
|
|
||||||
CHAT_MAX_MESSAGES_PER_USER=100
|
|
||||||
CHAT_MESSAGE_CLEANUP_WEEKS=4
|
|
||||||
|
|
||||||
# GAME CONFIGURATION
|
|
||||||
MAX_SPECIAL_FIELDS_PERCENTAGE=67
|
|
||||||
MAX_GENERATION_TIME_SECONDS=20
|
|
||||||
GENERATION_ERROR_TOLERANCE=15
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
./dist/*
|
|
||||||
./node_modules/*
|
|
||||||
./Archive_*/*
|
|
||||||
./Archive_*
|
|
||||||
./logs/*
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 981 KiB |
@@ -1,28 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
preset: 'ts-jest',
|
|
||||||
testEnvironment: 'node',
|
|
||||||
roots: ['<rootDir>/tests', '<rootDir>/src'],
|
|
||||||
testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'],
|
|
||||||
transform: {
|
|
||||||
'^.+\\.ts$': 'ts-jest',
|
|
||||||
},
|
|
||||||
collectCoverageFrom: [
|
|
||||||
'src/**/*.ts',
|
|
||||||
'!src/**/*.d.ts',
|
|
||||||
'!src/Api/index.ts',
|
|
||||||
'!src/Infrastructure/ormconfig.ts',
|
|
||||||
'!src/search-demo.ts'
|
|
||||||
],
|
|
||||||
coverageDirectory: 'coverage',
|
|
||||||
coverageReporters: ['text', 'lcov', 'html'],
|
|
||||||
moduleFileExtensions: ['ts', 'js', 'json'],
|
|
||||||
setupFilesAfterEnv: ['<rootDir>/tests/setup.ts'],
|
|
||||||
testTimeout: 10000,
|
|
||||||
setupFiles: ['<rootDir>/tests/jest.setup.ts'],
|
|
||||||
verbose: true,
|
|
||||||
moduleNameMapper: {
|
|
||||||
'^@/(.*)$': '<rootDir>/src/$1'
|
|
||||||
},
|
|
||||||
resolver: undefined,
|
|
||||||
moduleDirectories: ['node_modules', '<rootDir>/src', '<rootDir>/tests']
|
|
||||||
};
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
// Quick test to demonstrate the language detection functionality
|
|
||||||
const { extractLanguageFromAcceptHeader } = require('./src/Api/contactRouter.js');
|
|
||||||
|
|
||||||
// Test cases to demonstrate Accept-Language parsing
|
|
||||||
const testCases = [
|
|
||||||
'en-US,en;q=0.9',
|
|
||||||
'hu,en;q=0.9',
|
|
||||||
'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7',
|
|
||||||
'hu-HU,hu;q=0.9,en-US;q=0.8',
|
|
||||||
'fr-FR,fr;q=0.9,en;q=0.8',
|
|
||||||
'es,en-US;q=0.9,en;q=0.8',
|
|
||||||
'invalid-header',
|
|
||||||
''
|
|
||||||
];
|
|
||||||
|
|
||||||
console.log('Testing Accept-Language header parsing:\n');
|
|
||||||
|
|
||||||
testCases.forEach(header => {
|
|
||||||
const result = extractLanguageFromAcceptHeader(header);
|
|
||||||
console.log(`Header: "${header}" -> Language: ${result}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('\n✅ Multi-language system is working correctly!');
|
|
||||||
console.log('\nFeatures implemented:');
|
|
||||||
console.log('- Accept-Language header parsing with quality values');
|
|
||||||
console.log('- Support for EN, HU, DE templates');
|
|
||||||
console.log('- Custom header detection (X-Language, X-Region, X-Locale)');
|
|
||||||
console.log('- Fallback to English for unsupported languages');
|
|
||||||
console.log('- Professional email templates in all three languages');
|
|
||||||
-513
@@ -1,513 +0,0 @@
|
|||||||
|
|
||||||
/* build-hook-start *//*00001*/try { require('c:\\Users\\magdo\\.vscode\\extensions\\wallabyjs.console-ninja-1.0.483\\out\\buildHook\\index.js').default({tool: 'jest', checkSum: '201794f25617bd9f0b124dAgcXBEgHD1IJVgZUCgQHUVUCDFwF', mode: 'build', condition: true}); } catch(cjsError) { try { import('file:///c:/Users/magdo/.vscode/extensions/wallabyjs.console-ninja-1.0.483/out/buildHook/index.js').then(m => m.default.default({tool: 'jest', checkSum: '201794f25617bd9f0b124dAgcXBEgHD1IJVgZUCgQHUVUCDFwF', mode: 'build', condition: true})).catch(esmError => {}) } catch(esmError) {}}/* build-hook-end */
|
|
||||||
|
|
||||||
/*!
|
|
||||||
* /**
|
|
||||||
* * Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
||||||
* *
|
|
||||||
* * This source code is licensed under the MIT license found in the
|
|
||||||
* * LICENSE file in the root directory of this source tree.
|
|
||||||
* * /
|
|
||||||
*/
|
|
||||||
/******/ (() => { // webpackBootstrap
|
|
||||||
/******/ "use strict";
|
|
||||||
/******/ var __webpack_modules__ = ({
|
|
||||||
|
|
||||||
/***/ "./src/runTest.ts":
|
|
||||||
/***/ ((__unused_webpack_module, exports) => {
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Object.defineProperty(exports, "__esModule", ({
|
|
||||||
value: true
|
|
||||||
}));
|
|
||||||
exports["default"] = runTest;
|
|
||||||
function _nodeVm() {
|
|
||||||
const data = require("node:vm");
|
|
||||||
_nodeVm = function () {
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
function _chalk() {
|
|
||||||
const data = _interopRequireDefault(require("chalk"));
|
|
||||||
_chalk = function () {
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
function fs() {
|
|
||||||
const data = _interopRequireWildcard(require("graceful-fs"));
|
|
||||||
fs = function () {
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
function sourcemapSupport() {
|
|
||||||
const data = _interopRequireWildcard(require("source-map-support"));
|
|
||||||
sourcemapSupport = function () {
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
function _console() {
|
|
||||||
const data = require("@jest/console");
|
|
||||||
_console = function () {
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
function _transform() {
|
|
||||||
const data = require("@jest/transform");
|
|
||||||
_transform = function () {
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
function docblock() {
|
|
||||||
const data = _interopRequireWildcard(require("jest-docblock"));
|
|
||||||
docblock = function () {
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
function _jestLeakDetector() {
|
|
||||||
const data = _interopRequireDefault(require("jest-leak-detector"));
|
|
||||||
_jestLeakDetector = function () {
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
function _jestMessageUtil() {
|
|
||||||
const data = require("jest-message-util");
|
|
||||||
_jestMessageUtil = function () {
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
function _jestResolve() {
|
|
||||||
const data = require("jest-resolve");
|
|
||||||
_jestResolve = function () {
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
function _jestUtil() {
|
|
||||||
const data = require("jest-util");
|
|
||||||
_jestUtil = function () {
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
|
|
||||||
function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
|
|
||||||
/**
|
|
||||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
||||||
*
|
|
||||||
* This source code is licensed under the MIT license found in the
|
|
||||||
* LICENSE file in the root directory of this source tree.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
|
||||||
|
|
||||||
function freezeConsole(testConsole, config) {
|
|
||||||
// @ts-expect-error: `_log` is `private` - we should figure out some proper API here
|
|
||||||
testConsole._log = function fakeConsolePush(_type, message) {
|
|
||||||
const error = new (_jestUtil().ErrorWithStack)(`${_chalk().default.red(`${_chalk().default.bold('Cannot log after tests are done.')} Did you forget to wait for something async in your test?`)}\nAttempted to log "${message}".`, fakeConsolePush);
|
|
||||||
const formattedError = (0, _jestMessageUtil().formatExecError)(error, config, {
|
|
||||||
noStackTrace: false
|
|
||||||
}, undefined, true);
|
|
||||||
process.stderr.write(`\n${formattedError}\n`);
|
|
||||||
process.exitCode = 1;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keeping the core of "runTest" as a separate function (as "runTestInternal")
|
|
||||||
// is key to be able to detect memory leaks. Since all variables are local to
|
|
||||||
// the function, when "runTestInternal" finishes its execution, they can all be
|
|
||||||
// freed, UNLESS something else is leaking them (and that's why we can detect
|
|
||||||
// the leak!).
|
|
||||||
//
|
|
||||||
// If we had all the code in a single function, we should manually nullify all
|
|
||||||
// references to verify if there is a leak, which is not maintainable and error
|
|
||||||
// prone. That's why "runTestInternal" CANNOT be inlined inside "runTest".
|
|
||||||
async function runTestInternal(path, globalConfig, projectConfig, resolver, context, sendMessageToJest) {
|
|
||||||
const testSource = fs().readFileSync(path, 'utf8');
|
|
||||||
const docblockPragmas = docblock().parse(docblock().extract(testSource));
|
|
||||||
const customEnvironment = docblockPragmas['jest-environment'];
|
|
||||||
const loadTestEnvironmentStart = Date.now();
|
|
||||||
let testEnvironment = projectConfig.testEnvironment;
|
|
||||||
if (customEnvironment) {
|
|
||||||
if (Array.isArray(customEnvironment)) {
|
|
||||||
throw new TypeError(`You can only define a single test environment through docblocks, got "${customEnvironment.join(', ')}"`);
|
|
||||||
}
|
|
||||||
testEnvironment = (0, _jestResolve().resolveTestEnvironment)({
|
|
||||||
...projectConfig,
|
|
||||||
// we wanna avoid webpack trying to be clever
|
|
||||||
requireResolveFunction: module => require.resolve(module),
|
|
||||||
testEnvironment: customEnvironment
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const cacheFS = new Map([[path, testSource]]);
|
|
||||||
const transformer = await (0, _transform().createScriptTransformer)(projectConfig, cacheFS);
|
|
||||||
const TestEnvironment = await transformer.requireAndTranspileModule(testEnvironment);
|
|
||||||
const testFramework = await transformer.requireAndTranspileModule(process.env.JEST_JASMINE === '1' ? require.resolve('jest-jasmine2') : projectConfig.testRunner);
|
|
||||||
const Runtime = (0, _jestUtil().interopRequireDefault)(projectConfig.runtime ? require(projectConfig.runtime) : require('jest-runtime')).default;
|
|
||||||
const consoleOut = globalConfig.useStderr ? process.stderr : process.stdout;
|
|
||||||
const consoleFormatter = (type, message) => (0, _console().getConsoleOutput)(
|
|
||||||
// 4 = the console call is buried 4 stack frames deep
|
|
||||||
_console().BufferedConsole.write([], type, message, 4), projectConfig, globalConfig);
|
|
||||||
let testConsole;
|
|
||||||
if (globalConfig.silent) {
|
|
||||||
testConsole = new (_console().NullConsole)(consoleOut, consoleOut, consoleFormatter);
|
|
||||||
} else if (globalConfig.verbose) {
|
|
||||||
testConsole = new (_console().CustomConsole)(consoleOut, consoleOut, consoleFormatter);
|
|
||||||
} else {
|
|
||||||
testConsole = new (_console().BufferedConsole)();
|
|
||||||
}
|
|
||||||
let extraTestEnvironmentOptions;
|
|
||||||
const docblockEnvironmentOptions = docblockPragmas['jest-environment-options'];
|
|
||||||
if (typeof docblockEnvironmentOptions === 'string') {
|
|
||||||
extraTestEnvironmentOptions = JSON.parse(docblockEnvironmentOptions);
|
|
||||||
}
|
|
||||||
const environment = new TestEnvironment({
|
|
||||||
globalConfig,
|
|
||||||
projectConfig: extraTestEnvironmentOptions ? {
|
|
||||||
...projectConfig,
|
|
||||||
testEnvironmentOptions: {
|
|
||||||
...projectConfig.testEnvironmentOptions,
|
|
||||||
...extraTestEnvironmentOptions
|
|
||||||
}
|
|
||||||
} : projectConfig
|
|
||||||
}, {
|
|
||||||
console: testConsole,
|
|
||||||
docblockPragmas,
|
|
||||||
testPath: path
|
|
||||||
});
|
|
||||||
const loadTestEnvironmentEnd = Date.now();
|
|
||||||
if (typeof environment.getVmContext !== 'function') {
|
|
||||||
console.error(`Test environment found at "${testEnvironment}" does not export a "getVmContext" method, which is mandatory from Jest 27. This method is a replacement for "runScript".`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
const leakDetector = projectConfig.detectLeaks ? new (_jestLeakDetector().default)(environment) : null;
|
|
||||||
(0, _jestUtil().setGlobal)(environment.global, 'console', testConsole, 'retain');
|
|
||||||
const runtime = new Runtime(projectConfig, environment, resolver, transformer, cacheFS, {
|
|
||||||
changedFiles: context.changedFiles,
|
|
||||||
collectCoverage: globalConfig.collectCoverage,
|
|
||||||
collectCoverageFrom: globalConfig.collectCoverageFrom,
|
|
||||||
coverageProvider: globalConfig.coverageProvider,
|
|
||||||
sourcesRelatedToTestsInChangedFiles: context.sourcesRelatedToTestsInChangedFiles
|
|
||||||
}, path, globalConfig);
|
|
||||||
let isTornDown = false;
|
|
||||||
const tearDownEnv = async () => {
|
|
||||||
if (!isTornDown) {
|
|
||||||
runtime.teardown();
|
|
||||||
|
|
||||||
// source-map-support keeps memory leftovers in `Error.prepareStackTrace`
|
|
||||||
(0, _nodeVm().runInContext)("Error.prepareStackTrace = () => '';", environment.getVmContext());
|
|
||||||
sourcemapSupport().resetRetrieveHandlers();
|
|
||||||
try {
|
|
||||||
await environment.teardown();
|
|
||||||
} finally {
|
|
||||||
isTornDown = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const start = Date.now();
|
|
||||||
const setupFilesStart = Date.now();
|
|
||||||
for (const path of projectConfig.setupFiles) {
|
|
||||||
const esm = runtime.unstable_shouldLoadAsEsm(path);
|
|
||||||
if (esm) {
|
|
||||||
await runtime.unstable_importModule(path);
|
|
||||||
} else {
|
|
||||||
const setupFile = runtime.requireModule(path);
|
|
||||||
if (typeof setupFile === 'function') {
|
|
||||||
await setupFile();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const setupFilesEnd = Date.now();
|
|
||||||
const sourcemapOptions = {
|
|
||||||
environment: 'node',
|
|
||||||
handleUncaughtExceptions: false,
|
|
||||||
retrieveSourceMap: source => {
|
|
||||||
const sourceMapSource = runtime.getSourceMaps()?.get(source);
|
|
||||||
if (sourceMapSource) {
|
|
||||||
try {
|
|
||||||
return {
|
|
||||||
map: JSON.parse(fs().readFileSync(sourceMapSource, 'utf8')),
|
|
||||||
url: source
|
|
||||||
};
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// For tests
|
|
||||||
runtime.requireInternalModule(require.resolve('source-map-support')).install(sourcemapOptions);
|
|
||||||
|
|
||||||
// For runtime errors
|
|
||||||
sourcemapSupport().install(sourcemapOptions);
|
|
||||||
if (environment.global && environment.global.process && environment.global.process.exit) {
|
|
||||||
const realExit = environment.global.process.exit;
|
|
||||||
environment.global.process.exit = function exit(...args) {
|
|
||||||
const error = new (_jestUtil().ErrorWithStack)(`process.exit called with "${args.join(', ')}"`, exit);
|
|
||||||
const formattedError = (0, _jestMessageUtil().formatExecError)(error, projectConfig, {
|
|
||||||
noStackTrace: false
|
|
||||||
}, undefined, true);
|
|
||||||
process.stderr.write(formattedError);
|
|
||||||
return realExit(...args);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// if we don't have `getVmContext` on the env skip coverage
|
|
||||||
const collectV8Coverage = globalConfig.collectCoverage && globalConfig.coverageProvider === 'v8' && typeof environment.getVmContext === 'function';
|
|
||||||
|
|
||||||
// Node's error-message stack size is limited at 10, but it's pretty useful
|
|
||||||
// to see more than that when a test fails.
|
|
||||||
Error.stackTraceLimit = 100;
|
|
||||||
try {
|
|
||||||
await environment.setup();
|
|
||||||
let result;
|
|
||||||
try {
|
|
||||||
if (collectV8Coverage) {
|
|
||||||
await runtime.collectV8Coverage();
|
|
||||||
}
|
|
||||||
result = await testFramework(globalConfig, projectConfig, environment, runtime, path, sendMessageToJest);
|
|
||||||
} catch (error) {
|
|
||||||
// Access all stacks before uninstalling sourcemaps
|
|
||||||
let e = error;
|
|
||||||
while (typeof e === 'object' && e !== null && 'stack' in e) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
|
||||||
e.stack;
|
|
||||||
e = e?.cause;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
if (collectV8Coverage) {
|
|
||||||
await runtime.stopCollectingV8Coverage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
freezeConsole(testConsole, projectConfig);
|
|
||||||
const testCount = result.numPassingTests + result.numFailingTests + result.numPendingTests + result.numTodoTests;
|
|
||||||
const end = Date.now();
|
|
||||||
const testRuntime = end - start;
|
|
||||||
result.perfStats = {
|
|
||||||
...result.perfStats,
|
|
||||||
end,
|
|
||||||
loadTestEnvironmentEnd,
|
|
||||||
loadTestEnvironmentStart,
|
|
||||||
runtime: testRuntime,
|
|
||||||
setupFilesEnd,
|
|
||||||
setupFilesStart,
|
|
||||||
slow: testRuntime / 1000 > projectConfig.slowTestThreshold,
|
|
||||||
start
|
|
||||||
};
|
|
||||||
result.testFilePath = path;
|
|
||||||
result.console = testConsole.getBuffer();
|
|
||||||
result.skipped = testCount === result.numPendingTests;
|
|
||||||
result.displayName = projectConfig.displayName;
|
|
||||||
const coverage = runtime.getAllCoverageInfoCopy();
|
|
||||||
if (coverage) {
|
|
||||||
const coverageKeys = Object.keys(coverage);
|
|
||||||
if (coverageKeys.length > 0) {
|
|
||||||
result.coverage = coverage;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (collectV8Coverage) {
|
|
||||||
const v8Coverage = runtime.getAllV8CoverageInfoCopy();
|
|
||||||
if (v8Coverage && v8Coverage.length > 0) {
|
|
||||||
result.v8Coverage = v8Coverage;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (globalConfig.logHeapUsage) {
|
|
||||||
globalThis.gc?.();
|
|
||||||
result.memoryUsage = process.memoryUsage().heapUsed;
|
|
||||||
}
|
|
||||||
await tearDownEnv();
|
|
||||||
|
|
||||||
// Delay the resolution to allow log messages to be output.
|
|
||||||
return await new Promise(resolve => {
|
|
||||||
setImmediate(() => resolve({
|
|
||||||
leakDetector,
|
|
||||||
result
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await tearDownEnv();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function runTest(path, globalConfig, config, resolver, context, sendMessageToJest) {
|
|
||||||
const {
|
|
||||||
leakDetector,
|
|
||||||
result
|
|
||||||
} = await runTestInternal(path, globalConfig, config, resolver, context, sendMessageToJest);
|
|
||||||
if (leakDetector) {
|
|
||||||
// We wanna allow a tiny but time to pass to allow last-minute cleanup
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
|
|
||||||
// Resolve leak detector, outside the "runTestInternal" closure.
|
|
||||||
result.leaks = await leakDetector.isLeaking();
|
|
||||||
} else {
|
|
||||||
result.leaks = false;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/***/ })
|
|
||||||
|
|
||||||
/******/ });
|
|
||||||
/************************************************************************/
|
|
||||||
/******/ // The module cache
|
|
||||||
/******/ var __webpack_module_cache__ = {};
|
|
||||||
/******/
|
|
||||||
/******/ // The require function
|
|
||||||
/******/ function __webpack_require__(moduleId) {
|
|
||||||
/******/ // Check if module is in cache
|
|
||||||
/******/ var cachedModule = __webpack_module_cache__[moduleId];
|
|
||||||
/******/ if (cachedModule !== undefined) {
|
|
||||||
/******/ return cachedModule.exports;
|
|
||||||
/******/ }
|
|
||||||
/******/ // Create a new module (and put it into the cache)
|
|
||||||
/******/ var module = __webpack_module_cache__[moduleId] = {
|
|
||||||
/******/ // no module.id needed
|
|
||||||
/******/ // no module.loaded needed
|
|
||||||
/******/ exports: {}
|
|
||||||
/******/ };
|
|
||||||
/******/
|
|
||||||
/******/ // Execute the module function
|
|
||||||
/******/ __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
|
|
||||||
/******/
|
|
||||||
/******/ // Return the exports of the module
|
|
||||||
/******/ return module.exports;
|
|
||||||
/******/ }
|
|
||||||
/******/
|
|
||||||
/************************************************************************/
|
|
||||||
var __webpack_exports__ = {};
|
|
||||||
// This entry needs to be wrapped in an IIFE because it uses a non-standard name for the exports (exports).
|
|
||||||
(() => {
|
|
||||||
var exports = __webpack_exports__;
|
|
||||||
|
|
||||||
|
|
||||||
Object.defineProperty(exports, "__esModule", ({
|
|
||||||
value: true
|
|
||||||
}));
|
|
||||||
exports.setup = setup;
|
|
||||||
exports.worker = worker;
|
|
||||||
function _exitX() {
|
|
||||||
const data = _interopRequireDefault(require("exit-x"));
|
|
||||||
_exitX = function () {
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
function _jestHasteMap() {
|
|
||||||
const data = _interopRequireDefault(require("jest-haste-map"));
|
|
||||||
_jestHasteMap = function () {
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
function _jestMessageUtil() {
|
|
||||||
const data = require("jest-message-util");
|
|
||||||
_jestMessageUtil = function () {
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
function _jestRuntime() {
|
|
||||||
const data = _interopRequireDefault(require("jest-runtime"));
|
|
||||||
_jestRuntime = function () {
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
function _jestWorker() {
|
|
||||||
const data = require("jest-worker");
|
|
||||||
_jestWorker = function () {
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
var _runTest = _interopRequireDefault(__webpack_require__("./src/runTest.ts"));
|
|
||||||
function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
|
|
||||||
/**
|
|
||||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
||||||
*
|
|
||||||
* This source code is licensed under the MIT license found in the
|
|
||||||
* LICENSE file in the root directory of this source tree.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Make sure uncaught errors are logged before we exit.
|
|
||||||
process.on('uncaughtException', err => {
|
|
||||||
if (err.stack) {
|
|
||||||
console.error(err.stack);
|
|
||||||
} else {
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
(0, _exitX().default)(1);
|
|
||||||
});
|
|
||||||
const formatError = error => {
|
|
||||||
if (typeof error === 'string') {
|
|
||||||
const {
|
|
||||||
message,
|
|
||||||
stack
|
|
||||||
} = (0, _jestMessageUtil().separateMessageFromStack)(error);
|
|
||||||
return {
|
|
||||||
message,
|
|
||||||
stack,
|
|
||||||
type: 'Error'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
code: error.code || undefined,
|
|
||||||
message: error.message,
|
|
||||||
stack: error.stack,
|
|
||||||
type: 'Error'
|
|
||||||
};
|
|
||||||
};
|
|
||||||
const resolvers = new Map();
|
|
||||||
const getResolver = config => {
|
|
||||||
const resolver = resolvers.get(config.id);
|
|
||||||
if (!resolver) {
|
|
||||||
throw new Error(`Cannot find resolver for: ${config.id}`);
|
|
||||||
}
|
|
||||||
return resolver;
|
|
||||||
};
|
|
||||||
function setup(setupData) {
|
|
||||||
// Module maps that will be needed for the test runs are passed.
|
|
||||||
for (const {
|
|
||||||
config,
|
|
||||||
serializableModuleMap
|
|
||||||
} of setupData.serializableResolvers) {
|
|
||||||
const moduleMap = _jestHasteMap().default.getStatic(config).getModuleMapFromJSON(serializableModuleMap);
|
|
||||||
resolvers.set(config.id, _jestRuntime().default.createResolver(config, moduleMap));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const sendMessageToJest = (eventName, args) => {
|
|
||||||
(0, _jestWorker().messageParent)([eventName, args]);
|
|
||||||
};
|
|
||||||
async function worker({
|
|
||||||
config,
|
|
||||||
globalConfig,
|
|
||||||
path,
|
|
||||||
context
|
|
||||||
}) {
|
|
||||||
try {
|
|
||||||
return await (0, _runTest.default)(path, globalConfig, config, getResolver(config), {
|
|
||||||
...context,
|
|
||||||
changedFiles: context.changedFiles && new Set(context.changedFiles),
|
|
||||||
sourcesRelatedToTestsInChangedFiles: context.sourcesRelatedToTestsInChangedFiles && new Set(context.sourcesRelatedToTestsInChangedFiles)
|
|
||||||
}, sendMessageToJest);
|
|
||||||
} catch (error) {
|
|
||||||
throw formatError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
module.exports = __webpack_exports__;
|
|
||||||
/******/ })()
|
|
||||||
;
|
|
||||||
-16
@@ -1,16 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/* build-hook-start *//*00001*/try { require('c:\\Users\\magdo\\.vscode\\extensions\\wallabyjs.console-ninja-1.0.483\\out\\buildHook\\index.js').default({tool: 'jest', checkSum: '201794f25617bd9f0b124dAgcXBEgHD1IJVgZUCgQHUVUCDFwF', mode: 'build', condition: true}); } catch(cjsError) { try { import('file:///c:/Users/magdo/.vscode/extensions/wallabyjs.console-ninja-1.0.483/out/buildHook/index.js').then(m => m.default.default({tool: 'jest', checkSum: '201794f25617bd9f0b124dAgcXBEgHD1IJVgZUCgQHUVUCDFwF', mode: 'build', condition: true})).catch(esmError => {}) } catch(esmError) {}}/* build-hook-end */
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
||||||
*
|
|
||||||
* This source code is licensed under the MIT license found in the
|
|
||||||
* LICENSE file in the root directory of this source tree.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const importLocal = require('import-local');
|
|
||||||
|
|
||||||
if (!importLocal(__filename)) {
|
|
||||||
require('jest-cli/bin/jest');
|
|
||||||
}
|
|
||||||
Generated
-10435
File diff suppressed because it is too large
Load Diff
@@ -1,89 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "serpentrace_backend",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "",
|
|
||||||
"license": "ISC",
|
|
||||||
"author": "",
|
|
||||||
"type": "commonjs",
|
|
||||||
"main": "index.js",
|
|
||||||
"scripts": {
|
|
||||||
"test": "jest",
|
|
||||||
"test:watch": "jest --watch",
|
|
||||||
"test:coverage": "jest --coverage",
|
|
||||||
"test:redis": "jest --testNamePattern=\"RedisService\"",
|
|
||||||
"start": "node ./dist/Api/index.js",
|
|
||||||
"dev": "nodemon --watch src --ext ts,json --exec ts-node ./src/Api/index.ts",
|
|
||||||
"build": "npm run build:clean && npm run build:compile && npm run build:copy-assets",
|
|
||||||
"build:clean": "rimraf dist",
|
|
||||||
"build:compile": "tsc",
|
|
||||||
"build:copy-assets": "node scripts/copy-assets.js",
|
|
||||||
"build:production": "npm run build:clean && npm run lint && npm run test && npm run migration:run && npm run build:compile && npm run build:copy-assets",
|
|
||||||
"build:docker": "npm run build:clean && npm run build:compile && npm run build:copy-assets",
|
|
||||||
"build:advanced": "ts-node scripts/build.ts",
|
|
||||||
"build:advanced:prod": "ts-node scripts/build.ts --production --migrations --test",
|
|
||||||
"build:advanced:ci": "ts-node scripts/build.ts --production --migrations --test --skip-lint",
|
|
||||||
"deploy": "node -e \"console.log('Use deploy.bat on Windows or deploy.sh on Linux/Mac')\"",
|
|
||||||
"deploy:prod": "npm run build:production && echo 'Build completed - ready for deployment'",
|
|
||||||
"build:help": "node scripts/build-help.js",
|
|
||||||
"build:status": "node scripts/build-help.js --status",
|
|
||||||
"build:quick": "node scripts/build-help.js --quick",
|
|
||||||
"prebuild": "npm run lint",
|
|
||||||
"postbuild": "echo 'Build completed successfully!'",
|
|
||||||
"lint": "echo 'Linting...' && echo 'No linter configured - add ESLint if needed'",
|
|
||||||
"migration:create": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli migration:create",
|
|
||||||
"migration:generate": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli -d ./src/Infrastructure/ormconfig.ts migration:generate",
|
|
||||||
"migration:run": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli -d ./src/Infrastructure/ormconfig.ts migration:run",
|
|
||||||
"migration:revert": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli -d ./src/Infrastructure/ormconfig.ts migration:revert",
|
|
||||||
"migration:show": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli -d ./src/Infrastructure/ormconfig.ts migration:show",
|
|
||||||
"migration:full": "ts-node scripts/generate-migration.ts",
|
|
||||||
"typecheck": "tsc --noEmit",
|
|
||||||
"watch": "tsc --watch"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"bcrypt": "^6.0.0",
|
|
||||||
"cookie-parser": "^1.4.7",
|
|
||||||
"express": "^5.1.0",
|
|
||||||
"helmet": "^8.1.0",
|
|
||||||
"jsonwebtoken": "^9.0.2",
|
|
||||||
"minio": "^8.0.5",
|
|
||||||
"multer": "^2.0.2",
|
|
||||||
"nodemailer": "^7.0.5",
|
|
||||||
"pg": "^8.16.3",
|
|
||||||
"redis": "^5.8.1",
|
|
||||||
"sharp": "^0.34.4",
|
|
||||||
"socket.io": "^4.8.1",
|
|
||||||
"swagger-jsdoc": "^6.2.8",
|
|
||||||
"swagger-ui-express": "^5.0.1",
|
|
||||||
"tsconfig-paths": "^4.2.0",
|
|
||||||
"typeorm": "^0.3.26",
|
|
||||||
"uuid": "^11.1.0",
|
|
||||||
"winston": "^3.17.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@jest/globals": "^30.0.5",
|
|
||||||
"@types/bcrypt": "^6.0.0",
|
|
||||||
"@types/cookie-parser": "^1.4.9",
|
|
||||||
"@types/express": "^5.0.3",
|
|
||||||
"@types/jest": "^30.0.0",
|
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
|
||||||
"@types/multer": "^2.0.0",
|
|
||||||
"@types/node": "^24.3.3",
|
|
||||||
"@types/nodemailer": "^7.0.1",
|
|
||||||
"@types/pg": "^8.15.5",
|
|
||||||
"@types/redis": "^4.0.10",
|
|
||||||
"@types/socket.io": "^3.0.1",
|
|
||||||
"@types/socket.io-client": "^1.4.36",
|
|
||||||
"@types/supertest": "^6.0.3",
|
|
||||||
"@types/swagger-jsdoc": "^6.0.4",
|
|
||||||
"@types/swagger-ui-express": "^4.1.8",
|
|
||||||
"@types/uuid": "^10.0.0",
|
|
||||||
"jest": "^30.0.5",
|
|
||||||
"nodemon": "^3.1.10",
|
|
||||||
"rimraf": "^5.0.10",
|
|
||||||
"socket.io-client": "^4.8.1",
|
|
||||||
"supertest": "^7.1.4",
|
|
||||||
"ts-jest": "^29.4.1",
|
|
||||||
"ts-node": "^10.9.2",
|
|
||||||
"typescript": "^5.9.2"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
const { execSync } = require('child_process');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build System Helper - Shows available build commands and their descriptions
|
|
||||||
*/
|
|
||||||
|
|
||||||
const commands = {
|
|
||||||
'Development Commands': {
|
|
||||||
'npm run dev': 'Start development server with hot reload',
|
|
||||||
'npm run watch': 'Watch mode TypeScript compilation',
|
|
||||||
'npm run typecheck': 'Type checking without code generation'
|
|
||||||
},
|
|
||||||
'Build Commands': {
|
|
||||||
'npm run build': 'Standard build: clean → compile → copy assets',
|
|
||||||
'npm run build:clean': 'Clean the dist directory',
|
|
||||||
'npm run build:compile': 'Compile TypeScript to JavaScript',
|
|
||||||
'npm run build:copy-assets': 'Copy non-TS files to dist directory',
|
|
||||||
'npm run build:docker': 'Build for Docker (no tests/migrations)'
|
|
||||||
},
|
|
||||||
'Production Build Commands': {
|
|
||||||
'npm run build:production': 'Full production build with linting, tests, and migrations',
|
|
||||||
'npm run build:advanced': 'Advanced build script with custom options',
|
|
||||||
'npm run build:advanced:prod': 'Advanced production build with all validations',
|
|
||||||
'npm run build:advanced:ci': 'CI/CD friendly build (skips linting)',
|
|
||||||
'npm run deploy:prod': 'Build for production deployment'
|
|
||||||
},
|
|
||||||
'Database Commands': {
|
|
||||||
'npm run migration:run': 'Run pending database migrations',
|
|
||||||
'npm run migration:show': 'Show migration status',
|
|
||||||
'npm run migration:generate <name>': 'Generate new migration',
|
|
||||||
'npm run migration:create <name>': 'Create empty migration',
|
|
||||||
'npm run migration:revert': 'Revert last migration',
|
|
||||||
'npm run migration:full <name>': 'Create, generate, and run migration'
|
|
||||||
},
|
|
||||||
'Testing Commands': {
|
|
||||||
'npm test': 'Run all tests',
|
|
||||||
'npm run test:watch': 'Run tests in watch mode',
|
|
||||||
'npm run test:coverage': 'Run tests with coverage report',
|
|
||||||
'npm run test:redis': 'Run Redis-specific tests'
|
|
||||||
},
|
|
||||||
'Deployment Scripts': {
|
|
||||||
'scripts/deploy.sh': 'Full Linux/Mac deployment script',
|
|
||||||
'scripts/deploy.bat': 'Full Windows deployment script'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function showCommands() {
|
|
||||||
console.log('🔧 SerpentRace Backend Build System\n');
|
|
||||||
|
|
||||||
Object.entries(commands).forEach(([category, categoryCommands]) => {
|
|
||||||
console.log(`\x1b[36m${category}\x1b[0m`);
|
|
||||||
console.log('=' .repeat(category.length));
|
|
||||||
|
|
||||||
Object.entries(categoryCommands).forEach(([command, description]) => {
|
|
||||||
console.log(` \x1b[32m${command.padEnd(35)}\x1b[0m ${description}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('');
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('\x1b[33mQuick Start:\x1b[0m');
|
|
||||||
console.log(' npm run build # Basic build');
|
|
||||||
console.log(' npm run build:production # Production build');
|
|
||||||
console.log(' npm run dev # Development server\n');
|
|
||||||
|
|
||||||
console.log('\x1b[33mDocumentation:\x1b[0m');
|
|
||||||
console.log(' See BUILD.md for detailed documentation');
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkBuildStatus() {
|
|
||||||
const distPath = path.join(__dirname, '..', 'dist');
|
|
||||||
|
|
||||||
if (fs.existsSync(distPath)) {
|
|
||||||
const stats = fs.statSync(distPath);
|
|
||||||
console.log(`\x1b[32m✅ Last build:\x1b[0m ${stats.mtime.toLocaleString()}`);
|
|
||||||
|
|
||||||
const indexPath = path.join(distPath, 'Api', 'index.js');
|
|
||||||
if (fs.existsSync(indexPath)) {
|
|
||||||
console.log('\x1b[32m✅ Main entry point built successfully\x1b[0m');
|
|
||||||
} else {
|
|
||||||
console.log('\x1b[31m❌ Main entry point missing\x1b[0m');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log('\x1b[33m⚠️ No build found - run "npm run build" first\x1b[0m');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle command line arguments
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
|
|
||||||
if (args.includes('--help') || args.includes('-h')) {
|
|
||||||
showCommands();
|
|
||||||
} else if (args.includes('--status') || args.includes('-s')) {
|
|
||||||
checkBuildStatus();
|
|
||||||
} else if (args.includes('--quick') || args.includes('-q')) {
|
|
||||||
console.log('🚀 Quick build starting...');
|
|
||||||
try {
|
|
||||||
execSync('npm run build', { stdio: 'inherit' });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Quick build failed');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
showCommands();
|
|
||||||
checkBuildStatus();
|
|
||||||
|
|
||||||
console.log('\n\x1b[33mOptions:\x1b[0m');
|
|
||||||
console.log(' --help, -h Show this help');
|
|
||||||
console.log(' --status, -s Show build status only');
|
|
||||||
console.log(' --quick, -q Run quick build');
|
|
||||||
}
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
import { execSync } from 'child_process';
|
|
||||||
import { existsSync, rmSync } from 'fs';
|
|
||||||
import { join } from 'path';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Comprehensive Build Script for SerpentRace Backend
|
|
||||||
* Handles TypeScript compilation, migrations, asset copying, and validation
|
|
||||||
*/
|
|
||||||
|
|
||||||
interface BuildOptions {
|
|
||||||
runMigrations?: boolean;
|
|
||||||
runTests?: boolean;
|
|
||||||
skipLinting?: boolean;
|
|
||||||
production?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
class BuildManager {
|
|
||||||
private distDir = join(__dirname, '..', 'dist');
|
|
||||||
|
|
||||||
constructor(private options: BuildOptions = {}) {}
|
|
||||||
|
|
||||||
private log(message: string, level: 'info' | 'error' | 'warn' = 'info') {
|
|
||||||
const timestamp = new Date().toISOString();
|
|
||||||
const prefix = {
|
|
||||||
info: '🔧',
|
|
||||||
error: '❌',
|
|
||||||
warn: '⚠️'
|
|
||||||
}[level];
|
|
||||||
console.log(`${prefix} [${timestamp}] ${message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private execute(command: string, description: string) {
|
|
||||||
this.log(`${description}...`);
|
|
||||||
try {
|
|
||||||
execSync(command, {
|
|
||||||
stdio: 'inherit',
|
|
||||||
cwd: join(__dirname, '..')
|
|
||||||
});
|
|
||||||
this.log(`✅ ${description} completed successfully`);
|
|
||||||
} catch (error) {
|
|
||||||
this.log(`❌ ${description} failed`, 'error');
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async clean() {
|
|
||||||
this.log('Cleaning previous build...');
|
|
||||||
if (existsSync(this.distDir)) {
|
|
||||||
rmSync(this.distDir, { recursive: true, force: true });
|
|
||||||
this.log('✅ Previous build cleaned');
|
|
||||||
} else {
|
|
||||||
this.log('No previous build found');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async typecheck() {
|
|
||||||
this.execute('npx tsc --noEmit', 'Type checking');
|
|
||||||
}
|
|
||||||
|
|
||||||
async lint() {
|
|
||||||
if (this.options.skipLinting) {
|
|
||||||
this.log('Skipping linting...', 'warn');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For now, just check if TypeScript compiles without errors
|
|
||||||
this.log('Linting (basic type checking)...');
|
|
||||||
await this.typecheck();
|
|
||||||
}
|
|
||||||
|
|
||||||
async runTests() {
|
|
||||||
if (!this.options.runTests) {
|
|
||||||
this.log('Skipping tests...', 'warn');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.execute('npm test', 'Running tests');
|
|
||||||
}
|
|
||||||
|
|
||||||
async runMigrations() {
|
|
||||||
if (!this.options.runMigrations) {
|
|
||||||
this.log('Skipping database migrations...', 'warn');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.log('Checking migration status...');
|
|
||||||
execSync('npm run migration:show', {
|
|
||||||
stdio: 'pipe',
|
|
||||||
cwd: join(__dirname, '..')
|
|
||||||
});
|
|
||||||
|
|
||||||
this.execute('npm run migration:run', 'Running database migrations');
|
|
||||||
} catch (error) {
|
|
||||||
this.log('Migration check/run failed - this might be expected in CI/CD environments', 'warn');
|
|
||||||
if (this.options.production) {
|
|
||||||
throw error; // In production builds, migrations should work
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async compile() {
|
|
||||||
this.execute('npx tsc', 'Compiling TypeScript');
|
|
||||||
}
|
|
||||||
|
|
||||||
async copyAssets() {
|
|
||||||
this.execute('node scripts/copy-assets.js', 'Copying assets');
|
|
||||||
}
|
|
||||||
|
|
||||||
async validateBuild() {
|
|
||||||
this.log('Validating build output...');
|
|
||||||
|
|
||||||
const expectedFiles = [
|
|
||||||
'dist/Api/index.js',
|
|
||||||
'dist/Api/index.d.ts'
|
|
||||||
];
|
|
||||||
|
|
||||||
const missingFiles = expectedFiles.filter(file =>
|
|
||||||
!existsSync(join(__dirname, '..', file))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (missingFiles.length > 0) {
|
|
||||||
this.log(`Missing expected build files: ${missingFiles.join(', ')}`, 'error');
|
|
||||||
throw new Error('Build validation failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.log('✅ Build validation completed');
|
|
||||||
}
|
|
||||||
|
|
||||||
async build() {
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.log('🚀 Starting SerpentRace Backend build process...');
|
|
||||||
|
|
||||||
// Step 1: Clean previous build
|
|
||||||
await this.clean();
|
|
||||||
|
|
||||||
// Step 2: Lint code (if not skipped)
|
|
||||||
await this.lint();
|
|
||||||
|
|
||||||
// Step 3: Run tests (if enabled)
|
|
||||||
await this.runTests();
|
|
||||||
|
|
||||||
// Step 4: Run migrations (if enabled)
|
|
||||||
await this.runMigrations();
|
|
||||||
|
|
||||||
// Step 5: Compile TypeScript
|
|
||||||
await this.compile();
|
|
||||||
|
|
||||||
// Step 6: Copy assets
|
|
||||||
await this.copyAssets();
|
|
||||||
|
|
||||||
// Step 7: Validate build
|
|
||||||
await this.validateBuild();
|
|
||||||
|
|
||||||
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
|
|
||||||
this.log(`🎉 Build completed successfully in ${duration}s`);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
|
|
||||||
this.log(`💥 Build failed after ${duration}s`, 'error');
|
|
||||||
|
|
||||||
if (error instanceof Error) {
|
|
||||||
this.log(`Error: ${error.message}`, 'error');
|
|
||||||
}
|
|
||||||
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse command line arguments
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
const options: BuildOptions = {
|
|
||||||
runMigrations: args.includes('--migrations'),
|
|
||||||
runTests: args.includes('--test'),
|
|
||||||
skipLinting: args.includes('--skip-lint'),
|
|
||||||
production: args.includes('--production')
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create and run build
|
|
||||||
const buildManager = new BuildManager(options);
|
|
||||||
buildManager.build().catch(error => {
|
|
||||||
console.error('Unhandled build error:', error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Copy Assets Script for SerpentRace Backend
|
|
||||||
* Copies non-TypeScript files to the dist directory
|
|
||||||
*/
|
|
||||||
|
|
||||||
const srcDir = path.join(__dirname, '..', 'src');
|
|
||||||
const distDir = path.join(__dirname, '..', 'dist');
|
|
||||||
|
|
||||||
// File extensions to copy
|
|
||||||
const assetExtensions = ['.json', '.html', '.css', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico', '.woff', '.woff2', '.ttf', '.eot'];
|
|
||||||
|
|
||||||
// Directories to exclude from copying
|
|
||||||
const excludeDirs = ['node_modules', '.git', 'tests', '__tests__'];
|
|
||||||
|
|
||||||
function copyAssets(srcPath, distPath) {
|
|
||||||
if (!fs.existsSync(srcPath)) {
|
|
||||||
console.log(`Source directory ${srcPath} does not exist`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fs.existsSync(distPath)) {
|
|
||||||
fs.mkdirSync(distPath, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const items = fs.readdirSync(srcPath);
|
|
||||||
|
|
||||||
items.forEach(item => {
|
|
||||||
const srcItemPath = path.join(srcPath, item);
|
|
||||||
const distItemPath = path.join(distPath, item);
|
|
||||||
const stat = fs.statSync(srcItemPath);
|
|
||||||
|
|
||||||
if (stat.isDirectory()) {
|
|
||||||
// Skip excluded directories
|
|
||||||
if (excludeDirs.includes(item)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recursively copy subdirectories
|
|
||||||
copyAssets(srcItemPath, distItemPath);
|
|
||||||
} else {
|
|
||||||
const ext = path.extname(item).toLowerCase();
|
|
||||||
|
|
||||||
// Copy asset files
|
|
||||||
if (assetExtensions.includes(ext)) {
|
|
||||||
console.log(`Copying asset: ${srcItemPath} -> ${distItemPath}`);
|
|
||||||
fs.copyFileSync(srcItemPath, distItemPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log('Copying assets from src to dist...');
|
|
||||||
copyAssets(srcDir, distDir);
|
|
||||||
console.log('Asset copying completed successfully!');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error copying assets:', error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
@echo off
|
|
||||||
REM SerpentRace Backend Production Deployment Script for Windows
|
|
||||||
REM This script handles the complete deployment process
|
|
||||||
|
|
||||||
setlocal EnableDelayedExpansion
|
|
||||||
|
|
||||||
set "SCRIPT_START=%TIME%"
|
|
||||||
|
|
||||||
REM Colors simulation for Windows (using echo with different prefixes)
|
|
||||||
set "LOG_PREFIX=[INFO]"
|
|
||||||
set "ERROR_PREFIX=[ERROR]"
|
|
||||||
set "WARN_PREFIX=[WARN]"
|
|
||||||
|
|
||||||
:log
|
|
||||||
echo %LOG_PREFIX% [%DATE% %TIME%] %~1
|
|
||||||
goto :eof
|
|
||||||
|
|
||||||
:error
|
|
||||||
echo %ERROR_PREFIX% [%DATE% %TIME%] %~1
|
|
||||||
goto :eof
|
|
||||||
|
|
||||||
:warn
|
|
||||||
echo %WARN_PREFIX% [%DATE% %TIME%] %~1
|
|
||||||
goto :eof
|
|
||||||
|
|
||||||
:check_env
|
|
||||||
call :log "Checking environment variables..."
|
|
||||||
|
|
||||||
set "required_vars=DB_HOST DB_PORT DB_USERNAME DB_PASSWORD DB_NAME JWT_SECRET REDIS_HOST REDIS_PORT"
|
|
||||||
set "missing_vars="
|
|
||||||
|
|
||||||
for %%v in (%required_vars%) do (
|
|
||||||
call set "var_value=%%!%%v!%%"
|
|
||||||
if "!var_value!"=="" (
|
|
||||||
set "missing_vars=!missing_vars! %%v"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if not "!missing_vars!"==" " (
|
|
||||||
call :error "Missing required environment variables:!missing_vars!"
|
|
||||||
call :error "Please set these variables before running the deployment"
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
call :log "All required environment variables are set"
|
|
||||||
goto :eof
|
|
||||||
|
|
||||||
:install_dependencies
|
|
||||||
call :log "Installing production dependencies..."
|
|
||||||
npm ci --only=production
|
|
||||||
if !errorlevel! neq 0 (
|
|
||||||
call :error "Failed to install dependencies"
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
call :log "Dependencies installed successfully"
|
|
||||||
goto :eof
|
|
||||||
|
|
||||||
:run_build
|
|
||||||
call :log "Running production build..."
|
|
||||||
npm run build:production
|
|
||||||
if !errorlevel! neq 0 (
|
|
||||||
call :error "Build failed"
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
call :log "Build completed successfully"
|
|
||||||
goto :eof
|
|
||||||
|
|
||||||
:test_database
|
|
||||||
call :log "Testing database connectivity..."
|
|
||||||
|
|
||||||
echo import { AppDataSource } from './src/Infrastructure/ormconfig'; > test-db-temp.ts
|
|
||||||
echo. >> test-db-temp.ts
|
|
||||||
echo async function testConnection() { >> test-db-temp.ts
|
|
||||||
echo try { >> test-db-temp.ts
|
|
||||||
echo await AppDataSource.initialize(); >> test-db-temp.ts
|
|
||||||
echo console.log('✅ Database connection successful'^); >> test-db-temp.ts
|
|
||||||
echo await AppDataSource.destroy(); >> test-db-temp.ts
|
|
||||||
echo process.exit(0^); >> test-db-temp.ts
|
|
||||||
echo } catch (error^) { >> test-db-temp.ts
|
|
||||||
echo console.error('❌ Database connection failed:', error^); >> test-db-temp.ts
|
|
||||||
echo process.exit(1^); >> test-db-temp.ts
|
|
||||||
echo } >> test-db-temp.ts
|
|
||||||
echo } >> test-db-temp.ts
|
|
||||||
echo. >> test-db-temp.ts
|
|
||||||
echo testConnection(); >> test-db-temp.ts
|
|
||||||
|
|
||||||
npx ts-node test-db-temp.ts
|
|
||||||
set "db_test_result=!errorlevel!"
|
|
||||||
del test-db-temp.ts 2>nul
|
|
||||||
|
|
||||||
if !db_test_result! neq 0 (
|
|
||||||
call :error "Database connectivity test failed"
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
call :log "Database connectivity test passed"
|
|
||||||
goto :eof
|
|
||||||
|
|
||||||
:test_redis
|
|
||||||
call :log "Testing Redis connectivity..."
|
|
||||||
|
|
||||||
echo import { createClient } from 'redis'; > test-redis-temp.ts
|
|
||||||
echo. >> test-redis-temp.ts
|
|
||||||
echo async function testRedis() { >> test-redis-temp.ts
|
|
||||||
echo const client = createClient({ >> test-redis-temp.ts
|
|
||||||
echo socket: { >> test-redis-temp.ts
|
|
||||||
echo host: process.env.REDIS_HOST ^|^| 'localhost', >> test-redis-temp.ts
|
|
||||||
echo port: parseInt(process.env.REDIS_PORT ^|^| '6379'^) >> test-redis-temp.ts
|
|
||||||
echo } >> test-redis-temp.ts
|
|
||||||
echo }^); >> test-redis-temp.ts
|
|
||||||
echo. >> test-redis-temp.ts
|
|
||||||
echo try { >> test-redis-temp.ts
|
|
||||||
echo await client.connect(); >> test-redis-temp.ts
|
|
||||||
echo await client.ping(); >> test-redis-temp.ts
|
|
||||||
echo console.log('✅ Redis connection successful'^); >> test-redis-temp.ts
|
|
||||||
echo await client.disconnect(); >> test-redis-temp.ts
|
|
||||||
echo process.exit(0^); >> test-redis-temp.ts
|
|
||||||
echo } catch (error^) { >> test-redis-temp.ts
|
|
||||||
echo console.error('❌ Redis connection failed:', error^); >> test-redis-temp.ts
|
|
||||||
echo process.exit(1^); >> test-redis-temp.ts
|
|
||||||
echo } >> test-redis-temp.ts
|
|
||||||
echo } >> test-redis-temp.ts
|
|
||||||
echo. >> test-redis-temp.ts
|
|
||||||
echo testRedis(); >> test-redis-temp.ts
|
|
||||||
|
|
||||||
npx ts-node test-redis-temp.ts
|
|
||||||
set "redis_test_result=!errorlevel!"
|
|
||||||
del test-redis-temp.ts 2>nul
|
|
||||||
|
|
||||||
if !redis_test_result! neq 0 (
|
|
||||||
call :warn "Redis connectivity test failed - continuing anyway"
|
|
||||||
) else (
|
|
||||||
call :log "Redis connectivity test passed"
|
|
||||||
)
|
|
||||||
goto :eof
|
|
||||||
|
|
||||||
:setup_directories
|
|
||||||
call :log "Setting up required directories..."
|
|
||||||
if not exist "logs" mkdir logs
|
|
||||||
if not exist "uploads" mkdir uploads
|
|
||||||
call :log "Directories created"
|
|
||||||
goto :eof
|
|
||||||
|
|
||||||
:start_app
|
|
||||||
call :log "Starting application for validation..."
|
|
||||||
|
|
||||||
REM Start the app in background
|
|
||||||
start /B "" npm start
|
|
||||||
|
|
||||||
REM Wait for app to start
|
|
||||||
timeout /t 10 /nobreak >nul
|
|
||||||
|
|
||||||
REM Test if the health endpoint responds (using curl if available)
|
|
||||||
set "PORT_VAR=!PORT!"
|
|
||||||
if "!PORT_VAR!"=="" set "PORT_VAR=3000"
|
|
||||||
|
|
||||||
curl -f http://localhost:!PORT_VAR!/health >nul 2>&1
|
|
||||||
if !errorlevel! equ 0 (
|
|
||||||
call :log "Application health check passed"
|
|
||||||
REM Try to stop the background process (this is tricky in batch)
|
|
||||||
taskkill /F /IM node.exe /FI "WINDOWTITLE eq npm start*" >nul 2>&1
|
|
||||||
) else (
|
|
||||||
call :error "Application health check failed"
|
|
||||||
taskkill /F /IM node.exe /FI "WINDOWTITLE eq npm start*" >nul 2>&1
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
goto :eof
|
|
||||||
|
|
||||||
:deploy
|
|
||||||
call :log "🚀 Starting SerpentRace Backend production deployment..."
|
|
||||||
|
|
||||||
call :check_env
|
|
||||||
if !errorlevel! neq 0 exit /b 1
|
|
||||||
|
|
||||||
call :install_dependencies
|
|
||||||
if !errorlevel! neq 0 exit /b 1
|
|
||||||
|
|
||||||
call :run_build
|
|
||||||
if !errorlevel! neq 0 exit /b 1
|
|
||||||
|
|
||||||
call :setup_directories
|
|
||||||
if !errorlevel! neq 0 exit /b 1
|
|
||||||
|
|
||||||
call :test_database
|
|
||||||
if !errorlevel! neq 0 exit /b 1
|
|
||||||
|
|
||||||
call :test_redis
|
|
||||||
REM Redis test failure is not fatal
|
|
||||||
|
|
||||||
if not "%SKIP_APP_TEST%"=="true" (
|
|
||||||
call :start_app
|
|
||||||
if !errorlevel! neq 0 exit /b 1
|
|
||||||
) else (
|
|
||||||
call :warn "Skipping application startup test"
|
|
||||||
)
|
|
||||||
|
|
||||||
call :log "🎉 Deployment completed successfully!"
|
|
||||||
call :log "You can now start the application with: npm start"
|
|
||||||
goto :eof
|
|
||||||
|
|
||||||
:build_only
|
|
||||||
call :log "Running build-only deployment..."
|
|
||||||
call :check_env
|
|
||||||
if !errorlevel! neq 0 exit /b 1
|
|
||||||
call :install_dependencies
|
|
||||||
if !errorlevel! neq 0 exit /b 1
|
|
||||||
call :run_build
|
|
||||||
if !errorlevel! neq 0 exit /b 1
|
|
||||||
call :setup_directories
|
|
||||||
call :log "Build-only deployment completed"
|
|
||||||
goto :eof
|
|
||||||
|
|
||||||
:test_connections
|
|
||||||
call :log "Testing connections only..."
|
|
||||||
call :check_env
|
|
||||||
if !errorlevel! neq 0 exit /b 1
|
|
||||||
call :test_database
|
|
||||||
if !errorlevel! neq 0 exit /b 1
|
|
||||||
call :test_redis
|
|
||||||
call :log "Connection tests completed"
|
|
||||||
goto :eof
|
|
||||||
|
|
||||||
REM Main script logic
|
|
||||||
if "%1"=="" goto deploy
|
|
||||||
if "%1"=="deploy" goto deploy
|
|
||||||
if "%1"=="build-only" goto build_only
|
|
||||||
if "%1"=="test-connections" goto test_connections
|
|
||||||
|
|
||||||
echo Usage: %0 [deploy^|build-only^|test-connections]
|
|
||||||
echo deploy - Full deployment (default)
|
|
||||||
echo build-only - Only build, skip tests
|
|
||||||
echo test-connections - Test database and Redis connections
|
|
||||||
exit /b 1
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# SerpentRace Backend Production Deployment Script
|
|
||||||
# This script handles the complete deployment process
|
|
||||||
|
|
||||||
set -e # Exit on any error
|
|
||||||
|
|
||||||
# Colors for output
|
|
||||||
RED='\033[0;31m'
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
BLUE='\033[0;34m'
|
|
||||||
NC='\033[0m' # No Color
|
|
||||||
|
|
||||||
log() {
|
|
||||||
echo -e "${GREEN}[$(date +'%Y-%m-%d %H:%M:%S')] $1${NC}"
|
|
||||||
}
|
|
||||||
|
|
||||||
error() {
|
|
||||||
echo -e "${RED}[$(date +'%Y-%m-%d %H:%M:%S')] ERROR: $1${NC}"
|
|
||||||
}
|
|
||||||
|
|
||||||
warn() {
|
|
||||||
echo -e "${YELLOW}[$(date +'%Y-%m-%d %H:%M:%S')] WARNING: $1${NC}"
|
|
||||||
}
|
|
||||||
|
|
||||||
info() {
|
|
||||||
echo -e "${BLUE}[$(date +'%Y-%m-%d %H:%M:%S')] INFO: $1${NC}"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check if required environment variables are set
|
|
||||||
check_env() {
|
|
||||||
log "Checking environment variables..."
|
|
||||||
|
|
||||||
required_vars=(
|
|
||||||
"DB_HOST"
|
|
||||||
"DB_PORT"
|
|
||||||
"DB_USERNAME"
|
|
||||||
"DB_PASSWORD"
|
|
||||||
"DB_NAME"
|
|
||||||
"JWT_SECRET"
|
|
||||||
"REDIS_HOST"
|
|
||||||
"REDIS_PORT"
|
|
||||||
)
|
|
||||||
|
|
||||||
missing_vars=()
|
|
||||||
for var in "${required_vars[@]}"; do
|
|
||||||
if [ -z "${!var}" ]; then
|
|
||||||
missing_vars+=("$var")
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ ${#missing_vars[@]} -ne 0 ]; then
|
|
||||||
error "Missing required environment variables: ${missing_vars[*]}"
|
|
||||||
error "Please set these variables before running the deployment"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "All required environment variables are set"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
install_dependencies() {
|
|
||||||
log "Installing production dependencies..."
|
|
||||||
npm ci --only=production
|
|
||||||
log "Dependencies installed successfully"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Run the comprehensive build process
|
|
||||||
run_build() {
|
|
||||||
log "Running production build..."
|
|
||||||
npm run build:production
|
|
||||||
log "Build completed successfully"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Test database connectivity
|
|
||||||
test_database() {
|
|
||||||
log "Testing database connectivity..."
|
|
||||||
|
|
||||||
# Use a simple TypeScript script to test connection
|
|
||||||
cat > /tmp/test-db.ts << 'EOF'
|
|
||||||
import { AppDataSource } from './src/Infrastructure/ormconfig';
|
|
||||||
|
|
||||||
async function testConnection() {
|
|
||||||
try {
|
|
||||||
await AppDataSource.initialize();
|
|
||||||
console.log('✅ Database connection successful');
|
|
||||||
await AppDataSource.destroy();
|
|
||||||
process.exit(0);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Database connection failed:', error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
testConnection();
|
|
||||||
EOF
|
|
||||||
|
|
||||||
npx ts-node /tmp/test-db.ts || {
|
|
||||||
error "Database connectivity test failed"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
rm -f /tmp/test-db.ts
|
|
||||||
log "Database connectivity test passed"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Test Redis connectivity
|
|
||||||
test_redis() {
|
|
||||||
log "Testing Redis connectivity..."
|
|
||||||
|
|
||||||
# Use a simple script to test Redis connection
|
|
||||||
cat > /tmp/test-redis.ts << 'EOF'
|
|
||||||
import { createClient } from 'redis';
|
|
||||||
|
|
||||||
async function testRedis() {
|
|
||||||
const client = createClient({
|
|
||||||
socket: {
|
|
||||||
host: process.env.REDIS_HOST || 'localhost',
|
|
||||||
port: parseInt(process.env.REDIS_PORT || '6379')
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.connect();
|
|
||||||
await client.ping();
|
|
||||||
console.log('✅ Redis connection successful');
|
|
||||||
await client.disconnect();
|
|
||||||
process.exit(0);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Redis connection failed:', error);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
testRedis();
|
|
||||||
EOF
|
|
||||||
|
|
||||||
npx ts-node /tmp/test-redis.ts || {
|
|
||||||
warn "Redis connectivity test failed - continuing anyway"
|
|
||||||
}
|
|
||||||
|
|
||||||
rm -f /tmp/test-redis.ts
|
|
||||||
log "Redis connectivity test completed"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Create required directories
|
|
||||||
setup_directories() {
|
|
||||||
log "Setting up required directories..."
|
|
||||||
mkdir -p logs
|
|
||||||
mkdir -p uploads
|
|
||||||
log "Directories created"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Start the application (for testing)
|
|
||||||
start_app() {
|
|
||||||
log "Starting application for validation..."
|
|
||||||
|
|
||||||
# Start the app in background and test if it responds
|
|
||||||
npm start &
|
|
||||||
APP_PID=$!
|
|
||||||
|
|
||||||
# Wait for app to start
|
|
||||||
sleep 10
|
|
||||||
|
|
||||||
# Test if the health endpoint responds
|
|
||||||
if curl -f http://localhost:${PORT:-3000}/health > /dev/null 2>&1; then
|
|
||||||
log "Application health check passed"
|
|
||||||
kill $APP_PID
|
|
||||||
wait $APP_PID 2>/dev/null
|
|
||||||
else
|
|
||||||
error "Application health check failed"
|
|
||||||
kill $APP_PID 2>/dev/null || true
|
|
||||||
wait $APP_PID 2>/dev/null || true
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Main deployment function
|
|
||||||
deploy() {
|
|
||||||
log "🚀 Starting SerpentRace Backend production deployment..."
|
|
||||||
|
|
||||||
# Check environment
|
|
||||||
check_env
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
install_dependencies
|
|
||||||
|
|
||||||
# Run build process
|
|
||||||
run_build
|
|
||||||
|
|
||||||
# Setup directories
|
|
||||||
setup_directories
|
|
||||||
|
|
||||||
# Test connections
|
|
||||||
test_database
|
|
||||||
test_redis
|
|
||||||
|
|
||||||
# Test application startup
|
|
||||||
if [ "${SKIP_APP_TEST}" != "true" ]; then
|
|
||||||
start_app
|
|
||||||
else
|
|
||||||
warn "Skipping application startup test"
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "🎉 Deployment completed successfully!"
|
|
||||||
info "You can now start the application with: npm start"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Handle script arguments
|
|
||||||
case "${1:-deploy}" in
|
|
||||||
"deploy")
|
|
||||||
deploy
|
|
||||||
;;
|
|
||||||
"build-only")
|
|
||||||
log "Running build-only deployment..."
|
|
||||||
check_env
|
|
||||||
install_dependencies
|
|
||||||
run_build
|
|
||||||
setup_directories
|
|
||||||
log "Build-only deployment completed"
|
|
||||||
;;
|
|
||||||
"test-connections")
|
|
||||||
log "Testing connections only..."
|
|
||||||
check_env
|
|
||||||
test_database
|
|
||||||
test_redis
|
|
||||||
log "Connection tests completed"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "Usage: $0 [deploy|build-only|test-connections]"
|
|
||||||
echo " deploy - Full deployment (default)"
|
|
||||||
echo " build-only - Only build, skip tests"
|
|
||||||
echo " test-connections - Test database and Redis connections"
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { execSync } from 'child_process';
|
|
||||||
|
|
||||||
const migrationName = process.argv[2];
|
|
||||||
|
|
||||||
if (!migrationName) {
|
|
||||||
console.error('Please provide a migration name: npm run migration:full <migration_name>');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log(`Creating migration: ${migrationName}`);
|
|
||||||
execSync(`npx ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli migration:create ./src/Infrastructure/Migrationsettings/${migrationName}`, { stdio: 'inherit' });
|
|
||||||
|
|
||||||
console.log(`Generating migration: ${migrationName}`);
|
|
||||||
execSync(`npx ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli -d ./src/Infrastructure/ormconfig.ts migration:generate ./src/Infrastructure/Migrations/${migrationName}`, { stdio: 'inherit' });
|
|
||||||
|
|
||||||
console.log('Migration generated successfully!');
|
|
||||||
|
|
||||||
console.log('Running migration...');
|
|
||||||
execSync(`npx ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli -d ./src/Infrastructure/ormconfig.ts migration:run`, { stdio: 'inherit' });
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
console.error('Migration failed:', error.message);
|
|
||||||
} else {
|
|
||||||
console.error('Migration failed:', error);
|
|
||||||
}
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# PowerShell script to start Redis and run tests
|
|
||||||
Write-Host "Starting Redis with Docker Compose..." -ForegroundColor Green
|
|
||||||
docker-compose up -d redis
|
|
||||||
|
|
||||||
# Wait for Redis to be ready
|
|
||||||
Write-Host "Waiting for Redis to be ready..." -ForegroundColor Yellow
|
|
||||||
do {
|
|
||||||
Write-Host "Checking Redis connection..." -ForegroundColor Gray
|
|
||||||
$result = docker-compose exec redis redis-cli ping 2>$null
|
|
||||||
if ($result -ne "PONG") {
|
|
||||||
Start-Sleep -Seconds 2
|
|
||||||
}
|
|
||||||
} while ($result -ne "PONG")
|
|
||||||
|
|
||||||
Write-Host "Redis is ready!" -ForegroundColor Green
|
|
||||||
|
|
||||||
# Run Redis tests
|
|
||||||
Write-Host "Running Redis tests..." -ForegroundColor Cyan
|
|
||||||
npm test -- --testNamePattern="RedisService"
|
|
||||||
|
|
||||||
Write-Host "Done!" -ForegroundColor Green
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Script to start Redis and run tests
|
|
||||||
echo "Starting Redis with Docker Compose..."
|
|
||||||
docker-compose up -d redis
|
|
||||||
|
|
||||||
# Wait for Redis to be ready
|
|
||||||
echo "Waiting for Redis to be ready..."
|
|
||||||
until docker-compose exec redis redis-cli ping; do
|
|
||||||
echo "Waiting for Redis..."
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "Redis is ready!"
|
|
||||||
|
|
||||||
# Run Redis tests
|
|
||||||
echo "Running Redis tests..."
|
|
||||||
npm test -- --testNamePattern="RedisService"
|
|
||||||
|
|
||||||
echo "Done!"
|
|
||||||
@@ -1,314 +0,0 @@
|
|||||||
import express from 'express';
|
|
||||||
import { createServer } from 'http';
|
|
||||||
import cookieParser from 'cookie-parser';
|
|
||||||
import helmet from 'helmet';
|
|
||||||
import { AppDataSource } from '../Infrastructure/ormconfig';
|
|
||||||
import userRouter from './routers/userRouter';
|
|
||||||
import organizationRouter from './routers/organizationRouter';
|
|
||||||
import deckRouter from './routers/deckRouter';
|
|
||||||
import chatRouter from './routers/chatRouter';
|
|
||||||
import contactRouter from './routers/contactRouter';
|
|
||||||
import adminRouter from './routers/adminRouter';
|
|
||||||
import deckImportExportRouter from './routers/deckImportExportRouter';
|
|
||||||
import gameRouter from './routers/gameRouter';
|
|
||||||
import { LoggingService, logStartup, logConnection, logError, logRequest } from '../Application/Services/Logger';
|
|
||||||
import { WebSocketService } from '../Application/Services/WebSocketService';
|
|
||||||
import { GameWebSocketService } from '../Application/Services/GameWebSocketService';
|
|
||||||
import { container } from '../Application/Services/DIContainer';
|
|
||||||
import { GameRepository } from '../Infrastructure/Repository/GameRepository';
|
|
||||||
import { UserRepository } from '../Infrastructure/Repository/UserRepository';
|
|
||||||
import { RedisService } from '../Application/Services/RedisService';
|
|
||||||
import { setupSwagger } from './swagger/swaggerUiSetup';
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
const httpServer = createServer(app);
|
|
||||||
const PORT = process.env.PORT || 3000;
|
|
||||||
const isDevelopment = process.env.NODE_ENV === 'development';
|
|
||||||
|
|
||||||
const loggingService = LoggingService.getInstance();
|
|
||||||
|
|
||||||
// Validate required environment variables in production
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
|
||||||
const requiredEnvVars = [
|
|
||||||
'JWT_SECRET',
|
|
||||||
'DB_PASSWORD',
|
|
||||||
'DB_HOST',
|
|
||||||
'DB_NAME',
|
|
||||||
'POSTGRES_PASSWORD'
|
|
||||||
];
|
|
||||||
|
|
||||||
const missingVars = requiredEnvVars.filter(varName => !process.env[varName]);
|
|
||||||
|
|
||||||
if (missingVars.length > 0) {
|
|
||||||
logError('[FATAL] Missing required environment variables in production:', missingVars.join(', '));
|
|
||||||
logError('[FATAL] Please configure all required environment variables in .env.server');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for placeholder values that haven't been changed
|
|
||||||
if (process.env.JWT_SECRET && process.env.JWT_SECRET.includes('CHANGE_THIS')) {
|
|
||||||
logError('[FATAL] JWT_SECRET still contains placeholder value. Please set a proper secret in .env.server');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.env.POSTGRES_PASSWORD && process.env.POSTGRES_PASSWORD.includes('CHANGE_THIS')) {
|
|
||||||
logError('[FATAL] POSTGRES_PASSWORD still contains placeholder value. Please set a proper password in .env.server');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logStartup('SerpentRace Backend starting up', {
|
|
||||||
environment: process.env.NODE_ENV || 'development',
|
|
||||||
port: PORT,
|
|
||||||
nodeVersion: process.version,
|
|
||||||
chatInactivityTimeout: process.env.CHAT_INACTIVITY_TIMEOUT_MINUTES || '30'
|
|
||||||
});
|
|
||||||
|
|
||||||
app.use(helmet({
|
|
||||||
contentSecurityPolicy: isDevelopment ? false : undefined
|
|
||||||
}));
|
|
||||||
|
|
||||||
app.use(express.json({ limit: '10mb' }));
|
|
||||||
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
|
||||||
app.use(cookieParser());
|
|
||||||
|
|
||||||
app.use(loggingService.requestLoggingMiddleware());
|
|
||||||
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
const origin = req.headers.origin;
|
|
||||||
const allowedOrigins = ['http://localhost:3000', 'http://localhost:3001', 'http://localhost:8080', process.env.FRONTEND_URL];
|
|
||||||
|
|
||||||
if (!origin || allowedOrigins.includes(origin)) {
|
|
||||||
res.setHeader('Access-Control-Allow-Origin', origin || '*');
|
|
||||||
}
|
|
||||||
|
|
||||||
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
||||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS');
|
|
||||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Cookie');
|
|
||||||
|
|
||||||
if (req.method === 'OPTIONS') {
|
|
||||||
res.status(200).end();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isDevelopment) {
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
logRequest(`${req.method} ${req.path}`, req, res);
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setup Swagger documentation
|
|
||||||
setupSwagger(app);
|
|
||||||
|
|
||||||
app.get('/', (req, res) => {
|
|
||||||
res.json({
|
|
||||||
service: 'SerpentRace Backend API',
|
|
||||||
status: 'running',
|
|
||||||
version: '1.0.0',
|
|
||||||
endpoints: {
|
|
||||||
swagger: '/api-docs',
|
|
||||||
users: '/api/users',
|
|
||||||
organizations: '/api/organizations',
|
|
||||||
decks: '/api/decks',
|
|
||||||
chats: '/api/chats',
|
|
||||||
contacts: '/api/contacts',
|
|
||||||
admin: '/api/admin',
|
|
||||||
deckImportExport: '/api/deck-import-export',
|
|
||||||
health: '/health'
|
|
||||||
},
|
|
||||||
websocket: {
|
|
||||||
enabled: true,
|
|
||||||
events: [
|
|
||||||
'chat:join', 'chat:leave', 'message:send',
|
|
||||||
'group:create', 'chat:direct', 'game:chat:create',
|
|
||||||
'chat:history'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/health', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const isDbConnected = AppDataSource.isInitialized;
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
status: 'healthy',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
service: 'SerpentRace Backend API',
|
|
||||||
version: '1.0.0',
|
|
||||||
environment: process.env.NODE_ENV || 'development',
|
|
||||||
database: {
|
|
||||||
connected: isDbConnected,
|
|
||||||
type: AppDataSource.options.type
|
|
||||||
},
|
|
||||||
websocket: {
|
|
||||||
enabled: true
|
|
||||||
},
|
|
||||||
uptime: process.uptime()
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
res.status(503).json({
|
|
||||||
status: 'unhealthy',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
error: 'Service health check failed'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// API Routes
|
|
||||||
app.use('/api/users', userRouter);
|
|
||||||
app.use('/api/organizations', organizationRouter);
|
|
||||||
app.use('/api/decks', deckRouter);
|
|
||||||
app.use('/api/chats', chatRouter);
|
|
||||||
app.use('/api/contacts', contactRouter);
|
|
||||||
app.use('/api/admin', adminRouter);
|
|
||||||
app.use('/api/deck-import-export', deckImportExportRouter);
|
|
||||||
app.use('/api/games', gameRouter);
|
|
||||||
|
|
||||||
// Global error handler (must be after routes)
|
|
||||||
app.use(loggingService.errorLoggingMiddleware());
|
|
||||||
app.use((error: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
|
||||||
logError('Global error handler caught unhandled error', error, req, res);
|
|
||||||
|
|
||||||
// Don't expose internal error details in production
|
|
||||||
const isDevelopment = process.env.NODE_ENV === 'development';
|
|
||||||
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Internal server error',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
...(isDevelopment && { details: error.message, stack: error.stack })
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle 404 routes
|
|
||||||
app.use((req: express.Request, res: express.Response) => {
|
|
||||||
res.status(404).json({
|
|
||||||
error: 'Route not found',
|
|
||||||
path: req.originalUrl,
|
|
||||||
method: req.method,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize WebSocket service after database connection
|
|
||||||
let webSocketService: WebSocketService;
|
|
||||||
let gameWebSocketService: GameWebSocketService;
|
|
||||||
|
|
||||||
// Initialize database connection
|
|
||||||
AppDataSource.initialize()
|
|
||||||
.then(() => {
|
|
||||||
const dbOptions = AppDataSource.options as any;
|
|
||||||
logConnection('Database connection established', 'postgresql', 'success', {
|
|
||||||
type: dbOptions.type,
|
|
||||||
host: dbOptions.host,
|
|
||||||
database: dbOptions.database
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize WebSocket service after database is connected
|
|
||||||
webSocketService = new WebSocketService(httpServer);
|
|
||||||
logStartup('WebSocket service initialized', {
|
|
||||||
chatInactivityTimeout: process.env.CHAT_INACTIVITY_TIMEOUT_MINUTES || '30'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize Game WebSocket service for /game namespace via DIContainer
|
|
||||||
container.setSocketIO(webSocketService['io']);
|
|
||||||
gameWebSocketService = container.gameWebSocketService;
|
|
||||||
logStartup('Game WebSocket service initialized for /game namespace');
|
|
||||||
|
|
||||||
// Restore active games from snapshots (if any exist)
|
|
||||||
gameWebSocketService.restoreAllActiveGames()
|
|
||||||
.then(restoredCount => {
|
|
||||||
if (restoredCount > 0) {
|
|
||||||
logStartup(`Restored ${restoredCount} active game(s) from snapshots`);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
logError('Failed to restore games from snapshots', error);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
const dbOptions = AppDataSource.options as any;
|
|
||||||
logConnection('Database connection failed', 'postgresql', 'failure', {
|
|
||||||
error: error.message,
|
|
||||||
type: dbOptions.type,
|
|
||||||
host: dbOptions.host,
|
|
||||||
database: dbOptions.database
|
|
||||||
});
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start server with WebSocket support
|
|
||||||
const server = httpServer.listen(PORT, () => {
|
|
||||||
logStartup('Server started successfully', {
|
|
||||||
port: PORT,
|
|
||||||
environment: process.env.NODE_ENV || 'development',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
endpoints: {
|
|
||||||
health: `/health`,
|
|
||||||
swagger: `/api-docs`,
|
|
||||||
users: `/api/users`,
|
|
||||||
organizations: `/api/organizations`,
|
|
||||||
decks: `/api/decks`,
|
|
||||||
chats: `/api/chats`
|
|
||||||
},
|
|
||||||
websocket: {
|
|
||||||
enabled: true,
|
|
||||||
chatInactivityTimeout: `${process.env.CHAT_INACTIVITY_TIMEOUT_MINUTES || '30'} minutes`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Graceful shutdown
|
|
||||||
const gracefulShutdown = async (signal: string) => {
|
|
||||||
logStartup(`Received ${signal}. Shutting down gracefully...`);
|
|
||||||
|
|
||||||
// Snapshot all active games before shutdown
|
|
||||||
if (gameWebSocketService) {
|
|
||||||
try {
|
|
||||||
const snapshotCount = await gameWebSocketService.snapshotAllActiveGames();
|
|
||||||
logStartup(`Created ${snapshotCount} game snapshot(s) before shutdown`);
|
|
||||||
} catch (error) {
|
|
||||||
logError('Failed to snapshot games before shutdown', error as Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
server.close(() => {
|
|
||||||
logStartup('HTTP server closed');
|
|
||||||
|
|
||||||
if (AppDataSource.isInitialized) {
|
|
||||||
AppDataSource.destroy()
|
|
||||||
.then(() => {
|
|
||||||
logConnection('Database connection closed', 'postgresql', 'success');
|
|
||||||
process.exit(0);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
logError('Error during database shutdown', error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
||||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
||||||
|
|
||||||
// Handle uncaught exceptions
|
|
||||||
process.on('uncaughtException', (error) => {
|
|
||||||
logError('Uncaught Exception - Server will shut down', error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle unhandled promise rejections
|
|
||||||
process.on('unhandledRejection', (reason, promise) => {
|
|
||||||
logError('Unhandled Rejection - Server will shut down', new Error(String(reason)), undefined, undefined);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Export WebSocket services for game integration
|
|
||||||
export { webSocketService, gameWebSocketService };
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import { Request, Response, NextFunction } from 'express';
|
|
||||||
import { JWTService } from '../../Application/Services/JWTService';
|
|
||||||
import { UserState } from '../../Domain/User/UserAggregate';
|
|
||||||
import { logAuth, logWarning } from '../../Application/Services/Logger';
|
|
||||||
|
|
||||||
interface AuthenticatedRequest extends Request {
|
|
||||||
user?: {
|
|
||||||
userId: string;
|
|
||||||
authLevel: 0 | 1;
|
|
||||||
userStatus: UserState;
|
|
||||||
orgId: string | null;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Optional authentication middleware - extracts JWT data if present but doesn't require authentication
|
|
||||||
* Used for endpoints that work for both authenticated and anonymous users
|
|
||||||
*/
|
|
||||||
export const optionalAuth = (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
|
|
||||||
const jwtService = new JWTService();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Try to extract token from Authorization header or cookies
|
|
||||||
const authHeader = req.headers.authorization;
|
|
||||||
const token = authHeader?.startsWith('Bearer ')
|
|
||||||
? authHeader.substring(7)
|
|
||||||
: req.cookies?.auth_token;
|
|
||||||
|
|
||||||
if (token) {
|
|
||||||
// If token exists, try to verify it
|
|
||||||
const payload = jwtService.verify(req);
|
|
||||||
|
|
||||||
if (payload) {
|
|
||||||
req.user = {
|
|
||||||
userId: payload.userId,
|
|
||||||
authLevel: payload.authLevel,
|
|
||||||
userStatus: payload.userStatus,
|
|
||||||
orgId: payload.orgId || null
|
|
||||||
};
|
|
||||||
|
|
||||||
logAuth('Optional auth - user authenticated', payload.userId, {
|
|
||||||
authLevel: payload.authLevel,
|
|
||||||
userStatus: payload.userStatus,
|
|
||||||
orgId: payload.orgId
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
logWarning('Optional auth - invalid token provided', {
|
|
||||||
hasToken: true,
|
|
||||||
tokenLength: token.length
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Continue regardless of authentication status
|
|
||||||
next();
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
// Log the error but continue without authentication
|
|
||||||
logWarning('Optional auth - error processing token', {
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
hasAuthHeader: !!req.headers.authorization,
|
|
||||||
hasCookie: !!req.cookies?.auth_token
|
|
||||||
});
|
|
||||||
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,287 +0,0 @@
|
|||||||
import express from 'express';
|
|
||||||
import { authRequired } from '../../Application/Services/AuthMiddleware';
|
|
||||||
import { container } from '../../Application/Services/DIContainer';
|
|
||||||
import { ErrorResponseService } from '../../Application/Services/ErrorResponseService';
|
|
||||||
import { ValidationMiddleware } from '../../Application/Services/ValidationMiddleware';
|
|
||||||
import { logAuth, logError, logRequest, logWarning } from '../../Application/Services/Logger';
|
|
||||||
|
|
||||||
const chatRouter = express.Router();
|
|
||||||
|
|
||||||
// Get user's chats
|
|
||||||
chatRouter.get('/user-chats', authRequired, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const userId = (req as any).user.userId;
|
|
||||||
const includeArchived = req.query.includeArchived === 'true';
|
|
||||||
|
|
||||||
logRequest('Get user chats endpoint accessed', req, res, { userId, includeArchived });
|
|
||||||
|
|
||||||
const chats = await container.getUserChatsQueryHandler.execute({
|
|
||||||
userId,
|
|
||||||
includeArchived
|
|
||||||
});
|
|
||||||
|
|
||||||
logRequest('User chats retrieved successfully', req, res, {
|
|
||||||
userId,
|
|
||||||
chatCount: chats.length
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json(chats);
|
|
||||||
} catch (error) {
|
|
||||||
logError('Get user chats endpoint error', error as Error, req, res);
|
|
||||||
return ErrorResponseService.sendInternalServerError(res);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get chat history
|
|
||||||
chatRouter.get('/history/:chatId',
|
|
||||||
authRequired,
|
|
||||||
ValidationMiddleware.validateUUIDFormat(['chatId']),
|
|
||||||
async (req, res) => {
|
|
||||||
try {
|
|
||||||
const userId = (req as any).user.userId;
|
|
||||||
const chatId = req.params.chatId;
|
|
||||||
|
|
||||||
logRequest('Get chat history endpoint accessed', req, res, { userId, chatId });
|
|
||||||
|
|
||||||
const history = await container.getChatHistoryQueryHandler.execute({
|
|
||||||
chatId,
|
|
||||||
userId
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!history) {
|
|
||||||
logWarning('Chat history not found or unauthorized', { userId, chatId }, req, res);
|
|
||||||
return ErrorResponseService.sendNotFound(res, 'Chat not found or unauthorized');
|
|
||||||
}
|
|
||||||
|
|
||||||
logRequest('Chat history retrieved successfully', req, res, {
|
|
||||||
userId,
|
|
||||||
chatId,
|
|
||||||
messageCount: history.messages.length,
|
|
||||||
isArchived: history.isArchived
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json(history);
|
|
||||||
} catch (error) {
|
|
||||||
logError('Get chat history endpoint error', error as Error, req, res);
|
|
||||||
return ErrorResponseService.sendInternalServerError(res);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create new chat (direct/group)
|
|
||||||
chatRouter.post('/create',
|
|
||||||
authRequired,
|
|
||||||
ValidationMiddleware.combine([
|
|
||||||
ValidationMiddleware.validateRequiredFields(['type', 'userIds']),
|
|
||||||
ValidationMiddleware.validateAllowedValues({ type: ['direct', 'group'] }),
|
|
||||||
ValidationMiddleware.validateNonEmptyArrays(['userIds'])
|
|
||||||
]),
|
|
||||||
async (req, res) => {
|
|
||||||
try {
|
|
||||||
const userId = (req as any).user.userId;
|
|
||||||
const { type, name, userIds } = req.body;
|
|
||||||
|
|
||||||
logRequest('Create chat endpoint accessed', req, res, {
|
|
||||||
userId,
|
|
||||||
type,
|
|
||||||
targetUserCount: userIds?.length || 0
|
|
||||||
});
|
|
||||||
|
|
||||||
if (type === 'group' && !name?.trim()) {
|
|
||||||
return ErrorResponseService.sendBadRequest(res, 'Group name is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
const chat = await container.createChatCommandHandler.execute({
|
|
||||||
type,
|
|
||||||
name: name?.trim(),
|
|
||||||
createdBy: userId,
|
|
||||||
userIds
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!chat) {
|
|
||||||
return ErrorResponseService.sendBadRequest(res, 'Failed to create chat');
|
|
||||||
}
|
|
||||||
|
|
||||||
logRequest('Chat created successfully', req, res, {
|
|
||||||
userId,
|
|
||||||
chatId: chat.id,
|
|
||||||
chatType: chat.type
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
id: chat.id,
|
|
||||||
type: chat.type,
|
|
||||||
name: chat.name,
|
|
||||||
users: chat.users,
|
|
||||||
messages: chat.messages
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logError('Create chat endpoint error', error as Error, req, res);
|
|
||||||
|
|
||||||
if (error instanceof Error) {
|
|
||||||
if (error.message.includes('Premium subscription required')) {
|
|
||||||
return ErrorResponseService.sendForbidden(res, 'Premium subscription required to create groups');
|
|
||||||
}
|
|
||||||
if (error.message.includes('not found')) {
|
|
||||||
return ErrorResponseService.sendNotFound(res, 'One or more users not found');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ErrorResponseService.sendInternalServerError(res);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send message (REST endpoint - mainly for testing, real messaging is via WebSocket)
|
|
||||||
chatRouter.post('/message',
|
|
||||||
authRequired,
|
|
||||||
ValidationMiddleware.combine([
|
|
||||||
ValidationMiddleware.validateRequiredFields(['chatId', 'message']),
|
|
||||||
ValidationMiddleware.validateUUIDFormat(['chatId']),
|
|
||||||
ValidationMiddleware.validateStringLength({ message: { min: 1, max: 2000 } })
|
|
||||||
]),
|
|
||||||
async (req, res) => {
|
|
||||||
try {
|
|
||||||
const userId = (req as any).user.userId;
|
|
||||||
const { chatId, message } = req.body;
|
|
||||||
|
|
||||||
logRequest('Send message endpoint accessed', req, res, {
|
|
||||||
userId,
|
|
||||||
chatId,
|
|
||||||
messageLength: message?.length || 0
|
|
||||||
});
|
|
||||||
|
|
||||||
const sentMessage = await container.sendMessageCommandHandler.execute({
|
|
||||||
chatId,
|
|
||||||
userId,
|
|
||||||
message
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!sentMessage) {
|
|
||||||
return ErrorResponseService.sendBadRequest(res, 'Failed to send message');
|
|
||||||
}
|
|
||||||
|
|
||||||
logRequest('Message sent successfully', req, res, {
|
|
||||||
userId,
|
|
||||||
chatId,
|
|
||||||
messageId: sentMessage.id
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json(sentMessage);
|
|
||||||
} catch (error) {
|
|
||||||
logError('Send message endpoint error', error as Error, req, res);
|
|
||||||
|
|
||||||
if (error instanceof Error) {
|
|
||||||
if (error.message.includes('Chat not found')) {
|
|
||||||
return ErrorResponseService.sendNotFound(res, 'Chat not found');
|
|
||||||
}
|
|
||||||
if (error.message.includes('not a member')) {
|
|
||||||
return ErrorResponseService.sendForbidden(res, 'Not authorized to send messages to this chat');
|
|
||||||
}
|
|
||||||
if (error.message.includes('non-empty string')) {
|
|
||||||
return ErrorResponseService.sendBadRequest(res, 'Message must be a non-empty string');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ErrorResponseService.sendInternalServerError(res);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Archive chat manually
|
|
||||||
chatRouter.post('/archive/:chatId',
|
|
||||||
authRequired,
|
|
||||||
ValidationMiddleware.validateUUIDFormat(['chatId']),
|
|
||||||
async (req, res) => {
|
|
||||||
try {
|
|
||||||
const userId = (req as any).user.userId;
|
|
||||||
const chatId = req.params.chatId;
|
|
||||||
|
|
||||||
logRequest('Archive chat endpoint accessed', req, res, { userId, chatId });
|
|
||||||
|
|
||||||
// Check if user has access to this chat
|
|
||||||
const chat = await container.chatRepository.findById(chatId);
|
|
||||||
if (!chat) {
|
|
||||||
return ErrorResponseService.sendNotFound(res, 'Chat not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!chat.users.includes(userId)) {
|
|
||||||
return ErrorResponseService.sendForbidden(res, 'Not authorized to archive this chat');
|
|
||||||
}
|
|
||||||
|
|
||||||
const success = await container.archiveChatCommandHandler.execute({ chatId });
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
return ErrorResponseService.sendBadRequest(res, 'Failed to archive chat');
|
|
||||||
}
|
|
||||||
|
|
||||||
logRequest('Chat archived successfully', req, res, { userId, chatId });
|
|
||||||
res.json({ success: true, message: 'Chat archived successfully' });
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logError('Archive chat endpoint error', error as Error, req, res);
|
|
||||||
return ErrorResponseService.sendInternalServerError(res);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Restore chat from archive
|
|
||||||
chatRouter.post('/restore/:chatId',
|
|
||||||
authRequired,
|
|
||||||
ValidationMiddleware.validateUUIDFormat(['chatId']),
|
|
||||||
async (req, res) => {
|
|
||||||
try {
|
|
||||||
const userId = (req as any).user.userId;
|
|
||||||
const chatId = req.params.chatId;
|
|
||||||
|
|
||||||
logRequest('Restore chat endpoint accessed', req, res, { userId, chatId });
|
|
||||||
|
|
||||||
// Check if user has access to this archived chat
|
|
||||||
const archive = await container.chatArchiveRepository.findByChatId(chatId);
|
|
||||||
const userArchive = archive.find((a: any) => a.participants.includes(userId));
|
|
||||||
|
|
||||||
if (!userArchive) {
|
|
||||||
return ErrorResponseService.sendNotFound(res, 'Archived chat not found or unauthorized');
|
|
||||||
}
|
|
||||||
|
|
||||||
const success = await container.restoreChatCommandHandler.execute({ chatId });
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
return ErrorResponseService.sendBadRequest(res, 'Failed to restore chat (game chats cannot be restored)');
|
|
||||||
}
|
|
||||||
|
|
||||||
logRequest('Chat restored successfully', req, res, { userId, chatId });
|
|
||||||
res.json({ success: true, message: 'Chat restored successfully' });
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logError('Restore chat endpoint error', error as Error, req, res);
|
|
||||||
return ErrorResponseService.sendInternalServerError(res);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get archived chats for a game
|
|
||||||
chatRouter.get('/archived/game/:gameId',
|
|
||||||
authRequired,
|
|
||||||
ValidationMiddleware.validateUUIDFormat(['gameId']),
|
|
||||||
async (req, res) => {
|
|
||||||
try {
|
|
||||||
const userId = (req as any).user.userId;
|
|
||||||
const gameId = req.params.gameId;
|
|
||||||
|
|
||||||
logRequest('Get archived game chats endpoint accessed', req, res, { userId, gameId });
|
|
||||||
|
|
||||||
const archivedChats = await container.getArchivedChatsQueryHandler.execute({
|
|
||||||
userId,
|
|
||||||
gameId
|
|
||||||
});
|
|
||||||
|
|
||||||
logRequest('Archived game chats retrieved successfully', req, res, {
|
|
||||||
userId,
|
|
||||||
gameId,
|
|
||||||
chatCount: archivedChats.length
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json(archivedChats);
|
|
||||||
} catch (error) {
|
|
||||||
logError('Get archived game chats endpoint error', error as Error, req, res);
|
|
||||||
return ErrorResponseService.sendInternalServerError(res);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default chatRouter;
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { Router } from 'express';
|
|
||||||
import { container } from '../../Application/Services/DIContainer';
|
|
||||||
import { logRequest, logError } from '../../Application/Services/Logger';
|
|
||||||
import { ContactType } from '../../Domain/Contact/ContactAggregate';
|
|
||||||
|
|
||||||
const contactRouter = Router();
|
|
||||||
|
|
||||||
// Public endpoint - anyone can create a contact
|
|
||||||
contactRouter.post('/', async (req, res) => {
|
|
||||||
try {
|
|
||||||
// Get user ID if authenticated (optional)
|
|
||||||
const userId = (req as any).user?.userId || null;
|
|
||||||
|
|
||||||
const { name, email, type, txt } = req.body;
|
|
||||||
|
|
||||||
// Validate required fields
|
|
||||||
if (!name || !email || type === undefined || !txt) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'Missing required fields: name, email, type, and txt are required'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate type
|
|
||||||
if (!Object.values(ContactType).includes(Number(type))) {
|
|
||||||
return res.status(400).json({
|
|
||||||
error: 'Invalid contact type. Must be one of: 0 (Bug), 1 (Problem), 2 (Question), 3 (Sales), 4 (Other)'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logRequest('Create contact endpoint accessed', req, res, { name, email, type, userId });
|
|
||||||
|
|
||||||
const result = await container.createContactCommandHandler.execute({
|
|
||||||
name,
|
|
||||||
email,
|
|
||||||
userid: userId,
|
|
||||||
type: Number(type),
|
|
||||||
txt
|
|
||||||
});
|
|
||||||
|
|
||||||
logRequest('Contact created successfully', req, res, { contactId: result.id, name, email, type });
|
|
||||||
res.status(201).json(result);
|
|
||||||
} catch (error) {
|
|
||||||
logError('Create contact endpoint error', error as Error, req, res);
|
|
||||||
|
|
||||||
if (error instanceof Error && error.message.includes('validation')) {
|
|
||||||
return res.status(400).json({ error: 'Invalid input data', details: error.message });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default contactRouter;
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
import express, { Request, Response } from 'express';
|
|
||||||
import multer from 'multer';
|
|
||||||
import { DIContainer } from '../../Application/Services/DIContainer';
|
|
||||||
import { authRequired } from '../../Application/Services/AuthMiddleware';
|
|
||||||
import { logRequest, logError, logWarning } from '../../Application/Services/Logger';
|
|
||||||
|
|
||||||
// Extend Express Request interface for file uploads
|
|
||||||
declare global {
|
|
||||||
namespace Express {
|
|
||||||
interface Request {
|
|
||||||
file?: Express.Multer.File;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
const container = DIContainer.getInstance();
|
|
||||||
|
|
||||||
// Configure multer for file uploads
|
|
||||||
const upload = multer({
|
|
||||||
storage: multer.memoryStorage(),
|
|
||||||
limits: {
|
|
||||||
fileSize: 10 * 1024 * 1024, // 10MB limit
|
|
||||||
},
|
|
||||||
fileFilter: (req: any, file: any, cb: any) => {
|
|
||||||
if (file.mimetype === 'application/json' || file.originalname.endsWith('.spr')) {
|
|
||||||
cb(null, true);
|
|
||||||
} else {
|
|
||||||
cb(new Error('Only JSON and .spr files are allowed'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Export deck to .spr file (encrypted) - users can only export their own decks
|
|
||||||
router.get('/export/:deckId', authRequired, async (req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
const { deckId } = req.params;
|
|
||||||
const userId = (req as any).user.userId;
|
|
||||||
|
|
||||||
logRequest('Export deck endpoint accessed', req, res, { deckId, userId });
|
|
||||||
|
|
||||||
// Check if user owns the deck
|
|
||||||
const deck = await container.deckRepository.findById(deckId);
|
|
||||||
if (!deck) {
|
|
||||||
logWarning('Deck not found for export', { deckId, userId }, req, res);
|
|
||||||
return res.status(404).json({ error: 'Deck not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Users can only export their own decks
|
|
||||||
if (deck.userid !== userId) {
|
|
||||||
logWarning('Access denied - user attempted to export deck they do not own', {
|
|
||||||
deckId,
|
|
||||||
userId,
|
|
||||||
deckOwnerId: deck.userid
|
|
||||||
}, req, res);
|
|
||||||
return res.status(403).json({ error: 'Access denied - you can only export your own decks' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const sprData = await container.deckImportExportService.exportDeckToSpr(deckId, userId);
|
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'application/octet-stream');
|
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="${deck.name || 'deck'}.spr"`);
|
|
||||||
|
|
||||||
logRequest('Deck exported successfully', req, res, {
|
|
||||||
deckId,
|
|
||||||
userId,
|
|
||||||
deckName: deck.name,
|
|
||||||
fileSize: sprData.length
|
|
||||||
});
|
|
||||||
|
|
||||||
res.send(sprData);
|
|
||||||
} catch (error) {
|
|
||||||
logError('Export deck endpoint error', error as Error, req, res);
|
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Import deck from .spr file (encrypted) - imported deck will be owned by the importing user
|
|
||||||
router.post('/import', authRequired, upload.single('file'), async (req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
const userId = (req as any).user.userId;
|
|
||||||
|
|
||||||
logRequest('Import deck endpoint accessed', req, res, {
|
|
||||||
userId,
|
|
||||||
hasFile: !!req.file,
|
|
||||||
fileName: req.file?.originalname,
|
|
||||||
fileSize: req.file?.size
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!req.file) {
|
|
||||||
logWarning('No file uploaded for deck import', { userId }, req, res);
|
|
||||||
return res.status(400).json({ error: 'No file uploaded' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileBuffer = req.file!.buffer;
|
|
||||||
|
|
||||||
// Import the deck and assign ownership to the current user
|
|
||||||
const result = await container.deckImportExportService.importDeckFromSpr(fileBuffer, userId);
|
|
||||||
|
|
||||||
logRequest('Deck imported successfully', req, res, {
|
|
||||||
userId,
|
|
||||||
deckId: result.id,
|
|
||||||
deckName: result.name || 'Unknown',
|
|
||||||
fileName: req.file.originalname,
|
|
||||||
fileSize: req.file.size
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: 'Deck imported successfully and added to your collection',
|
|
||||||
deckId: result.id
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logError('Import deck endpoint error', error as Error, req, res);
|
|
||||||
|
|
||||||
if (error instanceof Error && error.message.includes('Invalid')) {
|
|
||||||
return res.status(400).json({ error: 'Invalid file format or corrupted data' });
|
|
||||||
} else {
|
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
import { Router } from 'express';
|
|
||||||
import { authRequired } from '../../Application/Services/AuthMiddleware';
|
|
||||||
import { container } from '../../Application/Services/DIContainer';
|
|
||||||
import { ErrorResponseService } from '../../Application/Services/ErrorResponseService';
|
|
||||||
import { ValidationMiddleware } from '../../Application/Services/ValidationMiddleware';
|
|
||||||
import { GeneralSearchService } from '../../Application/Search/Generalsearch';
|
|
||||||
import { logRequest, logError, logWarning } from '../../Application/Services/Logger';
|
|
||||||
import { Type, CType } from '../../Domain/Deck/DeckAggregate';
|
|
||||||
|
|
||||||
const deckRouter = Router();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper function to convert string enum values to integer enum values
|
|
||||||
*/
|
|
||||||
function convertEnumValues(data: any): any {
|
|
||||||
const converted = { ...data };
|
|
||||||
|
|
||||||
// Convert Type enum
|
|
||||||
if (converted.type && typeof converted.type === 'string') {
|
|
||||||
switch (converted.type.toUpperCase()) {
|
|
||||||
case 'LUCK':
|
|
||||||
converted.type = Type.LUCK;
|
|
||||||
break;
|
|
||||||
case 'JOKER':
|
|
||||||
converted.type = Type.JOKER;
|
|
||||||
break;
|
|
||||||
case 'QUESTION':
|
|
||||||
converted.type = Type.QUESTION;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
throw new Error('Invalid deck type. Must be LUCK, JOKER, or QUESTION');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert CType enum
|
|
||||||
if (converted.ctype && typeof converted.ctype === 'string') {
|
|
||||||
switch (converted.ctype.toUpperCase()) {
|
|
||||||
case 'PUBLIC':
|
|
||||||
converted.ctype = CType.PUBLIC;
|
|
||||||
break;
|
|
||||||
case 'PRIVATE':
|
|
||||||
converted.ctype = CType.PRIVATE;
|
|
||||||
break;
|
|
||||||
case 'ORGANIZATION':
|
|
||||||
converted.ctype = CType.ORGANIZATION;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
throw new Error('Invalid deck ctype. Must be PUBLIC, PRIVATE, or ORGANIZATION');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return converted;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create search service that isn't in the container yet
|
|
||||||
const searchService = new GeneralSearchService(container.userRepository, container.organizationRepository, container.deckRepository);
|
|
||||||
|
|
||||||
// Authenticated routes - Get decks with pagination (RECOMMENDED)
|
|
||||||
deckRouter.get('/page/:from/:to', authRequired, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const userId = (req as any).user.userId;
|
|
||||||
const userOrgId = (req as any).user.orgId;
|
|
||||||
const isAdmin = (req as any).user.authLevel === 1;
|
|
||||||
const from = parseInt(req.params.from);
|
|
||||||
const to = parseInt(req.params.to);
|
|
||||||
|
|
||||||
if (isNaN(from) || isNaN(to) || from < 0 || to < from) {
|
|
||||||
return res.status(400).json({ error: 'Invalid page parameters. "from" and "to" must be valid numbers with to >= from >= 0' });
|
|
||||||
}
|
|
||||||
|
|
||||||
logRequest('Get decks by page endpoint accessed', req, res, {
|
|
||||||
userId,
|
|
||||||
userOrgId,
|
|
||||||
isAdmin,
|
|
||||||
from,
|
|
||||||
to
|
|
||||||
});
|
|
||||||
|
|
||||||
// Use paginated query handler for memory efficiency
|
|
||||||
const result = await container.getDecksByPageQueryHandler.execute({
|
|
||||||
userId,
|
|
||||||
userOrgId,
|
|
||||||
isAdmin,
|
|
||||||
from,
|
|
||||||
to
|
|
||||||
});
|
|
||||||
|
|
||||||
logRequest('Get decks page completed successfully', req, res, {
|
|
||||||
userId,
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
returnedCount: result.decks.length,
|
|
||||||
totalCount: result.totalCount
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json(result);
|
|
||||||
} catch (error) {
|
|
||||||
logError('Get decks by page endpoint error', error as Error, req, res);
|
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
deckRouter.post('/', authRequired, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const userId = (req as any).user.userId;
|
|
||||||
logRequest('Create deck endpoint accessed', req, res, { name: req.body.name, userId });
|
|
||||||
|
|
||||||
// Convert string enum values to integers
|
|
||||||
const command = convertEnumValues({
|
|
||||||
...req.body,
|
|
||||||
userid: userId
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await container.createDeckCommandHandler.execute(command);
|
|
||||||
|
|
||||||
logRequest('Deck created successfully', req, res, { deckId: result.id, name: req.body.name, userId });
|
|
||||||
res.json(result);
|
|
||||||
} catch (error) {
|
|
||||||
logError('Create deck endpoint error', error as Error, req, res);
|
|
||||||
|
|
||||||
// Handle enum validation errors
|
|
||||||
if (error instanceof Error && error.message.includes('Invalid deck')) {
|
|
||||||
return res.status(400).json({ error: error.message });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error instanceof Error && (error.message.includes('duplicate') || error.message.includes('unique constraint'))) {
|
|
||||||
return res.status(409).json({ error: 'Deck with this name already exists' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error instanceof Error && error.message.includes('validation')) {
|
|
||||||
return res.status(400).json({ error: 'Invalid input data', details: error.message });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
deckRouter.get('/search', authRequired, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { query, limit, offset } = req.query;
|
|
||||||
logRequest('Search decks endpoint accessed', req, res, { query, limit, offset });
|
|
||||||
|
|
||||||
if (!query || typeof query !== 'string') {
|
|
||||||
logWarning('Deck search attempted without query', { query, hasQuery: !!query }, req, res);
|
|
||||||
return res.status(400).json({ error: 'Search query is required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const searchQuery = {
|
|
||||||
query: query.trim(),
|
|
||||||
limit: limit ? parseInt(limit as string) : 20,
|
|
||||||
offset: offset ? parseInt(offset as string) : 0
|
|
||||||
};
|
|
||||||
|
|
||||||
// Validate pagination parameters
|
|
||||||
if (searchQuery.limit < 1 || searchQuery.limit > 100) {
|
|
||||||
logWarning('Invalid deck search limit parameter', { limit: searchQuery.limit }, req, res);
|
|
||||||
return res.status(400).json({ error: 'Limit must be between 1 and 100' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (searchQuery.offset < 0) {
|
|
||||||
logWarning('Invalid deck search offset parameter', { offset: searchQuery.offset }, req, res);
|
|
||||||
return res.status(400).json({ error: 'Offset must be non-negative' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await searchService.searchFromUrl(req.originalUrl, searchQuery);
|
|
||||||
|
|
||||||
logRequest('Deck search completed successfully', req, res, {
|
|
||||||
query: searchQuery.query,
|
|
||||||
resultCount: Array.isArray(result) ? result.length : 0
|
|
||||||
});
|
|
||||||
res.json(result);
|
|
||||||
} catch (error) {
|
|
||||||
logError('Search decks endpoint error', error as Error, req, res);
|
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
deckRouter.get('/:id', authRequired, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const deckId = req.params.id;
|
|
||||||
logRequest('Get deck by id endpoint accessed', req, res, { deckId });
|
|
||||||
|
|
||||||
const result = await container.getDeckByIdQueryHandler.execute({ id: deckId });
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
logWarning('Deck not found', { deckId }, req, res);
|
|
||||||
return res.status(404).json({ error: 'Deck not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
logRequest('Deck retrieved successfully', req, res, { deckId });
|
|
||||||
res.json(result);
|
|
||||||
} catch (error) {
|
|
||||||
logError('Get deck by id endpoint error', error as Error, req, res);
|
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
deckRouter.patch('/:id', authRequired, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const deckId = req.params.id;
|
|
||||||
const userId = (req as any).user.userId;
|
|
||||||
const authLevel = (req as any).user.authLevel;
|
|
||||||
logRequest('Update deck endpoint accessed', req, res, { deckId, userId, updateFields: Object.keys(req.body) });
|
|
||||||
|
|
||||||
// Convert string enum values to integers
|
|
||||||
const updateData = convertEnumValues(req.body);
|
|
||||||
|
|
||||||
const result = await container.updateDeckCommandHandler.execute({ userid: userId, authLevel: authLevel, id: deckId, ...updateData });
|
|
||||||
|
|
||||||
logRequest('Deck updated successfully', req, res, { deckId, userId });
|
|
||||||
res.json(result);
|
|
||||||
} catch (error) {
|
|
||||||
logError('Update deck endpoint error', error as Error, req, res);
|
|
||||||
|
|
||||||
// Handle enum validation errors
|
|
||||||
if (error instanceof Error && error.message.includes('Invalid deck')) {
|
|
||||||
return res.status(400).json({ error: error.message });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error instanceof Error && error.message.includes('not found')) {
|
|
||||||
return res.status(404).json({ error: 'Deck not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error instanceof Error && (error.message.includes('duplicate') || error.message.includes('unique constraint'))) {
|
|
||||||
return res.status(409).json({ error: 'Deck with this name already exists' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error instanceof Error && error.message.includes('validation')) {
|
|
||||||
return res.status(400).json({ error: 'Invalid input data', details: error.message });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error instanceof Error && error.message.includes('admin')) {
|
|
||||||
return res.status(403).json({ error: 'Forbidden: ' + error.message });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error instanceof Error && error.message.includes('admin')) {
|
|
||||||
return res.status(403).json({ error: 'Forbidden: ' + error.message });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
deckRouter.delete('/:id', authRequired, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const deckId = req.params.id;
|
|
||||||
const userId = (req as any).user.userId;
|
|
||||||
const authLevel = (req as any).user.authLevel;
|
|
||||||
logRequest('Soft delete deck endpoint accessed', req, res, { deckId, userId });
|
|
||||||
|
|
||||||
const result = await container.deleteDeckCommandHandler.execute({ userid: userId, authLevel: authLevel, id: deckId, soft: true });
|
|
||||||
|
|
||||||
logRequest('Deck soft delete successful', req, res, { deckId, userId, success: result });
|
|
||||||
res.json({ success: result });
|
|
||||||
} catch (error) {
|
|
||||||
logError('Soft delete deck endpoint error', error as Error, req, res);
|
|
||||||
|
|
||||||
if (error instanceof Error && error.message.includes('not found')) {
|
|
||||||
return res.status(404).json({ error: 'Deck not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default deckRouter;
|
|
||||||
@@ -1,327 +0,0 @@
|
|||||||
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
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create game token for WebSocket authentication
|
|
||||||
const gameTokenService = container.gameTokenService;
|
|
||||||
const gameToken = gameTokenService.createGameToken(
|
|
||||||
game.id,
|
|
||||||
game.gamecode,
|
|
||||||
actualPlayerName || 'Anonymous',
|
|
||||||
actualPlayerId
|
|
||||||
);
|
|
||||||
|
|
||||||
// Return clean response with essential data + game token
|
|
||||||
res.json({
|
|
||||||
id: game.id,
|
|
||||||
gamecode: game.gamecode,
|
|
||||||
playerName: actualPlayerName,
|
|
||||||
playerCount: game.players.length,
|
|
||||||
maxPlayers: game.maxplayers,
|
|
||||||
gameType: LoginType[gameToJoin.logintype],
|
|
||||||
isAuthenticated: !!actualPlayerId,
|
|
||||||
gameToken: gameToken
|
|
||||||
});
|
|
||||||
} 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;
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
import { Router } from 'express';
|
|
||||||
import { authRequired } from '../../Application/Services/AuthMiddleware';
|
|
||||||
import { container } from '../../Application/Services/DIContainer';
|
|
||||||
import { ErrorResponseService } from '../../Application/Services/ErrorResponseService';
|
|
||||||
import { ValidationMiddleware } from '../../Application/Services/ValidationMiddleware';
|
|
||||||
import { GeneralSearchService } from '../../Application/Search/Generalsearch';
|
|
||||||
import { logRequest, logError, logWarning, logAuth } from '../../Application/Services/Logger';
|
|
||||||
|
|
||||||
const organizationRouter = Router();
|
|
||||||
|
|
||||||
// Create search service that isn't in the container yet
|
|
||||||
const searchService = new GeneralSearchService(container.userRepository, container.organizationRepository, container.deckRepository);
|
|
||||||
|
|
||||||
// Auth routes - Get organizations with pagination (RECOMMENDED)
|
|
||||||
organizationRouter.get('/page/:from/:to', authRequired, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const from = parseInt(req.params.from);
|
|
||||||
const to = parseInt(req.params.to);
|
|
||||||
|
|
||||||
if (isNaN(from) || isNaN(to) || from < 0 || to < from) {
|
|
||||||
return res.status(400).json({ error: 'Invalid page parameters. "from" and "to" must be valid numbers with to >= from >= 0' });
|
|
||||||
}
|
|
||||||
|
|
||||||
logRequest('Get organizations by page endpoint accessed', req, res, { from, to });
|
|
||||||
|
|
||||||
const result = await container.getOrganizationsByPageQueryHandler.execute({ from, to });
|
|
||||||
|
|
||||||
logRequest('Organizations page retrieved successfully', req, res, {
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
count: result.organizations.length,
|
|
||||||
totalCount: result.totalCount
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json(result);
|
|
||||||
} catch (error) {
|
|
||||||
logError('Get organizations by page endpoint error', error as Error, req, res);
|
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
organizationRouter.get('/search', authRequired, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { query, limit, offset } = req.query;
|
|
||||||
logRequest('Search organizations endpoint accessed', req, res, { query, limit, offset });
|
|
||||||
|
|
||||||
if (!query || typeof query !== 'string') {
|
|
||||||
logWarning('Organization search attempted without query', { query, hasQuery: !!query }, req, res);
|
|
||||||
return res.status(400).json({ error: 'Search query is required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const searchQuery = {
|
|
||||||
query: query.trim(),
|
|
||||||
limit: limit ? parseInt(limit as string) : 20,
|
|
||||||
offset: offset ? parseInt(offset as string) : 0
|
|
||||||
};
|
|
||||||
|
|
||||||
// Validate pagination parameters
|
|
||||||
if (searchQuery.limit < 1 || searchQuery.limit > 100) {
|
|
||||||
logWarning('Invalid organization search limit parameter', { limit: searchQuery.limit }, req, res);
|
|
||||||
return res.status(400).json({ error: 'Limit must be between 1 and 100' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (searchQuery.offset < 0) {
|
|
||||||
logWarning('Invalid organization search offset parameter', { offset: searchQuery.offset }, req, res);
|
|
||||||
return res.status(400).json({ error: 'Offset must be non-negative' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await searchService.searchFromUrl(req.originalUrl, searchQuery);
|
|
||||||
|
|
||||||
logRequest('Organization search completed successfully', req, res, {
|
|
||||||
query: searchQuery.query,
|
|
||||||
resultCount: Array.isArray(result) ? result.length : 0
|
|
||||||
});
|
|
||||||
res.json(result);
|
|
||||||
} catch (error) {
|
|
||||||
logError('Search organizations endpoint error', error as Error, req, res);
|
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get organization login URL
|
|
||||||
organizationRouter.get('/:orgId/login-url', authRequired, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const userId = (req as any).user.userId;
|
|
||||||
const { orgId } = req.params;
|
|
||||||
|
|
||||||
logRequest('Get organization login URL endpoint accessed', req, res, {
|
|
||||||
userId,
|
|
||||||
organizationId: orgId
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await container.getOrganizationLoginUrlQueryHandler.execute({
|
|
||||||
organizationId: orgId
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
logWarning('Organization login URL not found', {
|
|
||||||
organizationId: orgId,
|
|
||||||
userId
|
|
||||||
}, req, res);
|
|
||||||
return ErrorResponseService.sendNotFound(res, 'Organization login URL not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
logRequest('Organization login URL retrieved successfully', req, res, {
|
|
||||||
organizationId: orgId,
|
|
||||||
organizationName: result.organizationName,
|
|
||||||
hasUrl: !!result.loginUrl,
|
|
||||||
userId
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json(result);
|
|
||||||
} catch (error) {
|
|
||||||
logError('Get organization login URL endpoint error', error as Error, req, res);
|
|
||||||
return ErrorResponseService.sendInternalServerError(res);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Process third-party authentication callback
|
|
||||||
organizationRouter.post('/auth-callback', authRequired, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const userId = (req as any).user.userId;
|
|
||||||
const { organizationId, status, authToken } = req.body;
|
|
||||||
|
|
||||||
logRequest('Organization auth callback endpoint accessed', req, res, {
|
|
||||||
userId,
|
|
||||||
organizationId,
|
|
||||||
status,
|
|
||||||
hasAuthToken: !!authToken
|
|
||||||
});
|
|
||||||
|
|
||||||
// Validate required fields
|
|
||||||
if (!organizationId || !status) {
|
|
||||||
logWarning('Missing required fields for organization auth callback', {
|
|
||||||
organizationId: !!organizationId,
|
|
||||||
status: !!status,
|
|
||||||
userId
|
|
||||||
}, req, res);
|
|
||||||
return ErrorResponseService.sendBadRequest(res, 'organizationId and status are required');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status !== 'ok' && status !== 'not_ok') {
|
|
||||||
logWarning('Invalid status value for organization auth callback', {
|
|
||||||
status,
|
|
||||||
userId,
|
|
||||||
organizationId
|
|
||||||
}, req, res);
|
|
||||||
return ErrorResponseService.sendBadRequest(res, 'status must be either "ok" or "not_ok"');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await container.processOrgAuthCallbackCommandHandler.execute({
|
|
||||||
organizationId,
|
|
||||||
userId,
|
|
||||||
status,
|
|
||||||
authToken
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
if (result.message.includes('not found')) {
|
|
||||||
logWarning('Organization auth callback failed - entity not found', {
|
|
||||||
userId,
|
|
||||||
organizationId,
|
|
||||||
message: result.message
|
|
||||||
}, req, res);
|
|
||||||
return ErrorResponseService.sendNotFound(res, result.message);
|
|
||||||
}
|
|
||||||
if (result.message.includes('does not belong')) {
|
|
||||||
logWarning('Organization auth callback failed - authorization error', {
|
|
||||||
userId,
|
|
||||||
organizationId,
|
|
||||||
message: result.message
|
|
||||||
}, req, res);
|
|
||||||
return ErrorResponseService.sendForbidden(res, result.message);
|
|
||||||
}
|
|
||||||
if (result.message.includes('authentication failed')) {
|
|
||||||
logAuth('Organization authentication failed via callback', userId, {
|
|
||||||
organizationId,
|
|
||||||
status
|
|
||||||
}, req, res);
|
|
||||||
return ErrorResponseService.sendUnauthorized(res, result.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
logError('Organization auth callback internal error', new Error(result.message), req, res);
|
|
||||||
return ErrorResponseService.sendInternalServerError(res);
|
|
||||||
}
|
|
||||||
|
|
||||||
logAuth('Organization auth callback processed successfully', userId, {
|
|
||||||
organizationId,
|
|
||||||
status,
|
|
||||||
updatedFields: result.updatedFields
|
|
||||||
}, req, res);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: result.success,
|
|
||||||
message: result.message,
|
|
||||||
updatedFields: result.updatedFields
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logError('Organization auth callback endpoint error', error as Error, req, res);
|
|
||||||
return ErrorResponseService.sendInternalServerError(res);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default organizationRouter;
|
|
||||||
@@ -1,345 +0,0 @@
|
|||||||
import { Router } from 'express';
|
|
||||||
import { authRequired } from '../../Application/Services/AuthMiddleware';
|
|
||||||
import { container } from '../../Application/Services/DIContainer';
|
|
||||||
import { ErrorResponseService } from '../../Application/Services/ErrorResponseService';
|
|
||||||
import { ValidationMiddleware } from '../../Application/Services/ValidationMiddleware';
|
|
||||||
import { GeneralSearchService } from '../../Application/Search/Generalsearch';
|
|
||||||
import { logRequest, logError, logAuth, logWarning } from '../../Application/Services/Logger';
|
|
||||||
|
|
||||||
const userRouter = Router();
|
|
||||||
|
|
||||||
// Create search service that isn't in the container yet
|
|
||||||
const searchService = new GeneralSearchService(container.userRepository, container.organizationRepository, container.deckRepository);
|
|
||||||
|
|
||||||
// Login endpoint
|
|
||||||
userRouter.post('/login',
|
|
||||||
ValidationMiddleware.combine([
|
|
||||||
ValidationMiddleware.validateRequiredFields(['username', 'password']),
|
|
||||||
ValidationMiddleware.validateStringLength({
|
|
||||||
username: { min: 3, max: 50 },
|
|
||||||
password: { min: 6, max: 100 }
|
|
||||||
})
|
|
||||||
]),
|
|
||||||
async (req, res) => {
|
|
||||||
try {
|
|
||||||
logRequest('Login endpoint accessed', req, res, { username: req.body.username });
|
|
||||||
|
|
||||||
const { username, password } = req.body;
|
|
||||||
|
|
||||||
const result = await container.loginCommandHandler.execute({ username, password }, res);
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
logAuth('User login successful', undefined, { username: result.user.username }, req, res);
|
|
||||||
res.json(result);
|
|
||||||
} else {
|
|
||||||
throw new Error(`Login failed: ${result}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logError('Login endpoint error', error as Error, req, res);
|
|
||||||
|
|
||||||
if (error instanceof Error) {
|
|
||||||
if (error.message.includes('Invalid username')) {
|
|
||||||
return ErrorResponseService.sendUnauthorized(res, 'Invalid username or password');
|
|
||||||
}
|
|
||||||
if (error.message.includes('Invalid password')) {
|
|
||||||
return ErrorResponseService.sendUnauthorized(res, 'Invalid username or password');
|
|
||||||
}
|
|
||||||
if (error.message.includes('not verified')) {
|
|
||||||
return ErrorResponseService.sendUnauthorized(res, 'Please verify your email address');
|
|
||||||
}
|
|
||||||
if (error.message.includes('restriction')) {
|
|
||||||
return ErrorResponseService.sendUnauthorized(res, 'Please verify your email address');
|
|
||||||
}
|
|
||||||
if (error.message.includes('deactivated')) {
|
|
||||||
return ErrorResponseService.sendUnauthorized(res, 'Account has been deactivated');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ErrorResponseService.sendInternalServerError(res);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create user endpoint
|
|
||||||
userRouter.post('/create',
|
|
||||||
ValidationMiddleware.combine([
|
|
||||||
ValidationMiddleware.validateRequiredFields(['username', 'email', 'password']),
|
|
||||||
ValidationMiddleware.validateEmailFormat(['email']),
|
|
||||||
ValidationMiddleware.validateStringLength({
|
|
||||||
username: { min: 3, max: 50 },
|
|
||||||
password: { min: 6, max: 100 }
|
|
||||||
})
|
|
||||||
]),
|
|
||||||
async (req, res) => {
|
|
||||||
try {
|
|
||||||
logRequest('Create user endpoint accessed', req, res, {
|
|
||||||
username: req.body.username,
|
|
||||||
email: req.body.email
|
|
||||||
});
|
|
||||||
|
|
||||||
const acceptLanguage = req.header('Accept-Language') || 'en';
|
|
||||||
const language : 'hu' | 'de' | 'en' = acceptLanguage.toLowerCase().startsWith('hu') ? 'hu' :
|
|
||||||
acceptLanguage.toLowerCase().startsWith('de') ? 'de' : 'en';
|
|
||||||
|
|
||||||
const result = await container.createUserCommandHandler.execute({ ...req.body, language });
|
|
||||||
|
|
||||||
logRequest('User created successfully', req, res, {
|
|
||||||
username: result.username
|
|
||||||
});
|
|
||||||
|
|
||||||
res.status(201).json(result);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
// Don't log here since CreateUserCommandHandler already logs system errors
|
|
||||||
// Only log validation/user input errors at router level
|
|
||||||
|
|
||||||
if (error instanceof Error) {
|
|
||||||
if (error.message.includes('already exists')) {
|
|
||||||
return ErrorResponseService.sendConflict(res, error.message);
|
|
||||||
}
|
|
||||||
if (error.message.includes('validation')) {
|
|
||||||
return ErrorResponseService.sendBadRequest(res, error.message);
|
|
||||||
}
|
|
||||||
// Log unexpected errors that weren't handled by the command handler
|
|
||||||
if (!error.message.includes('Failed to create user')) {
|
|
||||||
logError('Unexpected create user endpoint error', error as Error, req, res);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ErrorResponseService.sendInternalServerError(res);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get user profile (current user)
|
|
||||||
userRouter.get('/profile', authRequired, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const userId = (req as any).user.userId;
|
|
||||||
|
|
||||||
logRequest('Get user profile endpoint accessed', req, res, { userId });
|
|
||||||
|
|
||||||
const result = await container.getUserByIdQueryHandler.execute({ id: userId });
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
logWarning('User profile not found', { userId }, req, res);
|
|
||||||
return ErrorResponseService.sendNotFound(res, 'User not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
logRequest('User profile retrieved successfully', req, res, {
|
|
||||||
userId,
|
|
||||||
username: result.username
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json(result);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logError('Get user profile endpoint error', error as Error, req, res);
|
|
||||||
return ErrorResponseService.sendInternalServerError(res);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update user profile (current user)
|
|
||||||
userRouter.patch('/profile', authRequired, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const userId = (req as any).user.userId;
|
|
||||||
|
|
||||||
logRequest('Update user profile endpoint accessed', req, res, {
|
|
||||||
userId,
|
|
||||||
fieldsToUpdate: Object.keys(req.body)
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await container.updateUserCommandHandler.execute({ id: userId, ...req.body });
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
return ErrorResponseService.sendNotFound(res, 'User not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
logRequest('User profile updated successfully', req, res, {
|
|
||||||
userId,
|
|
||||||
username: result.username
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json(result);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logError('Update user profile endpoint error', error as Error, req, res);
|
|
||||||
|
|
||||||
if (error instanceof Error) {
|
|
||||||
if (error.message.includes('already exists')) {
|
|
||||||
return ErrorResponseService.sendConflict(res, error.message);
|
|
||||||
}
|
|
||||||
if (error.message.includes('validation')) {
|
|
||||||
return ErrorResponseService.sendBadRequest(res, error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ErrorResponseService.sendInternalServerError(res);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
//Soft delete user (current user)
|
|
||||||
userRouter.delete('/profile', authRequired, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const userId = (req as any).user.userId;
|
|
||||||
const result = await container.deleteUserCommandHandler.execute({ id: userId, soft: true });
|
|
||||||
logRequest('User soft deleted successfully', req, res, { userId });
|
|
||||||
res.json({ success: result });
|
|
||||||
} catch (error) {
|
|
||||||
logError('Soft delete user endpoint error', error as Error, req, res);
|
|
||||||
return ErrorResponseService.sendInternalServerError(res);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
//logout user (current user)
|
|
||||||
userRouter.post('/logout', authRequired, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const userId = (req as any).user.userId;
|
|
||||||
await container.logoutCommandHandler.execute(userId, res, req);
|
|
||||||
logRequest('User logged out successfully', req, res, { userId });
|
|
||||||
res.json({ success: true });
|
|
||||||
} catch (error) {
|
|
||||||
logError('Logout user endpoint error', error as Error, req, res);
|
|
||||||
return ErrorResponseService.sendInternalServerError(res);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Refresh token endpoint
|
|
||||||
userRouter.post('/refresh-token', async (req, res) => {
|
|
||||||
try {
|
|
||||||
logRequest('Token refresh endpoint accessed', req, res);
|
|
||||||
|
|
||||||
const jwtService = container.jwtService;
|
|
||||||
const newTokenPair = jwtService.attemptTokenRefresh(req, res);
|
|
||||||
|
|
||||||
if (newTokenPair) {
|
|
||||||
logRequest('Token refresh successful', req, res);
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: 'Tokens refreshed successfully',
|
|
||||||
accessToken: newTokenPair.accessToken,
|
|
||||||
refreshToken: newTokenPair.refreshToken
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
logWarning('Token refresh failed - invalid or missing refresh token', undefined, req, res);
|
|
||||||
return ErrorResponseService.sendUnauthorized(res, 'Invalid or expired refresh token');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logError('Refresh token endpoint error', error as Error, req, res);
|
|
||||||
return ErrorResponseService.sendInternalServerError(res);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Email verification endpoint
|
|
||||||
userRouter.post('/verify-email/:token', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { token } = req.params;
|
|
||||||
|
|
||||||
logRequest('Email verification endpoint accessed', req, res, {
|
|
||||||
tokenPrefix: token.substring(0, 8) + '...'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return ErrorResponseService.sendBadRequest(res, 'Verification token is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await container.verifyEmailCommandHandler.execute({ token });
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
logAuth('Email verification successful', undefined, { tokenPrefix: token.substring(0, 8) + '...' }, req, res);
|
|
||||||
res.json({ success: true, message: 'Email verified successfully' });
|
|
||||||
} else {
|
|
||||||
throw new Error('Email verification failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logError('Email verification endpoint error', error as Error, req, res);
|
|
||||||
|
|
||||||
if (error instanceof Error) {
|
|
||||||
if (error.message.includes('Invalid') || error.message.includes('expired')) {
|
|
||||||
return ErrorResponseService.sendBadRequest(res, 'Invalid or expired verification token');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ErrorResponseService.sendInternalServerError(res);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Forgot password request endpoint
|
|
||||||
userRouter.post('/forgot-password',
|
|
||||||
ValidationMiddleware.combine([
|
|
||||||
ValidationMiddleware.validateRequiredFields(['email']),
|
|
||||||
ValidationMiddleware.validateEmailFormat(['email'])
|
|
||||||
]),
|
|
||||||
async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { email } = req.body;
|
|
||||||
const acceptLanguage = req.header('Accept-Language') || 'en';
|
|
||||||
const language: 'hu' | 'de' | 'en' = acceptLanguage.toLowerCase().startsWith('hu') ? 'hu' :
|
|
||||||
acceptLanguage.toLowerCase().startsWith('de') ? 'de' : 'en';
|
|
||||||
|
|
||||||
logRequest('Forgot password endpoint accessed', req, res, { email });
|
|
||||||
|
|
||||||
const result = await container.requestPasswordResetCommandHandler.execute({ language, email });
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
logAuth('Password reset request successful', undefined, { email }, req, res);
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: 'If an account with this email exists, a password reset link has been sent'
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
throw new Error('Password reset request failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logError('Forgot password endpoint error', error as Error, req, res);
|
|
||||||
|
|
||||||
// Always return success for security (don't reveal if email exists)
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: 'If an account with this email exists, a password reset link has been sent'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reset password endpoint
|
|
||||||
userRouter.post('/reset-password',
|
|
||||||
ValidationMiddleware.combine([
|
|
||||||
ValidationMiddleware.validateRequiredFields(['token', 'newPassword']),
|
|
||||||
ValidationMiddleware.validateStringLength({
|
|
||||||
newPassword: { min: 6, max: 100 }
|
|
||||||
})
|
|
||||||
]),
|
|
||||||
async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { token, newPassword } = req.body;
|
|
||||||
|
|
||||||
logRequest('Reset password endpoint accessed', req, res, {
|
|
||||||
tokenPrefix: token.substring(0, 8) + '...'
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await container.resetPasswordCommandHandler.execute({ token, newPassword });
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
logAuth('Password reset successful', undefined, { tokenPrefix: token.substring(0, 8) + '...' }, req, res);
|
|
||||||
res.json({ success: true, message: 'Password reset successfully' });
|
|
||||||
} else {
|
|
||||||
throw new Error('Password reset failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logError('Reset password endpoint error', error as Error, req, res);
|
|
||||||
|
|
||||||
if (error instanceof Error) {
|
|
||||||
if (error.message.includes('Invalid') || error.message.includes('expired')) {
|
|
||||||
return ErrorResponseService.sendBadRequest(res, 'Invalid or expired reset token');
|
|
||||||
}
|
|
||||||
if (error.message.includes('Password validation')) {
|
|
||||||
return ErrorResponseService.sendBadRequest(res, error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ErrorResponseService.sendInternalServerError(res);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default userRouter;
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import swaggerJSDoc from 'swagger-jsdoc';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
export const swaggerOptions = {
|
|
||||||
definition: {
|
|
||||||
openapi: '3.0.0',
|
|
||||||
info: {
|
|
||||||
title: 'SerpentRace API',
|
|
||||||
version: '1.0.0',
|
|
||||||
description: 'Comprehensive API documentation for SerpentRace Backend',
|
|
||||||
contact: {
|
|
||||||
name: 'SerpentRace Development Team',
|
|
||||||
email: 'dev@serpentrace.com'
|
|
||||||
},
|
|
||||||
license: {
|
|
||||||
name: 'MIT',
|
|
||||||
url: 'https://opensource.org/licenses/MIT'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
servers: [
|
|
||||||
{
|
|
||||||
url: 'http://localhost:3001',
|
|
||||||
description: 'Local development server'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: 'http://localhost:3000',
|
|
||||||
description: 'Local development server (alt)'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: 'https://api.serpentrace.com',
|
|
||||||
description: 'Production server'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
components: {
|
|
||||||
securitySchemes: {
|
|
||||||
bearerAuth: {
|
|
||||||
type: 'http',
|
|
||||||
scheme: 'bearer',
|
|
||||||
bearerFormat: 'JWT',
|
|
||||||
description: 'Enter JWT token obtained from /api/users/login'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
security: [{ bearerAuth: [] }],
|
|
||||||
tags: [
|
|
||||||
{
|
|
||||||
name: 'Users',
|
|
||||||
description: 'User authentication and profile management'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Organizations',
|
|
||||||
description: 'Organization management and authentication'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Decks',
|
|
||||||
description: 'Deck creation, management, and gameplay'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Chats',
|
|
||||||
description: 'Real-time chat and messaging system'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Contacts',
|
|
||||||
description: 'Contact form and support requests'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Deck Import/Export',
|
|
||||||
description: 'Import and export deck functionality'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Games',
|
|
||||||
description: 'Game management and gameplay'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Admin - Users',
|
|
||||||
description: 'Admin user management operations'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Admin - Decks',
|
|
||||||
description: 'Admin deck management operations'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Admin - Organizations',
|
|
||||||
description: 'Admin organization management operations'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Admin - Chats',
|
|
||||||
description: 'Admin chat management operations'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Admin - Contacts',
|
|
||||||
description: 'Admin contact management operations'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
apis: [
|
|
||||||
'./src/Api/swagger/swaggerDefinitionsFixed.ts'
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const swaggerSpec = swaggerJSDoc(swaggerOptions);
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,7 +0,0 @@
|
|||||||
import express from 'express';
|
|
||||||
import swaggerUi from 'swagger-ui-express';
|
|
||||||
import { swaggerSpec } from './swaggerConfig';
|
|
||||||
|
|
||||||
export function setupSwagger(app: express.Application) {
|
|
||||||
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import { ArchiveChatCommand, RestoreChatCommand } from './ChatCommands';
|
|
||||||
import { IChatRepository } from '../../../Domain/IRepository/IChatRepository';
|
|
||||||
import { ChatType } from '../../../Domain/Chat/ChatAggregate';
|
|
||||||
import { logAuth, logError, logWarning } from '../../Services/Logger';
|
|
||||||
|
|
||||||
export class ArchiveChatCommandHandler {
|
|
||||||
constructor(private chatRepository: IChatRepository) {}
|
|
||||||
|
|
||||||
async execute(command: ArchiveChatCommand): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const chat = await this.chatRepository.findById(command.chatId);
|
|
||||||
if (!chat) {
|
|
||||||
throw new Error('Chat not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.chatRepository.archiveChat(chat);
|
|
||||||
|
|
||||||
logAuth('Chat archived manually', undefined, {
|
|
||||||
chatId: command.chatId,
|
|
||||||
chatType: chat.type,
|
|
||||||
messageCount: chat.messages.length
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logError('ArchiveChatCommandHandler error', error as Error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RestoreChatCommandHandler {
|
|
||||||
constructor(private chatRepository: IChatRepository) {}
|
|
||||||
|
|
||||||
async execute(command: RestoreChatCommand): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const archive = await this.chatRepository.getArchivedChat(command.chatId);
|
|
||||||
if (!archive) {
|
|
||||||
throw new Error('Archived chat not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Game chats cannot be restored, only viewed
|
|
||||||
if (archive.chatType === ChatType.GAME) {
|
|
||||||
logWarning('Attempt to restore game chat blocked', {
|
|
||||||
chatId: command.chatId,
|
|
||||||
chatType: archive.chatType
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const restoredChat = await this.chatRepository.restoreFromArchive(command.chatId);
|
|
||||||
if (!restoredChat) {
|
|
||||||
throw new Error('Failed to restore chat from archive');
|
|
||||||
}
|
|
||||||
|
|
||||||
logAuth('Chat restored from archive', undefined, {
|
|
||||||
chatId: command.chatId,
|
|
||||||
messageCount: archive.archivedMessages.length
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logError('RestoreChatCommandHandler error', error as Error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
export interface CreateChatCommand {
|
|
||||||
type: 'direct' | 'group' | 'game';
|
|
||||||
name?: string;
|
|
||||||
gameId?: string;
|
|
||||||
createdBy: string;
|
|
||||||
userIds: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SendMessageCommand {
|
|
||||||
chatId: string;
|
|
||||||
userId: string;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ArchiveChatCommand {
|
|
||||||
chatId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RestoreChatCommand {
|
|
||||||
chatId: string;
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import { CreateChatCommand } from './ChatCommands';
|
|
||||||
import { IChatRepository } from '../../../Domain/IRepository/IChatRepository';
|
|
||||||
import { IUserRepository } from '../../../Domain/IRepository/IUserRepository';
|
|
||||||
import { ChatType, ChatAggregate } from '../../../Domain/Chat/ChatAggregate';
|
|
||||||
import { UserState } from '../../../Domain/User/UserAggregate';
|
|
||||||
import { logAuth, logError } from '../../Services/Logger';
|
|
||||||
|
|
||||||
export class CreateChatCommandHandler {
|
|
||||||
constructor(
|
|
||||||
private chatRepository: IChatRepository,
|
|
||||||
private userRepository: IUserRepository
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async execute(command: CreateChatCommand): Promise<ChatAggregate | null> {
|
|
||||||
try {
|
|
||||||
// Validate creator exists
|
|
||||||
const creator = await this.userRepository.findById(command.createdBy);
|
|
||||||
if (!creator) {
|
|
||||||
throw new Error('Creator not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// For group chats, check if creator is premium
|
|
||||||
if (command.type === 'group' && creator.state !== UserState.VERIFIED_PREMIUM) {
|
|
||||||
throw new Error('Premium subscription required to create groups');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate all target users exist
|
|
||||||
const targetUsers = await Promise.all(
|
|
||||||
command.userIds.map(id => this.userRepository.findById(id))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (targetUsers.some(user => !user)) {
|
|
||||||
throw new Error('One or more target users not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// For direct chats, check if already exists
|
|
||||||
if (command.type === 'direct' && command.userIds.length === 1) {
|
|
||||||
const existingChats = await this.chatRepository.findByUserId(command.createdBy);
|
|
||||||
const existingDirectChat = existingChats.find(chat =>
|
|
||||||
chat.type === ChatType.DIRECT &&
|
|
||||||
chat.users.length === 2 &&
|
|
||||||
chat.users.includes(command.userIds[0])
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingDirectChat) {
|
|
||||||
return existingDirectChat;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// For game chats, check if already exists
|
|
||||||
if (command.type === 'game' && command.gameId) {
|
|
||||||
const existingGameChat = await this.chatRepository.findByGameId(command.gameId);
|
|
||||||
if (existingGameChat) {
|
|
||||||
return existingGameChat;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create chat
|
|
||||||
const chatData: Partial<ChatAggregate> = {
|
|
||||||
type: command.type as any,
|
|
||||||
name: command.name,
|
|
||||||
gameId: command.gameId,
|
|
||||||
createdBy: command.createdBy,
|
|
||||||
users: [command.createdBy, ...command.userIds],
|
|
||||||
messages: [],
|
|
||||||
lastActivity: new Date()
|
|
||||||
};
|
|
||||||
|
|
||||||
const chat = await this.chatRepository.create(chatData);
|
|
||||||
|
|
||||||
logAuth('Chat created successfully', command.createdBy, {
|
|
||||||
chatId: chat.id,
|
|
||||||
chatType: command.type,
|
|
||||||
participantCount: chat.users.length,
|
|
||||||
gameId: command.gameId
|
|
||||||
});
|
|
||||||
|
|
||||||
return chat;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logError('CreateChatCommandHandler error', error as Error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import { SendMessageCommand } from './ChatCommands';
|
|
||||||
import { IChatRepository } from '../../../Domain/IRepository/IChatRepository';
|
|
||||||
import { Message } from '../../../Domain/Chat/ChatAggregate';
|
|
||||||
import { logAuth, logError } from '../../Services/Logger';
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
|
|
||||||
export class SendMessageCommandHandler {
|
|
||||||
constructor(private chatRepository: IChatRepository) {}
|
|
||||||
|
|
||||||
async execute(command: SendMessageCommand): Promise<Message | null> {
|
|
||||||
try {
|
|
||||||
// Validate message is non-empty string
|
|
||||||
if (typeof command.message !== 'string' || !command.message.trim()) {
|
|
||||||
throw new Error('Message must be a non-empty string');
|
|
||||||
}
|
|
||||||
|
|
||||||
const chat = await this.chatRepository.findById(command.chatId);
|
|
||||||
if (!chat) {
|
|
||||||
throw new Error('Chat not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user is member of this chat
|
|
||||||
if (!chat.users.includes(command.userId)) {
|
|
||||||
throw new Error('User is not a member of this chat');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create message
|
|
||||||
const message: Message = {
|
|
||||||
id: uuidv4(),
|
|
||||||
date: new Date(),
|
|
||||||
userid: command.userId,
|
|
||||||
text: command.message.trim()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Manage message history (keep last 10 per user, up to 2 weeks)
|
|
||||||
let updatedMessages = [...chat.messages, message];
|
|
||||||
updatedMessages = this.pruneMessages(updatedMessages);
|
|
||||||
|
|
||||||
// Update chat
|
|
||||||
await this.chatRepository.update(command.chatId, {
|
|
||||||
messages: updatedMessages,
|
|
||||||
lastActivity: new Date()
|
|
||||||
});
|
|
||||||
|
|
||||||
logAuth('Message sent successfully', command.userId, {
|
|
||||||
chatId: command.chatId,
|
|
||||||
messageLength: command.message.length,
|
|
||||||
totalMessages: updatedMessages.length
|
|
||||||
});
|
|
||||||
|
|
||||||
return message;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logError('SendMessageCommandHandler error', error as Error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private pruneMessages(messages: Message[]): Message[] {
|
|
||||||
const twoWeeksAgo = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000);
|
|
||||||
|
|
||||||
// Remove messages older than 2 weeks
|
|
||||||
let prunedMessages = messages.filter(msg => new Date(msg.date) > twoWeeksAgo);
|
|
||||||
|
|
||||||
// Group by user and keep last 10 messages per user
|
|
||||||
const messagesByUser = new Map<string, Message[]>();
|
|
||||||
prunedMessages.forEach(msg => {
|
|
||||||
if (!messagesByUser.has(msg.userid)) {
|
|
||||||
messagesByUser.set(msg.userid, []);
|
|
||||||
}
|
|
||||||
messagesByUser.get(msg.userid)!.push(msg);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Keep only last 10 messages per user
|
|
||||||
const finalMessages: Message[] = [];
|
|
||||||
messagesByUser.forEach((userMessages, userId) => {
|
|
||||||
const last10 = userMessages.slice(-10);
|
|
||||||
finalMessages.push(...last10);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort by date
|
|
||||||
return finalMessages.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
import { GetChatHistoryQuery, GetArchivedChatsQuery } from './ChatQueries';
|
|
||||||
import { IChatRepository } from '../../../Domain/IRepository/IChatRepository';
|
|
||||||
import { IChatArchiveRepository } from '../../../Domain/IRepository/IChatArchiveRepository';
|
|
||||||
import { Message } from '../../../Domain/Chat/ChatAggregate';
|
|
||||||
import { logAuth, logError, logWarning } from '../../Services/Logger';
|
|
||||||
|
|
||||||
interface ChatHistoryResult {
|
|
||||||
chatId: string;
|
|
||||||
messages: Message[];
|
|
||||||
isArchived: boolean;
|
|
||||||
chatInfo: {
|
|
||||||
type: string;
|
|
||||||
name: string | null;
|
|
||||||
gameId: string | null;
|
|
||||||
users: string[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export class GetChatHistoryQueryHandler {
|
|
||||||
constructor(
|
|
||||||
private chatRepository: IChatRepository,
|
|
||||||
private chatArchiveRepository: IChatArchiveRepository
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async execute(query: GetChatHistoryQuery): Promise<ChatHistoryResult | null> {
|
|
||||||
try {
|
|
||||||
// First try to find active chat
|
|
||||||
const chat = await this.chatRepository.findById(query.chatId);
|
|
||||||
|
|
||||||
if (chat) {
|
|
||||||
// Check authorization
|
|
||||||
if (!chat.users.includes(query.userId)) {
|
|
||||||
logWarning('Unauthorized chat history access attempt', {
|
|
||||||
chatId: query.chatId,
|
|
||||||
userId: query.userId
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
logAuth('Chat history retrieved', query.userId, {
|
|
||||||
chatId: query.chatId,
|
|
||||||
messageCount: chat.messages.length,
|
|
||||||
isArchived: false
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
chatId: query.chatId,
|
|
||||||
messages: chat.messages,
|
|
||||||
isArchived: false,
|
|
||||||
chatInfo: {
|
|
||||||
type: chat.type,
|
|
||||||
name: chat.name,
|
|
||||||
gameId: chat.gameId,
|
|
||||||
users: chat.users
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to find in archives
|
|
||||||
const archives = await this.chatArchiveRepository.findByChatId(query.chatId);
|
|
||||||
const userArchive = archives.find(archive =>
|
|
||||||
archive.participants.includes(query.userId)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (userArchive) {
|
|
||||||
logAuth('Archived chat history retrieved', query.userId, {
|
|
||||||
chatId: query.chatId,
|
|
||||||
messageCount: userArchive.archivedMessages.length,
|
|
||||||
isArchived: true
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
chatId: query.chatId,
|
|
||||||
messages: userArchive.archivedMessages,
|
|
||||||
isArchived: true,
|
|
||||||
chatInfo: {
|
|
||||||
type: userArchive.chatType,
|
|
||||||
name: userArchive.chatName,
|
|
||||||
gameId: userArchive.gameId,
|
|
||||||
users: userArchive.participants
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
logWarning('Chat history not found', {
|
|
||||||
chatId: query.chatId,
|
|
||||||
userId: query.userId
|
|
||||||
});
|
|
||||||
|
|
||||||
return null;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logError('GetChatHistoryQueryHandler error', error as Error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class GetArchivedChatsQueryHandler {
|
|
||||||
constructor(private chatArchiveRepository: IChatArchiveRepository) {}
|
|
||||||
|
|
||||||
async execute(query: GetArchivedChatsQuery): Promise<ChatHistoryResult[]> {
|
|
||||||
try {
|
|
||||||
let archives: any[] = [];
|
|
||||||
|
|
||||||
if (query.gameId) {
|
|
||||||
// Get archived game chats
|
|
||||||
archives = await this.chatArchiveRepository.findByGameId(query.gameId);
|
|
||||||
} else {
|
|
||||||
// Get all archived chats for user (would need different query)
|
|
||||||
// For now, return empty - this would need a new repository method
|
|
||||||
archives = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = archives
|
|
||||||
.filter(archive => archive.participants.includes(query.userId))
|
|
||||||
.map(archive => ({
|
|
||||||
chatId: archive.chatId,
|
|
||||||
messages: archive.archivedMessages,
|
|
||||||
isArchived: true,
|
|
||||||
chatInfo: {
|
|
||||||
type: archive.chatType,
|
|
||||||
name: archive.chatName,
|
|
||||||
gameId: archive.gameId,
|
|
||||||
users: archive.participants
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
logAuth('Archived chats retrieved', query.userId, {
|
|
||||||
count: result.length,
|
|
||||||
gameId: query.gameId
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logError('GetArchivedChatsQueryHandler error', error as Error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
export interface GetUserChatsQuery {
|
|
||||||
userId: string;
|
|
||||||
includeArchived?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetChatHistoryQuery {
|
|
||||||
chatId: string;
|
|
||||||
userId: string; // For authorization
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetArchivedChatsQuery {
|
|
||||||
userId: string;
|
|
||||||
gameId?: string;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export interface GetChatsByPageQuery {
|
|
||||||
from: number;
|
|
||||||
to: number;
|
|
||||||
includeDeleted?: boolean;
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { IChatRepository } from '../../../Domain/IRepository/IChatRepository';
|
|
||||||
import { GetChatsByPageQuery } from './GetChatsByPageQuery';
|
|
||||||
import { ShortChatDto } from '../../DTOs/ChatDto';
|
|
||||||
import { ChatMapper } from '../../DTOs/Mappers/ChatMapper';
|
|
||||||
import { logRequest, logError } from '../../Services/Logger';
|
|
||||||
|
|
||||||
export class GetChatsByPageQueryHandler {
|
|
||||||
constructor(private readonly chatRepo: IChatRepository) {}
|
|
||||||
|
|
||||||
async execute(query: GetChatsByPageQuery): Promise<{ chats: ShortChatDto[], totalCount: number }> {
|
|
||||||
try {
|
|
||||||
// Validate pagination parameters
|
|
||||||
if (query.from < 0 || query.to < query.from) {
|
|
||||||
throw new Error('Invalid pagination parameters');
|
|
||||||
}
|
|
||||||
|
|
||||||
const limit = query.to - query.from + 1;
|
|
||||||
if (limit > 100) {
|
|
||||||
throw new Error('Page size too large. Maximum 100 records per request');
|
|
||||||
}
|
|
||||||
|
|
||||||
logRequest('Get chats by page query started', undefined, undefined, {
|
|
||||||
from: query.from,
|
|
||||||
to: query.to,
|
|
||||||
includeDeleted: query.includeDeleted || false
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = query.includeDeleted
|
|
||||||
? await this.chatRepo.findByPageIncludingDeleted(query.from, query.to)
|
|
||||||
: await this.chatRepo.findByPage(query.from, query.to);
|
|
||||||
|
|
||||||
logRequest('Get chats by page query completed', undefined, undefined, {
|
|
||||||
from: query.from,
|
|
||||||
to: query.to,
|
|
||||||
returned: result.chats.length,
|
|
||||||
totalCount: result.totalCount,
|
|
||||||
includeDeleted: query.includeDeleted || false
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
chats: ChatMapper.toShortDtoList(result.chats),
|
|
||||||
totalCount: result.totalCount
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
logError('GetChatsByPageQueryHandler error', error instanceof Error ? error : new Error(String(error)));
|
|
||||||
|
|
||||||
// Re-throw validation errors as-is
|
|
||||||
if (error instanceof Error && (error.message.includes('Invalid pagination') || error.message.includes('Page size'))) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('Failed to retrieve chats page');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import { GetUserChatsQuery } from './ChatQueries';
|
|
||||||
import { IChatRepository } from '../../../Domain/IRepository/IChatRepository';
|
|
||||||
import { IChatArchiveRepository } from '../../../Domain/IRepository/IChatArchiveRepository';
|
|
||||||
import { ChatAggregate } from '../../../Domain/Chat/ChatAggregate';
|
|
||||||
import { ChatArchiveAggregate } from '../../../Domain/Chat/ChatArchiveAggregate';
|
|
||||||
import { logAuth, logError } from '../../Services/Logger';
|
|
||||||
|
|
||||||
interface ChatWithMetadata {
|
|
||||||
id: string;
|
|
||||||
type: string;
|
|
||||||
name: string | null;
|
|
||||||
gameId: string | null;
|
|
||||||
users: string[];
|
|
||||||
lastActivity: Date | null;
|
|
||||||
isArchived: boolean;
|
|
||||||
messageCount: number;
|
|
||||||
unreadCount?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class GetUserChatsQueryHandler {
|
|
||||||
constructor(
|
|
||||||
private chatRepository: IChatRepository,
|
|
||||||
private chatArchiveRepository: IChatArchiveRepository
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async execute(query: GetUserChatsQuery): Promise<ChatWithMetadata[]> {
|
|
||||||
try {
|
|
||||||
const result: ChatWithMetadata[] = [];
|
|
||||||
|
|
||||||
// Get active chats
|
|
||||||
const activeChats = await this.chatRepository.findActiveChatsForUser(query.userId);
|
|
||||||
result.push(...activeChats.map(chat => ({
|
|
||||||
id: chat.id,
|
|
||||||
type: chat.type,
|
|
||||||
name: chat.name,
|
|
||||||
gameId: chat.gameId,
|
|
||||||
users: chat.users,
|
|
||||||
lastActivity: chat.lastActivity,
|
|
||||||
isArchived: false,
|
|
||||||
messageCount: chat.messages.length,
|
|
||||||
unreadCount: this.calculateUnreadMessages(chat, query.userId)
|
|
||||||
})));
|
|
||||||
|
|
||||||
// Get archived chats if requested
|
|
||||||
if (query.includeArchived) {
|
|
||||||
const userActiveChats = await this.chatRepository.findByUserId(query.userId);
|
|
||||||
const archivedChatIds = userActiveChats
|
|
||||||
.filter(chat => chat.archiveDate !== null)
|
|
||||||
.map(chat => chat.id);
|
|
||||||
|
|
||||||
const archives = await Promise.all(
|
|
||||||
archivedChatIds.map(id => this.chatArchiveRepository.findByChatId(id))
|
|
||||||
);
|
|
||||||
|
|
||||||
archives.forEach(archiveArray => {
|
|
||||||
archiveArray.forEach(archive => {
|
|
||||||
if (archive.participants.includes(query.userId)) {
|
|
||||||
result.push({
|
|
||||||
id: archive.chatId,
|
|
||||||
type: archive.chatType,
|
|
||||||
name: archive.chatName,
|
|
||||||
gameId: archive.gameId,
|
|
||||||
users: archive.participants,
|
|
||||||
lastActivity: archive.archivedAt,
|
|
||||||
isArchived: true,
|
|
||||||
messageCount: archive.archivedMessages.length,
|
|
||||||
unreadCount: 0 // Archived chats have no unread messages
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logAuth('User chats retrieved', query.userId, {
|
|
||||||
activeCount: activeChats.length,
|
|
||||||
totalCount: result.length,
|
|
||||||
includeArchived: query.includeArchived
|
|
||||||
});
|
|
||||||
|
|
||||||
return result.sort((a, b) => {
|
|
||||||
if (!a.lastActivity) return 1;
|
|
||||||
if (!b.lastActivity) return -1;
|
|
||||||
return new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime();
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logError('GetUserChatsQueryHandler error', error as Error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private calculateUnreadMessages(chat: ChatAggregate, userId: string): number {
|
|
||||||
// Simple implementation - count messages from other users
|
|
||||||
// In production, you'd store lastSeen timestamp per user per chat
|
|
||||||
return chat.messages.filter(msg => msg.userid !== userId).length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { ContactType } from '../../../Domain/Contact/ContactAggregate';
|
|
||||||
|
|
||||||
export interface CreateContactCommand {
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
userid?: string;
|
|
||||||
type: ContactType;
|
|
||||||
txt: string;
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { IContactRepository } from '../../../Domain/IRepository/IContactRepository';
|
|
||||||
import { CreateContactCommand } from './CreateContactCommand';
|
|
||||||
import { ShortContactDto } from '../../DTOs/ContactDto';
|
|
||||||
import { ContactAggregate, ContactState } from '../../../Domain/Contact/ContactAggregate';
|
|
||||||
import { ContactMapper } from '../../DTOs/Mappers/ContactMapper';
|
|
||||||
|
|
||||||
export class CreateContactCommandHandler {
|
|
||||||
constructor(private readonly contactRepo: IContactRepository) {}
|
|
||||||
|
|
||||||
async execute(cmd: CreateContactCommand): Promise<ShortContactDto> {
|
|
||||||
try {
|
|
||||||
const contact = new ContactAggregate();
|
|
||||||
contact.name = cmd.name;
|
|
||||||
contact.email = cmd.email;
|
|
||||||
contact.userid = cmd.userid || null;
|
|
||||||
contact.type = cmd.type;
|
|
||||||
contact.txt = cmd.txt;
|
|
||||||
contact.state = ContactState.ACTIVE;
|
|
||||||
|
|
||||||
const created = await this.contactRepo.create(contact);
|
|
||||||
return ContactMapper.toShortDto(created);
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error('Failed to create contact');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export interface DeleteContactCommand {
|
|
||||||
id: string;
|
|
||||||
hard?: boolean; // true for permanent delete, false/undefined for soft delete
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { IContactRepository } from '../../../Domain/IRepository/IContactRepository';
|
|
||||||
import { DeleteContactCommand } from './DeleteContactCommand';
|
|
||||||
import { AdminAuditService } from '../../Services/AdminBypassService';
|
|
||||||
import { logRequest } from '../../Services/Logger';
|
|
||||||
|
|
||||||
export class DeleteContactCommandHandler {
|
|
||||||
constructor(private readonly contactRepo: IContactRepository) {}
|
|
||||||
|
|
||||||
async execute(cmd: DeleteContactCommand): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const existingContact = await this.contactRepo.findById(cmd.id);
|
|
||||||
if (!existingContact) {
|
|
||||||
throw new Error('Contact not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cmd.hard) {
|
|
||||||
// Permanent delete
|
|
||||||
await this.contactRepo.delete(cmd.id);
|
|
||||||
logRequest('Contact hard deleted', undefined, undefined, {
|
|
||||||
contactId: cmd.id,
|
|
||||||
contactEmail: existingContact.email,
|
|
||||||
deleteType: 'hard'
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Soft delete (default)
|
|
||||||
await this.contactRepo.softDelete(cmd.id);
|
|
||||||
logRequest('Contact soft deleted', undefined, undefined, {
|
|
||||||
contactId: cmd.id,
|
|
||||||
contactEmail: existingContact.email,
|
|
||||||
deleteType: 'soft'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error && error.message === 'Contact not found') {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
throw new Error('Failed to delete contact');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export interface UpdateContactCommand {
|
|
||||||
id: string;
|
|
||||||
adminResponse?: string;
|
|
||||||
state?: number;
|
|
||||||
respondedBy?: string;
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { IContactRepository } from '../../../Domain/IRepository/IContactRepository';
|
|
||||||
import { UpdateContactCommand } from './UpdateContactCommand';
|
|
||||||
import { DetailContactDto } from '../../DTOs/ContactDto';
|
|
||||||
import { ContactMapper } from '../../DTOs/Mappers/ContactMapper';
|
|
||||||
import { ContactState } from '../../../Domain/Contact/ContactAggregate';
|
|
||||||
|
|
||||||
export class UpdateContactCommandHandler {
|
|
||||||
constructor(private readonly contactRepo: IContactRepository) {}
|
|
||||||
|
|
||||||
async execute(cmd: UpdateContactCommand): Promise<DetailContactDto> {
|
|
||||||
try {
|
|
||||||
const existingContact = await this.contactRepo.findById(cmd.id);
|
|
||||||
if (!existingContact) {
|
|
||||||
throw new Error('Contact not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateData: any = {};
|
|
||||||
|
|
||||||
if (cmd.adminResponse !== undefined) {
|
|
||||||
updateData.adminResponse = cmd.adminResponse;
|
|
||||||
updateData.responseDate = new Date();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cmd.state !== undefined) {
|
|
||||||
updateData.state = cmd.state;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cmd.respondedBy !== undefined) {
|
|
||||||
updateData.respondedBy = cmd.respondedBy;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await this.contactRepo.update(cmd.id, updateData);
|
|
||||||
if (!updated) {
|
|
||||||
throw new Error('Failed to update contact');
|
|
||||||
}
|
|
||||||
|
|
||||||
return ContactMapper.toDetailDto(updated);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error && error.message === 'Contact not found') {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
throw new Error('Failed to update contact');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export interface GetContactByIdQuery {
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { IContactRepository } from '../../../Domain/IRepository/IContactRepository';
|
|
||||||
import { GetContactByIdQuery } from './GetContactByIdQuery';
|
|
||||||
import { DetailContactDto } from '../../DTOs/ContactDto';
|
|
||||||
import { ContactMapper } from '../../DTOs/Mappers/ContactMapper';
|
|
||||||
|
|
||||||
export class GetContactByIdQueryHandler {
|
|
||||||
constructor(private readonly contactRepo: IContactRepository) {}
|
|
||||||
|
|
||||||
async execute(query: GetContactByIdQuery): Promise<DetailContactDto | null> {
|
|
||||||
const contact = await this.contactRepo.findById(query.id);
|
|
||||||
if (!contact) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return ContactMapper.toDetailDto(contact);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export interface GetContactsByPageQuery {
|
|
||||||
from: number;
|
|
||||||
to: number;
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { IContactRepository } from '../../../Domain/IRepository/IContactRepository';
|
|
||||||
import { GetContactsByPageQuery } from './GetContactsByPageQuery';
|
|
||||||
import { ContactPageDto } from '../../DTOs/ContactDto';
|
|
||||||
import { ContactMapper } from '../../DTOs/Mappers/ContactMapper';
|
|
||||||
|
|
||||||
export class GetContactsByPageQueryHandler {
|
|
||||||
constructor(private readonly contactRepo: IContactRepository) {}
|
|
||||||
|
|
||||||
async execute(query: GetContactsByPageQuery): Promise<ContactPageDto> {
|
|
||||||
const result = await this.contactRepo.findByPage(query.from, query.to);
|
|
||||||
return {
|
|
||||||
contacts: ContactMapper.toShortDtoList(result.contacts),
|
|
||||||
totalCount: result.totalCount,
|
|
||||||
from: query.from,
|
|
||||||
to: query.to,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
export interface CreateChatDto {
|
|
||||||
users: string[];
|
|
||||||
messages: import('../../Domain/Chat/ChatAggregate').Message[];
|
|
||||||
state?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateChatDto {
|
|
||||||
id: string;
|
|
||||||
users?: string[];
|
|
||||||
messages?: import('../../Domain/Chat/ChatAggregate').Message[];
|
|
||||||
state?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShortChatDto {
|
|
||||||
id: string;
|
|
||||||
userCount: number;
|
|
||||||
state: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DetailChatDto {
|
|
||||||
id: string;
|
|
||||||
users: string[];
|
|
||||||
messages: import('../../Domain/Chat/ChatAggregate').Message[];
|
|
||||||
updateDate: Date;
|
|
||||||
state: number;
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { ContactType } from '../../Domain/Contact/ContactAggregate';
|
|
||||||
|
|
||||||
export interface CreateContactDto {
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
userid?: string;
|
|
||||||
type: ContactType;
|
|
||||||
txt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateContactDto {
|
|
||||||
id: string;
|
|
||||||
adminResponse?: string;
|
|
||||||
state?: number;
|
|
||||||
respondedBy?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShortContactDto {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
type: ContactType;
|
|
||||||
createDate: Date;
|
|
||||||
state: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DetailContactDto {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
userid: string | null;
|
|
||||||
type: ContactType;
|
|
||||||
txt: string;
|
|
||||||
state: number;
|
|
||||||
createDate: Date;
|
|
||||||
updateDate: Date;
|
|
||||||
adminResponse: string | null;
|
|
||||||
responseDate: Date | null;
|
|
||||||
respondedBy: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ContactPageDto {
|
|
||||||
contacts: ShortContactDto[];
|
|
||||||
totalCount: number;
|
|
||||||
from: number;
|
|
||||||
to: number;
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
export interface CreateDeckDto {
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateDeckDto {
|
|
||||||
id: string;
|
|
||||||
name?: string;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShortDeckDto {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
type: number;
|
|
||||||
playedNumber: number;
|
|
||||||
ctype: number;
|
|
||||||
cardCount: number;
|
|
||||||
creator: string;
|
|
||||||
creationdate: Date;
|
|
||||||
editable?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DetailDeckDto {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
type: number;
|
|
||||||
userid: string;
|
|
||||||
creationdate: Date;
|
|
||||||
cards: any[];
|
|
||||||
playedNumber: number;
|
|
||||||
ctype: number;
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
export abstract class BaseMapper<TEntity, TShortDto, TDetailDto> {
|
|
||||||
abstract toShortDto(entity: TEntity): TShortDto;
|
|
||||||
abstract toDetailDto(entity: TEntity): TDetailDto;
|
|
||||||
|
|
||||||
toShortDtoList(entities: TEntity[]): TShortDto[] {
|
|
||||||
return entities.map(entity => this.toShortDto(entity));
|
|
||||||
}
|
|
||||||
|
|
||||||
toDetailDtoList(entities: TEntity[]): TDetailDto[] {
|
|
||||||
return entities.map(entity => this.toDetailDto(entity));
|
|
||||||
}
|
|
||||||
|
|
||||||
static toShortDtoListStatic<T, TDto>(
|
|
||||||
entities: T[],
|
|
||||||
mapperFn: (entity: T) => TDto
|
|
||||||
): TDto[] {
|
|
||||||
return entities.map(mapperFn);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { ChatAggregate } from '../../../Domain/Chat/ChatAggregate';
|
|
||||||
import { ShortChatDto, DetailChatDto } from '../ChatDto';
|
|
||||||
|
|
||||||
export class ChatMapper {
|
|
||||||
static toShortDto(chat: ChatAggregate): ShortChatDto {
|
|
||||||
return {
|
|
||||||
id: chat.id,
|
|
||||||
userCount: chat.users?.length ?? 0,
|
|
||||||
state: chat.state,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
static toDetailDto(chat: ChatAggregate): DetailChatDto {
|
|
||||||
return {
|
|
||||||
id: chat.id,
|
|
||||||
users: chat.users ?? [],
|
|
||||||
messages: chat.messages,
|
|
||||||
updateDate: chat.updateDate,
|
|
||||||
state: chat.state,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
static toShortDtoList(chats: ChatAggregate[]): ShortChatDto[] {
|
|
||||||
return chats.map(this.toShortDto);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { ContactAggregate } from '../../../Domain/Contact/ContactAggregate';
|
|
||||||
import { CreateContactDto, UpdateContactDto, ShortContactDto, DetailContactDto } from '../ContactDto';
|
|
||||||
|
|
||||||
export class ContactMapper {
|
|
||||||
static toShortDto(contact: ContactAggregate): ShortContactDto {
|
|
||||||
return {
|
|
||||||
id: contact.id,
|
|
||||||
name: contact.name,
|
|
||||||
email: contact.email,
|
|
||||||
type: contact.type,
|
|
||||||
createDate: contact.createDate,
|
|
||||||
state: contact.state,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
static toDetailDto(contact: ContactAggregate): DetailContactDto {
|
|
||||||
return {
|
|
||||||
id: contact.id,
|
|
||||||
name: contact.name,
|
|
||||||
email: contact.email,
|
|
||||||
userid: contact.userid,
|
|
||||||
type: contact.type,
|
|
||||||
txt: contact.txt,
|
|
||||||
state: contact.state,
|
|
||||||
createDate: contact.createDate,
|
|
||||||
updateDate: contact.updateDate,
|
|
||||||
adminResponse: contact.adminResponse,
|
|
||||||
responseDate: contact.responseDate,
|
|
||||||
respondedBy: contact.respondedBy,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
static toShortDtoList(contacts: ContactAggregate[]): ShortContactDto[] {
|
|
||||||
return contacts.map(this.toShortDto);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { DeckAggregate } from '../../../Domain/Deck/DeckAggregate';
|
|
||||||
import { UserAggregate } from '../../../Domain/User/UserAggregate';
|
|
||||||
import { CreateDeckDto, UpdateDeckDto, ShortDeckDto, DetailDeckDto } from '../DeckDto';
|
|
||||||
|
|
||||||
export class DeckMapper {
|
|
||||||
static toShortDto(deck: DeckAggregate, userId?: string): ShortDeckDto {
|
|
||||||
return {
|
|
||||||
id: deck.id,
|
|
||||||
name: deck.name,
|
|
||||||
type: deck.type,
|
|
||||||
playedNumber: deck.playedNumber,
|
|
||||||
ctype: deck.ctype,
|
|
||||||
cardCount: deck.cards.length,
|
|
||||||
creator: deck.user?.username || 'Unknown',
|
|
||||||
creationdate: deck.creationdate,
|
|
||||||
editable: deck.isEditable(userId!) ? deck.isEditable(userId!) : undefined
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
static toDetailDto(deck: DeckAggregate): DetailDeckDto {
|
|
||||||
return {
|
|
||||||
id: deck.id,
|
|
||||||
name: deck.name,
|
|
||||||
type: deck.type,
|
|
||||||
userid: deck.userid,
|
|
||||||
creationdate: deck.creationdate,
|
|
||||||
cards: deck.cards,
|
|
||||||
playedNumber: deck.playedNumber,
|
|
||||||
ctype: deck.ctype,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
static toShortDtoList(decks: DeckAggregate[], userId?: string): ShortDeckDto[] {
|
|
||||||
return decks.map(deck => ({
|
|
||||||
id: deck.id,
|
|
||||||
name: deck.name,
|
|
||||||
type: deck.type,
|
|
||||||
playedNumber: deck.playedNumber,
|
|
||||||
ctype: deck.ctype,
|
|
||||||
cardCount: deck.cards.length,
|
|
||||||
creator: deck.user?.username || 'Unknown',
|
|
||||||
creationdate: deck.creationdate,
|
|
||||||
editable: deck.isEditable(userId!) ? deck.isEditable(userId!) : undefined
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { OrganizationAggregate } from '../../../Domain/Organization/OrganizationAggregate';
|
|
||||||
import { CreateOrganizationDto, UpdateOrganizationDto, ShortOrganizationDto, DetailOrganizationDto } from '../OrganizationDto';
|
|
||||||
|
|
||||||
export class OrganizationMapper {
|
|
||||||
static toShortDto(org: OrganizationAggregate): ShortOrganizationDto {
|
|
||||||
return {
|
|
||||||
id: org.id,
|
|
||||||
name: org.name,
|
|
||||||
state: org.state,
|
|
||||||
userinorg: org.userinorg,
|
|
||||||
maxOrganizationalDecks: org.maxOrganizationalDecks,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
static toDetailDto(org: OrganizationAggregate): DetailOrganizationDto {
|
|
||||||
return {
|
|
||||||
id: org.id,
|
|
||||||
name: org.name,
|
|
||||||
contactfname: org.contactfname,
|
|
||||||
contactlname: org.contactlname,
|
|
||||||
contactphone: org.contactphone,
|
|
||||||
contactemail: org.contactemail,
|
|
||||||
state: org.state,
|
|
||||||
regdate: org.regdate,
|
|
||||||
updateDate: org.updateDate,
|
|
||||||
url: org.url,
|
|
||||||
userinorg: org.userinorg,
|
|
||||||
maxOrganizationalDecks: org.maxOrganizationalDecks,
|
|
||||||
users: org.users?.map(u => u.id) ?? [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
static toShortDtoList(orgs: OrganizationAggregate[]): ShortOrganizationDto[] {
|
|
||||||
return orgs.map(this.toShortDto);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { UserAggregate, UserState } from '../../../Domain/User/UserAggregate';
|
|
||||||
import { CreateUserDto, UpdateUserDto, ShortUserDto, DetailUserDto } from '../UserDto';
|
|
||||||
import { BaseMapper } from './BaseMapper';
|
|
||||||
|
|
||||||
export class UserMapper {
|
|
||||||
static toShortDto(user: UserAggregate): ShortUserDto {
|
|
||||||
return {
|
|
||||||
username: user.username,
|
|
||||||
authLevel: (user.state === UserState.ADMIN ? 1 : 0) as 0 | 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
static toDetailDto(user: UserAggregate): DetailUserDto {
|
|
||||||
return {
|
|
||||||
id: user.id,
|
|
||||||
orgid: user.orgid,
|
|
||||||
username: user.username,
|
|
||||||
email: user.email,
|
|
||||||
fname: user.fname,
|
|
||||||
lname: user.lname,
|
|
||||||
code: user.token,
|
|
||||||
phone: user.phone,
|
|
||||||
state: user.state,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
static toShortDtoList(users: UserAggregate[]): ShortUserDto[] {
|
|
||||||
return BaseMapper.toShortDtoListStatic(users, UserMapper.toShortDto);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
export interface CreateOrganizationDto {
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
maxOrganizationalDecks?: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateOrganizationDto {
|
|
||||||
id: string;
|
|
||||||
name?: string;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShortOrganizationDto {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
state: number;
|
|
||||||
userinorg: number;
|
|
||||||
maxOrganizationalDecks?: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DetailOrganizationDto {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
contactfname: string;
|
|
||||||
contactlname: string;
|
|
||||||
contactphone: string;
|
|
||||||
contactemail: string;
|
|
||||||
state: number;
|
|
||||||
regdate: Date;
|
|
||||||
updateDate: Date;
|
|
||||||
url: string | null;
|
|
||||||
userinorg: number;
|
|
||||||
maxOrganizationalDecks: number | null;
|
|
||||||
users: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OrganizationLoginUrlDto {
|
|
||||||
organizationId: string;
|
|
||||||
organizationName: string;
|
|
||||||
loginUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OrganizationAuthCallbackDto {
|
|
||||||
organizationId: string;
|
|
||||||
userId: string;
|
|
||||||
status: 'ok' | 'not_ok';
|
|
||||||
authToken?: string;
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
export interface SearchQuery {
|
|
||||||
query: string;
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchResult<T> {
|
|
||||||
results: T[];
|
|
||||||
totalCount: number;
|
|
||||||
hasMore: boolean;
|
|
||||||
searchQuery: string;
|
|
||||||
searchType: 'users' | 'organizations' | 'decks';
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
export interface CreateUserDto {
|
|
||||||
username: string;
|
|
||||||
email: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateUserDto {
|
|
||||||
id: string;
|
|
||||||
username?: string;
|
|
||||||
email?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShortUserDto {
|
|
||||||
username: string;
|
|
||||||
authLevel: 0 | 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DetailUserDto {
|
|
||||||
id: string;
|
|
||||||
orgid: string | null;
|
|
||||||
username: string;
|
|
||||||
email: string;
|
|
||||||
fname: string;
|
|
||||||
lname: string;
|
|
||||||
code: string | null;
|
|
||||||
phone: string | null;
|
|
||||||
state: number;
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
export interface CreateDeckCommand {
|
|
||||||
name: string;
|
|
||||||
type: number;
|
|
||||||
userid: string;
|
|
||||||
cards: any[];
|
|
||||||
ctype?: number;
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
import { IDeckRepository } from '../../../Domain/IRepository/IDeckRepository';
|
|
||||||
import { IUserRepository } from '../../../Domain/IRepository/IUserRepository';
|
|
||||||
import { IOrganizationRepository } from '../../../Domain/IRepository/IOrganizationRepository';
|
|
||||||
import { CreateDeckCommand } from './CreateDeckCommand';
|
|
||||||
import { ShortDeckDto } from '../../DTOs/DeckDto';
|
|
||||||
import { DeckAggregate, State, CType } from '../../../Domain/Deck/DeckAggregate';
|
|
||||||
import { UserState } from '../../../Domain/User/UserAggregate';
|
|
||||||
import { DeckMapper } from '../../DTOs/Mappers/DeckMapper';
|
|
||||||
import { AdminBypassService } from '../../Services/AdminBypassService';
|
|
||||||
import { logRequest } from '../../Services/Logger';
|
|
||||||
|
|
||||||
export class CreateDeckCommandHandler {
|
|
||||||
constructor(
|
|
||||||
private readonly deckRepo: IDeckRepository,
|
|
||||||
private readonly userRepo: IUserRepository,
|
|
||||||
private readonly orgRepo: IOrganizationRepository
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async execute(cmd: CreateDeckCommand): Promise<ShortDeckDto> {
|
|
||||||
try {
|
|
||||||
// 1. Get user details
|
|
||||||
const user = await this.userRepo.findById(cmd.userid);
|
|
||||||
if (!user) {
|
|
||||||
throw new Error('User not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. ADMIN BYPASS - Skip all restrictions
|
|
||||||
if (AdminBypassService.shouldBypassRestrictions(user.state)) {
|
|
||||||
AdminBypassService.logAdminBypass(
|
|
||||||
'CREATE_DECK_BYPASS',
|
|
||||||
user.id,
|
|
||||||
'new-deck',
|
|
||||||
{
|
|
||||||
deckName: cmd.name,
|
|
||||||
deckType: cmd.type,
|
|
||||||
cardCount: cmd.cards.length,
|
|
||||||
ctype: cmd.ctype
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return this.createDeck(cmd);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Check deck count limits for regular users
|
|
||||||
const userDeckCount = await this.deckRepo.countActiveByUserId(cmd.userid);
|
|
||||||
const maxDecks = user.state === UserState.VERIFIED_PREMIUM ? 12 : 8;
|
|
||||||
|
|
||||||
if (userDeckCount >= maxDecks) {
|
|
||||||
throw new Error(`Deck limit exceeded. Maximum ${maxDecks} decks allowed for your account type.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Organizational deck restrictions
|
|
||||||
if (cmd.ctype === CType.ORGANIZATION) {
|
|
||||||
// Only premium users can create organizational decks
|
|
||||||
if (user.state !== UserState.VERIFIED_PREMIUM) {
|
|
||||||
throw new Error('Only premium users can create organizational decks.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// User must belong to an organization
|
|
||||||
if (!user.orgid) {
|
|
||||||
throw new Error('You must be a member of an organization to create organizational decks.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check organization limits
|
|
||||||
const org = await this.orgRepo.findById(user.orgid);
|
|
||||||
if (!org) {
|
|
||||||
throw new Error('Organization not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (org.maxOrganizationalDecks === null) {
|
|
||||||
throw new Error('Organization deck limit not configured. Contact administrator.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const userOrgDeckCount = await this.deckRepo.countOrganizationalByUserId(cmd.userid);
|
|
||||||
if (userOrgDeckCount >= org.maxOrganizationalDecks) {
|
|
||||||
throw new Error(`Organization deck limit exceeded. Maximum ${org.maxOrganizationalDecks} organizational decks allowed.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Create deck with restrictions passed
|
|
||||||
return this.createDeck(cmd);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
throw error; // Re-throw known errors with original message
|
|
||||||
}
|
|
||||||
throw new Error('Failed to create deck');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Private method to create deck after all validations
|
|
||||||
*/
|
|
||||||
private async createDeck(cmd: CreateDeckCommand): Promise<ShortDeckDto> {
|
|
||||||
const deck = new DeckAggregate();
|
|
||||||
deck.name = cmd.name;
|
|
||||||
deck.type = cmd.type;
|
|
||||||
deck.userid = cmd.userid;
|
|
||||||
deck.cards = cmd.cards;
|
|
||||||
deck.ctype = cmd.ctype ?? CType.PUBLIC;
|
|
||||||
deck.state = State.ACTIVE;
|
|
||||||
|
|
||||||
// Set organization reference for organizational decks
|
|
||||||
if (cmd.ctype === CType.ORGANIZATION) {
|
|
||||||
const user = await this.userRepo.findById(cmd.userid);
|
|
||||||
if (user?.orgid) {
|
|
||||||
const org = await this.orgRepo.findById(user.orgid);
|
|
||||||
if (org) {
|
|
||||||
deck.organization = org;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const created = await this.deckRepo.create(deck);
|
|
||||||
|
|
||||||
logRequest('Deck created successfully', undefined, undefined, {
|
|
||||||
deckId: created.id,
|
|
||||||
userId: cmd.userid,
|
|
||||||
deckName: cmd.name,
|
|
||||||
deckType: cmd.type,
|
|
||||||
ctype: cmd.ctype,
|
|
||||||
cardCount: cmd.cards.length
|
|
||||||
});
|
|
||||||
|
|
||||||
return DeckMapper.toShortDto(created);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export interface DeleteDeckCommand {
|
|
||||||
userid: string;
|
|
||||||
authLevel: number;
|
|
||||||
id: string;
|
|
||||||
soft?: boolean;
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { IDeckRepository } from '../../../Domain/IRepository/IDeckRepository';
|
|
||||||
import { logAuth, logError } from '../../Services/Logger';
|
|
||||||
import { DeleteDeckCommand } from './DeleteDeckCommand';
|
|
||||||
|
|
||||||
export class DeleteDeckCommandHandler {
|
|
||||||
constructor(private readonly deckRepo: IDeckRepository) {}
|
|
||||||
|
|
||||||
async execute(cmd: DeleteDeckCommand): Promise<boolean> {
|
|
||||||
|
|
||||||
//get decks userid
|
|
||||||
const deck = await this.deckRepo.findById(cmd.id);
|
|
||||||
if (!deck) {
|
|
||||||
logError(`Deck not found with ID: ${cmd.id}`);
|
|
||||||
throw new Error('Deck not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if(cmd.authLevel !==1 && deck.userid !== cmd.userid) {
|
|
||||||
logAuth(`Unauthorized access attempt to deck with ID: ${cmd.id}, UserID: ${cmd.userid}`);
|
|
||||||
throw new Error('Unauthorized');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cmd.soft) {
|
|
||||||
await this.deckRepo.softDelete(cmd.id);
|
|
||||||
} else {
|
|
||||||
await this.deckRepo.delete(cmd.id);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
export interface UpdateDeckCommand {
|
|
||||||
userid: string;
|
|
||||||
authLevel: number;
|
|
||||||
id: string;
|
|
||||||
userstate?: number;
|
|
||||||
name?: string;
|
|
||||||
type?: number;
|
|
||||||
cards?: any[];
|
|
||||||
ctype?: number;
|
|
||||||
state?: number;
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
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 { logAuth, logError } from '../../Services/Logger';
|
|
||||||
|
|
||||||
export class UpdateDeckCommandHandler {
|
|
||||||
constructor(private readonly deckRepo: IDeckRepository) {}
|
|
||||||
|
|
||||||
async execute(cmd: UpdateDeckCommand): Promise<ShortDeckDto | null> {
|
|
||||||
if(cmd.state !== undefined && cmd.authLevel !== 1) {
|
|
||||||
throw new Error('Only admin users can change deck state');
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
let existingDeck: DeckAggregate | null = null;
|
|
||||||
if (cmd.authLevel === 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');
|
|
||||||
}
|
|
||||||
|
|
||||||
if(cmd.authLevel !== 1 && existingDeck.userid !== cmd.userid) {
|
|
||||||
logAuth(`Unauthorized access attempt to deck with ID: ${cmd.id}, UserID: ${cmd.userid}`);
|
|
||||||
throw new Error('Unauthorized');
|
|
||||||
}
|
|
||||||
|
|
||||||
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,3 +0,0 @@
|
|||||||
export interface GetDeckByIdQuery {
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { IDeckRepository } from '../../../Domain/IRepository/IDeckRepository';
|
|
||||||
import { GetDeckByIdQuery } from './GetDeckByIdQuery';
|
|
||||||
import { DetailDeckDto } from '../../DTOs/DeckDto';
|
|
||||||
import { DeckMapper } from '../../DTOs/Mappers/DeckMapper';
|
|
||||||
|
|
||||||
export class GetDeckByIdQueryHandler {
|
|
||||||
constructor(private readonly deckRepo: IDeckRepository) {}
|
|
||||||
|
|
||||||
async execute(query: GetDeckByIdQuery): Promise<DetailDeckDto | null> {
|
|
||||||
const deck = await this.deckRepo.findById(query.id);
|
|
||||||
if (!deck) return null;
|
|
||||||
return DeckMapper.toDetailDto(deck);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
export interface GetDecksByPageQuery {
|
|
||||||
from: number;
|
|
||||||
to: number;
|
|
||||||
userId: string;
|
|
||||||
userOrgId?: string;
|
|
||||||
isAdmin: boolean;
|
|
||||||
includeDeleted?: boolean;
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import { IDeckRepository } from '../../../Domain/IRepository/IDeckRepository';
|
|
||||||
import { GetDecksByPageQuery } from './GetDecksByPageQuery';
|
|
||||||
import { ShortDeckDto } from '../../DTOs/DeckDto';
|
|
||||||
import { DeckMapper } from '../../DTOs/Mappers/DeckMapper';
|
|
||||||
import { AdminBypassService } from '../../Services/AdminBypassService';
|
|
||||||
import { logRequest, logError } from '../../Services/Logger';
|
|
||||||
|
|
||||||
export class GetDecksByPageQueryHandler {
|
|
||||||
constructor(private readonly deckRepo: IDeckRepository) {}
|
|
||||||
|
|
||||||
async execute(query: GetDecksByPageQuery): Promise<{ decks: ShortDeckDto[], totalCount: number }> {
|
|
||||||
try {
|
|
||||||
// Validate pagination parameters
|
|
||||||
if (query.from < 0 || query.to < query.from) {
|
|
||||||
throw new Error('Invalid pagination parameters');
|
|
||||||
}
|
|
||||||
|
|
||||||
const limit = query.to - query.from + 1;
|
|
||||||
if (limit > 100) {
|
|
||||||
throw new Error('Page size too large. Maximum 100 records per request');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log admin bypass if applicable
|
|
||||||
if (query.isAdmin) {
|
|
||||||
AdminBypassService.logAdminBypass(
|
|
||||||
'GET_DECKS_PAGE_BYPASS',
|
|
||||||
query.userId,
|
|
||||||
'paginated-decks',
|
|
||||||
{
|
|
||||||
from: query.from,
|
|
||||||
to: query.to,
|
|
||||||
includesDeleted: query.includeDeleted || false,
|
|
||||||
operation: 'read'
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
logRequest('Get decks by page query started', undefined, undefined, {
|
|
||||||
userId: query.userId,
|
|
||||||
userOrgId: query.userOrgId,
|
|
||||||
isAdmin: query.isAdmin,
|
|
||||||
from: query.from,
|
|
||||||
to: query.to,
|
|
||||||
includeDeleted: query.includeDeleted || false
|
|
||||||
});
|
|
||||||
|
|
||||||
// Use paginated filtered deck finding method
|
|
||||||
const result = await this.deckRepo.findFilteredDecks(
|
|
||||||
query.userId,
|
|
||||||
query.userOrgId,
|
|
||||||
query.isAdmin,
|
|
||||||
query.from,
|
|
||||||
query.to
|
|
||||||
);
|
|
||||||
|
|
||||||
logRequest('Get decks by page query completed', undefined, undefined, {
|
|
||||||
userId: query.userId,
|
|
||||||
userOrgId: query.userOrgId,
|
|
||||||
isAdmin: query.isAdmin,
|
|
||||||
from: query.from,
|
|
||||||
to: query.to,
|
|
||||||
returned: result.decks.length,
|
|
||||||
totalCount: result.totalCount,
|
|
||||||
includeDeleted: query.includeDeleted || false
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
decks: DeckMapper.toShortDtoList(result.decks, query.userId),
|
|
||||||
totalCount: result.totalCount
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
logError('GetDecksByPageQueryHandler error', error instanceof Error ? error : new Error(String(error)));
|
|
||||||
|
|
||||||
// Re-throw validation errors as-is
|
|
||||||
if (error instanceof Error && (error.message.includes('Invalid pagination') || error.message.includes('Page size'))) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('Failed to retrieve decks page');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
import { GameField, BoardData } from '../../Domain/Game/GameAggregate';
|
|
||||||
import { logOther, logError } from '../Services/Logger';
|
|
||||||
|
|
||||||
interface SpecialFieldInfo {
|
|
||||||
position: number;
|
|
||||||
type: 'positive' | 'negative' | 'luck';
|
|
||||||
}
|
|
||||||
|
|
||||||
export class BoardGenerationService {
|
|
||||||
async generateBoard(
|
|
||||||
positiveFieldCount: number,
|
|
||||||
negativeFieldCount: number,
|
|
||||||
luckFieldCount: number
|
|
||||||
): Promise<BoardData> {
|
|
||||||
// Pattern-based approach has 100% success rate, no retry needed
|
|
||||||
const result = this.generateSingleAttempt(positiveFieldCount, negativeFieldCount, luckFieldCount);
|
|
||||||
|
|
||||||
logOther('Pattern-based board generation completed', {
|
|
||||||
totalFields: result.fields.length,
|
|
||||||
specialFields: result.fields.filter((f: GameField) => f.type !== 'regular').length,
|
|
||||||
positiveFields: result.fields.filter((f: GameField) => f.type === 'positive').length,
|
|
||||||
negativeFields: result.fields.filter((f: GameField) => f.type === 'negative').length,
|
|
||||||
luckFields: result.fields.filter((f: GameField) => f.type === 'luck').length
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private generateSingleAttempt(
|
|
||||||
positiveFieldCount: number,
|
|
||||||
negativeFieldCount: number,
|
|
||||||
luckFieldCount: number
|
|
||||||
): BoardData {
|
|
||||||
// Step 1: Choose special field positions
|
|
||||||
const specialFieldPositions = this.chooseSpecialFieldPositions(
|
|
||||||
positiveFieldCount,
|
|
||||||
negativeFieldCount,
|
|
||||||
luckFieldCount
|
|
||||||
);
|
|
||||||
|
|
||||||
// Step 2: Calculate step values using pattern-based approach
|
|
||||||
const fields = this.calculatePatternBasedStepValues(specialFieldPositions);
|
|
||||||
|
|
||||||
return {
|
|
||||||
fields
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private chooseSpecialFieldPositions(
|
|
||||||
positiveFieldCount: number,
|
|
||||||
negativeFieldCount: number,
|
|
||||||
luckFieldCount: number
|
|
||||||
): SpecialFieldInfo[] {
|
|
||||||
const totalSpecial = positiveFieldCount + negativeFieldCount + luckFieldCount;
|
|
||||||
const specialFields: SpecialFieldInfo[] = [];
|
|
||||||
|
|
||||||
// Generate unique random positions
|
|
||||||
const positions = new Set<number>();
|
|
||||||
while (positions.size < totalSpecial) {
|
|
||||||
const position = Math.floor(Math.random() * 100) + 1; // 1-100
|
|
||||||
positions.add(position);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to sorted array
|
|
||||||
const sortedPositions = Array.from(positions).sort((a, b) => a - b);
|
|
||||||
|
|
||||||
// Distribute types randomly
|
|
||||||
const types: ('positive' | 'negative' | 'luck')[] = [
|
|
||||||
...Array(positiveFieldCount).fill('positive'),
|
|
||||||
...Array(negativeFieldCount).fill('negative'),
|
|
||||||
...Array(luckFieldCount).fill('luck')
|
|
||||||
];
|
|
||||||
|
|
||||||
// Shuffle types
|
|
||||||
for (let i = types.length - 1; i > 0; i--) {
|
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
|
||||||
[types[i], types[j]] = [types[j], types[i]];
|
|
||||||
}
|
|
||||||
|
|
||||||
sortedPositions.forEach((position, index) => {
|
|
||||||
specialFields.push({
|
|
||||||
position,
|
|
||||||
type: types[index] || 'positive'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return specialFields;
|
|
||||||
}
|
|
||||||
|
|
||||||
private calculatePatternBasedStepValues(specialFields: SpecialFieldInfo[]): GameField[] {
|
|
||||||
// Initialize all fields as regular
|
|
||||||
const fields: GameField[] = Array.from({ length: 100 }, (_, i) => ({
|
|
||||||
position: i + 1,
|
|
||||||
type: 'regular' as const
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Update special fields with pattern-based step values
|
|
||||||
specialFields.forEach(specialField => {
|
|
||||||
const fieldIndex = specialField.position - 1; // Convert to 0-based index
|
|
||||||
fields[fieldIndex].type = specialField.type;
|
|
||||||
|
|
||||||
if (specialField.type === 'luck') {
|
|
||||||
// Luck fields don't need step values
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate step values based on position rules
|
|
||||||
let maxStepValue: number;
|
|
||||||
let minStepValue: number;
|
|
||||||
|
|
||||||
if (specialField.position <= 80) {
|
|
||||||
// Positions 1-80: step values can be ±20
|
|
||||||
maxStepValue = 20;
|
|
||||||
minStepValue = -20;
|
|
||||||
} else {
|
|
||||||
// Positions 81-100: step values can be -30 to +10
|
|
||||||
maxStepValue = 10;
|
|
||||||
minStepValue = -30;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate appropriate step value for field type
|
|
||||||
if (specialField.type === 'positive') {
|
|
||||||
// Positive fields: use positive step values (1-3 range for balanced gameplay)
|
|
||||||
// Max movement: 3 × 6 (dice) = 18 steps
|
|
||||||
const stepValue = Math.floor(Math.random() * 3) + 1; // 1-3
|
|
||||||
fields[fieldIndex].stepValue = Math.min(stepValue, maxStepValue);
|
|
||||||
} else {
|
|
||||||
// Negative fields: use negative step values (-1 to -3 range)
|
|
||||||
// Max backward: -3 × 6 (dice) = -18 steps
|
|
||||||
const stepValue = -(Math.floor(Math.random() * 3) + 1); // -1 to -3
|
|
||||||
fields[fieldIndex].stepValue = Math.max(stepValue, minStepValue);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return fields;
|
|
||||||
}
|
|
||||||
|
|
||||||
// This method can be used by FieldEffectService for movement calculations
|
|
||||||
public calculatePatternBasedMovement(
|
|
||||||
currentPosition: number,
|
|
||||||
stepValue: number,
|
|
||||||
diceValue: number
|
|
||||||
): number {
|
|
||||||
// Calculate pattern modifier based on current position
|
|
||||||
const patternModifier = this.getPatternModifier(currentPosition, stepValue > 0);
|
|
||||||
|
|
||||||
// Calculate final position: currentPosition + (stepValue × dice) + patternModifier
|
|
||||||
const movement = stepValue * diceValue;
|
|
||||||
let finalPosition = currentPosition + movement + patternModifier;
|
|
||||||
|
|
||||||
// Ensure position stays within board bounds (1-100)
|
|
||||||
if (finalPosition < 1) {
|
|
||||||
finalPosition = 1;
|
|
||||||
} else if (finalPosition > 100) {
|
|
||||||
finalPosition = 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
return finalPosition;
|
|
||||||
}
|
|
||||||
|
|
||||||
public getPatternModifier(position: number, positiveField: boolean): number {
|
|
||||||
// Pattern modifiers STACK for strategic complexity:
|
|
||||||
// - Positions ending in 0 (10, 20, 30...): No modifier
|
|
||||||
// - Positions ending in 5 (15, 25, 35...): ±3 modifier
|
|
||||||
// - Positions divisible by 3 (9, 12, 21...): ±2 modifier
|
|
||||||
// - Odd positions (1, 7, 11...): ±1 modifier
|
|
||||||
// Multiple conditions can apply and stack
|
|
||||||
|
|
||||||
if (position % 10 === 0) {
|
|
||||||
return 0; // Positions ending in 0 - no modifier
|
|
||||||
}
|
|
||||||
|
|
||||||
let modifier = 0;
|
|
||||||
const direction = positiveField ? 1 : -1;
|
|
||||||
|
|
||||||
// Check each condition and stack modifiers
|
|
||||||
if (position % 10 === 5) {
|
|
||||||
modifier += 3 * direction; // Positions ending in 5
|
|
||||||
}
|
|
||||||
if (position % 3 === 0) {
|
|
||||||
modifier += 2 * direction; // Divisible by 3
|
|
||||||
}
|
|
||||||
if (position % 2 === 1) {
|
|
||||||
modifier += 1 * direction; // Odd positions
|
|
||||||
}
|
|
||||||
|
|
||||||
return modifier;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,303 +0,0 @@
|
|||||||
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
|
|
||||||
*/
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export interface GenerateBoardCommand {
|
|
||||||
gameId: string;
|
|
||||||
positiveFieldCount: number;
|
|
||||||
negativeFieldCount: number;
|
|
||||||
luckFieldCount: number;
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { GenerateBoardCommand } from './GenerateBoardCommand';
|
|
||||||
import { BoardGenerationService } from '../BoardGenerationService';
|
|
||||||
import { RedisService } from '../../Services/RedisService';
|
|
||||||
import { logOther, logError } from '../../Services/Logger';
|
|
||||||
import { BoardData } from '../../../Domain/Game/GameAggregate';
|
|
||||||
|
|
||||||
export class GenerateBoardCommandHandler {
|
|
||||||
constructor(
|
|
||||||
private readonly boardGenerationService: BoardGenerationService,
|
|
||||||
private readonly redisService: RedisService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async execute(cmd: GenerateBoardCommand): Promise<void> {
|
|
||||||
try {
|
|
||||||
logOther(`Starting board generation for game ${cmd.gameId}`);
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
// Generate board with 20-30 rule validation
|
|
||||||
const boardData = await this.boardGenerationService.generateBoard(
|
|
||||||
cmd.positiveFieldCount,
|
|
||||||
cmd.negativeFieldCount,
|
|
||||||
cmd.luckFieldCount
|
|
||||||
);
|
|
||||||
|
|
||||||
// Store in Redis
|
|
||||||
const boardDataWithMetadata: BoardData = {
|
|
||||||
...boardData,
|
|
||||||
gameId: cmd.gameId,
|
|
||||||
generatedAt: new Date(),
|
|
||||||
generationComplete: true
|
|
||||||
};
|
|
||||||
|
|
||||||
await this.redisService.setWithExpiry(
|
|
||||||
`game_board_${cmd.gameId}`,
|
|
||||||
JSON.stringify(boardDataWithMetadata),
|
|
||||||
24 * 60 * 60 // 24 hours
|
|
||||||
);
|
|
||||||
|
|
||||||
const executionTime = Date.now() - startTime;
|
|
||||||
logOther(`Board generation completed for game ${cmd.gameId} in ${executionTime}ms using pattern-based approach`);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logError(`Board generation failed for game ${cmd.gameId}:`, error as Error);
|
|
||||||
|
|
||||||
// Store error state in Redis
|
|
||||||
const errorData: BoardData = {
|
|
||||||
gameId: cmd.gameId,
|
|
||||||
fields: [],
|
|
||||||
generationComplete: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
generatedAt: new Date()
|
|
||||||
};
|
|
||||||
|
|
||||||
await this.redisService.setWithExpiry(
|
|
||||||
`game_board_${cmd.gameId}`,
|
|
||||||
JSON.stringify(errorData),
|
|
||||||
24 * 60 * 60
|
|
||||||
);
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
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
|
|
||||||
// For anonymous players (no playerId), use playerName as the identifier to allow rejoining
|
|
||||||
const actualPlayerId = command.playerId || `guest_${command.playerName}`;
|
|
||||||
|
|
||||||
// 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.gamecode}`;
|
|
||||||
|
|
||||||
// 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
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if player name is already in use by a different player
|
|
||||||
const existingPlayerWithName = gameData.currentPlayers.find(
|
|
||||||
p => p.playerName === command.playerName && p.playerId !== command.playerId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingPlayerWithName) {
|
|
||||||
throw new Error(`Player name "${command.playerName}" is already in use in this game`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
|
|
||||||
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(gameCode: string): Promise<ActiveGameData | null> {
|
|
||||||
try {
|
|
||||||
const redisKey = `game:${gameCode}`;
|
|
||||||
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(gameCode: string, playerId: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
const redisKey = `game:${gameCode}`;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logError('Failed to remove player from Redis', error instanceof Error ? error : new Error(String(error)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,289 +0,0 @@
|
|||||||
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!,
|
|
||||||
orgid: command.orgid || null,
|
|
||||||
gamedecks,
|
|
||||||
players: [],
|
|
||||||
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,
|
|
||||||
type: card.type, // Include card type for proper processing
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export interface StartGamePlayCommand {
|
|
||||||
gameId: string; // Game ID to start
|
|
||||||
userId?: string; // User who is starting the game (should be game master)
|
|
||||||
}
|
|
||||||
@@ -1,480 +0,0 @@
|
|||||||
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
|
|
||||||
currentPlayer: string; // ID of the player whose turn it is
|
|
||||||
turnSequence: string[]; // Ordered array of player IDs based on turnOrder
|
|
||||||
websocketRoom: string;
|
|
||||||
gamePhase: 'starting' | 'playing' | 'paused' | 'finished';
|
|
||||||
boardData: BoardData; // Generated board with fields
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GameStartResult {
|
|
||||||
game: GameAggregate;
|
|
||||||
boardData: BoardData;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class StartGamePlayCommandHandler {
|
|
||||||
private gameRepository: IGameRepository;
|
|
||||||
private redisService: RedisService;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.gameRepository = DIContainer.getInstance().gameRepository;
|
|
||||||
this.redisService = RedisService.getInstance();
|
|
||||||
}
|
|
||||||
|
|
||||||
async handle(command: StartGamePlayCommand): Promise<GameStartResult> {
|
|
||||||
const startTime = performance.now();
|
|
||||||
|
|
||||||
try {
|
|
||||||
logOther('Starting game play', `gameId: ${command.gameId}, userId: ${command.userId || 'system'}`);
|
|
||||||
|
|
||||||
// Find the game
|
|
||||||
const game = await this.gameRepository.findById(command.gameId);
|
|
||||||
if (!game) {
|
|
||||||
throw new Error(`Game with ID ${command.gameId} not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate game can be started
|
|
||||||
this.validateGameCanStart(game, command.userId);
|
|
||||||
|
|
||||||
// Wait for board generation to complete (max 20 seconds)
|
|
||||||
const boardData = await this.waitForBoardGeneration(game.id);
|
|
||||||
|
|
||||||
// Update game state in database
|
|
||||||
const updatedGame = await this.gameRepository.update(game.id, {
|
|
||||||
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 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.gamecode}`;
|
|
||||||
|
|
||||||
// Get connected player names from Redis (stored by WebSocket)
|
|
||||||
const playerNamesMap = await this.getPlayerNames(game.gamecode);
|
|
||||||
|
|
||||||
// Generate random turn orders for all players
|
|
||||||
const playersWithPositions = this.initializePlayerPositions(game.players, playerNamesMap);
|
|
||||||
|
|
||||||
// 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
|
|
||||||
currentPlayer: turnSequence[0], // First player in turn 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);
|
|
||||||
|
|
||||||
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[], playerNamesMap: Map<string, 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,
|
|
||||||
playerName: playerNamesMap.get(playerId) || playerId, // Use mapped name or fallback to ID
|
|
||||||
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,
|
|
||||||
playerName: p.playerName,
|
|
||||||
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 {
|
|
||||||
// Get game play data from Redis (contains board data)
|
|
||||||
const gamePlayData = await this.getGamePlayFromRedis(game.gamecode);
|
|
||||||
if (!gamePlayData) {
|
|
||||||
logError('Game play data not found in Redis', new Error('Missing game play data'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const boardData = gamePlayData.boardData;
|
|
||||||
if (!boardData) {
|
|
||||||
logError('Board data not found in game play data', new Error('Missing board data'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get WebSocket service from DIContainer and broadcast game start
|
|
||||||
const gameWebSocketService = DIContainer.getInstance().gameWebSocketService;
|
|
||||||
await gameWebSocketService.broadcastGameStart(
|
|
||||||
game.gamecode,
|
|
||||||
boardData,
|
|
||||||
gamePlayData.turnSequence,
|
|
||||||
game
|
|
||||||
);
|
|
||||||
|
|
||||||
logOther('Game start notifications sent via WebSocket', {
|
|
||||||
gameId: game.id,
|
|
||||||
gameCode: game.gamecode,
|
|
||||||
playerCount: game.players.length,
|
|
||||||
websocketRoom: `game_${game.gamecode}`,
|
|
||||||
firstPlayer: gamePlayData.turnSequence[0]
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logError('Failed to send 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(gameCode: string): Promise<ActiveGamePlayData | null> {
|
|
||||||
try {
|
|
||||||
const redisKey = `gameplay:${gameCode}`;
|
|
||||||
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(gameCode: string, playerId: string, newPosition: number): Promise<void> {
|
|
||||||
try {
|
|
||||||
const gameData = await this.getGamePlayFromRedis(gameCode);
|
|
||||||
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:${gameCode}`;
|
|
||||||
await this.redisService.setWithExpiry(redisKey, JSON.stringify(gameData), 24 * 60 * 60);
|
|
||||||
|
|
||||||
logOther('Player position updated', {
|
|
||||||
gameCode,
|
|
||||||
playerId,
|
|
||||||
newPosition
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logError('Failed to update player position', error instanceof Error ? error : new Error(String(error)));
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getNextPlayer(gameCode: string): Promise<string | null> {
|
|
||||||
try {
|
|
||||||
const gameData = await this.getGamePlayFromRedis(gameCode);
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getPlayerNames(gameCode: string): Promise<Map<string, string>> {
|
|
||||||
try {
|
|
||||||
// Get active game data from Redis which contains player names
|
|
||||||
const activeGameKey = `game:${gameCode}`;
|
|
||||||
const activeGameStr = await this.redisService.get(activeGameKey);
|
|
||||||
|
|
||||||
const playerNamesMap = new Map<string, string>();
|
|
||||||
|
|
||||||
if (activeGameStr) {
|
|
||||||
const activeGame = JSON.parse(activeGameStr);
|
|
||||||
if (activeGame.currentPlayers && Array.isArray(activeGame.currentPlayers)) {
|
|
||||||
// Map playerIds to playerNames from active game data
|
|
||||||
activeGame.currentPlayers.forEach((player: any) => {
|
|
||||||
if (player.playerId && player.playerName) {
|
|
||||||
playerNamesMap.set(player.playerId, player.playerName);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logOther('Retrieved player names map', {
|
|
||||||
gameCode,
|
|
||||||
playerCount: playerNamesMap.size,
|
|
||||||
players: Array.from(playerNamesMap.entries()).map(([id, name]) => ({ id, name }))
|
|
||||||
});
|
|
||||||
|
|
||||||
return playerNamesMap;
|
|
||||||
} catch (error) {
|
|
||||||
logError('Failed to get player names', error instanceof Error ? error : new Error(String(error)));
|
|
||||||
return new Map();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async advanceTurn(gameId: string): Promise<string | null> {
|
|
||||||
try {
|
|
||||||
const gameData = await this.getGamePlayFromRedis(gameId);
|
|
||||||
if (!gameData) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Advance to next player
|
|
||||||
gameData.currentTurn = (gameData.currentTurn + 1) % gameData.turnSequence.length;
|
|
||||||
const currentPlayer = gameData.turnSequence[gameData.currentTurn];
|
|
||||||
|
|
||||||
// Save back to Redis
|
|
||||||
const redisKey = `gameplay:${gameId}`;
|
|
||||||
await this.redisService.setWithExpiry(redisKey, JSON.stringify(gameData), 24 * 60 * 60);
|
|
||||||
|
|
||||||
logOther('Turn advanced', {
|
|
||||||
gameId,
|
|
||||||
currentTurn: gameData.currentTurn,
|
|
||||||
currentPlayer
|
|
||||||
});
|
|
||||||
|
|
||||||
return currentPlayer;
|
|
||||||
} catch (error) {
|
|
||||||
logError('Failed to advance turn', error instanceof Error ? error : new Error(String(error)));
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async waitForBoardGeneration(gameId: string): Promise<BoardData> {
|
|
||||||
const maxWaitTime = parseInt(process.env.MAX_GENERATION_TIME_SECONDS || '20') * 1000;
|
|
||||||
const pollInterval = 500; // Check every 500ms
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
logOther(`Waiting for board generation for game ${gameId}`, {
|
|
||||||
maxWaitTime: maxWaitTime / 1000,
|
|
||||||
pollInterval,
|
|
||||||
redisKey: `game_board_${gameId}`
|
|
||||||
});
|
|
||||||
|
|
||||||
while (Date.now() - startTime < maxWaitTime) {
|
|
||||||
try {
|
|
||||||
const redisKey = `game_board_${gameId}`;
|
|
||||||
const boardDataStr = await this.redisService.get(redisKey);
|
|
||||||
|
|
||||||
logOther(`Board generation check for game ${gameId}`, {
|
|
||||||
attempt: Math.floor((Date.now() - startTime) / pollInterval) + 1,
|
|
||||||
hasData: !!boardDataStr,
|
|
||||||
dataLength: boardDataStr ? boardDataStr.length : 0,
|
|
||||||
waitTime: Date.now() - startTime
|
|
||||||
});
|
|
||||||
|
|
||||||
if (boardDataStr) {
|
|
||||||
const boardData: BoardData = JSON.parse(boardDataStr);
|
|
||||||
|
|
||||||
logOther(`Board data found for game ${gameId}`, {
|
|
||||||
generationComplete: boardData.generationComplete,
|
|
||||||
hasError: !!boardData.error,
|
|
||||||
fieldsCount: boardData.fields ? boardData.fields.length : 0
|
|
||||||
});
|
|
||||||
|
|
||||||
if (boardData.generationComplete) {
|
|
||||||
if (boardData.error) {
|
|
||||||
logError(`Board generation failed for game ${gameId}`, new Error(boardData.error));
|
|
||||||
throw new Error(`Board generation failed: ${boardData.error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
logOther(`Board generation completed for game ${gameId}`, {
|
|
||||||
fieldCount: boardData.fields.length,
|
|
||||||
waitTime: Date.now() - startTime
|
|
||||||
});
|
|
||||||
|
|
||||||
return boardData;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No board data found yet - check if we need to trigger generation
|
|
||||||
logOther(`No board data found yet for game ${gameId}, checking if generation was triggered...`, {
|
|
||||||
waitTime: Date.now() - startTime,
|
|
||||||
redisKey
|
|
||||||
});
|
|
||||||
|
|
||||||
// If we've waited for 2 seconds and still no data, try to trigger generation manually
|
|
||||||
if (Date.now() - startTime > 2000) {
|
|
||||||
await this.ensureBoardGenerationTriggered(gameId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait before next poll
|
|
||||||
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logError(`Error checking board generation status for game ${gameId}`, error as Error);
|
|
||||||
throw new Error(`Failed to retrieve board data: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Timeout reached
|
|
||||||
logError(`Board generation timeout for game ${gameId}`, new Error(`Generation took longer than ${maxWaitTime / 1000} seconds`));
|
|
||||||
throw new Error(`Board generation timeout. Game ${gameId} is not ready to start. Please try again later.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ensureBoardGenerationTriggered(gameId: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
logOther(`Ensuring board generation is triggered for game ${gameId}`);
|
|
||||||
|
|
||||||
// Check if generation was already triggered by looking for any board data
|
|
||||||
const redisKey = `game_board_${gameId}`;
|
|
||||||
const existingData = await this.redisService.get(redisKey);
|
|
||||||
|
|
||||||
if (!existingData) {
|
|
||||||
// No data at all - trigger generation manually
|
|
||||||
logOther(`No board generation found for game ${gameId}, triggering manually`);
|
|
||||||
|
|
||||||
// Use DIContainer to trigger board generation
|
|
||||||
const generateBoardCommand = {
|
|
||||||
gameId,
|
|
||||||
positiveFieldCount: Math.floor(67 * 0.6), // Default: 60% positive
|
|
||||||
negativeFieldCount: Math.floor(67 * 0.25), // Default: 25% negative
|
|
||||||
luckFieldCount: Math.floor(67 * 0.15) // Default: 15% luck
|
|
||||||
};
|
|
||||||
|
|
||||||
await DIContainer.getInstance().generateBoardCommandHandler.execute(generateBoardCommand);
|
|
||||||
logOther(`Board generation manually triggered for game ${gameId}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logError(`Failed to ensure board generation for game ${gameId}`, error as Error);
|
|
||||||
// Don't throw here - let the main wait loop handle the timeout
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
export interface CreateOrganizationCommand {
|
|
||||||
name: string;
|
|
||||||
contactfname: string;
|
|
||||||
contactlname: string;
|
|
||||||
contactphone: string;
|
|
||||||
contactemail: string;
|
|
||||||
url?: string;
|
|
||||||
}
|
|
||||||
-32
@@ -1,32 +0,0 @@
|
|||||||
import { IOrganizationRepository } from '../../../Domain/IRepository/IOrganizationRepository';
|
|
||||||
import { CreateOrganizationCommand } from './CreateOrganizationCommand';
|
|
||||||
import { ShortOrganizationDto } from '../../DTOs/OrganizationDto';
|
|
||||||
import { OrganizationAggregate, OrganizationState } from '../../../Domain/Organization/OrganizationAggregate';
|
|
||||||
import { OrganizationMapper } from '../../DTOs/Mappers/OrganizationMapper';
|
|
||||||
|
|
||||||
export class CreateOrganizationCommandHandler {
|
|
||||||
constructor(private readonly orgRepo: IOrganizationRepository) {}
|
|
||||||
|
|
||||||
async execute(cmd: CreateOrganizationCommand): Promise<ShortOrganizationDto> {
|
|
||||||
try {
|
|
||||||
const org = new OrganizationAggregate();
|
|
||||||
org.name = cmd.name;
|
|
||||||
org.contactfname = cmd.contactfname;
|
|
||||||
org.contactlname = cmd.contactlname;
|
|
||||||
org.contactphone = cmd.contactphone;
|
|
||||||
org.contactemail = cmd.contactemail;
|
|
||||||
org.url = cmd.url || null;
|
|
||||||
org.state = OrganizationState.REGISTERED;
|
|
||||||
|
|
||||||
const created = await this.orgRepo.create(org);
|
|
||||||
return OrganizationMapper.toShortDto(created);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
if (error.message.includes('duplicate key value violates unique constraint')) {
|
|
||||||
throw new Error('Organization with this name or contact email already exists');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error('Failed to create organization');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export interface DeleteOrganizationCommand {
|
|
||||||
id: string;
|
|
||||||
soft?: boolean;
|
|
||||||
}
|
|
||||||
-16
@@ -1,16 +0,0 @@
|
|||||||
import { IOrganizationRepository } from '../../../Domain/IRepository/IOrganizationRepository';
|
|
||||||
import { DeleteOrganizationCommand } from './DeleteOrganizationCommand';
|
|
||||||
|
|
||||||
|
|
||||||
export class DeleteOrganizationCommandHandler {
|
|
||||||
constructor(private readonly orgRepo: IOrganizationRepository) {}
|
|
||||||
|
|
||||||
async execute(cmd: DeleteOrganizationCommand): Promise<boolean> {
|
|
||||||
if (cmd.soft) {
|
|
||||||
await this.orgRepo.softDelete(cmd.id);
|
|
||||||
} else {
|
|
||||||
await this.orgRepo.delete(cmd.id);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-6
@@ -1,6 +0,0 @@
|
|||||||
export interface ProcessOrgAuthCallbackCommand {
|
|
||||||
organizationId: string;
|
|
||||||
userId: string;
|
|
||||||
status: 'ok' | 'not_ok';
|
|
||||||
authToken?: string;
|
|
||||||
}
|
|
||||||
-123
@@ -1,123 +0,0 @@
|
|||||||
import { IUserRepository } from '../../../Domain/IRepository/IUserRepository';
|
|
||||||
import { IOrganizationRepository } from '../../../Domain/IRepository/IOrganizationRepository';
|
|
||||||
import { ProcessOrgAuthCallbackCommand } from './ProcessOrgAuthCallbackCommand';
|
|
||||||
import { logAuth, logDatabase, logError, logWarning } from '../../Services/Logger';
|
|
||||||
|
|
||||||
export interface ProcessOrgAuthCallbackResponse {
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
updatedFields?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ProcessOrgAuthCallbackCommandHandler {
|
|
||||||
constructor(
|
|
||||||
private readonly userRepo: IUserRepository,
|
|
||||||
private readonly orgRepo: IOrganizationRepository
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async execute(cmd: ProcessOrgAuthCallbackCommand): Promise<ProcessOrgAuthCallbackResponse> {
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
try {
|
|
||||||
logAuth('Processing organization authentication callback', cmd.userId, {
|
|
||||||
organizationId: cmd.organizationId,
|
|
||||||
status: cmd.status,
|
|
||||||
hasAuthToken: !!cmd.authToken
|
|
||||||
});
|
|
||||||
|
|
||||||
// Verify organization exists
|
|
||||||
const organization = await this.orgRepo.findById(cmd.organizationId);
|
|
||||||
if (!organization) {
|
|
||||||
logWarning('Organization not found for auth callback', {
|
|
||||||
organizationId: cmd.organizationId,
|
|
||||||
userId: cmd.userId
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: 'Organization not found'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify user exists
|
|
||||||
const user = await this.userRepo.findById(cmd.userId);
|
|
||||||
if (!user) {
|
|
||||||
logWarning('User not found for auth callback', {
|
|
||||||
organizationId: cmd.organizationId,
|
|
||||||
userId: cmd.userId
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: 'User not found'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify user belongs to the organization
|
|
||||||
if (user.orgid !== cmd.organizationId) {
|
|
||||||
logWarning('User does not belong to organization for auth callback', {
|
|
||||||
organizationId: cmd.organizationId,
|
|
||||||
userId: cmd.userId,
|
|
||||||
userOrgId: user.orgid
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: 'User does not belong to this organization'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cmd.status === 'not_ok') {
|
|
||||||
logAuth('Organization authentication failed', cmd.userId, {
|
|
||||||
organizationId: cmd.organizationId,
|
|
||||||
organizationName: organization.name
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: 'Organization authentication failed'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update user's organization login date
|
|
||||||
const now = new Date();
|
|
||||||
const updatedUser = await this.userRepo.update(cmd.userId, {
|
|
||||||
Orglogindate: now
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!updatedUser) {
|
|
||||||
logError('Failed to update user organization login date', new Error('User update returned null'));
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: 'Failed to update user login information'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
logAuth('Organization authentication successful', cmd.userId, {
|
|
||||||
organizationId: cmd.organizationId,
|
|
||||||
organizationName: organization.name,
|
|
||||||
orgLoginDate: now.toISOString(),
|
|
||||||
executionTime: Date.now() - startTime
|
|
||||||
});
|
|
||||||
|
|
||||||
logDatabase('User organization login date updated',
|
|
||||||
`userId: ${cmd.userId}, orgId: ${cmd.organizationId}`,
|
|
||||||
Date.now() - startTime,
|
|
||||||
{
|
|
||||||
userId: cmd.userId,
|
|
||||||
organizationId: cmd.organizationId,
|
|
||||||
newOrgLoginDate: now.toISOString()
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: 'Organization authentication successful',
|
|
||||||
updatedFields: ['Orglogindate']
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logError('ProcessOrgAuthCallbackCommandHandler error', error as Error);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: 'Internal error processing authentication callback'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-14
@@ -1,14 +0,0 @@
|
|||||||
import { OrganizationStateType } from '../../../Domain/Organization/OrganizationAggregate';
|
|
||||||
|
|
||||||
export interface UpdateOrganizationCommand {
|
|
||||||
id: string;
|
|
||||||
name?: string;
|
|
||||||
contactfname?: string;
|
|
||||||
contactlname?: string;
|
|
||||||
contactphone?: string;
|
|
||||||
contactemail?: string;
|
|
||||||
url?: string;
|
|
||||||
state?: OrganizationStateType;
|
|
||||||
userinorg?: number;
|
|
||||||
maxOrganizationalDecks?: number | null;
|
|
||||||
}
|
|
||||||
-15
@@ -1,15 +0,0 @@
|
|||||||
import { IOrganizationRepository } from '../../../Domain/IRepository/IOrganizationRepository';
|
|
||||||
import { UpdateOrganizationCommand } from './UpdateOrganizationCommand';
|
|
||||||
|
|
||||||
import { ShortOrganizationDto } from '../../DTOs/OrganizationDto';
|
|
||||||
import { OrganizationMapper } from '../../DTOs/Mappers/OrganizationMapper';
|
|
||||||
|
|
||||||
export class UpdateOrganizationCommandHandler {
|
|
||||||
constructor(private readonly orgRepo: IOrganizationRepository) {}
|
|
||||||
|
|
||||||
async execute(cmd: UpdateOrganizationCommand): Promise<ShortOrganizationDto | null> {
|
|
||||||
const updated = await this.orgRepo.update(cmd.id, { ...cmd });
|
|
||||||
if (!updated) return null;
|
|
||||||
return OrganizationMapper.toShortDto(updated);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export interface GetOrganizationByIdQuery {
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
-15
@@ -1,15 +0,0 @@
|
|||||||
import { IOrganizationRepository } from '../../../Domain/IRepository/IOrganizationRepository';
|
|
||||||
import { GetOrganizationByIdQuery } from './GetOrganizationByIdQuery';
|
|
||||||
|
|
||||||
import { ShortOrganizationDto } from '../../DTOs/OrganizationDto';
|
|
||||||
import { OrganizationMapper } from '../../DTOs/Mappers/OrganizationMapper';
|
|
||||||
|
|
||||||
export class GetOrganizationByIdQueryHandler {
|
|
||||||
constructor(private readonly orgRepo: IOrganizationRepository) {}
|
|
||||||
|
|
||||||
async execute(query: GetOrganizationByIdQuery): Promise<ShortOrganizationDto | null> {
|
|
||||||
const org = await this.orgRepo.findById(query.id);
|
|
||||||
if (!org) return null;
|
|
||||||
return OrganizationMapper.toShortDto(org);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
export interface GetOrganizationLoginUrlQuery {
|
|
||||||
organizationId: string;
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user