fel kesz game backend
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
import * as DeckAggregate from "../../Domain/Deck/DeckAggregate";
|
||||
|
||||
export interface GameStartDto {
|
||||
gameid: string;
|
||||
maxplayers: number;
|
||||
logintype: number;
|
||||
gamecode: string;
|
||||
deck: gamedeck[];
|
||||
}
|
||||
|
||||
enum decktype {
|
||||
JOCKER = 0,
|
||||
LUCK = 1,
|
||||
QUEST = 2
|
||||
}
|
||||
|
||||
export interface cards {
|
||||
cardid: string;
|
||||
question?: string;
|
||||
answer?: string;
|
||||
consequence?: DeckAggregate.Consequence | null;
|
||||
played?: boolean;
|
||||
playerid?: string;
|
||||
}
|
||||
|
||||
export interface gamedeck {
|
||||
deckid: string;
|
||||
decktype: decktype;
|
||||
cards: cards[];
|
||||
}
|
||||
|
||||
export interface GameDataDto {
|
||||
id: string;
|
||||
gamecode: string;
|
||||
maxplayers: number;
|
||||
logintype: number;
|
||||
gamedecks: gamedeck[];
|
||||
players: string[];
|
||||
started: boolean;
|
||||
finished: boolean;
|
||||
winner?: string;
|
||||
currentplayer?: string;
|
||||
createdate: Date;
|
||||
startdate?: Date;
|
||||
enddate?: Date;
|
||||
}
|
||||
@@ -21,7 +21,6 @@ export class UserMapper {
|
||||
fname: user.fname,
|
||||
lname: user.lname,
|
||||
code: user.token,
|
||||
type: user.type,
|
||||
phone: user.phone,
|
||||
state: user.state,
|
||||
};
|
||||
|
||||
@@ -24,7 +24,6 @@ export interface DetailUserDto {
|
||||
fname: string;
|
||||
lname: string;
|
||||
code: string | null;
|
||||
type: string;
|
||||
phone: string | null;
|
||||
state: number;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { n } from "framer-motion/dist/types.d-D0HXPxHm";
|
||||
|
||||
export interface UpdateDeckCommand {
|
||||
id: string;
|
||||
userstate?: number;
|
||||
name?: string;
|
||||
type?: number;
|
||||
userid?: string;
|
||||
|
||||
@@ -2,13 +2,49 @@ import { IDeckRepository } from '../../../Domain/IRepository/IDeckRepository';
|
||||
import { UpdateDeckCommand } from './UpdateDeckCommand';
|
||||
import { ShortDeckDto } from '../../DTOs/DeckDto';
|
||||
import { DeckMapper } from '../../DTOs/Mappers/DeckMapper';
|
||||
import { DeckAggregate } from '../../../Domain/Deck/DeckAggregate';
|
||||
import { logError } from '../../Services/Logger';
|
||||
|
||||
export class UpdateDeckCommandHandler {
|
||||
constructor(private readonly deckRepo: IDeckRepository) {}
|
||||
|
||||
async execute(cmd: UpdateDeckCommand): Promise<ShortDeckDto | null> {
|
||||
const updated = await this.deckRepo.update(cmd.id, { ...cmd });
|
||||
if (!updated) return null;
|
||||
return DeckMapper.toShortDto(updated);
|
||||
if(cmd.state !== undefined && cmd.userstate!==1) {
|
||||
throw new Error('Only admin users can change deck state');
|
||||
}
|
||||
try {
|
||||
let existingDeck: DeckAggregate | null = null;
|
||||
if (cmd.userstate === 1) {
|
||||
existingDeck = await this.deckRepo.findByIdIncludingDeleted(cmd.id);
|
||||
} else {
|
||||
existingDeck = await this.deckRepo.findById(cmd.id);
|
||||
}
|
||||
if (!existingDeck) {
|
||||
logError(`Deck not found with ID: ${cmd.id}`);
|
||||
throw new Error('Deck not found');
|
||||
}
|
||||
|
||||
const for_update: Partial<DeckAggregate> = {};
|
||||
if(cmd.name !== undefined) for_update.name = cmd.name;
|
||||
if(cmd.type !== undefined) for_update.type = cmd.type;
|
||||
if(cmd.cards !== undefined) for_update.cards = cmd.cards;
|
||||
if(cmd.ctype !== undefined) for_update.ctype = cmd.ctype;
|
||||
if(cmd.state !== undefined) for_update.state = cmd.state;
|
||||
|
||||
// Ensure we have something to update
|
||||
if (Object.keys(for_update).length === 0) {
|
||||
throw new Error('No fields provided for update');
|
||||
}
|
||||
|
||||
const deck = await this.deckRepo.update(cmd.id, { ...for_update });
|
||||
if(!deck) {
|
||||
logError(`Deck update failed for ID: ${cmd.id}. Update returned null.`);
|
||||
throw new Error('Failed to update deck');
|
||||
}
|
||||
return DeckMapper.toShortDto(deck);
|
||||
} catch (error: any) {
|
||||
logError(`Error updating deck: ${cmd.id}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { IDeckRepository } from '../../../Domain/IRepository/IDeckRepository';
|
||||
import { GetDeckByIdQuery } from './GetDeckByIdQuery';
|
||||
import { ShortDeckDto } from '../../DTOs/DeckDto';
|
||||
import { DetailDeckDto } from '../../DTOs/DeckDto';
|
||||
import { DeckMapper } from '../../DTOs/Mappers/DeckMapper';
|
||||
|
||||
export class GetDeckByIdQueryHandler {
|
||||
constructor(private readonly deckRepo: IDeckRepository) {}
|
||||
|
||||
async execute(query: GetDeckByIdQuery): Promise<ShortDeckDto | null> {
|
||||
async execute(query: GetDeckByIdQuery): Promise<DetailDeckDto | null> {
|
||||
const deck = await this.deckRepo.findById(query.id);
|
||||
if (!deck) return null;
|
||||
return DeckMapper.toShortDto(deck);
|
||||
return DeckMapper.toDetailDto(deck);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
import { GameField, BoardData } from '../../Domain/Game/GameAggregate';
|
||||
import { logOther, logError } from '../Services/Logger';
|
||||
|
||||
interface TargetField {
|
||||
fieldNumber: number;
|
||||
distance: number;
|
||||
}
|
||||
|
||||
interface SpecialFieldInfo {
|
||||
position: number;
|
||||
type: 'positive' | 'negative' | 'luck';
|
||||
}
|
||||
|
||||
export class BoardGenerationService {
|
||||
private readonly MAX_GENERATION_TIME = parseInt(process.env.MAX_GENERATION_TIME_SECONDS || '20') * 1000;
|
||||
private readonly ERROR_TOLERANCE = parseInt(process.env.GENERATION_ERROR_TOLERANCE || '15');
|
||||
|
||||
async generateBoard(
|
||||
positiveFieldCount: number,
|
||||
negativeFieldCount: number,
|
||||
luckFieldCount: number
|
||||
): Promise<BoardData> {
|
||||
const startTime = Date.now();
|
||||
let bestAttempt: BoardData | null = null;
|
||||
let attemptCount = 0;
|
||||
|
||||
while (Date.now() - startTime < this.MAX_GENERATION_TIME) {
|
||||
attemptCount++;
|
||||
|
||||
try {
|
||||
const attempt = this.generateSingleAttempt(positiveFieldCount, negativeFieldCount, luckFieldCount);
|
||||
|
||||
if (attempt.totalErrorRate <= this.ERROR_TOLERANCE) {
|
||||
logOther(`Board generation successful on attempt ${attemptCount}. Error rate: ${attempt.totalErrorRate}%`);
|
||||
return attempt;
|
||||
}
|
||||
|
||||
if (!bestAttempt || attempt.totalErrorRate < bestAttempt.totalErrorRate) {
|
||||
bestAttempt = attempt;
|
||||
}
|
||||
|
||||
logOther(`Attempt ${attemptCount}: Error rate ${attempt.totalErrorRate}% (target: ${this.ERROR_TOLERANCE}%)`);
|
||||
|
||||
} catch (error) {
|
||||
logError(`Board generation attempt ${attemptCount} failed:`, error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
logOther(`Using best attempt with error rate: ${bestAttempt?.totalErrorRate || 100}%`);
|
||||
return bestAttempt || this.generateFallbackBoard(positiveFieldCount, negativeFieldCount, luckFieldCount);
|
||||
}
|
||||
|
||||
private generateSingleAttempt(
|
||||
positiveFieldCount: number,
|
||||
negativeFieldCount: number,
|
||||
luckFieldCount: number
|
||||
): BoardData {
|
||||
// Step 1: Choose special field positions
|
||||
const specialFieldPositions = this.chooseSpecialFieldPositions(
|
||||
positiveFieldCount,
|
||||
negativeFieldCount,
|
||||
luckFieldCount
|
||||
);
|
||||
|
||||
// Step 2: Select target fields for each special field (6 targets per field for dice 1-6)
|
||||
const targetFieldsMap = this.selectTargetFields(specialFieldPositions);
|
||||
|
||||
// Step 3: Create border with strategic placement
|
||||
const border = this.createStrategicBorder(targetFieldsMap);
|
||||
|
||||
// Step 4: Calculate step values based on border positions
|
||||
const fields = this.calculateStepValues(specialFieldPositions, targetFieldsMap, border);
|
||||
|
||||
// Step 5: Validate against 20-30 rule and calculate error rate
|
||||
const validationResults = this.validateBoardGeneration(fields, border);
|
||||
|
||||
// Log generation statistics
|
||||
logOther('Board generation attempt completed', {
|
||||
totalFields: fields.length,
|
||||
specialFields: fields.filter(f => f.type !== 'regular').length,
|
||||
positiveFields: fields.filter(f => f.type === 'positive').length,
|
||||
negativeFields: fields.filter(f => f.type === 'negative').length,
|
||||
luckFields: fields.filter(f => f.type === 'luck').length,
|
||||
errorRate: validationResults.errorRate,
|
||||
targetCount: Array.from(targetFieldsMap.values()).reduce((sum, targets) => sum + targets.length, 0)
|
||||
});
|
||||
|
||||
return {
|
||||
fields,
|
||||
border,
|
||||
validationResults: validationResults.validationResults,
|
||||
totalErrorRate: validationResults.errorRate
|
||||
};
|
||||
}
|
||||
|
||||
private chooseSpecialFieldPositions(
|
||||
positiveFieldCount: number,
|
||||
negativeFieldCount: number,
|
||||
luckFieldCount: number
|
||||
): SpecialFieldInfo[] {
|
||||
const totalSpecial = positiveFieldCount + negativeFieldCount + luckFieldCount;
|
||||
const positions: number[] = [];
|
||||
const specialFields: SpecialFieldInfo[] = [];
|
||||
|
||||
// Random placement with retry for good distribution
|
||||
let attempts = 0;
|
||||
while (positions.length < totalSpecial && attempts < 100) {
|
||||
const position = Math.floor(Math.random() * 100) + 1; // 1-100
|
||||
|
||||
if (!positions.includes(position)) {
|
||||
// Check minimum distance from existing positions
|
||||
const tooClose = positions.some(existingPos => Math.abs(existingPos - position) < 3);
|
||||
|
||||
if (!tooClose || attempts > 50) { // Relax distance requirement after many attempts
|
||||
positions.push(position);
|
||||
}
|
||||
}
|
||||
attempts++;
|
||||
}
|
||||
|
||||
// Sort positions and assign types
|
||||
positions.sort((a, b) => a - b);
|
||||
|
||||
// Distribute types randomly
|
||||
const types: ('positive' | 'negative' | 'luck')[] = [
|
||||
...Array(positiveFieldCount).fill('positive'),
|
||||
...Array(negativeFieldCount).fill('negative'),
|
||||
...Array(luckFieldCount).fill('luck')
|
||||
];
|
||||
|
||||
// Shuffle types
|
||||
for (let i = types.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[types[i], types[j]] = [types[j], types[i]];
|
||||
}
|
||||
|
||||
positions.forEach((position, index) => {
|
||||
specialFields.push({
|
||||
position,
|
||||
type: types[index] || 'positive'
|
||||
});
|
||||
});
|
||||
|
||||
return specialFields;
|
||||
}
|
||||
|
||||
private selectTargetFields(specialFields: SpecialFieldInfo[]): Map<number, TargetField[]> {
|
||||
const targetFieldsMap = new Map<number, TargetField[]>();
|
||||
|
||||
specialFields.forEach(field => {
|
||||
if (field.type === 'luck') {
|
||||
// Luck fields don't need target calculations
|
||||
targetFieldsMap.set(field.position, []);
|
||||
return;
|
||||
}
|
||||
|
||||
const targets: TargetField[] = [];
|
||||
const usedTargets = new Set<number>();
|
||||
|
||||
// Generate 6 different target fields (for dice 1-6) with 20-30 rule compliance
|
||||
for (let i = 0; i < 6; i++) {
|
||||
let targetField: number;
|
||||
let distance: number;
|
||||
let attempts = 0;
|
||||
|
||||
do {
|
||||
// Determine max distance based on field position (20-30 rule)
|
||||
let maxDistance: number;
|
||||
let maxBackward: number;
|
||||
|
||||
if (field.position <= 85) {
|
||||
maxDistance = 20;
|
||||
maxBackward = 20;
|
||||
} else {
|
||||
maxDistance = 20; // forward
|
||||
maxBackward = 30; // backward
|
||||
}
|
||||
|
||||
// Create variety in distances within the allowed range
|
||||
const distanceType = Math.random();
|
||||
if (distanceType < 0.5) {
|
||||
// Close distance (50% chance) - 1 to 1/3 of max
|
||||
distance = Math.floor(Math.random() * Math.floor(maxDistance / 3)) + 1;
|
||||
} else {
|
||||
// Far distance (50% chance) - 1/3 to max
|
||||
distance = Math.floor(Math.random() * (maxDistance - Math.floor(maxDistance / 3))) + Math.floor(maxDistance / 3);
|
||||
}
|
||||
|
||||
// Randomly choose forward or backward
|
||||
if (Math.random() < 0.5) {
|
||||
distance = -Math.min(distance, maxBackward);
|
||||
} else {
|
||||
distance = Math.min(distance, maxDistance);
|
||||
}
|
||||
|
||||
targetField = field.position + distance;
|
||||
|
||||
// Ensure target is within valid range
|
||||
if (targetField < 1) targetField = 1;
|
||||
if (targetField > 100) targetField = 100;
|
||||
|
||||
// Recalculate actual distance after clamping
|
||||
distance = Math.abs(targetField - field.position);
|
||||
|
||||
attempts++;
|
||||
} while (usedTargets.has(targetField) && attempts < 30);
|
||||
|
||||
if (!usedTargets.has(targetField)) {
|
||||
usedTargets.add(targetField);
|
||||
targets.push({
|
||||
fieldNumber: targetField,
|
||||
distance: Math.abs(targetField - field.position)
|
||||
});
|
||||
} else {
|
||||
// Fallback: use a nearby valid target
|
||||
let fallbackTarget = field.position + (i - 3); // Create some variety around current position
|
||||
if (fallbackTarget < 1) fallbackTarget = 1;
|
||||
if (fallbackTarget > 100) fallbackTarget = 100;
|
||||
|
||||
targets.push({
|
||||
fieldNumber: fallbackTarget,
|
||||
distance: Math.abs(fallbackTarget - field.position)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
targetFieldsMap.set(field.position, targets);
|
||||
});
|
||||
|
||||
return targetFieldsMap;
|
||||
}
|
||||
|
||||
private createStrategicBorder(targetFieldsMap: Map<number, TargetField[]>): number[] {
|
||||
// Collect all target field numbers
|
||||
const targetNumbers = new Set<number>();
|
||||
targetFieldsMap.forEach(targets => {
|
||||
targets.forEach(target => targetNumbers.add(target.fieldNumber));
|
||||
});
|
||||
|
||||
// Create array of all numbers 1-100
|
||||
const allNumbers = Array.from({ length: 100 }, (_, i) => i + 1);
|
||||
|
||||
// Separate target numbers from remaining numbers
|
||||
const remainingNumbers = allNumbers.filter(num => !targetNumbers.has(num));
|
||||
|
||||
// Shuffle remaining numbers
|
||||
for (let i = remainingNumbers.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[remainingNumbers[i], remainingNumbers[j]] = [remainingNumbers[j], remainingNumbers[i]];
|
||||
}
|
||||
|
||||
// Create border with strategic placement
|
||||
const border: number[] = [];
|
||||
const targetArray = Array.from(targetNumbers);
|
||||
|
||||
// Encourage overlap by placing target numbers first, then fill with random
|
||||
let targetIndex = 0;
|
||||
let remainingIndex = 0;
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
// Alternate between target numbers and remaining numbers, but favor targets when available
|
||||
if (targetIndex < targetArray.length && (remainingIndex >= remainingNumbers.length || Math.random() < 0.6)) {
|
||||
border.push(targetArray[targetIndex]);
|
||||
targetIndex++;
|
||||
} else if (remainingIndex < remainingNumbers.length) {
|
||||
border.push(remainingNumbers[remainingIndex]);
|
||||
remainingIndex++;
|
||||
} else {
|
||||
// Fallback - should not happen if logic is correct
|
||||
border.push((i % 100) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return border;
|
||||
}
|
||||
|
||||
private calculateStepValues(
|
||||
specialFields: SpecialFieldInfo[],
|
||||
targetFieldsMap: Map<number, TargetField[]>,
|
||||
border: number[]
|
||||
): GameField[] {
|
||||
// Initialize all fields as regular
|
||||
const fields: GameField[] = Array.from({ length: 100 }, (_, i) => ({
|
||||
position: i + 1,
|
||||
type: 'regular' as const
|
||||
}));
|
||||
|
||||
// Update special fields with calculated step values
|
||||
specialFields.forEach(specialField => {
|
||||
const fieldIndex = specialField.position - 1; // Convert to 0-based index
|
||||
fields[fieldIndex].type = specialField.type;
|
||||
|
||||
if (specialField.type === 'luck') {
|
||||
// Luck fields don't need step values
|
||||
return;
|
||||
}
|
||||
|
||||
const targets = targetFieldsMap.get(specialField.position) || [];
|
||||
if (targets.length === 0) return;
|
||||
|
||||
// NEW APPROACH: Calculate step value that will land on first target with dice=1
|
||||
// This ensures we have a baseline that works, then dice 2-6 will hit other targets
|
||||
const firstTarget = targets[0];
|
||||
const targetIndexInBorder = border.indexOf(firstTarget.fieldNumber);
|
||||
|
||||
if (targetIndexInBorder !== -1) {
|
||||
// Start from field position in border (field position = border index + 1, but we want 0-based)
|
||||
const startBorderIndex = (specialField.position - 1) % border.length;
|
||||
|
||||
// Calculate step value needed to reach target with dice=1
|
||||
let stepValue: number;
|
||||
|
||||
if (specialField.type === 'positive') {
|
||||
// For positive: move right to target, then +1 more for dice=1
|
||||
stepValue = targetIndexInBorder - startBorderIndex - 1; // -1 for dice offset
|
||||
|
||||
// Handle wrap-around
|
||||
if (stepValue < 0) {
|
||||
stepValue += border.length;
|
||||
}
|
||||
} else {
|
||||
// For negative: move left to target, then -1 more for dice=1
|
||||
stepValue = startBorderIndex - targetIndexInBorder + 1; // +1 for dice offset
|
||||
|
||||
// Handle wrap-around
|
||||
if (stepValue > border.length) {
|
||||
stepValue -= border.length;
|
||||
}
|
||||
|
||||
// Make negative for negative fields
|
||||
stepValue = -stepValue;
|
||||
}
|
||||
|
||||
fields[fieldIndex].stepValue = stepValue;
|
||||
|
||||
// Debug logging for step value calculation
|
||||
logOther(`Calculated step value for ${specialField.type} field at position ${specialField.position}`, {
|
||||
targetField: firstTarget.fieldNumber,
|
||||
targetIndexInBorder,
|
||||
startBorderIndex,
|
||||
calculatedStepValue: stepValue,
|
||||
fieldType: specialField.type
|
||||
});
|
||||
} else {
|
||||
// Fallback if target not found in border (shouldn't happen)
|
||||
fields[fieldIndex].stepValue = specialField.type === 'positive' ? 1 : -1;
|
||||
}
|
||||
});
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
private validateBoardGeneration(fields: GameField[], border: number[]): {
|
||||
validationResults: { [fieldIndex: number]: number[] };
|
||||
errorRate: number;
|
||||
} {
|
||||
const validationResults: { [fieldIndex: number]: number[] } = {};
|
||||
let totalCombinations = 0;
|
||||
let invalidCombinations = 0;
|
||||
|
||||
fields.forEach((field, fieldIndex) => {
|
||||
if (field.type !== 'positive' && field.type !== 'negative') {
|
||||
return; // Skip non-special fields
|
||||
}
|
||||
|
||||
const diceOutcomes: number[] = [];
|
||||
|
||||
for (let diceValue = 1; diceValue <= 6; diceValue++) {
|
||||
totalCombinations++;
|
||||
|
||||
try {
|
||||
const result = this.calculateBorderMovement(
|
||||
field.position,
|
||||
field.stepValue || 0,
|
||||
diceValue,
|
||||
border,
|
||||
field.type === 'positive'
|
||||
);
|
||||
|
||||
// Validate 20-30 rule
|
||||
const distance = Math.abs(result - field.position);
|
||||
const isValid = this.validate20_30Rule(field.position, result, distance);
|
||||
|
||||
if (isValid) {
|
||||
diceOutcomes.push(result);
|
||||
} else {
|
||||
diceOutcomes.push(-1); // Mark as invalid
|
||||
invalidCombinations++;
|
||||
}
|
||||
} catch (error) {
|
||||
diceOutcomes.push(-1); // Mark as invalid
|
||||
invalidCombinations++;
|
||||
}
|
||||
}
|
||||
|
||||
validationResults[fieldIndex] = diceOutcomes;
|
||||
});
|
||||
|
||||
const errorRate = totalCombinations > 0 ? (invalidCombinations / totalCombinations) * 100 : 0;
|
||||
|
||||
return {
|
||||
validationResults,
|
||||
errorRate: Math.round(errorRate * 100) / 100 // Round to 2 decimal places
|
||||
};
|
||||
}
|
||||
|
||||
private calculateBorderMovement(
|
||||
currentPosition: number,
|
||||
stepValue: number,
|
||||
diceValue: number,
|
||||
border: number[],
|
||||
isPositive: boolean
|
||||
): number {
|
||||
// Step 1: Find border index for current field (field position corresponds to border index)
|
||||
let borderIndex = (currentPosition - 1) % border.length; // Convert to 0-based, handle wraparound
|
||||
|
||||
// Step 2: Apply field step value (handle negative step values for negative fields)
|
||||
if (isPositive) {
|
||||
borderIndex = (borderIndex + Math.abs(stepValue)) % border.length;
|
||||
} else {
|
||||
// For negative fields, stepValue is already negative, so we subtract it (which adds its absolute value)
|
||||
borderIndex = (borderIndex - stepValue + border.length) % border.length;
|
||||
}
|
||||
|
||||
// Step 3: Apply dice value
|
||||
if (isPositive) {
|
||||
borderIndex = (borderIndex + diceValue) % border.length;
|
||||
} else {
|
||||
borderIndex = (borderIndex - diceValue + border.length) % border.length;
|
||||
}
|
||||
|
||||
// Step 4: Return the field number at final border position
|
||||
return border[borderIndex];
|
||||
}
|
||||
|
||||
private validate20_30Rule(currentPosition: number, targetPosition: number, distance: number): boolean {
|
||||
// Fields 1-85: max 20 fields in any direction
|
||||
if (currentPosition <= 85) {
|
||||
return distance <= 20;
|
||||
}
|
||||
|
||||
// Fields 86-100: max 30 fields backward, max 20 fields forward
|
||||
if (currentPosition > 85) {
|
||||
if (targetPosition > currentPosition) {
|
||||
// Moving forward: max 20 fields
|
||||
return distance <= 20;
|
||||
} else {
|
||||
// Moving backward: max 30 fields
|
||||
return distance <= 30;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private generateFallbackBoard(
|
||||
positiveFieldCount: number,
|
||||
negativeFieldCount: number,
|
||||
luckFieldCount: number
|
||||
): BoardData {
|
||||
// Simple fallback: create basic board with minimal special fields
|
||||
const fields: GameField[] = Array.from({ length: 100 }, (_, i) => ({
|
||||
position: i + 1,
|
||||
type: 'regular' as const
|
||||
}));
|
||||
|
||||
// Add a few special fields with safe step values
|
||||
let specialCount = 0;
|
||||
for (let i = 10; i < 90 && specialCount < positiveFieldCount + negativeFieldCount; i += 10) {
|
||||
if (specialCount < positiveFieldCount) {
|
||||
fields[i].type = 'positive';
|
||||
fields[i].stepValue = 1;
|
||||
} else {
|
||||
fields[i].type = 'negative';
|
||||
fields[i].stepValue = -1;
|
||||
}
|
||||
specialCount++;
|
||||
}
|
||||
|
||||
// Simple border: shuffled 1-100
|
||||
const border = Array.from({ length: 100 }, (_, i) => i + 1);
|
||||
for (let i = border.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[border[i], border[j]] = [border[j], border[i]];
|
||||
}
|
||||
|
||||
return {
|
||||
fields,
|
||||
border,
|
||||
validationResults: {},
|
||||
totalErrorRate: 100 // Mark as fallback
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import { StartGameCommand } from './commands/StartGameCommand';
|
||||
import { StartGameCommandHandler } from './commands/StartGameCommandHandler';
|
||||
import { JoinGameCommand } from './commands/JoinGameCommand';
|
||||
import { JoinGameCommandHandler } from './commands/JoinGameCommandHandler';
|
||||
import { StartGamePlayCommand } from './commands/StartGamePlayCommand';
|
||||
import { StartGamePlayCommandHandler, GameStartResult } from './commands/StartGamePlayCommandHandler';
|
||||
import { GameAggregate, LoginType } from '../../Domain/Game/GameAggregate';
|
||||
import { logOther, logError } from '../Services/Logger';
|
||||
|
||||
export class GameService {
|
||||
private startGameHandler: StartGameCommandHandler;
|
||||
private joinGameHandler: JoinGameCommandHandler;
|
||||
private startGamePlayHandler: StartGamePlayCommandHandler;
|
||||
|
||||
constructor() {
|
||||
this.startGameHandler = new StartGameCommandHandler();
|
||||
this.joinGameHandler = new JoinGameCommandHandler();
|
||||
this.startGamePlayHandler = new StartGamePlayCommandHandler();
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a new game with the provided deck IDs
|
||||
* @param deckids Array of deck IDs (should contain 3 types: LUCK, JOKER, QUESTION)
|
||||
* @param maxplayers Maximum number of players allowed in the game
|
||||
* @param logintype How players can join the game (PUBLIC, PRIVATE, ORGANIZATION)
|
||||
* @param userid Optional ID of the user creating the game
|
||||
* @returns Promise<GameAggregate> The created game
|
||||
*/
|
||||
async startGame(
|
||||
deckids: string[],
|
||||
maxplayers: number,
|
||||
logintype: LoginType,
|
||||
userid?: string,
|
||||
orgid?: string | null
|
||||
): Promise<GameAggregate> {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
logOther('GameService.startGame called', {
|
||||
deckCount: deckids.length,
|
||||
maxplayers,
|
||||
logintype,
|
||||
userid,
|
||||
orgid
|
||||
});
|
||||
|
||||
// Validate input parameters
|
||||
this.validateStartGameInput(deckids, maxplayers, logintype);
|
||||
|
||||
// Create and execute the command
|
||||
const command: StartGameCommand = {
|
||||
deckids,
|
||||
maxplayers,
|
||||
logintype,
|
||||
userid,
|
||||
orgid
|
||||
};
|
||||
|
||||
const game = await this.startGameHandler.handle(command);
|
||||
|
||||
const endTime = performance.now();
|
||||
logOther('Game started successfully', {
|
||||
gameId: game.id,
|
||||
gameCode: game.gamecode,
|
||||
deckCount: game.gamedecks.length,
|
||||
totalCards: game.gamedecks.reduce((sum, deck) => sum + deck.cards.length, 0),
|
||||
executionTime: Math.round(endTime - startTime)
|
||||
});
|
||||
|
||||
return game;
|
||||
|
||||
} catch (error) {
|
||||
const endTime = performance.now();
|
||||
logError('GameService.startGame failed', error instanceof Error ? error : new Error(String(error)));
|
||||
logOther('Game start failed', {
|
||||
executionTime: Math.round(endTime - startTime),
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Join an existing game using game code
|
||||
* @param gameCode 6-character game code
|
||||
* @param playerId ID of the player joining (optional for public games)
|
||||
* @param playerName Display name for the player
|
||||
* @param orgId Organization ID (for organization games)
|
||||
* @param loginType Type of join being attempted
|
||||
* @returns Promise<GameAggregate> The updated game with new player
|
||||
*/
|
||||
async joinGame(
|
||||
gameCode: string,
|
||||
playerId?: string,
|
||||
playerName?: string,
|
||||
orgId?: string | null,
|
||||
loginType?: LoginType
|
||||
): Promise<GameAggregate> {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
logOther('GameService.joinGame called', {
|
||||
gameCode,
|
||||
playerId: playerId || 'anonymous',
|
||||
playerName,
|
||||
orgId,
|
||||
loginType
|
||||
});
|
||||
|
||||
// Validate input parameters
|
||||
this.validateJoinGameInput(gameCode, playerId, loginType);
|
||||
|
||||
// Create and execute the command
|
||||
const command: JoinGameCommand = {
|
||||
gameCode,
|
||||
playerId,
|
||||
playerName,
|
||||
orgId,
|
||||
loginType: loginType || LoginType.PUBLIC
|
||||
};
|
||||
|
||||
const game = await this.joinGameHandler.handle(command);
|
||||
|
||||
const endTime = performance.now();
|
||||
logOther('Player joined game successfully', {
|
||||
gameId: game.id,
|
||||
gameCode: game.gamecode,
|
||||
playerId,
|
||||
playerCount: game.players.length,
|
||||
maxPlayers: game.maxplayers,
|
||||
executionTime: Math.round(endTime - startTime)
|
||||
});
|
||||
|
||||
return game;
|
||||
|
||||
} catch (error) {
|
||||
const endTime = performance.now();
|
||||
logError('GameService.joinGame failed', error instanceof Error ? error : new Error(String(error)));
|
||||
logOther('Game join failed', {
|
||||
gameCode,
|
||||
playerId,
|
||||
executionTime: Math.round(endTime - startTime),
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start an existing game (move from WAITING to ACTIVE)
|
||||
* Initializes all player positions to 0 and assigns random turn order
|
||||
* @param gameId Game ID to start
|
||||
* @param userId User ID of the game master (optional for public games)
|
||||
* @returns Promise<GameAggregate> The updated game
|
||||
*/
|
||||
async startGamePlay(
|
||||
gameId: string,
|
||||
userId?: string
|
||||
): Promise<GameStartResult> {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
logOther('GameService.startGamePlay called', {
|
||||
gameId,
|
||||
userId: userId || 'system'
|
||||
});
|
||||
|
||||
// Validate input parameters
|
||||
this.validateStartGamePlayInput(gameId);
|
||||
|
||||
// Create and execute the command
|
||||
const command: StartGamePlayCommand = {
|
||||
gameId,
|
||||
userId
|
||||
};
|
||||
|
||||
const result = await this.startGamePlayHandler.handle(command);
|
||||
|
||||
const endTime = performance.now();
|
||||
logOther('Game play started successfully', {
|
||||
gameId: result.game.id,
|
||||
gameCode: result.game.gamecode,
|
||||
playerCount: result.game.players.length,
|
||||
gameState: result.game.state,
|
||||
executionTime: Math.round(endTime - startTime)
|
||||
});
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
const endTime = performance.now();
|
||||
logError('GameService.startGamePlay failed', error instanceof Error ? error : new Error(String(error)));
|
||||
logOther('Game play start failed', {
|
||||
gameId,
|
||||
userId,
|
||||
executionTime: Math.round(endTime - startTime),
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private validateStartGamePlayInput(gameId: string): void {
|
||||
// Validate game ID
|
||||
if (!gameId || typeof gameId !== 'string') {
|
||||
throw new Error('Game ID is required and must be a string');
|
||||
}
|
||||
|
||||
logOther('Start game play input validation passed', {
|
||||
gameId
|
||||
});
|
||||
}
|
||||
|
||||
private validateJoinGameInput(gameCode: string, playerId?: string, loginType?: LoginType): void {
|
||||
// Validate game code
|
||||
if (!gameCode || typeof gameCode !== 'string') {
|
||||
throw new Error('Game code is required and must be a string');
|
||||
}
|
||||
|
||||
if (gameCode.length !== 6) {
|
||||
throw new Error('Game code must be exactly 6 characters long');
|
||||
}
|
||||
|
||||
// Validate login type specific requirements
|
||||
if (loginType === LoginType.PRIVATE || loginType === LoginType.ORGANIZATION) {
|
||||
if (!playerId || typeof playerId !== 'string') {
|
||||
throw new Error(`Player ID is required for ${LoginType[loginType]} games`);
|
||||
}
|
||||
}
|
||||
|
||||
logOther('Join game input validation passed', {
|
||||
gameCode,
|
||||
playerId: playerId || 'anonymous',
|
||||
loginType
|
||||
});
|
||||
}
|
||||
|
||||
private validateStartGameInput(deckids: string[], maxplayers: number, logintype: LoginType): void {
|
||||
// Validate deck IDs
|
||||
if (!deckids || deckids.length === 0) {
|
||||
throw new Error('At least one deck ID must be provided');
|
||||
}
|
||||
|
||||
if (deckids.length < 3) {
|
||||
throw new Error('At least 3 decks are required to start a game (one for each type: LUCK, JOKER, QUESTION)');
|
||||
}
|
||||
|
||||
// Validate max players
|
||||
if (!maxplayers || maxplayers < 2) {
|
||||
throw new Error('Maximum players must be at least 2');
|
||||
}
|
||||
|
||||
if (maxplayers > 8) {
|
||||
throw new Error('Maximum players cannot exceed 8');
|
||||
}
|
||||
|
||||
// Validate login type
|
||||
if (logintype < 0 || logintype > 2) {
|
||||
throw new Error('Invalid login type. Must be PUBLIC (0), PRIVATE (1), or ORGANIZATION (2)');
|
||||
}
|
||||
|
||||
// Check for duplicate deck IDs
|
||||
const uniqueIds = new Set(deckids);
|
||||
if (uniqueIds.size !== deckids.length) {
|
||||
throw new Error('Duplicate deck IDs are not allowed');
|
||||
}
|
||||
|
||||
logOther('Start game input validation passed', {
|
||||
deckCount: deckids.length,
|
||||
maxplayers,
|
||||
logintype
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Game flow explanation (to be implemented later):
|
||||
*
|
||||
* 1. START GAME (implemented above):
|
||||
* - Input: deckids, maxplayers, logintype, gamecode
|
||||
* - Process: Fetch decks, validate types, shuffle cards, create game
|
||||
* - Output: Game with shuffled deck objects
|
||||
*
|
||||
* 2. JOIN GAME (to be implemented):
|
||||
* - Input: gamecode, playerid
|
||||
* - Process: Find game, validate capacity, add player
|
||||
* - Output: Updated game with new player
|
||||
*
|
||||
* 3. GAME ROUNDS (to be implemented):
|
||||
* - Input: gameid, current player
|
||||
* - Process: Manage turn order, track game state
|
||||
* - Output: Current player information
|
||||
*
|
||||
* 4. PICK CARD (to be implemented):
|
||||
* - Input: gameid, playerid, deck type
|
||||
* - Process: Draw card from specific deck, apply consequence
|
||||
* - Output: Card details and consequence effects
|
||||
*
|
||||
* 5. END GAME (to be implemented):
|
||||
* - Input: gameid, winner
|
||||
* - Process: Set game as finished, record winner
|
||||
* - Output: Final game state
|
||||
*/
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface GenerateBoardCommand {
|
||||
gameId: string;
|
||||
positiveFieldCount: number;
|
||||
negativeFieldCount: number;
|
||||
luckFieldCount: number;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { GenerateBoardCommand } from './GenerateBoardCommand';
|
||||
import { BoardGenerationService } from '../BoardGenerationService';
|
||||
import { RedisService } from '../../Services/RedisService';
|
||||
import { logOther, logError } from '../../Services/Logger';
|
||||
import { BoardData } from '../../../Domain/Game/GameAggregate';
|
||||
|
||||
export class GenerateBoardCommandHandler {
|
||||
constructor(
|
||||
private readonly boardGenerationService: BoardGenerationService,
|
||||
private readonly redisService: RedisService
|
||||
) {}
|
||||
|
||||
async execute(cmd: GenerateBoardCommand): Promise<void> {
|
||||
try {
|
||||
logOther(`Starting board generation for game ${cmd.gameId}`);
|
||||
const startTime = Date.now();
|
||||
|
||||
// Generate board with 20-30 rule validation
|
||||
const boardData = await this.boardGenerationService.generateBoard(
|
||||
cmd.positiveFieldCount,
|
||||
cmd.negativeFieldCount,
|
||||
cmd.luckFieldCount
|
||||
);
|
||||
|
||||
// Store in Redis
|
||||
const boardDataWithMetadata: BoardData = {
|
||||
...boardData,
|
||||
gameId: cmd.gameId,
|
||||
generatedAt: new Date(),
|
||||
generationComplete: true
|
||||
};
|
||||
|
||||
await this.redisService.setWithExpiry(
|
||||
`game_board_${cmd.gameId}`,
|
||||
JSON.stringify(boardDataWithMetadata),
|
||||
24 * 60 * 60 // 24 hours
|
||||
);
|
||||
|
||||
const executionTime = Date.now() - startTime;
|
||||
logOther(`Board generation completed for game ${cmd.gameId} in ${executionTime}ms. Error rate: ${boardData.totalErrorRate}%`);
|
||||
|
||||
} catch (error) {
|
||||
logError(`Board generation failed for game ${cmd.gameId}:`, error as Error);
|
||||
|
||||
// Store error state in Redis
|
||||
const errorData: BoardData = {
|
||||
gameId: cmd.gameId,
|
||||
fields: [],
|
||||
border: [],
|
||||
validationResults: {},
|
||||
totalErrorRate: 100,
|
||||
generationComplete: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
generatedAt: new Date()
|
||||
};
|
||||
|
||||
await this.redisService.setWithExpiry(
|
||||
`game_board_${cmd.gameId}`,
|
||||
JSON.stringify(errorData),
|
||||
24 * 60 * 60
|
||||
);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { LoginType } from '../../../Domain/Game/GameAggregate';
|
||||
|
||||
export interface JoinGameCommand {
|
||||
gameCode: string; // 6-character game code
|
||||
playerId?: string; // User ID of the player joining (optional for public games)
|
||||
playerName?: string; // Display name for the player (required for public games)
|
||||
orgId?: string | null; // Organization ID (for organization games)
|
||||
loginType: LoginType; // Type of join being attempted
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { JoinGameCommand } from './JoinGameCommand';
|
||||
import { GameAggregate, GameState, LoginType } from '../../../Domain/Game/GameAggregate';
|
||||
import { IGameRepository } from '../../../Domain/IRepository/IGameRepository';
|
||||
import { DIContainer } from '../../Services/DIContainer';
|
||||
import { RedisService } from '../../Services/RedisService';
|
||||
import { logOther, logError } from '../../Services/Logger';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export interface GamePlayerData {
|
||||
playerId: string;
|
||||
playerName?: string;
|
||||
joinedAt: Date;
|
||||
isOnline: boolean;
|
||||
position?: number; // For game board position (to be used later)
|
||||
}
|
||||
|
||||
export interface ActiveGameData {
|
||||
gameId: string;
|
||||
gameCode: string;
|
||||
hostId?: string;
|
||||
maxPlayers: number;
|
||||
currentPlayers: GamePlayerData[];
|
||||
state: GameState;
|
||||
createdAt: Date;
|
||||
startedAt?: Date;
|
||||
currentTurn?: string; // Player ID whose turn it is
|
||||
websocketRoom: string; // WebSocket room name for real-time updates
|
||||
}
|
||||
|
||||
export class JoinGameCommandHandler {
|
||||
private gameRepository: IGameRepository;
|
||||
private redisService: RedisService;
|
||||
|
||||
constructor() {
|
||||
this.gameRepository = DIContainer.getInstance().gameRepository;
|
||||
this.redisService = RedisService.getInstance();
|
||||
}
|
||||
|
||||
async handle(command: JoinGameCommand): Promise<GameAggregate> {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
logOther('Joining game', `gameCode: ${command.gameCode}, playerId: ${command.playerId || 'anonymous'}, loginType: ${command.loginType}`);
|
||||
|
||||
// Find the game by game code
|
||||
const game = await this.gameRepository.findByGameCode(command.gameCode);
|
||||
if (!game) {
|
||||
throw new Error(`Game with code ${command.gameCode} not found`);
|
||||
}
|
||||
|
||||
// Generate player ID for public games or use provided one
|
||||
const actualPlayerId = command.playerId || uuidv4();
|
||||
|
||||
// Validate game joinability (authentication/org checks done in router)
|
||||
this.validateGameJoinability(game, actualPlayerId, command);
|
||||
|
||||
// Add player to database
|
||||
const updatedGame = await this.gameRepository.addPlayerToGame(game.id, actualPlayerId);
|
||||
if (!updatedGame) {
|
||||
throw new Error('Failed to add player to game');
|
||||
}
|
||||
|
||||
// Update Redis with the new player
|
||||
await this.updateGameInRedis(updatedGame, { ...command, playerId: actualPlayerId });
|
||||
|
||||
const endTime = performance.now();
|
||||
logOther('Player joined game successfully', {
|
||||
gameId: game.id,
|
||||
gameCode: game.gamecode,
|
||||
playerId: actualPlayerId,
|
||||
playerCount: updatedGame.players.length,
|
||||
maxPlayers: updatedGame.maxplayers,
|
||||
loginType: game.logintype,
|
||||
executionTime: Math.round(endTime - startTime)
|
||||
});
|
||||
|
||||
return updatedGame;
|
||||
|
||||
} catch (error) {
|
||||
const endTime = performance.now();
|
||||
logError('Failed to join game', error instanceof Error ? error : new Error(String(error)));
|
||||
logOther('Game join failed', {
|
||||
gameCode: command.gameCode,
|
||||
playerId: command.playerId || 'anonymous',
|
||||
loginType: command.loginType,
|
||||
executionTime: Math.round(endTime - startTime)
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private validateGameJoinability(game: GameAggregate, playerId: string, command: JoinGameCommand): void {
|
||||
// Check if game is in waiting state
|
||||
if (game.state !== GameState.WAITING) {
|
||||
throw new Error('Game is not accepting new players');
|
||||
}
|
||||
|
||||
// Check if player is already in the game
|
||||
if (game.players.includes(playerId)) {
|
||||
throw new Error('Player is already in this game');
|
||||
}
|
||||
|
||||
// Check if game is full
|
||||
if (game.players.length >= game.maxplayers) {
|
||||
throw new Error('Game is full');
|
||||
}
|
||||
|
||||
// Note: Login type validation is now handled in the router before reaching this handler
|
||||
// This ensures proper authentication and organization membership checks are done first
|
||||
|
||||
logOther('Game join validation passed', {
|
||||
gameId: game.id,
|
||||
gameCode: game.gamecode,
|
||||
currentPlayers: game.players.length,
|
||||
maxPlayers: game.maxplayers,
|
||||
gameState: game.state,
|
||||
loginType: game.logintype,
|
||||
playerId: playerId,
|
||||
isAuthenticated: !!command.playerId
|
||||
});
|
||||
}
|
||||
|
||||
private async updateGameInRedis(game: GameAggregate, command: JoinGameCommand & { playerId: string }): Promise<void> {
|
||||
try {
|
||||
const redisKey = `game:${game.id}`;
|
||||
|
||||
// Get existing game data from Redis or create new
|
||||
let gameData: ActiveGameData;
|
||||
const existingData = await this.redisService.get(redisKey);
|
||||
|
||||
if (existingData) {
|
||||
gameData = JSON.parse(existingData) as ActiveGameData;
|
||||
} else {
|
||||
// Create new game data structure
|
||||
gameData = {
|
||||
gameId: game.id,
|
||||
gameCode: game.gamecode,
|
||||
maxPlayers: game.maxplayers,
|
||||
currentPlayers: [],
|
||||
state: game.state,
|
||||
createdAt: game.createdate,
|
||||
websocketRoom: `game_${game.gamecode}`
|
||||
};
|
||||
}
|
||||
|
||||
// Add the new player
|
||||
const newPlayer: GamePlayerData = {
|
||||
playerId: command.playerId,
|
||||
playerName: command.playerName,
|
||||
joinedAt: new Date(),
|
||||
isOnline: true
|
||||
};
|
||||
|
||||
// Update players list (remove if exists, then add)
|
||||
gameData.currentPlayers = gameData.currentPlayers.filter(p => p.playerId !== command.playerId);
|
||||
gameData.currentPlayers.push(newPlayer);
|
||||
|
||||
// Update game state and player count
|
||||
gameData.state = game.state;
|
||||
|
||||
// Store updated data in Redis with TTL (24 hours)
|
||||
await this.redisService.setWithExpiry(redisKey, JSON.stringify(gameData), 24 * 60 * 60);
|
||||
|
||||
// Add player to active players set
|
||||
await this.redisService.setAdd(`active_players:${game.id}`, command.playerId);
|
||||
|
||||
logOther('Game data updated in Redis', {
|
||||
gameId: game.id,
|
||||
gameCode: game.gamecode,
|
||||
redisKey,
|
||||
playerCount: gameData.currentPlayers.length,
|
||||
websocketRoom: gameData.websocketRoom,
|
||||
playerId: command.playerId
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logError('Failed to update game in Redis', error instanceof Error ? error : new Error(String(error)));
|
||||
// Don't throw error here - Redis failure shouldn't prevent game join
|
||||
logOther('Game join completed despite Redis error', {
|
||||
gameId: game.id,
|
||||
playerId: command.playerId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getGameFromRedis(gameId: string): Promise<ActiveGameData | null> {
|
||||
try {
|
||||
const redisKey = `game:${gameId}`;
|
||||
const data = await this.redisService.get(redisKey);
|
||||
return data ? JSON.parse(data) as ActiveGameData : null;
|
||||
} catch (error) {
|
||||
logError('Failed to get game from Redis', error instanceof Error ? error : new Error(String(error)));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async removePlayerFromRedis(gameId: string, playerId: string): Promise<void> {
|
||||
try {
|
||||
const redisKey = `game:${gameId}`;
|
||||
const existingData = await this.redisService.get(redisKey);
|
||||
|
||||
if (existingData) {
|
||||
const gameData = JSON.parse(existingData) as ActiveGameData;
|
||||
gameData.currentPlayers = gameData.currentPlayers.filter(p => p.playerId !== playerId);
|
||||
|
||||
await this.redisService.setWithExpiry(redisKey, JSON.stringify(gameData), 24 * 60 * 60);
|
||||
await this.redisService.setRemove(`active_players:${gameId}`, playerId);
|
||||
}
|
||||
} catch (error) {
|
||||
logError('Failed to remove player from Redis', error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { LoginType } from '../../../Domain/Game/GameAggregate';
|
||||
|
||||
export interface StartGameCommand {
|
||||
deckids: string[]; // Array of deck IDs (3 types, multiple decks per type)
|
||||
maxplayers: number; // Maximum number of players
|
||||
logintype: LoginType; // How players can join the game
|
||||
userid?: string; // Optional user who created the game (becomes game master)
|
||||
orgid?: string | null; // Organization ID (for organization games)
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import { StartGameCommand } from './StartGameCommand';
|
||||
import { GameAggregate, GameDeck, GameCard, DeckType, GameState } from '../../../Domain/Game/GameAggregate';
|
||||
import { DeckAggregate } from '../../../Domain/Deck/DeckAggregate';
|
||||
import { IGameRepository } from '../../../Domain/IRepository/IGameRepository';
|
||||
import { IDeckRepository } from '../../../Domain/IRepository/IDeckRepository';
|
||||
import { DIContainer } from '../../Services/DIContainer';
|
||||
import { RedisService } from '../../Services/RedisService';
|
||||
import { logOther, logError } from '../../Services/Logger';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { GenerateBoardCommand } from './GenerateBoardCommand';
|
||||
|
||||
export interface ActiveGameData {
|
||||
gameId: string;
|
||||
gameCode: string;
|
||||
hostId?: string;
|
||||
maxPlayers: number;
|
||||
currentPlayers: GamePlayerData[];
|
||||
state: GameState;
|
||||
createdAt: Date;
|
||||
startedAt?: Date;
|
||||
currentTurn?: string;
|
||||
websocketRoom: string;
|
||||
}
|
||||
|
||||
export interface GamePlayerData {
|
||||
playerId: string;
|
||||
playerName?: string;
|
||||
joinedAt: Date;
|
||||
isOnline: boolean;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export class StartGameCommandHandler {
|
||||
private gameRepository: IGameRepository;
|
||||
private deckRepository: IDeckRepository;
|
||||
private redisService: RedisService;
|
||||
|
||||
constructor() {
|
||||
this.gameRepository = DIContainer.getInstance().gameRepository;
|
||||
this.deckRepository = DIContainer.getInstance().deckRepository;
|
||||
this.redisService = RedisService.getInstance();
|
||||
}
|
||||
|
||||
async handle(command: StartGameCommand): Promise<GameAggregate> {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
logOther('Starting game creation', `deckCount: ${command.deckids.length}, maxPlayers: ${command.maxplayers}, loginType: ${command.logintype}`);
|
||||
|
||||
// Generate unique game code
|
||||
const gamecode = this.generateGameCode();
|
||||
|
||||
// Fetch all decks by IDs
|
||||
const decks = await this.fetchDecks(command.deckids);
|
||||
|
||||
// Validate we have 3 deck types
|
||||
this.validateDeckTypes(decks);
|
||||
|
||||
// Group decks by type and shuffle cards within each type
|
||||
const gamedecks = await this.createShuffledGameDecks(decks);
|
||||
|
||||
// Create the game aggregate
|
||||
const gameData: Partial<GameAggregate> = {
|
||||
gamecode,
|
||||
maxplayers: command.maxplayers,
|
||||
logintype: command.logintype,
|
||||
createdby: command.userid || null,
|
||||
orgid: command.orgid || null,
|
||||
gamedecks,
|
||||
players: [],
|
||||
started: false,
|
||||
finished: false,
|
||||
winner: null,
|
||||
state: GameState.WAITING,
|
||||
startdate: null,
|
||||
enddate: null
|
||||
};
|
||||
|
||||
// Save the game to database
|
||||
const savedGame = await this.gameRepository.create(gameData);
|
||||
|
||||
// Create Redis object for real-time game management
|
||||
await this.createGameInRedis(savedGame, command.userid);
|
||||
|
||||
// Trigger async board generation (don't block game creation)
|
||||
this.triggerAsyncBoardGeneration(savedGame.id).catch((error: Error) => {
|
||||
logError('Async board generation failed', error);
|
||||
});
|
||||
|
||||
const endTime = performance.now();
|
||||
logOther('Game created successfully', `gameId: ${savedGame.id}, gameCode: ${savedGame.gamecode}, executionTime: ${Math.round(endTime - startTime)}ms`);
|
||||
|
||||
return savedGame;
|
||||
|
||||
} catch (error) {
|
||||
const endTime = performance.now();
|
||||
logError('Failed to create game', error instanceof Error ? error : new Error(String(error)));
|
||||
logOther('Game creation failed', `executionTime: ${Math.round(endTime - startTime)}ms`);
|
||||
throw new Error('Failed to start game: ' + (error instanceof Error ? error.message : String(error)));
|
||||
}
|
||||
}
|
||||
|
||||
private generateGameCode(): string {
|
||||
// Generate a 6-character alphanumeric game code
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
let result = '';
|
||||
const randomBytesArray = randomBytes(6);
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
result += chars[randomBytesArray[i] % chars.length];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async fetchDecks(deckIds: string[]): Promise<DeckAggregate[]> {
|
||||
const decks: DeckAggregate[] = [];
|
||||
|
||||
for (const deckId of deckIds) {
|
||||
const deck = await this.deckRepository.findById(deckId);
|
||||
if (!deck) {
|
||||
throw new Error(`Deck with ID ${deckId} not found`);
|
||||
}
|
||||
decks.push(deck);
|
||||
}
|
||||
|
||||
return decks;
|
||||
}
|
||||
|
||||
private validateDeckTypes(decks: DeckAggregate[]): void {
|
||||
const deckTypes = new Set(decks.map(deck => deck.type));
|
||||
|
||||
// Check if we have all 3 required deck types (LUCK=0, JOKER=1, QUESTION=2)
|
||||
const requiredTypes = [0, 1, 2]; // Based on Type enum in DeckAggregate
|
||||
const missingTypes = requiredTypes.filter(type => !deckTypes.has(type));
|
||||
|
||||
if (missingTypes.length > 0) {
|
||||
throw new Error(`Missing required deck types: ${missingTypes.join(', ')}. Game requires LUCK, JOKER, and QUESTION deck types.`);
|
||||
}
|
||||
|
||||
logOther('Deck types validation passed', `foundTypes: [${Array.from(deckTypes).join(', ')}]`);
|
||||
}
|
||||
|
||||
private async createShuffledGameDecks(decks: DeckAggregate[]): Promise<GameDeck[]> {
|
||||
// Group decks by type
|
||||
const decksByType = new Map<number, DeckAggregate[]>();
|
||||
|
||||
decks.forEach(deck => {
|
||||
if (!decksByType.has(deck.type)) {
|
||||
decksByType.set(deck.type, []);
|
||||
}
|
||||
decksByType.get(deck.type)!.push(deck);
|
||||
});
|
||||
|
||||
const gamedecks: GameDeck[] = [];
|
||||
|
||||
// Process each deck type
|
||||
for (const [deckType, typeDecks] of decksByType) {
|
||||
// Collect all cards from decks of this type
|
||||
const allCards: GameCard[] = [];
|
||||
|
||||
typeDecks.forEach(deck => {
|
||||
deck.cards.forEach(card => {
|
||||
const gameCard: GameCard = {
|
||||
cardid: this.generateCardId(),
|
||||
question: card.text,
|
||||
answer: card.answer || undefined,
|
||||
consequence: card.consequence || null,
|
||||
played: false,
|
||||
playerid: undefined
|
||||
};
|
||||
allCards.push(gameCard);
|
||||
});
|
||||
});
|
||||
|
||||
// Shuffle all cards of this type
|
||||
const shuffledCards = this.shuffleArray(allCards);
|
||||
|
||||
// Create game deck for this type
|
||||
const gameDeck: GameDeck = {
|
||||
deckid: typeDecks[0].id, // Use first deck ID as representative
|
||||
decktype: this.mapDeckTypeToGameDeckType(deckType),
|
||||
cards: shuffledCards
|
||||
};
|
||||
|
||||
gamedecks.push(gameDeck);
|
||||
|
||||
logOther('Created shuffled game deck', `type: ${deckType}, cardCount: ${shuffledCards.length}, sourceDecks: ${typeDecks.length}`);
|
||||
}
|
||||
|
||||
return gamedecks;
|
||||
}
|
||||
|
||||
private mapDeckTypeToGameDeckType(deckType: number): DeckType {
|
||||
// Map DeckAggregate.Type to GameAggregate.DeckType
|
||||
switch (deckType) {
|
||||
case 0: return DeckType.LUCK; // LUCK = 0
|
||||
case 1: return DeckType.JOCKER; // JOKER = 1
|
||||
case 2: return DeckType.QUEST; // QUESTION = 2
|
||||
default: throw new Error(`Unknown deck type: ${deckType}`);
|
||||
}
|
||||
}
|
||||
|
||||
private shuffleArray<T>(array: T[]): T[] {
|
||||
const shuffled = [...array];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
return shuffled;
|
||||
}
|
||||
|
||||
private generateCardId(): string {
|
||||
return randomBytes(8).toString('hex');
|
||||
}
|
||||
|
||||
private async createGameInRedis(game: GameAggregate, hostId?: string): Promise<void> {
|
||||
try {
|
||||
const redisKey = `game:${game.id}`;
|
||||
|
||||
const gameData: ActiveGameData = {
|
||||
gameId: game.id,
|
||||
gameCode: game.gamecode,
|
||||
hostId: hostId,
|
||||
maxPlayers: game.maxplayers,
|
||||
currentPlayers: [],
|
||||
state: game.state,
|
||||
createdAt: game.createdate,
|
||||
websocketRoom: `game_${game.gamecode}`
|
||||
};
|
||||
|
||||
// Store game data in Redis with TTL (24 hours)
|
||||
await this.redisService.setWithExpiry(redisKey, JSON.stringify(gameData), 24 * 60 * 60);
|
||||
|
||||
// Create game room for WebSocket connections
|
||||
await this.redisService.set(`game_room:${game.gamecode}`, game.id);
|
||||
|
||||
logOther('Game created in Redis', {
|
||||
gameId: game.id,
|
||||
gameCode: game.gamecode,
|
||||
hostId: hostId,
|
||||
websocketRoom: gameData.websocketRoom,
|
||||
redisKey
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logError('Failed to create game in Redis', error instanceof Error ? error : new Error(String(error)));
|
||||
// Don't throw error here - Redis failure shouldn't prevent game creation
|
||||
logOther('Game created successfully despite Redis error', {
|
||||
gameId: game.id,
|
||||
gameCode: game.gamecode
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async triggerAsyncBoardGeneration(gameId: string): Promise<void> {
|
||||
try {
|
||||
// Calculate default field counts based on game configuration
|
||||
// For now, use reasonable defaults - this should be configurable by host in the future
|
||||
const maxSpecialFieldsPercentage = parseInt(process.env.MAX_SPECIAL_FIELDS_PERCENTAGE || '67');
|
||||
const maxSpecialFields = Math.floor((100 * maxSpecialFieldsPercentage) / 100);
|
||||
|
||||
// Default distribution: 60% positive, 25% negative, 15% luck
|
||||
const positiveFieldCount = Math.floor(maxSpecialFields * 0.6);
|
||||
const negativeFieldCount = Math.floor(maxSpecialFields * 0.25);
|
||||
const luckFieldCount = Math.floor(maxSpecialFields * 0.15);
|
||||
|
||||
const command: GenerateBoardCommand = {
|
||||
gameId,
|
||||
positiveFieldCount,
|
||||
negativeFieldCount,
|
||||
luckFieldCount
|
||||
};
|
||||
|
||||
logOther(`Triggering async board generation for game ${gameId}`, {
|
||||
positiveFieldCount,
|
||||
negativeFieldCount,
|
||||
luckFieldCount,
|
||||
totalSpecialFields: positiveFieldCount + negativeFieldCount + luckFieldCount
|
||||
});
|
||||
|
||||
// Execute board generation in background
|
||||
await DIContainer.getInstance().generateBoardCommandHandler.execute(command);
|
||||
|
||||
} catch (error) {
|
||||
logError(`Async board generation failed for game ${gameId}`, error as Error);
|
||||
// Don't propagate error - board generation failure shouldn't affect game creation
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface StartGamePlayCommand {
|
||||
gameId: string; // Game ID to start
|
||||
userId?: string; // User who is starting the game (should be game master)
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
import { StartGamePlayCommand } from './StartGamePlayCommand';
|
||||
import { GameAggregate, GameState, BoardData, GameField } from '../../../Domain/Game/GameAggregate';
|
||||
import { IGameRepository } from '../../../Domain/IRepository/IGameRepository';
|
||||
import { DIContainer } from '../../Services/DIContainer';
|
||||
import { RedisService } from '../../Services/RedisService';
|
||||
import { WebSocketService } from '../../Services/WebSocketService';
|
||||
import { logOther, logError } from '../../Services/Logger';
|
||||
|
||||
export interface GamePlayerPosition {
|
||||
playerId: string;
|
||||
playerName?: string;
|
||||
position: number; // Board position (starts at 0)
|
||||
turnOrder: number; // Random number to determine turn sequence
|
||||
isOnline: boolean;
|
||||
joinedAt: Date;
|
||||
}
|
||||
|
||||
export interface ActiveGamePlayData {
|
||||
gameId: string;
|
||||
gameCode: string;
|
||||
hostId?: string;
|
||||
maxPlayers: number;
|
||||
players: GamePlayerPosition[];
|
||||
state: GameState;
|
||||
createdAt: Date;
|
||||
startedAt: Date;
|
||||
currentTurn: number; // Index of current player in turn order
|
||||
turnSequence: string[]; // Ordered array of player IDs based on turnOrder
|
||||
websocketRoom: string;
|
||||
gamePhase: 'starting' | 'playing' | 'paused' | 'finished';
|
||||
boardData: BoardData; // Generated board with fields and border
|
||||
}
|
||||
|
||||
export interface GameStartResult {
|
||||
game: GameAggregate;
|
||||
boardData: BoardData;
|
||||
}
|
||||
|
||||
export class StartGamePlayCommandHandler {
|
||||
private gameRepository: IGameRepository;
|
||||
private redisService: RedisService;
|
||||
|
||||
constructor() {
|
||||
this.gameRepository = DIContainer.getInstance().gameRepository;
|
||||
this.redisService = RedisService.getInstance();
|
||||
}
|
||||
|
||||
async handle(command: StartGamePlayCommand): Promise<GameStartResult> {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
logOther('Starting game play', `gameId: ${command.gameId}, userId: ${command.userId || 'system'}`);
|
||||
|
||||
// Find the game
|
||||
const game = await this.gameRepository.findById(command.gameId);
|
||||
if (!game) {
|
||||
throw new Error(`Game with ID ${command.gameId} not found`);
|
||||
}
|
||||
|
||||
// Validate game can be started
|
||||
this.validateGameCanStart(game, command.userId);
|
||||
|
||||
// Wait for board generation to complete (max 20 seconds)
|
||||
const boardData = await this.waitForBoardGeneration(game.id);
|
||||
|
||||
// Update game state in database
|
||||
const updatedGame = await this.gameRepository.update(game.id, {
|
||||
started: true,
|
||||
state: GameState.ACTIVE,
|
||||
startdate: new Date()
|
||||
});
|
||||
|
||||
if (!updatedGame) {
|
||||
throw new Error('Failed to update game state');
|
||||
}
|
||||
|
||||
// Initialize game play in Redis with board data
|
||||
await this.initializeGamePlayInRedis(updatedGame, boardData);
|
||||
|
||||
// Notify all players via WebSocket
|
||||
await this.notifyGameStart(updatedGame);
|
||||
|
||||
const endTime = performance.now();
|
||||
logOther('Game play started successfully', {
|
||||
gameId: updatedGame.id,
|
||||
gameCode: updatedGame.gamecode,
|
||||
playerCount: updatedGame.players.length,
|
||||
executionTime: Math.round(endTime - startTime)
|
||||
});
|
||||
|
||||
return {
|
||||
game: updatedGame,
|
||||
boardData: boardData
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const endTime = performance.now();
|
||||
logError('Failed to start game play', error instanceof Error ? error : new Error(String(error)));
|
||||
logOther('Game start failed', {
|
||||
gameId: command.gameId,
|
||||
userId: command.userId,
|
||||
executionTime: Math.round(endTime - startTime)
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private validateGameCanStart(game: GameAggregate, userId?: string): void {
|
||||
// Check if game is in waiting state
|
||||
if (game.state !== GameState.WAITING) {
|
||||
throw new Error('Game is not in waiting state and cannot be started');
|
||||
}
|
||||
|
||||
// Check if game is already started
|
||||
if (game.started) {
|
||||
throw new Error('Game has already been started');
|
||||
}
|
||||
|
||||
// Check if there are enough players (at least 2)
|
||||
if (game.players.length < 2) {
|
||||
throw new Error('Game needs at least 2 players to start');
|
||||
}
|
||||
|
||||
// For private and organization games, check if user is game master
|
||||
if (game.createdby && userId && game.createdby !== userId) {
|
||||
throw new Error('Only the game master can start this game');
|
||||
}
|
||||
|
||||
logOther('Game start validation passed', {
|
||||
gameId: game.id,
|
||||
gameCode: game.gamecode,
|
||||
playerCount: game.players.length,
|
||||
gameState: game.state,
|
||||
isGameMaster: !game.createdby || (userId && game.createdby === userId)
|
||||
});
|
||||
}
|
||||
|
||||
private async initializeGamePlayInRedis(game: GameAggregate, boardData: BoardData): Promise<void> {
|
||||
try {
|
||||
const redisKey = `gameplay:${game.id}`;
|
||||
|
||||
// Generate random turn orders for all players
|
||||
const playersWithPositions = this.initializePlayerPositions(game.players);
|
||||
|
||||
// Sort by turn order to create turn sequence
|
||||
const turnSequence = [...playersWithPositions]
|
||||
.sort((a, b) => a.turnOrder - b.turnOrder)
|
||||
.map(p => p.playerId);
|
||||
|
||||
const gamePlayData: ActiveGamePlayData = {
|
||||
gameId: game.id,
|
||||
gameCode: game.gamecode,
|
||||
hostId: game.createdby || undefined,
|
||||
maxPlayers: game.maxplayers,
|
||||
players: playersWithPositions,
|
||||
state: GameState.ACTIVE,
|
||||
createdAt: game.createdate,
|
||||
startedAt: new Date(),
|
||||
currentTurn: 0, // Start with first player in sequence
|
||||
turnSequence,
|
||||
websocketRoom: `game_${game.gamecode}`,
|
||||
gamePhase: 'starting',
|
||||
boardData
|
||||
};
|
||||
|
||||
// Store game play data in Redis with TTL (24 hours)
|
||||
await this.redisService.setWithExpiry(redisKey, JSON.stringify(gamePlayData), 24 * 60 * 60);
|
||||
|
||||
// Create turn sequence mapping for quick lookups
|
||||
await this.redisService.setWithExpiry(
|
||||
`game_turns:${game.id}`,
|
||||
JSON.stringify(turnSequence),
|
||||
24 * 60 * 60
|
||||
);
|
||||
|
||||
logOther('Game play initialized in Redis', {
|
||||
gameId: game.id,
|
||||
gameCode: game.gamecode,
|
||||
playerCount: playersWithPositions.length,
|
||||
turnSequence,
|
||||
currentPlayer: turnSequence[0],
|
||||
redisKey
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logError('Failed to initialize game play in Redis', error instanceof Error ? error : new Error(String(error)));
|
||||
throw new Error('Failed to initialize game session');
|
||||
}
|
||||
}
|
||||
|
||||
private initializePlayerPositions(playerIds: string[]): GamePlayerPosition[] {
|
||||
const players: GamePlayerPosition[] = [];
|
||||
|
||||
// Generate random turn orders (1 to playerCount)
|
||||
const turnOrders = this.generateRandomTurnOrders(playerIds.length);
|
||||
|
||||
playerIds.forEach((playerId, index) => {
|
||||
players.push({
|
||||
playerId,
|
||||
position: 0, // All players start at position 0
|
||||
turnOrder: turnOrders[index],
|
||||
isOnline: true, // Assume online when game starts
|
||||
joinedAt: new Date()
|
||||
});
|
||||
});
|
||||
|
||||
logOther('Player positions initialized', {
|
||||
playerCount: players.length,
|
||||
turnOrders: turnOrders,
|
||||
playersData: players.map(p => ({
|
||||
playerId: p.playerId,
|
||||
position: p.position,
|
||||
turnOrder: p.turnOrder
|
||||
}))
|
||||
});
|
||||
|
||||
return players;
|
||||
}
|
||||
|
||||
private generateRandomTurnOrders(playerCount: number): number[] {
|
||||
// Create array [1, 2, 3, ..., playerCount]
|
||||
const orders = Array.from({ length: playerCount }, (_, i) => i + 1);
|
||||
|
||||
// Fisher-Yates shuffle
|
||||
for (let i = orders.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[orders[i], orders[j]] = [orders[j], orders[i]];
|
||||
}
|
||||
|
||||
return orders;
|
||||
}
|
||||
|
||||
private async notifyGameStart(game: GameAggregate): Promise<void> {
|
||||
try {
|
||||
// Note: WebSocket notifications will be handled when WebSocket service is available
|
||||
// For now, just log the game start
|
||||
logOther('Game start notifications prepared', {
|
||||
gameId: game.id,
|
||||
gameCode: game.gamecode,
|
||||
playerCount: game.players.length,
|
||||
websocketRoom: `game_${game.gamecode}`
|
||||
});
|
||||
|
||||
// TODO: Implement WebSocket notifications when service is properly integrated
|
||||
// wsService.notifyGameStart(game.gamecode, game.players);
|
||||
// wsService.broadcastGameStateUpdate(game.gamecode, gameStateData);
|
||||
|
||||
} catch (error) {
|
||||
logError('Failed to prepare game start notifications', error instanceof Error ? error : new Error(String(error)));
|
||||
// Don't throw error here - notification failure shouldn't prevent game start
|
||||
}
|
||||
}
|
||||
|
||||
async getGamePlayFromRedis(gameId: string): Promise<ActiveGamePlayData | null> {
|
||||
try {
|
||||
const redisKey = `gameplay:${gameId}`;
|
||||
const data = await this.redisService.get(redisKey);
|
||||
return data ? JSON.parse(data) as ActiveGamePlayData : null;
|
||||
} catch (error) {
|
||||
logError('Failed to get game play from Redis', error instanceof Error ? error : new Error(String(error)));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async updatePlayerPosition(gameId: string, playerId: string, newPosition: number): Promise<void> {
|
||||
try {
|
||||
const gameData = await this.getGamePlayFromRedis(gameId);
|
||||
if (!gameData) {
|
||||
throw new Error('Game session not found');
|
||||
}
|
||||
|
||||
// Update player position
|
||||
const player = gameData.players.find(p => p.playerId === playerId);
|
||||
if (player) {
|
||||
player.position = newPosition;
|
||||
|
||||
// Save back to Redis
|
||||
const redisKey = `gameplay:${gameId}`;
|
||||
await this.redisService.setWithExpiry(redisKey, JSON.stringify(gameData), 24 * 60 * 60);
|
||||
|
||||
logOther('Player position updated', {
|
||||
gameId,
|
||||
playerId,
|
||||
newPosition
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logError('Failed to update player position', error instanceof Error ? error : new Error(String(error)));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getNextPlayer(gameId: string): Promise<string | null> {
|
||||
try {
|
||||
const gameData = await this.getGamePlayFromRedis(gameId);
|
||||
if (!gameData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextTurnIndex = (gameData.currentTurn + 1) % gameData.turnSequence.length;
|
||||
return gameData.turnSequence[nextTurnIndex];
|
||||
} catch (error) {
|
||||
logError('Failed to get next player', error instanceof Error ? error : new Error(String(error)));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async advanceTurn(gameId: string): Promise<string | null> {
|
||||
try {
|
||||
const gameData = await this.getGamePlayFromRedis(gameId);
|
||||
if (!gameData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Advance to next player
|
||||
gameData.currentTurn = (gameData.currentTurn + 1) % gameData.turnSequence.length;
|
||||
const currentPlayer = gameData.turnSequence[gameData.currentTurn];
|
||||
|
||||
// Save back to Redis
|
||||
const redisKey = `gameplay:${gameId}`;
|
||||
await this.redisService.setWithExpiry(redisKey, JSON.stringify(gameData), 24 * 60 * 60);
|
||||
|
||||
logOther('Turn advanced', {
|
||||
gameId,
|
||||
currentTurn: gameData.currentTurn,
|
||||
currentPlayer
|
||||
});
|
||||
|
||||
return currentPlayer;
|
||||
} catch (error) {
|
||||
logError('Failed to advance turn', error instanceof Error ? error : new Error(String(error)));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async waitForBoardGeneration(gameId: string): Promise<BoardData> {
|
||||
const maxWaitTime = parseInt(process.env.MAX_GENERATION_TIME_SECONDS || '20') * 1000;
|
||||
const pollInterval = 500; // Check every 500ms
|
||||
const startTime = Date.now();
|
||||
|
||||
logOther(`Waiting for board generation for game ${gameId}`, {
|
||||
maxWaitTime: maxWaitTime / 1000,
|
||||
pollInterval,
|
||||
redisKey: `game_board_${gameId}`
|
||||
});
|
||||
|
||||
while (Date.now() - startTime < maxWaitTime) {
|
||||
try {
|
||||
const redisKey = `game_board_${gameId}`;
|
||||
const boardDataStr = await this.redisService.get(redisKey);
|
||||
|
||||
logOther(`Board generation check for game ${gameId}`, {
|
||||
attempt: Math.floor((Date.now() - startTime) / pollInterval) + 1,
|
||||
hasData: !!boardDataStr,
|
||||
dataLength: boardDataStr ? boardDataStr.length : 0,
|
||||
waitTime: Date.now() - startTime
|
||||
});
|
||||
|
||||
if (boardDataStr) {
|
||||
const boardData: BoardData = JSON.parse(boardDataStr);
|
||||
|
||||
logOther(`Board data found for game ${gameId}`, {
|
||||
generationComplete: boardData.generationComplete,
|
||||
hasError: !!boardData.error,
|
||||
fieldsCount: boardData.fields ? boardData.fields.length : 0,
|
||||
borderLength: boardData.border ? boardData.border.length : 0,
|
||||
totalErrorRate: boardData.totalErrorRate
|
||||
});
|
||||
|
||||
if (boardData.generationComplete) {
|
||||
if (boardData.error) {
|
||||
logError(`Board generation failed for game ${gameId}`, new Error(boardData.error));
|
||||
throw new Error(`Board generation failed: ${boardData.error}`);
|
||||
}
|
||||
|
||||
logOther(`Board generation completed for game ${gameId}`, {
|
||||
errorRate: boardData.totalErrorRate,
|
||||
fieldCount: boardData.fields.length,
|
||||
borderLength: boardData.border.length,
|
||||
waitTime: Date.now() - startTime
|
||||
});
|
||||
|
||||
return boardData;
|
||||
}
|
||||
} else {
|
||||
// No board data found yet - check if we need to trigger generation
|
||||
logOther(`No board data found yet for game ${gameId}, checking if generation was triggered...`, {
|
||||
waitTime: Date.now() - startTime,
|
||||
redisKey
|
||||
});
|
||||
|
||||
// If we've waited for 2 seconds and still no data, try to trigger generation manually
|
||||
if (Date.now() - startTime > 2000) {
|
||||
await this.ensureBoardGenerationTriggered(gameId);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait before next poll
|
||||
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
||||
|
||||
} catch (error) {
|
||||
logError(`Error checking board generation status for game ${gameId}`, error as Error);
|
||||
throw new Error(`Failed to retrieve board data: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Timeout reached
|
||||
logError(`Board generation timeout for game ${gameId}`, new Error(`Generation took longer than ${maxWaitTime / 1000} seconds`));
|
||||
throw new Error(`Board generation timeout. Game ${gameId} is not ready to start. Please try again later.`);
|
||||
}
|
||||
|
||||
private async ensureBoardGenerationTriggered(gameId: string): Promise<void> {
|
||||
try {
|
||||
logOther(`Ensuring board generation is triggered for game ${gameId}`);
|
||||
|
||||
// Check if generation was already triggered by looking for any board data
|
||||
const redisKey = `game_board_${gameId}`;
|
||||
const existingData = await this.redisService.get(redisKey);
|
||||
|
||||
if (!existingData) {
|
||||
// No data at all - trigger generation manually
|
||||
logOther(`No board generation found for game ${gameId}, triggering manually`);
|
||||
|
||||
// Use DIContainer to trigger board generation
|
||||
const generateBoardCommand = {
|
||||
gameId,
|
||||
positiveFieldCount: Math.floor(67 * 0.6), // Default: 60% positive
|
||||
negativeFieldCount: Math.floor(67 * 0.25), // Default: 25% negative
|
||||
luckFieldCount: Math.floor(67 * 0.15) // Default: 15% luck
|
||||
};
|
||||
|
||||
await DIContainer.getInstance().generateBoardCommandHandler.execute(generateBoardCommand);
|
||||
logOther(`Board generation manually triggered for game ${gameId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logError(`Failed to ensure board generation for game ${gameId}`, error as Error);
|
||||
// Don't throw here - let the main wait loop handle the timeout
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,10 +49,14 @@ export class GeneralSearchService implements IGeneralSearchService {
|
||||
};
|
||||
}
|
||||
|
||||
// Ensure limit is at least 1 to prevent database issues
|
||||
const effectiveLimit = Math.max(limit || 20, 1);
|
||||
const effectiveOffset = Math.max(offset || 0, 0);
|
||||
|
||||
try {
|
||||
const { users, totalCount } = await this.userRepo.search(query.trim(), limit, offset);
|
||||
const { users, totalCount } = await this.userRepo.search(query.trim(), effectiveLimit, effectiveOffset);
|
||||
const results = users.map(user => UserMapper.toShortDto(user));
|
||||
const hasMore = (offset + limit) < totalCount;
|
||||
const hasMore = (effectiveOffset + effectiveLimit) < totalCount;
|
||||
|
||||
return {
|
||||
results,
|
||||
@@ -105,17 +109,25 @@ export class GeneralSearchService implements IGeneralSearchService {
|
||||
};
|
||||
}
|
||||
|
||||
const { decks, totalCount } = await this.deckRepo.search(query.trim(), limit, offset);
|
||||
const results = decks.map(deck => DeckMapper.toShortDto(deck));
|
||||
const hasMore = (offset + limit) < totalCount;
|
||||
// Ensure limit is at least 1 to prevent database issues
|
||||
const effectiveLimit = Math.max(limit || 20, 1);
|
||||
const effectiveOffset = Math.max(offset || 0, 0);
|
||||
|
||||
return {
|
||||
results,
|
||||
totalCount,
|
||||
hasMore,
|
||||
searchQuery: query,
|
||||
searchType: 'decks'
|
||||
};
|
||||
try {
|
||||
const { decks, totalCount } = await this.deckRepo.search(query.trim(), effectiveLimit, effectiveOffset);
|
||||
const results = decks.map(deck => DeckMapper.toShortDto(deck));
|
||||
const hasMore = (effectiveOffset + effectiveLimit) < totalCount;
|
||||
|
||||
return {
|
||||
results,
|
||||
totalCount,
|
||||
hasMore,
|
||||
searchQuery: query,
|
||||
searchType: 'decks'
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error('Failed to search decks');
|
||||
}
|
||||
}
|
||||
|
||||
async searchByType(
|
||||
|
||||
@@ -1,57 +1,146 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { JWTService } from './JWTService';
|
||||
import { RedisService } from './RedisService';
|
||||
import { logAuth, logWarning } from './Logger';
|
||||
|
||||
export const jwtService = new JWTService();
|
||||
const redisService = RedisService.getInstance();
|
||||
|
||||
export function authRequired(req: Request, res: Response, next: NextFunction) {
|
||||
const payload = jwtService.verify(req);
|
||||
if (!payload) {
|
||||
logAuth('Authentication failed - No valid token', undefined, {
|
||||
ip: req.ip,
|
||||
userAgent: req.get ? req.get('User-Agent') : 'unknown',
|
||||
path: req.path
|
||||
}, req);
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
/**
|
||||
* Check if a token is blacklisted
|
||||
*/
|
||||
async function isTokenBlacklisted(token: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await redisService.get(`blacklist:${token}`);
|
||||
return result === 'true';
|
||||
} catch (error) {
|
||||
// If Redis is down, allow the request to proceed (fail open)
|
||||
logWarning('Failed to check token blacklist - allowing request', { error: (error as Error).message });
|
||||
return false;
|
||||
}
|
||||
|
||||
logAuth('Authentication successful', payload.userId, {
|
||||
authLevel: payload.authLevel,
|
||||
orgId: payload.orgId
|
||||
}, req);
|
||||
|
||||
const refreshed = jwtService.refreshIfNeeded(payload, res);
|
||||
if (refreshed) {
|
||||
logAuth('Token refreshed', payload.userId, undefined, req);
|
||||
}
|
||||
|
||||
(req as any).user = payload;
|
||||
next();
|
||||
}
|
||||
|
||||
export function adminRequired(req: Request, res: Response, next: NextFunction) {
|
||||
const payload = jwtService.verify(req);
|
||||
if (!payload || payload.authLevel !== 1) {
|
||||
logWarning('Admin access denied', {
|
||||
hasPayload: !!payload,
|
||||
authLevel: payload?.authLevel,
|
||||
userId: payload?.userId,
|
||||
ip: req.ip,
|
||||
path: req.path
|
||||
/**
|
||||
* Extract token from request (cookie or Authorization header)
|
||||
*/
|
||||
function extractToken(req: Request): string | null {
|
||||
// First try to get token from cookie
|
||||
const cookieToken = req.cookies['auth_token'];
|
||||
if (cookieToken) {
|
||||
return cookieToken;
|
||||
}
|
||||
|
||||
// Fallback to Authorization header
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
return authHeader.substring(7);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function authRequired(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
// Extract token from request
|
||||
const token = extractToken(req);
|
||||
if (!token) {
|
||||
logAuth('Authentication failed - No token provided', undefined, {
|
||||
ip: req.ip,
|
||||
userAgent: req.get ? req.get('User-Agent') : 'unknown',
|
||||
path: req.path
|
||||
}, req);
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
// Check if token is blacklisted
|
||||
const isBlacklisted = await isTokenBlacklisted(token);
|
||||
if (isBlacklisted) {
|
||||
logAuth('Authentication failed - Token blacklisted', undefined, {
|
||||
ip: req.ip,
|
||||
userAgent: req.get ? req.get('User-Agent') : 'unknown',
|
||||
path: req.path
|
||||
}, req);
|
||||
return res.status(401).json({ error: 'Token has been invalidated' });
|
||||
}
|
||||
|
||||
// Verify token
|
||||
const payload = jwtService.verify(req);
|
||||
if (!payload) {
|
||||
logAuth('Authentication failed - Invalid token', undefined, {
|
||||
ip: req.ip,
|
||||
userAgent: req.get ? req.get('User-Agent') : 'unknown',
|
||||
path: req.path
|
||||
}, req);
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
logAuth('Authentication successful', payload.userId, {
|
||||
authLevel: payload.authLevel,
|
||||
orgId: payload.orgId
|
||||
}, req);
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
|
||||
const refreshed = jwtService.refreshIfNeeded(payload, res);
|
||||
if (refreshed) {
|
||||
logAuth('Token refreshed', payload.userId, undefined, req);
|
||||
}
|
||||
|
||||
(req as any).user = payload;
|
||||
next();
|
||||
} catch (error) {
|
||||
logWarning('Authentication middleware error', { error: (error as Error).message }, req);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
|
||||
logAuth('Admin authentication successful', payload.userId, {
|
||||
authLevel: payload.authLevel,
|
||||
orgId: payload.orgId
|
||||
}, req);
|
||||
|
||||
const refreshed = jwtService.refreshIfNeeded(payload, res);
|
||||
if (refreshed) {
|
||||
logAuth('Admin token refreshed', payload.userId, undefined, req);
|
||||
}
|
||||
|
||||
export async function adminRequired(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
// Extract token from request
|
||||
const token = extractToken(req);
|
||||
if (!token) {
|
||||
logWarning('Admin access denied - No token provided', {
|
||||
ip: req.ip,
|
||||
path: req.path
|
||||
}, req);
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
// Check if token is blacklisted
|
||||
const isBlacklisted = await isTokenBlacklisted(token);
|
||||
if (isBlacklisted) {
|
||||
logWarning('Admin access denied - Token blacklisted', {
|
||||
ip: req.ip,
|
||||
path: req.path
|
||||
}, req);
|
||||
return res.status(401).json({ error: 'Token has been invalidated' });
|
||||
}
|
||||
|
||||
// Verify token and check admin privileges
|
||||
const payload = jwtService.verify(req);
|
||||
if (!payload || payload.authLevel !== 1) {
|
||||
logWarning('Admin access denied', {
|
||||
hasPayload: !!payload,
|
||||
authLevel: payload?.authLevel,
|
||||
userId: payload?.userId,
|
||||
ip: req.ip,
|
||||
path: req.path
|
||||
}, req);
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
|
||||
logAuth('Admin authentication successful', payload.userId, {
|
||||
authLevel: payload.authLevel,
|
||||
orgId: payload.orgId
|
||||
}, req);
|
||||
|
||||
const refreshed = jwtService.refreshIfNeeded(payload, res);
|
||||
if (refreshed) {
|
||||
logAuth('Admin token refreshed', payload.userId, undefined, req);
|
||||
}
|
||||
|
||||
(req as any).user = payload;
|
||||
next();
|
||||
} catch (error) {
|
||||
logWarning('Admin authentication middleware error', { error: (error as Error).message }, req);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
|
||||
(req as any).user = payload;
|
||||
next();
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { IChatArchiveRepository } from '../../Domain/IRepository/IChatArchiveRep
|
||||
import { IDeckRepository } from '../../Domain/IRepository/IDeckRepository';
|
||||
import { IOrganizationRepository } from '../../Domain/IRepository/IOrganizationRepository';
|
||||
import { IContactRepository } from '../../Domain/IRepository/IContactRepository';
|
||||
import { IGameRepository } from '../../Domain/IRepository/IGameRepository';
|
||||
|
||||
// Repository Implementations
|
||||
import { UserRepository } from '../../Infrastructure/Repository/UserRepository';
|
||||
@@ -13,10 +14,12 @@ import { ChatArchiveRepository } from '../../Infrastructure/Repository/ChatArchi
|
||||
import { DeckRepository } from '../../Infrastructure/Repository/DeckRepository';
|
||||
import { OrganizationRepository } from '../../Infrastructure/Repository/OrganizationRepository';
|
||||
import { ContactRepository } from '../../Infrastructure/Repository/ContactRepository';
|
||||
import { GameRepository } from '../../Infrastructure/Repository/GameRepository';
|
||||
|
||||
// Command Handlers
|
||||
import { CreateUserCommandHandler } from '../User/commands/CreateUserCommandHandler';
|
||||
import { LoginCommandHandler } from '../User/commands/LoginCommandHandler';
|
||||
import { LogoutCommandHandler } from '../User/commands/LogoutCommandHandler';
|
||||
import { UpdateUserCommandHandler } from '../User/commands/UpdateUserCommandHandler';
|
||||
import { DeactivateUserCommandHandler } from '../User/commands/DeactivateUserCommandHandler';
|
||||
import { DeleteUserCommandHandler } from '../User/commands/DeleteUserCommandHandler';
|
||||
@@ -55,6 +58,10 @@ import { GetContactsByPageQueryHandler } from '../Contact/queries/GetContactsByP
|
||||
import { JWTService } from './JWTService';
|
||||
import { ContactEmailService } from './ContactEmailService';
|
||||
import { DeckImportExportService } from './DeckImportExportService';
|
||||
import { RedisService } from './RedisService';
|
||||
import { GameService } from '../Game/GameService';
|
||||
import { BoardGenerationService } from '../Game/BoardGenerationService';
|
||||
import { GenerateBoardCommandHandler } from '../Game/commands/GenerateBoardCommandHandler';
|
||||
|
||||
/**
|
||||
* Central Dependency Injection Container
|
||||
@@ -70,15 +77,19 @@ export class DIContainer {
|
||||
private _deckRepository: IDeckRepository | null = null;
|
||||
private _organizationRepository: IOrganizationRepository | null = null;
|
||||
private _contactRepository: IContactRepository | null = null;
|
||||
private _gameRepository: IGameRepository | null = null;
|
||||
|
||||
// Services
|
||||
private _jwtService: JWTService | null = null;
|
||||
private _contactEmailService: ContactEmailService | null = null;
|
||||
private _deckImportExportService: DeckImportExportService | null = null;
|
||||
private _gameService: GameService | null = null;
|
||||
private _boardGenerationService: BoardGenerationService | null = null;
|
||||
|
||||
// Command Handlers
|
||||
private _createUserCommandHandler: CreateUserCommandHandler | null = null;
|
||||
private _loginCommandHandler: LoginCommandHandler | null = null;
|
||||
private _logoutCommandHandler: LogoutCommandHandler | null = null;
|
||||
private _updateUserCommandHandler: UpdateUserCommandHandler | null = null;
|
||||
private _deactivateUserCommandHandler: DeactivateUserCommandHandler | null = null;
|
||||
private _deleteUserCommandHandler: DeleteUserCommandHandler | null = null;
|
||||
@@ -99,6 +110,7 @@ export class DIContainer {
|
||||
private _createContactCommandHandler: CreateContactCommandHandler | null = null;
|
||||
private _updateContactCommandHandler: UpdateContactCommandHandler | null = null;
|
||||
private _deleteContactCommandHandler: DeleteContactCommandHandler | null = null;
|
||||
private _generateBoardCommandHandler: GenerateBoardCommandHandler | null = null;
|
||||
|
||||
// Query Handlers
|
||||
private _getUserByIdQueryHandler: GetUserByIdQueryHandler | null = null;
|
||||
@@ -167,6 +179,13 @@ export class DIContainer {
|
||||
return this._contactRepository;
|
||||
}
|
||||
|
||||
public get gameRepository(): IGameRepository {
|
||||
if (!this._gameRepository) {
|
||||
this._gameRepository = new GameRepository();
|
||||
}
|
||||
return this._gameRepository;
|
||||
}
|
||||
|
||||
// Services getters
|
||||
public get jwtService(): JWTService {
|
||||
if (!this._jwtService) {
|
||||
@@ -189,6 +208,20 @@ export class DIContainer {
|
||||
return this._deckImportExportService;
|
||||
}
|
||||
|
||||
public get gameService(): GameService {
|
||||
if (!this._gameService) {
|
||||
this._gameService = new GameService();
|
||||
}
|
||||
return this._gameService;
|
||||
}
|
||||
|
||||
public get boardGenerationService(): BoardGenerationService {
|
||||
if (!this._boardGenerationService) {
|
||||
this._boardGenerationService = new BoardGenerationService();
|
||||
}
|
||||
return this._boardGenerationService;
|
||||
}
|
||||
|
||||
// Command Handler getters
|
||||
public get createUserCommandHandler(): CreateUserCommandHandler {
|
||||
if (!this._createUserCommandHandler) {
|
||||
@@ -204,6 +237,13 @@ export class DIContainer {
|
||||
return this._loginCommandHandler;
|
||||
}
|
||||
|
||||
public get logoutCommandHandler(): LogoutCommandHandler {
|
||||
if (!this._logoutCommandHandler) {
|
||||
this._logoutCommandHandler = new LogoutCommandHandler(this.userRepository);
|
||||
}
|
||||
return this._logoutCommandHandler;
|
||||
}
|
||||
|
||||
public get updateUserCommandHandler(): UpdateUserCommandHandler {
|
||||
if (!this._updateUserCommandHandler) {
|
||||
this._updateUserCommandHandler = new UpdateUserCommandHandler(this.userRepository);
|
||||
@@ -348,6 +388,13 @@ export class DIContainer {
|
||||
return this._deleteContactCommandHandler;
|
||||
}
|
||||
|
||||
public get generateBoardCommandHandler(): GenerateBoardCommandHandler {
|
||||
if (!this._generateBoardCommandHandler) {
|
||||
this._generateBoardCommandHandler = new GenerateBoardCommandHandler(this.boardGenerationService, RedisService.getInstance());
|
||||
}
|
||||
return this._generateBoardCommandHandler;
|
||||
}
|
||||
|
||||
// Query Handler getters
|
||||
public get getUserByIdQueryHandler(): GetUserByIdQueryHandler {
|
||||
if (!this._getUserByIdQueryHandler) {
|
||||
|
||||
@@ -95,16 +95,21 @@ export class LoggingService {
|
||||
console.warn('Minio configuration not found. Logs will only be stored locally and in console.');
|
||||
}
|
||||
} else {
|
||||
// Development-specific Minio configuration
|
||||
this.minioClient = new Minio.Client({
|
||||
endPoint: 'localhost',
|
||||
port: 9000,
|
||||
useSSL: false,
|
||||
accessKey: 'serpentrace',
|
||||
secretKey: 'serpentrace123!'
|
||||
});
|
||||
// Development mode - only use MinIO if explicitly configured
|
||||
if (process.env.MINIO_ENDPOINT || process.env.ENABLE_MINIO === 'true') {
|
||||
this.minioClient = new Minio.Client({
|
||||
endPoint: process.env.MINIO_ENDPOINT || 'localhost',
|
||||
port: parseInt(process.env.MINIO_PORT || '9000'),
|
||||
useSSL: false,
|
||||
accessKey: process.env.MINIO_ACCESS_KEY || 'serpentrace',
|
||||
secretKey: process.env.MINIO_SECRET_KEY || 'serpentrace123!'
|
||||
});
|
||||
|
||||
this.ensureBucketExists();
|
||||
this.ensureBucketExists();
|
||||
} else {
|
||||
console.log('Development mode: MinIO disabled. Set ENABLE_MINIO=true to enable MinIO logging.');
|
||||
this.minioClient = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -124,7 +129,9 @@ export class LoggingService {
|
||||
this.log(LogLevel.STARTUP, `Created Minio bucket: ${this.bucketName}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to ensure bucket exists:', error);
|
||||
console.warn('MinIO connection failed - disabling MinIO logging:', (error as Error).message);
|
||||
// Disable MinIO client if connection fails
|
||||
this.minioClient = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -303,4 +303,73 @@ export class RedisService {
|
||||
public isRedisConnected(): boolean {
|
||||
return this.isConnected;
|
||||
}
|
||||
|
||||
// Generic Redis methods for game data
|
||||
public async get(key: string): Promise<string | null> {
|
||||
try {
|
||||
return await this.client.get(key);
|
||||
} catch (error) {
|
||||
logError(`Failed to get key ${key}`, error as Error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async set(key: string, value: string): Promise<void> {
|
||||
try {
|
||||
await this.client.set(key, value);
|
||||
} catch (error) {
|
||||
logError(`Failed to set key ${key}`, error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
public async setWithExpiry(key: string, value: string, expirySeconds: number): Promise<void> {
|
||||
try {
|
||||
await this.client.setEx(key, expirySeconds, value);
|
||||
} catch (error) {
|
||||
logError(`Failed to set key ${key} with expiry`, error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
public async del(key: string): Promise<void> {
|
||||
try {
|
||||
await this.client.del(key);
|
||||
} catch (error) {
|
||||
logError(`Failed to delete key ${key}`, error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
public async setAdd(key: string, member: string): Promise<void> {
|
||||
try {
|
||||
await this.client.sAdd(key, member);
|
||||
} catch (error) {
|
||||
logError(`Failed to add member to set ${key}`, error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
public async setRemove(key: string, member: string): Promise<void> {
|
||||
try {
|
||||
await this.client.sRem(key, member);
|
||||
} catch (error) {
|
||||
logError(`Failed to remove member from set ${key}`, error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
public async setMembers(key: string): Promise<string[]> {
|
||||
try {
|
||||
return await this.client.sMembers(key);
|
||||
} catch (error) {
|
||||
logError(`Failed to get members of set ${key}`, error as Error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async exists(key: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await this.client.exists(key);
|
||||
return result === 1;
|
||||
} catch (error) {
|
||||
logError(`Failed to check existence of key ${key}`, error as Error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,31 @@ interface DeleteMessageData {
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
// Game-related WebSocket interfaces (prepared for future implementation)
|
||||
interface JoinGameRoomData {
|
||||
gameCode: string;
|
||||
}
|
||||
|
||||
interface LeaveGameRoomData {
|
||||
gameCode: string;
|
||||
}
|
||||
|
||||
interface GameStateUpdateData {
|
||||
gameId: string;
|
||||
gameCode: string;
|
||||
players: string[];
|
||||
state: string;
|
||||
currentTurn?: string;
|
||||
}
|
||||
|
||||
interface GameActionData {
|
||||
gameId: string;
|
||||
gameCode: string;
|
||||
playerId: string;
|
||||
action: 'pick_card' | 'play_card' | 'end_turn' | 'leave_game';
|
||||
data?: any;
|
||||
}
|
||||
|
||||
export class WebSocketService {
|
||||
private io: SocketIOServer;
|
||||
private jwtService: JWTService;
|
||||
@@ -236,6 +261,12 @@ export class WebSocketService {
|
||||
socket.on('chat:delete', (data: DeleteChatData) => this.handleDeleteChat(socket, data));
|
||||
socket.on('chat:archive:delete', (data: DeleteChatArchiveData) => this.handleDeleteChatArchive(socket, data));
|
||||
socket.on('message:delete', (data: DeleteMessageData) => this.handleDeleteMessage(socket, data));
|
||||
|
||||
// Game event handlers (prepared for future implementation)
|
||||
socket.on('game:join', (data: JoinGameRoomData) => this.handleJoinGameRoom(socket, data));
|
||||
socket.on('game:leave', (data: LeaveGameRoomData) => this.handleLeaveGameRoom(socket, data));
|
||||
socket.on('game:action', (data: GameActionData) => this.handleGameAction(socket, data));
|
||||
|
||||
socket.on('disconnect', () => this.handleDisconnection(socket));
|
||||
}
|
||||
|
||||
@@ -1172,4 +1203,211 @@ export class WebSocketService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Game-related WebSocket handlers (prepared for future implementation)
|
||||
|
||||
/**
|
||||
* Handle player joining a game room for real-time updates
|
||||
* @param socket The authenticated socket
|
||||
* @param data Game room data containing game code
|
||||
*/
|
||||
private async handleJoinGameRoom(socket: AuthenticatedSocket, data: JoinGameRoomData) {
|
||||
try {
|
||||
const userId = socket.userId!;
|
||||
const gameRoom = `game_${data.gameCode}`;
|
||||
|
||||
logAuth('Player joining game room', userId, {
|
||||
gameCode: data.gameCode,
|
||||
gameRoom,
|
||||
socketId: socket.id
|
||||
});
|
||||
|
||||
// Join the WebSocket room for this game
|
||||
await socket.join(gameRoom);
|
||||
|
||||
// Emit confirmation to the player
|
||||
socket.emit('game:joined', {
|
||||
gameCode: data.gameCode,
|
||||
room: gameRoom,
|
||||
message: 'Successfully joined game room'
|
||||
});
|
||||
|
||||
// Notify other players in the game room
|
||||
socket.to(gameRoom).emit('game:player_joined', {
|
||||
playerId: userId,
|
||||
gameCode: data.gameCode,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
logAuth('Player joined game room successfully', userId, {
|
||||
gameCode: data.gameCode,
|
||||
gameRoom
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logError('Error joining game room', error as Error);
|
||||
socket.emit('game:error', {
|
||||
message: 'Failed to join game room',
|
||||
gameCode: data.gameCode
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle player leaving a game room
|
||||
* @param socket The authenticated socket
|
||||
* @param data Game room data containing game code
|
||||
*/
|
||||
private async handleLeaveGameRoom(socket: AuthenticatedSocket, data: LeaveGameRoomData) {
|
||||
try {
|
||||
const userId = socket.userId!;
|
||||
const gameRoom = `game_${data.gameCode}`;
|
||||
|
||||
logAuth('Player leaving game room', userId, {
|
||||
gameCode: data.gameCode,
|
||||
gameRoom,
|
||||
socketId: socket.id
|
||||
});
|
||||
|
||||
// Leave the WebSocket room
|
||||
await socket.leave(gameRoom);
|
||||
|
||||
// Notify other players in the game room
|
||||
socket.to(gameRoom).emit('game:player_left', {
|
||||
playerId: userId,
|
||||
gameCode: data.gameCode,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Confirm to the leaving player
|
||||
socket.emit('game:left', {
|
||||
gameCode: data.gameCode,
|
||||
message: 'Successfully left game room'
|
||||
});
|
||||
|
||||
logAuth('Player left game room successfully', userId, {
|
||||
gameCode: data.gameCode,
|
||||
gameRoom
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logError('Error leaving game room', error as Error);
|
||||
socket.emit('game:error', {
|
||||
message: 'Failed to leave game room',
|
||||
gameCode: data.gameCode
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle game actions (cards, turns, etc.) - prepared for future implementation
|
||||
* @param socket The authenticated socket
|
||||
* @param data Game action data
|
||||
*/
|
||||
private async handleGameAction(socket: AuthenticatedSocket, data: GameActionData) {
|
||||
try {
|
||||
const userId = socket.userId!;
|
||||
const gameRoom = `game_${data.gameCode}`;
|
||||
|
||||
logAuth('Game action received', userId, {
|
||||
gameId: data.gameId,
|
||||
gameCode: data.gameCode,
|
||||
action: data.action,
|
||||
socketId: socket.id
|
||||
});
|
||||
|
||||
// Validate that the player is authorized to perform this action
|
||||
if (data.playerId !== userId) {
|
||||
socket.emit('game:error', {
|
||||
message: 'Unauthorized action',
|
||||
gameCode: data.gameCode
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Implement specific game logic here
|
||||
// This will be implemented when the game flow is discussed
|
||||
|
||||
// For now, just broadcast the action to other players
|
||||
socket.to(gameRoom).emit('game:action_performed', {
|
||||
playerId: userId,
|
||||
gameCode: data.gameCode,
|
||||
action: data.action,
|
||||
data: data.data,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Confirm action to the acting player
|
||||
socket.emit('game:action_confirmed', {
|
||||
gameCode: data.gameCode,
|
||||
action: data.action,
|
||||
message: 'Action processed successfully'
|
||||
});
|
||||
|
||||
logAuth('Game action processed', userId, {
|
||||
gameId: data.gameId,
|
||||
gameCode: data.gameCode,
|
||||
action: data.action
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logError('Error processing game action', error as Error);
|
||||
socket.emit('game:error', {
|
||||
message: 'Failed to process game action',
|
||||
gameCode: data.gameCode,
|
||||
action: data.action
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast game state updates to all players in a game
|
||||
* @param gameCode The game code
|
||||
* @param gameState The updated game state
|
||||
*/
|
||||
public broadcastGameStateUpdate(gameCode: string, gameState: GameStateUpdateData): void {
|
||||
try {
|
||||
const gameRoom = `game_${gameCode}`;
|
||||
|
||||
this.io.to(gameRoom).emit('game:state_updated', {
|
||||
...gameState,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
logRequest('Game state broadcasted', undefined, undefined, {
|
||||
gameCode,
|
||||
gameRoom,
|
||||
playerCount: gameState.players.length
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logError('Error broadcasting game state', error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify players when a game starts
|
||||
* @param gameCode The game code
|
||||
* @param players Array of player IDs
|
||||
*/
|
||||
public notifyGameStart(gameCode: string, players: string[]): void {
|
||||
try {
|
||||
const gameRoom = `game_${gameCode}`;
|
||||
|
||||
this.io.to(gameRoom).emit('game:started', {
|
||||
gameCode,
|
||||
players,
|
||||
message: 'Game has started!',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
logRequest('Game start notification sent', undefined, undefined, {
|
||||
gameCode,
|
||||
playerCount: players.length
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logError('Error notifying game start', error as Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,52 +38,55 @@ export class CreateUserCommandHandler {
|
||||
user.fname = cmd.fname;
|
||||
user.lname = cmd.lname;
|
||||
user.orgid = cmd.orgid || null;
|
||||
user.token = cmd.code || null;
|
||||
user.type = cmd.type;
|
||||
user.phone = cmd.phone || null;
|
||||
user.state = UserState.REGISTERED_NOT_VERIFIED;
|
||||
|
||||
const created = await this.userRepo.create(user);
|
||||
|
||||
// Send verification email
|
||||
try {
|
||||
const baseUrl = process.env.APP_BASE_URL || 'http://localhost:3000';
|
||||
const verificationUrl = TokenService.generateVerificationUrl(baseUrl, verificationTokenData.token);
|
||||
|
||||
const emailSent = await this.emailService.sendVerificationEmail(
|
||||
created.email,
|
||||
`${created.fname} ${created.lname}`,
|
||||
verificationTokenData.token,
|
||||
verificationUrl
|
||||
);
|
||||
|
||||
if (!emailSent) {
|
||||
logWarning('Failed to send verification email', { email: created.email, userId: created.id });
|
||||
// Don't throw error - user creation should still succeed even if email fails
|
||||
} else {
|
||||
logAuth('Verification email sent successfully', created.id, { email: created.email });
|
||||
}
|
||||
} catch (emailError) {
|
||||
logError('Error sending verification email', emailError as Error);
|
||||
// Don't throw error - user creation should still succeed even if email fails
|
||||
}
|
||||
// Send verification email (non-blocking)
|
||||
this.sendVerificationEmailAsync(created, verificationTokenData.token);
|
||||
|
||||
return UserMapper.toShortDto(created);
|
||||
} catch (error) {
|
||||
logError('CreateUserCommandHandler error', error as Error);
|
||||
// Only log the error once here, don't log again in router
|
||||
const errorMessage = (error as Error).message;
|
||||
|
||||
// Re-throw validation errors as-is
|
||||
if (error instanceof Error && error.message.includes('Password validation failed')) {
|
||||
// Re-throw validation errors as-is (don't log as these are user input errors)
|
||||
if (errorMessage.includes('Password validation failed')) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Handle database constraint errors
|
||||
if (error instanceof Error && (error.message.includes('duplicate') || error.message.includes('unique'))) {
|
||||
if (errorMessage.includes('duplicate') || errorMessage.includes('unique') ||
|
||||
errorMessage.includes('UNIQUE constraint') || errorMessage.includes('already exists')) {
|
||||
throw new Error('User with this username or email already exists');
|
||||
}
|
||||
|
||||
// Generic error for other cases
|
||||
// Log database/system errors but throw user-friendly message
|
||||
logError('CreateUserCommandHandler error', error as Error);
|
||||
throw new Error('Failed to create user');
|
||||
}
|
||||
}
|
||||
|
||||
private async sendVerificationEmailAsync(user: UserAggregate, token: string): Promise<void> {
|
||||
try {
|
||||
const baseUrl = process.env.APP_BASE_URL || 'http://localhost:3000';
|
||||
const verificationUrl = TokenService.generateVerificationUrl(baseUrl, token);
|
||||
|
||||
const emailSent = await this.emailService.sendVerificationEmail(
|
||||
user.email,
|
||||
`${user.fname} ${user.lname}`,
|
||||
token,
|
||||
verificationUrl
|
||||
);
|
||||
|
||||
if (!emailSent) {
|
||||
logWarning('Failed to send verification email', { email: user.email, userId: user.id });
|
||||
} else {
|
||||
logAuth('Verification email sent successfully', user.id, { email: user.email });
|
||||
}
|
||||
} catch (emailError) {
|
||||
logError('Error sending verification email', emailError as Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export class LoginCommandHandler {
|
||||
|
||||
if (!user) {
|
||||
logAuth('Login failed - User not found', undefined, { username: cmd.username });
|
||||
return null;
|
||||
throw new Error('Invalid username');
|
||||
}
|
||||
|
||||
// Check if user account state allows login
|
||||
@@ -52,15 +52,19 @@ export class LoginCommandHandler {
|
||||
|
||||
if (restrictedStates.includes(user.state)) {
|
||||
let stateDescription = '';
|
||||
let errorMessage = '';
|
||||
switch (user.state) {
|
||||
case UserState.REGISTERED_NOT_VERIFIED:
|
||||
stateDescription = 'Email not verified';
|
||||
errorMessage = 'User account not verified';
|
||||
break;
|
||||
case UserState.SOFT_DELETE:
|
||||
stateDescription = 'Account deleted';
|
||||
errorMessage = 'User account deactivated';
|
||||
break;
|
||||
case UserState.DEACTIVATED:
|
||||
stateDescription = 'Account deactivated';
|
||||
errorMessage = 'User account deactivated';
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -69,7 +73,7 @@ export class LoginCommandHandler {
|
||||
userState: user.state,
|
||||
stateDescription
|
||||
});
|
||||
return null;
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -86,11 +90,11 @@ export class LoginCommandHandler {
|
||||
userId: user.id,
|
||||
username: cmd.username
|
||||
});
|
||||
return null;
|
||||
throw new Error('Invalid password');
|
||||
}
|
||||
} catch (error) {
|
||||
logError('Password verification error', error as Error);
|
||||
return null;
|
||||
throw new Error('Invalid password');
|
||||
}
|
||||
|
||||
const mockRes = {
|
||||
@@ -174,8 +178,12 @@ export class LoginCommandHandler {
|
||||
throw new Error('Database connection error');
|
||||
}
|
||||
|
||||
// If it's already a properly formatted error, re-throw it
|
||||
if (error.message === 'Login failed due to internal error' ||
|
||||
// Re-throw authentication/validation errors as-is
|
||||
if (error.message.includes('Invalid username') ||
|
||||
error.message.includes('Invalid password') ||
|
||||
error.message.includes('not verified') ||
|
||||
error.message.includes('deactivated') ||
|
||||
error.message === 'Login failed due to internal error' ||
|
||||
error.message === 'Database connection error') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { logAuth, logError, logWarning } from '../../Services/Logger';
|
||||
import { IUserRepository } from '../../../Domain/IRepository/IUserRepository';
|
||||
import { JWTService } from '../../Services/JWTService';
|
||||
import { RedisService } from '../../Services/RedisService';
|
||||
|
||||
export class LogoutCommandHandler {
|
||||
private jwtService: JWTService;
|
||||
private redisService: RedisService;
|
||||
|
||||
constructor(private readonly userRepo: IUserRepository) {
|
||||
this.jwtService = new JWTService();
|
||||
this.redisService = RedisService.getInstance();
|
||||
}
|
||||
|
||||
async execute(userId: string, res: Response, req?: Request): Promise<boolean> {
|
||||
try {
|
||||
logAuth('Logout process started', userId);
|
||||
|
||||
// 1. Get token from request to blacklist it
|
||||
let tokenToBlacklist: string | null = null;
|
||||
if (req) {
|
||||
// Extract token from cookie
|
||||
tokenToBlacklist = req.cookies['auth_token'];
|
||||
|
||||
// Also check Authorization header as fallback
|
||||
if (!tokenToBlacklist && req.headers.authorization) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader.startsWith('Bearer ')) {
|
||||
tokenToBlacklist = authHeader.substring(7);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Blacklist the current JWT token in Redis (if available)
|
||||
if (tokenToBlacklist && req) {
|
||||
try {
|
||||
// Store token in blacklist with expiration matching token expiry
|
||||
const decoded = this.jwtService.verify(req);
|
||||
if (decoded && decoded.exp) {
|
||||
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
|
||||
if (ttl > 0) {
|
||||
await this.redisService.setWithExpiry(`blacklist:${tokenToBlacklist}`, 'true', ttl);
|
||||
logAuth('JWT token blacklisted', userId, { tokenExpiry: ttl });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logWarning('Failed to blacklist token', { userId, error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Clear authentication cookie
|
||||
res.clearCookie('auth_token', {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
path: '/'
|
||||
});
|
||||
|
||||
// 4. Remove user from active sessions in Redis
|
||||
try {
|
||||
await this.redisService.removeActiveUser(userId);
|
||||
logAuth('User removed from active sessions', userId);
|
||||
} catch (error) {
|
||||
logWarning('Failed to remove user from active sessions', { userId, error: (error as Error).message });
|
||||
// Continue even if this fails
|
||||
}
|
||||
|
||||
// 5. Update user's last logout timestamp in database
|
||||
try {
|
||||
const updateResult = await this.userRepo.update(userId, { updatedate: new Date() });
|
||||
if (updateResult) {
|
||||
logAuth('User last logout timestamp updated', userId);
|
||||
}
|
||||
} catch (error) {
|
||||
logWarning('Failed to update user logout timestamp', { userId, error: (error as Error).message });
|
||||
// Continue even if this fails
|
||||
}
|
||||
|
||||
// 6. Clear any user-specific cache entries
|
||||
try {
|
||||
// Clear user session data
|
||||
await this.redisService.del(`user:${userId}:session`);
|
||||
await this.redisService.del(`user:${userId}:active_chats`);
|
||||
logAuth('User cache cleared', userId);
|
||||
} catch (error) {
|
||||
logWarning('Failed to clear user cache', { userId, error: (error as Error).message });
|
||||
// Continue even if this fails
|
||||
}
|
||||
|
||||
logAuth('User logout completed successfully', userId);
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
logError('LogoutCommandHandler error', error as Error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a token is blacklisted
|
||||
*/
|
||||
async isTokenBlacklisted(token: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await this.redisService.get(`blacklist:${token}`);
|
||||
return result === 'true';
|
||||
} catch (error) {
|
||||
logError('Error checking token blacklist', error as Error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout user from all devices by blacklisting all their active tokens
|
||||
* This is a simplified version - in a real implementation you'd track active tokens per user
|
||||
*/
|
||||
async logoutFromAllDevices(userId: string): Promise<boolean> {
|
||||
try {
|
||||
// Clear all user-related Redis keys
|
||||
const userKeys = [
|
||||
`user:${userId}:session`,
|
||||
`user:${userId}:active_chats`,
|
||||
`user:${userId}:active_tokens`,
|
||||
`user:${userId}:websocket_connections`
|
||||
];
|
||||
|
||||
for (const key of userKeys) {
|
||||
try {
|
||||
await this.redisService.del(key);
|
||||
} catch (error) {
|
||||
logWarning(`Failed to delete Redis key: ${key}`, { userId, error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
// Update user logout timestamp
|
||||
await this.userRepo.update(userId, { updatedate: new Date() });
|
||||
|
||||
logAuth('User logged out from all devices', userId);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logError('Error logging out user from all devices', error as Error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
import { IUserRepository } from '../../../Domain/IRepository/IUserRepository';
|
||||
import { GetUserByIdQuery } from './GetUserByIdQuery';
|
||||
import { ShortUserDto } from '../../DTOs/UserDto';
|
||||
import { DetailUserDto } from '../../DTOs/UserDto';
|
||||
import { UserMapper } from '../../DTOs/Mappers/UserMapper';
|
||||
import { logError } from '../../Services/Logger';
|
||||
|
||||
export class GetUserByIdQueryHandler {
|
||||
constructor(private readonly userRepo: IUserRepository) {}
|
||||
|
||||
async execute(query: GetUserByIdQuery): Promise<ShortUserDto | null> {
|
||||
async execute(query: GetUserByIdQuery): Promise<DetailUserDto | null> {
|
||||
try {
|
||||
const user = await this.userRepo.findById(query.id);
|
||||
if (!user) return null;
|
||||
return UserMapper.toShortDto(user);
|
||||
return UserMapper.toDetailDto(user);
|
||||
} catch (error) {
|
||||
logError('GetUserByIdQueryHandler error', error instanceof Error ? error : new Error(String(error)));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user