Backend Complete: Interface Refactoring & Service Container Enhancements
Repository Interface Optimization: - Created IBaseRepository.ts and IPaginatedRepository.ts - Refactored all 7 repository interfaces to extend base interfaces - Eliminated ~200 lines of redundant code (70% reduction) - Improved type safety and maintainability Dependency Injection Improvements: - Added EmailService and GameTokenService to DIContainer - Updated CreateUserCommandHandler constructor for DI - Updated RequestPasswordResetCommandHandler constructor for DI - Enhanced testability and service consistency Environment Configuration: - Created comprehensive .env.example with 40+ variables - Organized into 12 logical sections (Database, Security, Email, etc.) - Added security guidelines and best practices - Documented all backend environment requirements Documentation: - Added comprehensive codebase review - Created refactoring summary report - Added frontend implementation guide Impact: Improved code quality, reduced maintenance overhead, enhanced developer experience
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import { ArchiveChatCommand, RestoreChatCommand } from './ChatCommands';
|
||||
import { IChatRepository } from '../../../Domain/IRepository/IChatRepository';
|
||||
import { ChatType } from '../../../Domain/Chat/ChatAggregate';
|
||||
import { logAuth, logError, logWarning } from '../../Services/Logger';
|
||||
|
||||
export class ArchiveChatCommandHandler {
|
||||
constructor(private chatRepository: IChatRepository) {}
|
||||
|
||||
async execute(command: ArchiveChatCommand): Promise<boolean> {
|
||||
try {
|
||||
const chat = await this.chatRepository.findById(command.chatId);
|
||||
if (!chat) {
|
||||
throw new Error('Chat not found');
|
||||
}
|
||||
|
||||
await this.chatRepository.archiveChat(chat);
|
||||
|
||||
logAuth('Chat archived manually', undefined, {
|
||||
chatId: command.chatId,
|
||||
chatType: chat.type,
|
||||
messageCount: chat.messages.length
|
||||
});
|
||||
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
logError('ArchiveChatCommandHandler error', error as Error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class RestoreChatCommandHandler {
|
||||
constructor(private chatRepository: IChatRepository) {}
|
||||
|
||||
async execute(command: RestoreChatCommand): Promise<boolean> {
|
||||
try {
|
||||
const archive = await this.chatRepository.getArchivedChat(command.chatId);
|
||||
if (!archive) {
|
||||
throw new Error('Archived chat not found');
|
||||
}
|
||||
|
||||
// Game chats cannot be restored, only viewed
|
||||
if (archive.chatType === ChatType.GAME) {
|
||||
logWarning('Attempt to restore game chat blocked', {
|
||||
chatId: command.chatId,
|
||||
chatType: archive.chatType
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const restoredChat = await this.chatRepository.restoreFromArchive(command.chatId);
|
||||
if (!restoredChat) {
|
||||
throw new Error('Failed to restore chat from archive');
|
||||
}
|
||||
|
||||
logAuth('Chat restored from archive', undefined, {
|
||||
chatId: command.chatId,
|
||||
messageCount: archive.archivedMessages.length
|
||||
});
|
||||
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
logError('RestoreChatCommandHandler error', error as Error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export interface CreateChatCommand {
|
||||
type: 'direct' | 'group' | 'game';
|
||||
name?: string;
|
||||
gameId?: string;
|
||||
createdBy: string;
|
||||
userIds: string[];
|
||||
}
|
||||
|
||||
export interface SendMessageCommand {
|
||||
chatId: string;
|
||||
userId: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ArchiveChatCommand {
|
||||
chatId: string;
|
||||
}
|
||||
|
||||
export interface RestoreChatCommand {
|
||||
chatId: string;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { CreateChatCommand } from './ChatCommands';
|
||||
import { IChatRepository } from '../../../Domain/IRepository/IChatRepository';
|
||||
import { IUserRepository } from '../../../Domain/IRepository/IUserRepository';
|
||||
import { ChatType, ChatAggregate } from '../../../Domain/Chat/ChatAggregate';
|
||||
import { UserState } from '../../../Domain/User/UserAggregate';
|
||||
import { logAuth, logError } from '../../Services/Logger';
|
||||
|
||||
export class CreateChatCommandHandler {
|
||||
constructor(
|
||||
private chatRepository: IChatRepository,
|
||||
private userRepository: IUserRepository
|
||||
) {}
|
||||
|
||||
async execute(command: CreateChatCommand): Promise<ChatAggregate | null> {
|
||||
try {
|
||||
// Validate creator exists
|
||||
const creator = await this.userRepository.findById(command.createdBy);
|
||||
if (!creator) {
|
||||
throw new Error('Creator not found');
|
||||
}
|
||||
|
||||
// For group chats, check if creator is premium
|
||||
if (command.type === 'group' && creator.state !== UserState.VERIFIED_PREMIUM) {
|
||||
throw new Error('Premium subscription required to create groups');
|
||||
}
|
||||
|
||||
// Validate all target users exist
|
||||
const targetUsers = await Promise.all(
|
||||
command.userIds.map(id => this.userRepository.findById(id))
|
||||
);
|
||||
|
||||
if (targetUsers.some(user => !user)) {
|
||||
throw new Error('One or more target users not found');
|
||||
}
|
||||
|
||||
// For direct chats, check if already exists
|
||||
if (command.type === 'direct' && command.userIds.length === 1) {
|
||||
const existingChats = await this.chatRepository.findByUserId(command.createdBy);
|
||||
const existingDirectChat = existingChats.find(chat =>
|
||||
chat.type === ChatType.DIRECT &&
|
||||
chat.users.length === 2 &&
|
||||
chat.users.includes(command.userIds[0])
|
||||
);
|
||||
|
||||
if (existingDirectChat) {
|
||||
return existingDirectChat;
|
||||
}
|
||||
}
|
||||
|
||||
// For game chats, check if already exists
|
||||
if (command.type === 'game' && command.gameId) {
|
||||
const existingGameChat = await this.chatRepository.findByGameId(command.gameId);
|
||||
if (existingGameChat) {
|
||||
return existingGameChat;
|
||||
}
|
||||
}
|
||||
|
||||
// Create chat
|
||||
const chatData: Partial<ChatAggregate> = {
|
||||
type: command.type as any,
|
||||
name: command.name,
|
||||
gameId: command.gameId,
|
||||
createdBy: command.createdBy,
|
||||
users: [command.createdBy, ...command.userIds],
|
||||
messages: [],
|
||||
lastActivity: new Date()
|
||||
};
|
||||
|
||||
const chat = await this.chatRepository.create(chatData);
|
||||
|
||||
logAuth('Chat created successfully', command.createdBy, {
|
||||
chatId: chat.id,
|
||||
chatType: command.type,
|
||||
participantCount: chat.users.length,
|
||||
gameId: command.gameId
|
||||
});
|
||||
|
||||
return chat;
|
||||
|
||||
} catch (error) {
|
||||
logError('CreateChatCommandHandler error', error as Error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { SendMessageCommand } from './ChatCommands';
|
||||
import { IChatRepository } from '../../../Domain/IRepository/IChatRepository';
|
||||
import { Message } from '../../../Domain/Chat/ChatAggregate';
|
||||
import { logAuth, logError } from '../../Services/Logger';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export class SendMessageCommandHandler {
|
||||
constructor(private chatRepository: IChatRepository) {}
|
||||
|
||||
async execute(command: SendMessageCommand): Promise<Message | null> {
|
||||
try {
|
||||
// Validate message is non-empty string
|
||||
if (typeof command.message !== 'string' || !command.message.trim()) {
|
||||
throw new Error('Message must be a non-empty string');
|
||||
}
|
||||
|
||||
const chat = await this.chatRepository.findById(command.chatId);
|
||||
if (!chat) {
|
||||
throw new Error('Chat not found');
|
||||
}
|
||||
|
||||
// Check if user is member of this chat
|
||||
if (!chat.users.includes(command.userId)) {
|
||||
throw new Error('User is not a member of this chat');
|
||||
}
|
||||
|
||||
// Create message
|
||||
const message: Message = {
|
||||
id: uuidv4(),
|
||||
date: new Date(),
|
||||
userid: command.userId,
|
||||
text: command.message.trim()
|
||||
};
|
||||
|
||||
// Manage message history (keep last 10 per user, up to 2 weeks)
|
||||
let updatedMessages = [...chat.messages, message];
|
||||
updatedMessages = this.pruneMessages(updatedMessages);
|
||||
|
||||
// Update chat
|
||||
await this.chatRepository.update(command.chatId, {
|
||||
messages: updatedMessages,
|
||||
lastActivity: new Date()
|
||||
});
|
||||
|
||||
logAuth('Message sent successfully', command.userId, {
|
||||
chatId: command.chatId,
|
||||
messageLength: command.message.length,
|
||||
totalMessages: updatedMessages.length
|
||||
});
|
||||
|
||||
return message;
|
||||
|
||||
} catch (error) {
|
||||
logError('SendMessageCommandHandler error', error as Error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private pruneMessages(messages: Message[]): Message[] {
|
||||
const twoWeeksAgo = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Remove messages older than 2 weeks
|
||||
let prunedMessages = messages.filter(msg => new Date(msg.date) > twoWeeksAgo);
|
||||
|
||||
// Group by user and keep last 10 messages per user
|
||||
const messagesByUser = new Map<string, Message[]>();
|
||||
prunedMessages.forEach(msg => {
|
||||
if (!messagesByUser.has(msg.userid)) {
|
||||
messagesByUser.set(msg.userid, []);
|
||||
}
|
||||
messagesByUser.get(msg.userid)!.push(msg);
|
||||
});
|
||||
|
||||
// Keep only last 10 messages per user
|
||||
const finalMessages: Message[] = [];
|
||||
messagesByUser.forEach((userMessages, userId) => {
|
||||
const last10 = userMessages.slice(-10);
|
||||
finalMessages.push(...last10);
|
||||
});
|
||||
|
||||
// Sort by date
|
||||
return finalMessages.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { GetChatHistoryQuery, GetArchivedChatsQuery } from './ChatQueries';
|
||||
import { IChatRepository } from '../../../Domain/IRepository/IChatRepository';
|
||||
import { IChatArchiveRepository } from '../../../Domain/IRepository/IChatArchiveRepository';
|
||||
import { Message } from '../../../Domain/Chat/ChatAggregate';
|
||||
import { logAuth, logError, logWarning } from '../../Services/Logger';
|
||||
|
||||
interface ChatHistoryResult {
|
||||
chatId: string;
|
||||
messages: Message[];
|
||||
isArchived: boolean;
|
||||
chatInfo: {
|
||||
type: string;
|
||||
name: string | null;
|
||||
gameId: string | null;
|
||||
users: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export class GetChatHistoryQueryHandler {
|
||||
constructor(
|
||||
private chatRepository: IChatRepository,
|
||||
private chatArchiveRepository: IChatArchiveRepository
|
||||
) {}
|
||||
|
||||
async execute(query: GetChatHistoryQuery): Promise<ChatHistoryResult | null> {
|
||||
try {
|
||||
// First try to find active chat
|
||||
const chat = await this.chatRepository.findById(query.chatId);
|
||||
|
||||
if (chat) {
|
||||
// Check authorization
|
||||
if (!chat.users.includes(query.userId)) {
|
||||
logWarning('Unauthorized chat history access attempt', {
|
||||
chatId: query.chatId,
|
||||
userId: query.userId
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
logAuth('Chat history retrieved', query.userId, {
|
||||
chatId: query.chatId,
|
||||
messageCount: chat.messages.length,
|
||||
isArchived: false
|
||||
});
|
||||
|
||||
return {
|
||||
chatId: query.chatId,
|
||||
messages: chat.messages,
|
||||
isArchived: false,
|
||||
chatInfo: {
|
||||
type: chat.type,
|
||||
name: chat.name,
|
||||
gameId: chat.gameId,
|
||||
users: chat.users
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Try to find in archives
|
||||
const archives = await this.chatArchiveRepository.findByChatId(query.chatId);
|
||||
const userArchive = archives.find(archive =>
|
||||
archive.participants.includes(query.userId)
|
||||
);
|
||||
|
||||
if (userArchive) {
|
||||
logAuth('Archived chat history retrieved', query.userId, {
|
||||
chatId: query.chatId,
|
||||
messageCount: userArchive.archivedMessages.length,
|
||||
isArchived: true
|
||||
});
|
||||
|
||||
return {
|
||||
chatId: query.chatId,
|
||||
messages: userArchive.archivedMessages,
|
||||
isArchived: true,
|
||||
chatInfo: {
|
||||
type: userArchive.chatType,
|
||||
name: userArchive.chatName,
|
||||
gameId: userArchive.gameId,
|
||||
users: userArchive.participants
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
logWarning('Chat history not found', {
|
||||
chatId: query.chatId,
|
||||
userId: query.userId
|
||||
});
|
||||
|
||||
return null;
|
||||
|
||||
} catch (error) {
|
||||
logError('GetChatHistoryQueryHandler error', error as Error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class GetArchivedChatsQueryHandler {
|
||||
constructor(private chatArchiveRepository: IChatArchiveRepository) {}
|
||||
|
||||
async execute(query: GetArchivedChatsQuery): Promise<ChatHistoryResult[]> {
|
||||
try {
|
||||
let archives: any[] = [];
|
||||
|
||||
if (query.gameId) {
|
||||
// Get archived game chats
|
||||
archives = await this.chatArchiveRepository.findByGameId(query.gameId);
|
||||
} else {
|
||||
// Get all archived chats for user (would need different query)
|
||||
// For now, return empty - this would need a new repository method
|
||||
archives = [];
|
||||
}
|
||||
|
||||
const result = archives
|
||||
.filter(archive => archive.participants.includes(query.userId))
|
||||
.map(archive => ({
|
||||
chatId: archive.chatId,
|
||||
messages: archive.archivedMessages,
|
||||
isArchived: true,
|
||||
chatInfo: {
|
||||
type: archive.chatType,
|
||||
name: archive.chatName,
|
||||
gameId: archive.gameId,
|
||||
users: archive.participants
|
||||
}
|
||||
}));
|
||||
|
||||
logAuth('Archived chats retrieved', query.userId, {
|
||||
count: result.length,
|
||||
gameId: query.gameId
|
||||
});
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
logError('GetArchivedChatsQueryHandler error', error as Error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export interface GetUserChatsQuery {
|
||||
userId: string;
|
||||
includeArchived?: boolean;
|
||||
}
|
||||
|
||||
export interface GetChatHistoryQuery {
|
||||
chatId: string;
|
||||
userId: string; // For authorization
|
||||
}
|
||||
|
||||
export interface GetArchivedChatsQuery {
|
||||
userId: string;
|
||||
gameId?: string;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface GetChatsByPageQuery {
|
||||
from: number;
|
||||
to: number;
|
||||
includeDeleted?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { IChatRepository } from '../../../Domain/IRepository/IChatRepository';
|
||||
import { GetChatsByPageQuery } from './GetChatsByPageQuery';
|
||||
import { ShortChatDto } from '../../DTOs/ChatDto';
|
||||
import { ChatMapper } from '../../DTOs/Mappers/ChatMapper';
|
||||
import { logRequest, logError } from '../../Services/Logger';
|
||||
|
||||
export class GetChatsByPageQueryHandler {
|
||||
constructor(private readonly chatRepo: IChatRepository) {}
|
||||
|
||||
async execute(query: GetChatsByPageQuery): Promise<{ chats: ShortChatDto[], totalCount: number }> {
|
||||
try {
|
||||
// Validate pagination parameters
|
||||
if (query.from < 0 || query.to < query.from) {
|
||||
throw new Error('Invalid pagination parameters');
|
||||
}
|
||||
|
||||
const limit = query.to - query.from + 1;
|
||||
if (limit > 100) {
|
||||
throw new Error('Page size too large. Maximum 100 records per request');
|
||||
}
|
||||
|
||||
logRequest('Get chats by page query started', undefined, undefined, {
|
||||
from: query.from,
|
||||
to: query.to,
|
||||
includeDeleted: query.includeDeleted || false
|
||||
});
|
||||
|
||||
const result = query.includeDeleted
|
||||
? await this.chatRepo.findByPageIncludingDeleted(query.from, query.to)
|
||||
: await this.chatRepo.findByPage(query.from, query.to);
|
||||
|
||||
logRequest('Get chats by page query completed', undefined, undefined, {
|
||||
from: query.from,
|
||||
to: query.to,
|
||||
returned: result.chats.length,
|
||||
totalCount: result.totalCount,
|
||||
includeDeleted: query.includeDeleted || false
|
||||
});
|
||||
|
||||
return {
|
||||
chats: ChatMapper.toShortDtoList(result.chats),
|
||||
totalCount: result.totalCount
|
||||
};
|
||||
} catch (error) {
|
||||
logError('GetChatsByPageQueryHandler error', error instanceof Error ? error : new Error(String(error)));
|
||||
|
||||
// Re-throw validation errors as-is
|
||||
if (error instanceof Error && (error.message.includes('Invalid pagination') || error.message.includes('Page size'))) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new Error('Failed to retrieve chats page');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { GetUserChatsQuery } from './ChatQueries';
|
||||
import { IChatRepository } from '../../../Domain/IRepository/IChatRepository';
|
||||
import { IChatArchiveRepository } from '../../../Domain/IRepository/IChatArchiveRepository';
|
||||
import { ChatAggregate } from '../../../Domain/Chat/ChatAggregate';
|
||||
import { ChatArchiveAggregate } from '../../../Domain/Chat/ChatArchiveAggregate';
|
||||
import { logAuth, logError } from '../../Services/Logger';
|
||||
|
||||
interface ChatWithMetadata {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string | null;
|
||||
gameId: string | null;
|
||||
users: string[];
|
||||
lastActivity: Date | null;
|
||||
isArchived: boolean;
|
||||
messageCount: number;
|
||||
unreadCount?: number;
|
||||
}
|
||||
|
||||
export class GetUserChatsQueryHandler {
|
||||
constructor(
|
||||
private chatRepository: IChatRepository,
|
||||
private chatArchiveRepository: IChatArchiveRepository
|
||||
) {}
|
||||
|
||||
async execute(query: GetUserChatsQuery): Promise<ChatWithMetadata[]> {
|
||||
try {
|
||||
const result: ChatWithMetadata[] = [];
|
||||
|
||||
// Get active chats
|
||||
const activeChats = await this.chatRepository.findActiveChatsForUser(query.userId);
|
||||
result.push(...activeChats.map(chat => ({
|
||||
id: chat.id,
|
||||
type: chat.type,
|
||||
name: chat.name,
|
||||
gameId: chat.gameId,
|
||||
users: chat.users,
|
||||
lastActivity: chat.lastActivity,
|
||||
isArchived: false,
|
||||
messageCount: chat.messages.length,
|
||||
unreadCount: this.calculateUnreadMessages(chat, query.userId)
|
||||
})));
|
||||
|
||||
// Get archived chats if requested
|
||||
if (query.includeArchived) {
|
||||
const userActiveChats = await this.chatRepository.findByUserId(query.userId);
|
||||
const archivedChatIds = userActiveChats
|
||||
.filter(chat => chat.archiveDate !== null)
|
||||
.map(chat => chat.id);
|
||||
|
||||
const archives = await Promise.all(
|
||||
archivedChatIds.map(id => this.chatArchiveRepository.findByChatId(id))
|
||||
);
|
||||
|
||||
archives.forEach(archiveArray => {
|
||||
archiveArray.forEach(archive => {
|
||||
if (archive.participants.includes(query.userId)) {
|
||||
result.push({
|
||||
id: archive.chatId,
|
||||
type: archive.chatType,
|
||||
name: archive.chatName,
|
||||
gameId: archive.gameId,
|
||||
users: archive.participants,
|
||||
lastActivity: archive.archivedAt,
|
||||
isArchived: true,
|
||||
messageCount: archive.archivedMessages.length,
|
||||
unreadCount: 0 // Archived chats have no unread messages
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
logAuth('User chats retrieved', query.userId, {
|
||||
activeCount: activeChats.length,
|
||||
totalCount: result.length,
|
||||
includeArchived: query.includeArchived
|
||||
});
|
||||
|
||||
return result.sort((a, b) => {
|
||||
if (!a.lastActivity) return 1;
|
||||
if (!b.lastActivity) return -1;
|
||||
return new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime();
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logError('GetUserChatsQueryHandler error', error as Error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private calculateUnreadMessages(chat: ChatAggregate, userId: string): number {
|
||||
// Simple implementation - count messages from other users
|
||||
// In production, you'd store lastSeen timestamp per user per chat
|
||||
return chat.messages.filter(msg => msg.userid !== userId).length;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ContactType } from '../../../Domain/Contact/ContactAggregate';
|
||||
|
||||
export interface CreateContactCommand {
|
||||
name: string;
|
||||
email: string;
|
||||
userid?: string;
|
||||
type: ContactType;
|
||||
txt: string;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { IContactRepository } from '../../../Domain/IRepository/IContactRepository';
|
||||
import { CreateContactCommand } from './CreateContactCommand';
|
||||
import { ShortContactDto } from '../../DTOs/ContactDto';
|
||||
import { ContactAggregate, ContactState } from '../../../Domain/Contact/ContactAggregate';
|
||||
import { ContactMapper } from '../../DTOs/Mappers/ContactMapper';
|
||||
|
||||
export class CreateContactCommandHandler {
|
||||
constructor(private readonly contactRepo: IContactRepository) {}
|
||||
|
||||
async execute(cmd: CreateContactCommand): Promise<ShortContactDto> {
|
||||
try {
|
||||
const contact = new ContactAggregate();
|
||||
contact.name = cmd.name;
|
||||
contact.email = cmd.email;
|
||||
contact.userid = cmd.userid || null;
|
||||
contact.type = cmd.type;
|
||||
contact.txt = cmd.txt;
|
||||
contact.state = ContactState.ACTIVE;
|
||||
|
||||
const created = await this.contactRepo.create(contact);
|
||||
return ContactMapper.toShortDto(created);
|
||||
} catch (error) {
|
||||
throw new Error('Failed to create contact');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface DeleteContactCommand {
|
||||
id: string;
|
||||
hard?: boolean; // true for permanent delete, false/undefined for soft delete
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { IContactRepository } from '../../../Domain/IRepository/IContactRepository';
|
||||
import { DeleteContactCommand } from './DeleteContactCommand';
|
||||
import { AdminAuditService } from '../../Services/AdminBypassService';
|
||||
import { logRequest } from '../../Services/Logger';
|
||||
|
||||
export class DeleteContactCommandHandler {
|
||||
constructor(private readonly contactRepo: IContactRepository) {}
|
||||
|
||||
async execute(cmd: DeleteContactCommand): Promise<boolean> {
|
||||
try {
|
||||
const existingContact = await this.contactRepo.findById(cmd.id);
|
||||
if (!existingContact) {
|
||||
throw new Error('Contact not found');
|
||||
}
|
||||
|
||||
if (cmd.hard) {
|
||||
// Permanent delete
|
||||
await this.contactRepo.delete(cmd.id);
|
||||
logRequest('Contact hard deleted', undefined, undefined, {
|
||||
contactId: cmd.id,
|
||||
contactEmail: existingContact.email,
|
||||
deleteType: 'hard'
|
||||
});
|
||||
} else {
|
||||
// Soft delete (default)
|
||||
await this.contactRepo.softDelete(cmd.id);
|
||||
logRequest('Contact soft deleted', undefined, undefined, {
|
||||
contactId: cmd.id,
|
||||
contactEmail: existingContact.email,
|
||||
deleteType: 'soft'
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Contact not found') {
|
||||
throw error;
|
||||
}
|
||||
throw new Error('Failed to delete contact');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface UpdateContactCommand {
|
||||
id: string;
|
||||
adminResponse?: string;
|
||||
state?: number;
|
||||
respondedBy?: string;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { IContactRepository } from '../../../Domain/IRepository/IContactRepository';
|
||||
import { UpdateContactCommand } from './UpdateContactCommand';
|
||||
import { DetailContactDto } from '../../DTOs/ContactDto';
|
||||
import { ContactMapper } from '../../DTOs/Mappers/ContactMapper';
|
||||
import { ContactState } from '../../../Domain/Contact/ContactAggregate';
|
||||
|
||||
export class UpdateContactCommandHandler {
|
||||
constructor(private readonly contactRepo: IContactRepository) {}
|
||||
|
||||
async execute(cmd: UpdateContactCommand): Promise<DetailContactDto> {
|
||||
try {
|
||||
const existingContact = await this.contactRepo.findById(cmd.id);
|
||||
if (!existingContact) {
|
||||
throw new Error('Contact not found');
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
|
||||
if (cmd.adminResponse !== undefined) {
|
||||
updateData.adminResponse = cmd.adminResponse;
|
||||
updateData.responseDate = new Date();
|
||||
}
|
||||
|
||||
if (cmd.state !== undefined) {
|
||||
updateData.state = cmd.state;
|
||||
}
|
||||
|
||||
if (cmd.respondedBy !== undefined) {
|
||||
updateData.respondedBy = cmd.respondedBy;
|
||||
}
|
||||
|
||||
const updated = await this.contactRepo.update(cmd.id, updateData);
|
||||
if (!updated) {
|
||||
throw new Error('Failed to update contact');
|
||||
}
|
||||
|
||||
return ContactMapper.toDetailDto(updated);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Contact not found') {
|
||||
throw error;
|
||||
}
|
||||
throw new Error('Failed to update contact');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface GetContactByIdQuery {
|
||||
id: string;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { IContactRepository } from '../../../Domain/IRepository/IContactRepository';
|
||||
import { GetContactByIdQuery } from './GetContactByIdQuery';
|
||||
import { DetailContactDto } from '../../DTOs/ContactDto';
|
||||
import { ContactMapper } from '../../DTOs/Mappers/ContactMapper';
|
||||
|
||||
export class GetContactByIdQueryHandler {
|
||||
constructor(private readonly contactRepo: IContactRepository) {}
|
||||
|
||||
async execute(query: GetContactByIdQuery): Promise<DetailContactDto | null> {
|
||||
const contact = await this.contactRepo.findById(query.id);
|
||||
if (!contact) {
|
||||
return null;
|
||||
}
|
||||
return ContactMapper.toDetailDto(contact);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface GetContactsByPageQuery {
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { IContactRepository } from '../../../Domain/IRepository/IContactRepository';
|
||||
import { GetContactsByPageQuery } from './GetContactsByPageQuery';
|
||||
import { ContactPageDto } from '../../DTOs/ContactDto';
|
||||
import { ContactMapper } from '../../DTOs/Mappers/ContactMapper';
|
||||
|
||||
export class GetContactsByPageQueryHandler {
|
||||
constructor(private readonly contactRepo: IContactRepository) {}
|
||||
|
||||
async execute(query: GetContactsByPageQuery): Promise<ContactPageDto> {
|
||||
const result = await this.contactRepo.findByPage(query.from, query.to);
|
||||
return {
|
||||
contacts: ContactMapper.toShortDtoList(result.contacts),
|
||||
totalCount: result.totalCount,
|
||||
from: query.from,
|
||||
to: query.to,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export interface CreateChatDto {
|
||||
users: string[];
|
||||
messages: import('../../Domain/Chat/ChatAggregate').Message[];
|
||||
state?: number;
|
||||
}
|
||||
|
||||
export interface UpdateChatDto {
|
||||
id: string;
|
||||
users?: string[];
|
||||
messages?: import('../../Domain/Chat/ChatAggregate').Message[];
|
||||
state?: number;
|
||||
}
|
||||
|
||||
export interface ShortChatDto {
|
||||
id: string;
|
||||
userCount: number;
|
||||
state: number;
|
||||
}
|
||||
|
||||
export interface DetailChatDto {
|
||||
id: string;
|
||||
users: string[];
|
||||
messages: import('../../Domain/Chat/ChatAggregate').Message[];
|
||||
updateDate: Date;
|
||||
state: number;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ContactType } from '../../Domain/Contact/ContactAggregate';
|
||||
|
||||
export interface CreateContactDto {
|
||||
name: string;
|
||||
email: string;
|
||||
userid?: string;
|
||||
type: ContactType;
|
||||
txt: string;
|
||||
}
|
||||
|
||||
export interface UpdateContactDto {
|
||||
id: string;
|
||||
adminResponse?: string;
|
||||
state?: number;
|
||||
respondedBy?: string;
|
||||
}
|
||||
|
||||
export interface ShortContactDto {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
type: ContactType;
|
||||
createDate: Date;
|
||||
state: number;
|
||||
}
|
||||
|
||||
export interface DetailContactDto {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
userid: string | null;
|
||||
type: ContactType;
|
||||
txt: string;
|
||||
state: number;
|
||||
createDate: Date;
|
||||
updateDate: Date;
|
||||
adminResponse: string | null;
|
||||
responseDate: Date | null;
|
||||
respondedBy: string | null;
|
||||
}
|
||||
|
||||
export interface ContactPageDto {
|
||||
contacts: ShortContactDto[];
|
||||
totalCount: number;
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export interface CreateDeckDto {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UpdateDeckDto {
|
||||
id: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ShortDeckDto {
|
||||
id: string;
|
||||
name: string;
|
||||
type: number;
|
||||
playedNumber: number;
|
||||
ctype: number;
|
||||
}
|
||||
|
||||
export interface DetailDeckDto {
|
||||
id: string;
|
||||
name: string;
|
||||
type: number;
|
||||
userid: string;
|
||||
creationdate: Date;
|
||||
cards: any[];
|
||||
playedNumber: number;
|
||||
ctype: number;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as DeckAggregate from "../../Domain/Deck/DeckAggregate";
|
||||
|
||||
export interface GameStartDto {
|
||||
gameid: string;
|
||||
maxplayers: number;
|
||||
logintype: number;
|
||||
gamecode: string;
|
||||
deck: gamedeck[];
|
||||
}
|
||||
|
||||
enum decktype {
|
||||
JOCKER = 0,
|
||||
LUCK = 1,
|
||||
QUEST = 2
|
||||
}
|
||||
|
||||
export interface cards {
|
||||
cardid: string;
|
||||
question?: string;
|
||||
answer?: string;
|
||||
consequence?: DeckAggregate.Consequence | null;
|
||||
played?: boolean;
|
||||
playerid?: string;
|
||||
}
|
||||
|
||||
export interface gamedeck {
|
||||
deckid: string;
|
||||
decktype: decktype;
|
||||
cards: cards[];
|
||||
}
|
||||
|
||||
export interface GameDataDto {
|
||||
id: string;
|
||||
gamecode: string;
|
||||
maxplayers: number;
|
||||
logintype: number;
|
||||
gamedecks: gamedeck[];
|
||||
players: string[];
|
||||
started: boolean;
|
||||
finished: boolean;
|
||||
winner?: string;
|
||||
currentplayer?: string;
|
||||
createdate: Date;
|
||||
startdate?: Date;
|
||||
enddate?: Date;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export abstract class BaseMapper<TEntity, TShortDto, TDetailDto> {
|
||||
abstract toShortDto(entity: TEntity): TShortDto;
|
||||
abstract toDetailDto(entity: TEntity): TDetailDto;
|
||||
|
||||
toShortDtoList(entities: TEntity[]): TShortDto[] {
|
||||
return entities.map(entity => this.toShortDto(entity));
|
||||
}
|
||||
|
||||
toDetailDtoList(entities: TEntity[]): TDetailDto[] {
|
||||
return entities.map(entity => this.toDetailDto(entity));
|
||||
}
|
||||
|
||||
static toShortDtoListStatic<T, TDto>(
|
||||
entities: T[],
|
||||
mapperFn: (entity: T) => TDto
|
||||
): TDto[] {
|
||||
return entities.map(mapperFn);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ChatAggregate } from '../../../Domain/Chat/ChatAggregate';
|
||||
import { ShortChatDto, DetailChatDto } from '../ChatDto';
|
||||
|
||||
export class ChatMapper {
|
||||
static toShortDto(chat: ChatAggregate): ShortChatDto {
|
||||
return {
|
||||
id: chat.id,
|
||||
userCount: chat.users?.length ?? 0,
|
||||
state: chat.state,
|
||||
};
|
||||
}
|
||||
|
||||
static toDetailDto(chat: ChatAggregate): DetailChatDto {
|
||||
return {
|
||||
id: chat.id,
|
||||
users: chat.users ?? [],
|
||||
messages: chat.messages,
|
||||
updateDate: chat.updateDate,
|
||||
state: chat.state,
|
||||
};
|
||||
}
|
||||
|
||||
static toShortDtoList(chats: ChatAggregate[]): ShortChatDto[] {
|
||||
return chats.map(this.toShortDto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ContactAggregate } from '../../../Domain/Contact/ContactAggregate';
|
||||
import { CreateContactDto, UpdateContactDto, ShortContactDto, DetailContactDto } from '../ContactDto';
|
||||
|
||||
export class ContactMapper {
|
||||
static toShortDto(contact: ContactAggregate): ShortContactDto {
|
||||
return {
|
||||
id: contact.id,
|
||||
name: contact.name,
|
||||
email: contact.email,
|
||||
type: contact.type,
|
||||
createDate: contact.createDate,
|
||||
state: contact.state,
|
||||
};
|
||||
}
|
||||
|
||||
static toDetailDto(contact: ContactAggregate): DetailContactDto {
|
||||
return {
|
||||
id: contact.id,
|
||||
name: contact.name,
|
||||
email: contact.email,
|
||||
userid: contact.userid,
|
||||
type: contact.type,
|
||||
txt: contact.txt,
|
||||
state: contact.state,
|
||||
createDate: contact.createDate,
|
||||
updateDate: contact.updateDate,
|
||||
adminResponse: contact.adminResponse,
|
||||
responseDate: contact.responseDate,
|
||||
respondedBy: contact.respondedBy,
|
||||
};
|
||||
}
|
||||
|
||||
static toShortDtoList(contacts: ContactAggregate[]): ShortContactDto[] {
|
||||
return contacts.map(this.toShortDto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { DeckAggregate } from '../../../Domain/Deck/DeckAggregate';
|
||||
import { CreateDeckDto, UpdateDeckDto, ShortDeckDto, DetailDeckDto } from '../DeckDto';
|
||||
|
||||
export class DeckMapper {
|
||||
static toShortDto(deck: DeckAggregate): ShortDeckDto {
|
||||
return {
|
||||
id: deck.id,
|
||||
name: deck.name,
|
||||
type: deck.type,
|
||||
playedNumber: deck.playedNumber,
|
||||
ctype: deck.ctype,
|
||||
};
|
||||
}
|
||||
|
||||
static toDetailDto(deck: DeckAggregate): DetailDeckDto {
|
||||
return {
|
||||
id: deck.id,
|
||||
name: deck.name,
|
||||
type: deck.type,
|
||||
userid: deck.userid,
|
||||
creationdate: deck.creationdate,
|
||||
cards: deck.cards,
|
||||
playedNumber: deck.playedNumber,
|
||||
ctype: deck.ctype,
|
||||
};
|
||||
}
|
||||
|
||||
static toShortDtoList(decks: DeckAggregate[]): ShortDeckDto[] {
|
||||
return decks.map(this.toShortDto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { OrganizationAggregate } from '../../../Domain/Organization/OrganizationAggregate';
|
||||
import { CreateOrganizationDto, UpdateOrganizationDto, ShortOrganizationDto, DetailOrganizationDto } from '../OrganizationDto';
|
||||
|
||||
export class OrganizationMapper {
|
||||
static toShortDto(org: OrganizationAggregate): ShortOrganizationDto {
|
||||
return {
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
state: org.state,
|
||||
userinorg: org.userinorg,
|
||||
maxOrganizationalDecks: org.maxOrganizationalDecks,
|
||||
};
|
||||
}
|
||||
|
||||
static toDetailDto(org: OrganizationAggregate): DetailOrganizationDto {
|
||||
return {
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
contactfname: org.contactfname,
|
||||
contactlname: org.contactlname,
|
||||
contactphone: org.contactphone,
|
||||
contactemail: org.contactemail,
|
||||
state: org.state,
|
||||
regdate: org.regdate,
|
||||
updatedate: org.updatedate,
|
||||
url: org.url,
|
||||
userinorg: org.userinorg,
|
||||
maxOrganizationalDecks: org.maxOrganizationalDecks,
|
||||
users: org.users?.map(u => u.id) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
static toShortDtoList(orgs: OrganizationAggregate[]): ShortOrganizationDto[] {
|
||||
return orgs.map(this.toShortDto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { UserAggregate, UserState } from '../../../Domain/User/UserAggregate';
|
||||
import { CreateUserDto, UpdateUserDto, ShortUserDto, DetailUserDto } from '../UserDto';
|
||||
import { BaseMapper } from './BaseMapper';
|
||||
|
||||
export class UserMapper {
|
||||
static toShortDto(user: UserAggregate): ShortUserDto {
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
state: user.state,
|
||||
authLevel: (user.state === UserState.ADMIN ? 1 : 0) as 0 | 1,
|
||||
};
|
||||
}
|
||||
|
||||
static toDetailDto(user: UserAggregate): DetailUserDto {
|
||||
return {
|
||||
id: user.id,
|
||||
orgid: user.orgid,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
fname: user.fname,
|
||||
lname: user.lname,
|
||||
code: user.token,
|
||||
phone: user.phone,
|
||||
state: user.state,
|
||||
};
|
||||
}
|
||||
|
||||
static toShortDtoList(users: UserAggregate[]): ShortUserDto[] {
|
||||
return BaseMapper.toShortDtoListStatic(users, UserMapper.toShortDto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
export interface CreateOrganizationDto {
|
||||
name: string;
|
||||
description?: string;
|
||||
maxOrganizationalDecks?: number | null;
|
||||
}
|
||||
|
||||
export interface UpdateOrganizationDto {
|
||||
id: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ShortOrganizationDto {
|
||||
id: string;
|
||||
name: string;
|
||||
state: number;
|
||||
userinorg: number;
|
||||
maxOrganizationalDecks?: number | null;
|
||||
}
|
||||
|
||||
export interface DetailOrganizationDto {
|
||||
id: string;
|
||||
name: string;
|
||||
contactfname: string;
|
||||
contactlname: string;
|
||||
contactphone: string;
|
||||
contactemail: string;
|
||||
state: number;
|
||||
regdate: Date;
|
||||
updatedate: Date;
|
||||
url: string | null;
|
||||
userinorg: number;
|
||||
maxOrganizationalDecks: number | null;
|
||||
users: string[];
|
||||
}
|
||||
|
||||
export interface OrganizationLoginUrlDto {
|
||||
organizationId: string;
|
||||
organizationName: string;
|
||||
loginUrl: string;
|
||||
}
|
||||
|
||||
export interface OrganizationAuthCallbackDto {
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
status: 'ok' | 'not_ok';
|
||||
authToken?: string;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export interface SearchQuery {
|
||||
query: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface SearchResult<T> {
|
||||
results: T[];
|
||||
totalCount: number;
|
||||
hasMore: boolean;
|
||||
searchQuery: string;
|
||||
searchType: 'users' | 'organizations' | 'decks';
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export interface CreateUserDto {
|
||||
username: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface UpdateUserDto {
|
||||
id: string;
|
||||
username?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface ShortUserDto {
|
||||
id: string;
|
||||
username: string;
|
||||
state: number;
|
||||
authLevel: 0 | 1;
|
||||
}
|
||||
|
||||
export interface DetailUserDto {
|
||||
id: string;
|
||||
orgid: string | null;
|
||||
username: string;
|
||||
email: string;
|
||||
fname: string;
|
||||
lname: string;
|
||||
code: string | null;
|
||||
phone: string | null;
|
||||
state: number;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface CreateDeckCommand {
|
||||
name: string;
|
||||
type: number;
|
||||
userid: string;
|
||||
cards: any[];
|
||||
ctype?: number;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { IDeckRepository } from '../../../Domain/IRepository/IDeckRepository';
|
||||
import { IUserRepository } from '../../../Domain/IRepository/IUserRepository';
|
||||
import { IOrganizationRepository } from '../../../Domain/IRepository/IOrganizationRepository';
|
||||
import { CreateDeckCommand } from './CreateDeckCommand';
|
||||
import { ShortDeckDto } from '../../DTOs/DeckDto';
|
||||
import { DeckAggregate, State, CType } from '../../../Domain/Deck/DeckAggregate';
|
||||
import { UserState } from '../../../Domain/User/UserAggregate';
|
||||
import { DeckMapper } from '../../DTOs/Mappers/DeckMapper';
|
||||
import { AdminBypassService } from '../../Services/AdminBypassService';
|
||||
import { logRequest } from '../../Services/Logger';
|
||||
|
||||
export class CreateDeckCommandHandler {
|
||||
constructor(
|
||||
private readonly deckRepo: IDeckRepository,
|
||||
private readonly userRepo: IUserRepository,
|
||||
private readonly orgRepo: IOrganizationRepository
|
||||
) {}
|
||||
|
||||
async execute(cmd: CreateDeckCommand): Promise<ShortDeckDto> {
|
||||
try {
|
||||
// 1. Get user details
|
||||
const user = await this.userRepo.findById(cmd.userid);
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
// 2. ADMIN BYPASS - Skip all restrictions
|
||||
if (AdminBypassService.shouldBypassRestrictions(user.state)) {
|
||||
AdminBypassService.logAdminBypass(
|
||||
'CREATE_DECK_BYPASS',
|
||||
user.id,
|
||||
'new-deck',
|
||||
{
|
||||
deckName: cmd.name,
|
||||
deckType: cmd.type,
|
||||
cardCount: cmd.cards.length,
|
||||
ctype: cmd.ctype
|
||||
}
|
||||
);
|
||||
return this.createDeck(cmd);
|
||||
}
|
||||
|
||||
// 3. Check deck count limits for regular users
|
||||
const userDeckCount = await this.deckRepo.countActiveByUserId(cmd.userid);
|
||||
const maxDecks = user.state === UserState.VERIFIED_PREMIUM ? 12 : 8;
|
||||
|
||||
if (userDeckCount >= maxDecks) {
|
||||
throw new Error(`Deck limit exceeded. Maximum ${maxDecks} decks allowed for your account type.`);
|
||||
}
|
||||
|
||||
// 4. Organizational deck restrictions
|
||||
if (cmd.ctype === CType.ORGANIZATION) {
|
||||
// Only premium users can create organizational decks
|
||||
if (user.state !== UserState.VERIFIED_PREMIUM) {
|
||||
throw new Error('Only premium users can create organizational decks.');
|
||||
}
|
||||
|
||||
// User must belong to an organization
|
||||
if (!user.orgid) {
|
||||
throw new Error('You must be a member of an organization to create organizational decks.');
|
||||
}
|
||||
|
||||
// Check organization limits
|
||||
const org = await this.orgRepo.findById(user.orgid);
|
||||
if (!org) {
|
||||
throw new Error('Organization not found.');
|
||||
}
|
||||
|
||||
if (org.maxOrganizationalDecks === null) {
|
||||
throw new Error('Organization deck limit not configured. Contact administrator.');
|
||||
}
|
||||
|
||||
const userOrgDeckCount = await this.deckRepo.countOrganizationalByUserId(cmd.userid);
|
||||
if (userOrgDeckCount >= org.maxOrganizationalDecks) {
|
||||
throw new Error(`Organization deck limit exceeded. Maximum ${org.maxOrganizationalDecks} organizational decks allowed.`);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Create deck with restrictions passed
|
||||
return this.createDeck(cmd);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw error; // Re-throw known errors with original message
|
||||
}
|
||||
throw new Error('Failed to create deck');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Private method to create deck after all validations
|
||||
*/
|
||||
private async createDeck(cmd: CreateDeckCommand): Promise<ShortDeckDto> {
|
||||
const deck = new DeckAggregate();
|
||||
deck.name = cmd.name;
|
||||
deck.type = cmd.type;
|
||||
deck.userid = cmd.userid;
|
||||
deck.cards = cmd.cards;
|
||||
deck.ctype = cmd.ctype ?? CType.PUBLIC;
|
||||
deck.state = State.ACTIVE;
|
||||
|
||||
// Set organization reference for organizational decks
|
||||
if (cmd.ctype === CType.ORGANIZATION) {
|
||||
const user = await this.userRepo.findById(cmd.userid);
|
||||
if (user?.orgid) {
|
||||
const org = await this.orgRepo.findById(user.orgid);
|
||||
if (org) {
|
||||
deck.organization = org;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const created = await this.deckRepo.create(deck);
|
||||
|
||||
logRequest('Deck created successfully', undefined, undefined, {
|
||||
deckId: created.id,
|
||||
userId: cmd.userid,
|
||||
deckName: cmd.name,
|
||||
deckType: cmd.type,
|
||||
ctype: cmd.ctype,
|
||||
cardCount: cmd.cards.length
|
||||
});
|
||||
|
||||
return DeckMapper.toShortDto(created);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface DeleteDeckCommand {
|
||||
id: string;
|
||||
soft?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { IDeckRepository } from '../../../Domain/IRepository/IDeckRepository';
|
||||
import { DeleteDeckCommand } from './DeleteDeckCommand';
|
||||
|
||||
export class DeleteDeckCommandHandler {
|
||||
constructor(private readonly deckRepo: IDeckRepository) {}
|
||||
|
||||
async execute(cmd: DeleteDeckCommand): Promise<boolean> {
|
||||
if (cmd.soft) {
|
||||
await this.deckRepo.softDelete(cmd.id);
|
||||
} else {
|
||||
await this.deckRepo.delete(cmd.id);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export interface UpdateDeckCommand {
|
||||
id: string;
|
||||
userstate?: number;
|
||||
name?: string;
|
||||
type?: number;
|
||||
userid?: string;
|
||||
cards?: any[];
|
||||
ctype?: number;
|
||||
state?: number;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { IDeckRepository } from '../../../Domain/IRepository/IDeckRepository';
|
||||
import { UpdateDeckCommand } from './UpdateDeckCommand';
|
||||
import { ShortDeckDto } from '../../DTOs/DeckDto';
|
||||
import { DeckMapper } from '../../DTOs/Mappers/DeckMapper';
|
||||
import { DeckAggregate } from '../../../Domain/Deck/DeckAggregate';
|
||||
import { logError } from '../../Services/Logger';
|
||||
|
||||
export class UpdateDeckCommandHandler {
|
||||
constructor(private readonly deckRepo: IDeckRepository) {}
|
||||
|
||||
async execute(cmd: UpdateDeckCommand): Promise<ShortDeckDto | null> {
|
||||
if(cmd.state !== undefined && cmd.userstate!==1) {
|
||||
throw new Error('Only admin users can change deck state');
|
||||
}
|
||||
try {
|
||||
let existingDeck: DeckAggregate | null = null;
|
||||
if (cmd.userstate === 1) {
|
||||
existingDeck = await this.deckRepo.findByIdIncludingDeleted(cmd.id);
|
||||
} else {
|
||||
existingDeck = await this.deckRepo.findById(cmd.id);
|
||||
}
|
||||
if (!existingDeck) {
|
||||
logError(`Deck not found with ID: ${cmd.id}`);
|
||||
throw new Error('Deck not found');
|
||||
}
|
||||
|
||||
const for_update: Partial<DeckAggregate> = {};
|
||||
if(cmd.name !== undefined) for_update.name = cmd.name;
|
||||
if(cmd.type !== undefined) for_update.type = cmd.type;
|
||||
if(cmd.cards !== undefined) for_update.cards = cmd.cards;
|
||||
if(cmd.ctype !== undefined) for_update.ctype = cmd.ctype;
|
||||
if(cmd.state !== undefined) for_update.state = cmd.state;
|
||||
|
||||
// Ensure we have something to update
|
||||
if (Object.keys(for_update).length === 0) {
|
||||
throw new Error('No fields provided for update');
|
||||
}
|
||||
|
||||
const deck = await this.deckRepo.update(cmd.id, { ...for_update });
|
||||
if(!deck) {
|
||||
logError(`Deck update failed for ID: ${cmd.id}. Update returned null.`);
|
||||
throw new Error('Failed to update deck');
|
||||
}
|
||||
return DeckMapper.toShortDto(deck);
|
||||
} catch (error: any) {
|
||||
logError(`Error updating deck: ${cmd.id}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface GetDeckByIdQuery {
|
||||
id: string;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { IDeckRepository } from '../../../Domain/IRepository/IDeckRepository';
|
||||
import { GetDeckByIdQuery } from './GetDeckByIdQuery';
|
||||
import { DetailDeckDto } from '../../DTOs/DeckDto';
|
||||
import { DeckMapper } from '../../DTOs/Mappers/DeckMapper';
|
||||
|
||||
export class GetDeckByIdQueryHandler {
|
||||
constructor(private readonly deckRepo: IDeckRepository) {}
|
||||
|
||||
async execute(query: GetDeckByIdQuery): Promise<DetailDeckDto | null> {
|
||||
const deck = await this.deckRepo.findById(query.id);
|
||||
if (!deck) return null;
|
||||
return DeckMapper.toDetailDto(deck);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface GetDecksByPageQuery {
|
||||
from: number;
|
||||
to: number;
|
||||
userId: string;
|
||||
userOrgId?: string;
|
||||
isAdmin: boolean;
|
||||
includeDeleted?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { IDeckRepository } from '../../../Domain/IRepository/IDeckRepository';
|
||||
import { GetDecksByPageQuery } from './GetDecksByPageQuery';
|
||||
import { ShortDeckDto } from '../../DTOs/DeckDto';
|
||||
import { DeckMapper } from '../../DTOs/Mappers/DeckMapper';
|
||||
import { AdminBypassService } from '../../Services/AdminBypassService';
|
||||
import { logRequest, logError } from '../../Services/Logger';
|
||||
|
||||
export class GetDecksByPageQueryHandler {
|
||||
constructor(private readonly deckRepo: IDeckRepository) {}
|
||||
|
||||
async execute(query: GetDecksByPageQuery): Promise<{ decks: ShortDeckDto[], totalCount: number }> {
|
||||
try {
|
||||
// Validate pagination parameters
|
||||
if (query.from < 0 || query.to < query.from) {
|
||||
throw new Error('Invalid pagination parameters');
|
||||
}
|
||||
|
||||
const limit = query.to - query.from + 1;
|
||||
if (limit > 100) {
|
||||
throw new Error('Page size too large. Maximum 100 records per request');
|
||||
}
|
||||
|
||||
// Log admin bypass if applicable
|
||||
if (query.isAdmin) {
|
||||
AdminBypassService.logAdminBypass(
|
||||
'GET_DECKS_PAGE_BYPASS',
|
||||
query.userId,
|
||||
'paginated-decks',
|
||||
{
|
||||
from: query.from,
|
||||
to: query.to,
|
||||
includesDeleted: query.includeDeleted || false,
|
||||
operation: 'read'
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
logRequest('Get decks by page query started', undefined, undefined, {
|
||||
userId: query.userId,
|
||||
userOrgId: query.userOrgId,
|
||||
isAdmin: query.isAdmin,
|
||||
from: query.from,
|
||||
to: query.to,
|
||||
includeDeleted: query.includeDeleted || false
|
||||
});
|
||||
|
||||
// Use paginated filtered deck finding method
|
||||
const result = await this.deckRepo.findFilteredDecks(
|
||||
query.userId,
|
||||
query.userOrgId,
|
||||
query.isAdmin,
|
||||
query.from,
|
||||
query.to
|
||||
);
|
||||
|
||||
logRequest('Get decks by page query completed', undefined, undefined, {
|
||||
userId: query.userId,
|
||||
userOrgId: query.userOrgId,
|
||||
isAdmin: query.isAdmin,
|
||||
from: query.from,
|
||||
to: query.to,
|
||||
returned: result.decks.length,
|
||||
totalCount: result.totalCount,
|
||||
includeDeleted: query.includeDeleted || false
|
||||
});
|
||||
|
||||
return {
|
||||
decks: DeckMapper.toShortDtoList(result.decks),
|
||||
totalCount: result.totalCount
|
||||
};
|
||||
} catch (error) {
|
||||
logError('GetDecksByPageQueryHandler error', error instanceof Error ? error : new Error(String(error)));
|
||||
|
||||
// Re-throw validation errors as-is
|
||||
if (error instanceof Error && (error.message.includes('Invalid pagination') || error.message.includes('Page size'))) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new Error('Failed to retrieve decks page');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { GameField, BoardData } from '../../Domain/Game/GameAggregate';
|
||||
import { logOther, logError } from '../Services/Logger';
|
||||
|
||||
interface SpecialFieldInfo {
|
||||
position: number;
|
||||
type: 'positive' | 'negative' | 'luck';
|
||||
}
|
||||
|
||||
export class BoardGenerationService {
|
||||
async generateBoard(
|
||||
positiveFieldCount: number,
|
||||
negativeFieldCount: number,
|
||||
luckFieldCount: number
|
||||
): Promise<BoardData> {
|
||||
// Pattern-based approach has 100% success rate, no retry needed
|
||||
const result = this.generateSingleAttempt(positiveFieldCount, negativeFieldCount, luckFieldCount);
|
||||
|
||||
logOther('Pattern-based board generation completed', {
|
||||
totalFields: result.fields.length,
|
||||
specialFields: result.fields.filter((f: GameField) => f.type !== 'regular').length,
|
||||
positiveFields: result.fields.filter((f: GameField) => f.type === 'positive').length,
|
||||
negativeFields: result.fields.filter((f: GameField) => f.type === 'negative').length,
|
||||
luckFields: result.fields.filter((f: GameField) => f.type === 'luck').length
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private generateSingleAttempt(
|
||||
positiveFieldCount: number,
|
||||
negativeFieldCount: number,
|
||||
luckFieldCount: number
|
||||
): BoardData {
|
||||
// Step 1: Choose special field positions
|
||||
const specialFieldPositions = this.chooseSpecialFieldPositions(
|
||||
positiveFieldCount,
|
||||
negativeFieldCount,
|
||||
luckFieldCount
|
||||
);
|
||||
|
||||
// Step 2: Calculate step values using pattern-based approach
|
||||
const fields = this.calculatePatternBasedStepValues(specialFieldPositions);
|
||||
|
||||
return {
|
||||
fields
|
||||
};
|
||||
}
|
||||
|
||||
private chooseSpecialFieldPositions(
|
||||
positiveFieldCount: number,
|
||||
negativeFieldCount: number,
|
||||
luckFieldCount: number
|
||||
): SpecialFieldInfo[] {
|
||||
const totalSpecial = positiveFieldCount + negativeFieldCount + luckFieldCount;
|
||||
const specialFields: SpecialFieldInfo[] = [];
|
||||
|
||||
// Generate unique random positions
|
||||
const positions = new Set<number>();
|
||||
while (positions.size < totalSpecial) {
|
||||
const position = Math.floor(Math.random() * 100) + 1; // 1-100
|
||||
positions.add(position);
|
||||
}
|
||||
|
||||
// Convert to sorted array
|
||||
const sortedPositions = Array.from(positions).sort((a, b) => a - b);
|
||||
|
||||
// Distribute types randomly
|
||||
const types: ('positive' | 'negative' | 'luck')[] = [
|
||||
...Array(positiveFieldCount).fill('positive'),
|
||||
...Array(negativeFieldCount).fill('negative'),
|
||||
...Array(luckFieldCount).fill('luck')
|
||||
];
|
||||
|
||||
// Shuffle types
|
||||
for (let i = types.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[types[i], types[j]] = [types[j], types[i]];
|
||||
}
|
||||
|
||||
sortedPositions.forEach((position, index) => {
|
||||
specialFields.push({
|
||||
position,
|
||||
type: types[index] || 'positive'
|
||||
});
|
||||
});
|
||||
|
||||
return specialFields;
|
||||
}
|
||||
|
||||
private calculatePatternBasedStepValues(specialFields: SpecialFieldInfo[]): GameField[] {
|
||||
// Initialize all fields as regular
|
||||
const fields: GameField[] = Array.from({ length: 100 }, (_, i) => ({
|
||||
position: i + 1,
|
||||
type: 'regular' as const
|
||||
}));
|
||||
|
||||
// Update special fields with pattern-based step values
|
||||
specialFields.forEach(specialField => {
|
||||
const fieldIndex = specialField.position - 1; // Convert to 0-based index
|
||||
fields[fieldIndex].type = specialField.type;
|
||||
|
||||
if (specialField.type === 'luck') {
|
||||
// Luck fields don't need step values
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate step values based on position rules
|
||||
let maxStepValue: number;
|
||||
let minStepValue: number;
|
||||
|
||||
if (specialField.position <= 80) {
|
||||
// Positions 1-80: step values can be ±20
|
||||
maxStepValue = 20;
|
||||
minStepValue = -20;
|
||||
} else {
|
||||
// Positions 81-100: step values can be -30 to +10
|
||||
maxStepValue = 10;
|
||||
minStepValue = -30;
|
||||
}
|
||||
|
||||
// Generate appropriate step value for field type
|
||||
if (specialField.type === 'positive') {
|
||||
// Positive fields: use positive step values (3-8 range for good gameplay)
|
||||
const stepValue = Math.floor(Math.random() * 6) + 3; // 3-8
|
||||
fields[fieldIndex].stepValue = Math.min(stepValue, maxStepValue);
|
||||
} else {
|
||||
// Negative fields: use negative step values (-3 to -8 range)
|
||||
const stepValue = -(Math.floor(Math.random() * 6) + 3); // -3 to -8
|
||||
fields[fieldIndex].stepValue = Math.max(stepValue, minStepValue);
|
||||
}
|
||||
});
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
// This method can be used by FieldEffectService for movement calculations
|
||||
public calculatePatternBasedMovement(
|
||||
currentPosition: number,
|
||||
stepValue: number,
|
||||
diceValue: number
|
||||
): number {
|
||||
// Calculate pattern modifier based on current position
|
||||
const patternModifier = this.getPatternModifier(currentPosition);
|
||||
|
||||
// Calculate final position: currentPosition + (stepValue × dice) + patternModifier
|
||||
const movement = stepValue * diceValue;
|
||||
let finalPosition = currentPosition + movement + patternModifier;
|
||||
|
||||
// Ensure position stays within board bounds (1-100)
|
||||
if (finalPosition < 1) {
|
||||
finalPosition = 1;
|
||||
} else if (finalPosition > 100) {
|
||||
finalPosition = 100;
|
||||
}
|
||||
|
||||
return finalPosition;
|
||||
}
|
||||
|
||||
private getPatternModifier(position: number): number {
|
||||
// Pattern modifiers for strategic complexity:
|
||||
// - Positions ending in 0 (10, 20, 30...): No modifier
|
||||
// - Positions ending in 5 (15, 25, 35...): ±3 modifier
|
||||
// - Positions divisible by 3 (9, 12, 21...): ±2 modifier
|
||||
// - Odd positions (1, 7, 11...): ±1 modifier
|
||||
// - Other even positions: No modifier
|
||||
|
||||
if (position % 10 === 0) {
|
||||
return 0; // Positions ending in 0
|
||||
} else if (position % 10 === 5) {
|
||||
return Math.random() < 0.5 ? 3 : -3; // Positions ending in 5
|
||||
} else if (position % 3 === 0) {
|
||||
return Math.random() < 0.5 ? 2 : -2; // Divisible by 3
|
||||
} else if (position % 2 === 1) {
|
||||
return Math.random() < 0.5 ? 1 : -1; // Odd positions
|
||||
} else {
|
||||
return 0; // Other even positions
|
||||
}
|
||||
}
|
||||
|
||||
private validate20_30Rule(currentPosition: number, targetPosition: number, distance: number): boolean {
|
||||
// Fields 1-85: max 20 fields in any direction
|
||||
if (currentPosition <= 85) {
|
||||
return distance <= 20;
|
||||
}
|
||||
|
||||
// Fields 86-100: max 30 fields backward, max 20 fields forward
|
||||
if (currentPosition > 85) {
|
||||
if (targetPosition > currentPosition) {
|
||||
// Moving forward: max 20 fields
|
||||
return distance <= 20;
|
||||
} else {
|
||||
// Moving backward: max 30 fields
|
||||
return distance <= 30;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import { StartGameCommand } from './commands/StartGameCommand';
|
||||
import { StartGameCommandHandler } from './commands/StartGameCommandHandler';
|
||||
import { JoinGameCommand } from './commands/JoinGameCommand';
|
||||
import { JoinGameCommandHandler } from './commands/JoinGameCommandHandler';
|
||||
import { StartGamePlayCommand } from './commands/StartGamePlayCommand';
|
||||
import { StartGamePlayCommandHandler, GameStartResult } from './commands/StartGamePlayCommandHandler';
|
||||
import { GameAggregate, LoginType } from '../../Domain/Game/GameAggregate';
|
||||
import { logOther, logError } from '../Services/Logger';
|
||||
|
||||
export class GameService {
|
||||
private startGameHandler: StartGameCommandHandler;
|
||||
private joinGameHandler: JoinGameCommandHandler;
|
||||
private startGamePlayHandler: StartGamePlayCommandHandler;
|
||||
|
||||
constructor() {
|
||||
this.startGameHandler = new StartGameCommandHandler();
|
||||
this.joinGameHandler = new JoinGameCommandHandler();
|
||||
this.startGamePlayHandler = new StartGamePlayCommandHandler();
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a new game with the provided deck IDs
|
||||
* @param deckids Array of deck IDs (should contain 3 types: LUCK, JOKER, QUESTION)
|
||||
* @param maxplayers Maximum number of players allowed in the game
|
||||
* @param logintype How players can join the game (PUBLIC, PRIVATE, ORGANIZATION)
|
||||
* @param userid Optional ID of the user creating the game
|
||||
* @returns Promise<GameAggregate> The created game
|
||||
*/
|
||||
async startGame(
|
||||
deckids: string[],
|
||||
maxplayers: number,
|
||||
logintype: LoginType,
|
||||
userid?: string,
|
||||
orgid?: string | null
|
||||
): Promise<GameAggregate> {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
logOther('GameService.startGame called', {
|
||||
deckCount: deckids.length,
|
||||
maxplayers,
|
||||
logintype,
|
||||
userid,
|
||||
orgid
|
||||
});
|
||||
|
||||
// Validate input parameters
|
||||
this.validateStartGameInput(deckids, maxplayers, logintype);
|
||||
|
||||
// Create and execute the command
|
||||
const command: StartGameCommand = {
|
||||
deckids,
|
||||
maxplayers,
|
||||
logintype,
|
||||
userid,
|
||||
orgid
|
||||
};
|
||||
|
||||
const game = await this.startGameHandler.handle(command);
|
||||
|
||||
const endTime = performance.now();
|
||||
logOther('Game started successfully', {
|
||||
gameId: game.id,
|
||||
gameCode: game.gamecode,
|
||||
deckCount: game.gamedecks.length,
|
||||
totalCards: game.gamedecks.reduce((sum, deck) => sum + deck.cards.length, 0),
|
||||
executionTime: Math.round(endTime - startTime)
|
||||
});
|
||||
|
||||
return game;
|
||||
|
||||
} catch (error) {
|
||||
const endTime = performance.now();
|
||||
logError('GameService.startGame failed', error instanceof Error ? error : new Error(String(error)));
|
||||
logOther('Game start failed', {
|
||||
executionTime: Math.round(endTime - startTime),
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Join an existing game using game code
|
||||
* @param gameCode 6-character game code
|
||||
* @param playerId ID of the player joining (optional for public games)
|
||||
* @param playerName Display name for the player
|
||||
* @param orgId Organization ID (for organization games)
|
||||
* @param loginType Type of join being attempted
|
||||
* @returns Promise<GameAggregate> The updated game with new player
|
||||
*/
|
||||
async joinGame(
|
||||
gameCode: string,
|
||||
playerId?: string,
|
||||
playerName?: string,
|
||||
orgId?: string | null,
|
||||
loginType?: LoginType
|
||||
): Promise<GameAggregate> {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
logOther('GameService.joinGame called', {
|
||||
gameCode,
|
||||
playerId: playerId || 'anonymous',
|
||||
playerName,
|
||||
orgId,
|
||||
loginType
|
||||
});
|
||||
|
||||
// Validate input parameters
|
||||
this.validateJoinGameInput(gameCode, playerId, loginType);
|
||||
|
||||
// Create and execute the command
|
||||
const command: JoinGameCommand = {
|
||||
gameCode,
|
||||
playerId,
|
||||
playerName,
|
||||
orgId,
|
||||
loginType: loginType || LoginType.PUBLIC
|
||||
};
|
||||
|
||||
const game = await this.joinGameHandler.handle(command);
|
||||
|
||||
const endTime = performance.now();
|
||||
logOther('Player joined game successfully', {
|
||||
gameId: game.id,
|
||||
gameCode: game.gamecode,
|
||||
playerId,
|
||||
playerCount: game.players.length,
|
||||
maxPlayers: game.maxplayers,
|
||||
executionTime: Math.round(endTime - startTime)
|
||||
});
|
||||
|
||||
return game;
|
||||
|
||||
} catch (error) {
|
||||
const endTime = performance.now();
|
||||
logError('GameService.joinGame failed', error instanceof Error ? error : new Error(String(error)));
|
||||
logOther('Game join failed', {
|
||||
gameCode,
|
||||
playerId,
|
||||
executionTime: Math.round(endTime - startTime),
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start an existing game (move from WAITING to ACTIVE)
|
||||
* Initializes all player positions to 0 and assigns random turn order
|
||||
* @param gameId Game ID to start
|
||||
* @param userId User ID of the game master (optional for public games)
|
||||
* @returns Promise<GameAggregate> The updated game
|
||||
*/
|
||||
async startGamePlay(
|
||||
gameId: string,
|
||||
userId?: string
|
||||
): Promise<GameStartResult> {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
logOther('GameService.startGamePlay called', {
|
||||
gameId,
|
||||
userId: userId || 'system'
|
||||
});
|
||||
|
||||
// Validate input parameters
|
||||
this.validateStartGamePlayInput(gameId);
|
||||
|
||||
// Create and execute the command
|
||||
const command: StartGamePlayCommand = {
|
||||
gameId,
|
||||
userId
|
||||
};
|
||||
|
||||
const result = await this.startGamePlayHandler.handle(command);
|
||||
|
||||
const endTime = performance.now();
|
||||
logOther('Game play started successfully', {
|
||||
gameId: result.game.id,
|
||||
gameCode: result.game.gamecode,
|
||||
playerCount: result.game.players.length,
|
||||
gameState: result.game.state,
|
||||
executionTime: Math.round(endTime - startTime)
|
||||
});
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
const endTime = performance.now();
|
||||
logError('GameService.startGamePlay failed', error instanceof Error ? error : new Error(String(error)));
|
||||
logOther('Game play start failed', {
|
||||
gameId,
|
||||
userId,
|
||||
executionTime: Math.round(endTime - startTime),
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private validateStartGamePlayInput(gameId: string): void {
|
||||
// Validate game ID
|
||||
if (!gameId || typeof gameId !== 'string') {
|
||||
throw new Error('Game ID is required and must be a string');
|
||||
}
|
||||
|
||||
logOther('Start game play input validation passed', {
|
||||
gameId
|
||||
});
|
||||
}
|
||||
|
||||
private validateJoinGameInput(gameCode: string, playerId?: string, loginType?: LoginType): void {
|
||||
// Validate game code
|
||||
if (!gameCode || typeof gameCode !== 'string') {
|
||||
throw new Error('Game code is required and must be a string');
|
||||
}
|
||||
|
||||
if (gameCode.length !== 6) {
|
||||
throw new Error('Game code must be exactly 6 characters long');
|
||||
}
|
||||
|
||||
// Validate login type specific requirements
|
||||
if (loginType === LoginType.PRIVATE || loginType === LoginType.ORGANIZATION) {
|
||||
if (!playerId || typeof playerId !== 'string') {
|
||||
throw new Error(`Player ID is required for ${LoginType[loginType]} games`);
|
||||
}
|
||||
}
|
||||
|
||||
logOther('Join game input validation passed', {
|
||||
gameCode,
|
||||
playerId: playerId || 'anonymous',
|
||||
loginType
|
||||
});
|
||||
}
|
||||
|
||||
private validateStartGameInput(deckids: string[], maxplayers: number, logintype: LoginType): void {
|
||||
// Validate deck IDs
|
||||
if (!deckids || deckids.length === 0) {
|
||||
throw new Error('At least one deck ID must be provided');
|
||||
}
|
||||
|
||||
if (deckids.length < 3) {
|
||||
throw new Error('At least 3 decks are required to start a game (one for each type: LUCK, JOKER, QUESTION)');
|
||||
}
|
||||
|
||||
// Validate max players
|
||||
if (!maxplayers || maxplayers < 2) {
|
||||
throw new Error('Maximum players must be at least 2');
|
||||
}
|
||||
|
||||
if (maxplayers > 8) {
|
||||
throw new Error('Maximum players cannot exceed 8');
|
||||
}
|
||||
|
||||
// Validate login type
|
||||
if (logintype < 0 || logintype > 2) {
|
||||
throw new Error('Invalid login type. Must be PUBLIC (0), PRIVATE (1), or ORGANIZATION (2)');
|
||||
}
|
||||
|
||||
// Check for duplicate deck IDs
|
||||
const uniqueIds = new Set(deckids);
|
||||
if (uniqueIds.size !== deckids.length) {
|
||||
throw new Error('Duplicate deck IDs are not allowed');
|
||||
}
|
||||
|
||||
logOther('Start game input validation passed', {
|
||||
deckCount: deckids.length,
|
||||
maxplayers,
|
||||
logintype
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Game flow explanation (to be implemented later):
|
||||
*
|
||||
* 1. START GAME (implemented above):
|
||||
* - Input: deckids, maxplayers, logintype, gamecode
|
||||
* - Process: Fetch decks, validate types, shuffle cards, create game
|
||||
* - Output: Game with shuffled deck objects
|
||||
*
|
||||
* 2. JOIN GAME (to be implemented):
|
||||
* - Input: gamecode, playerid
|
||||
* - Process: Find game, validate capacity, add player
|
||||
* - Output: Updated game with new player
|
||||
*
|
||||
* 3. GAME ROUNDS (to be implemented):
|
||||
* - Input: gameid, current player
|
||||
* - Process: Manage turn order, track game state
|
||||
* - Output: Current player information
|
||||
*
|
||||
* 4. PICK CARD (to be implemented):
|
||||
* - Input: gameid, playerid, deck type
|
||||
* - Process: Draw card from specific deck, apply consequence
|
||||
* - Output: Card details and consequence effects
|
||||
*
|
||||
* 5. END GAME (to be implemented):
|
||||
* - Input: gameid, winner
|
||||
* - Process: Set game as finished, record winner
|
||||
* - Output: Final game state
|
||||
*/
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface GenerateBoardCommand {
|
||||
gameId: string;
|
||||
positiveFieldCount: number;
|
||||
negativeFieldCount: number;
|
||||
luckFieldCount: number;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { GenerateBoardCommand } from './GenerateBoardCommand';
|
||||
import { BoardGenerationService } from '../BoardGenerationService';
|
||||
import { RedisService } from '../../Services/RedisService';
|
||||
import { logOther, logError } from '../../Services/Logger';
|
||||
import { BoardData } from '../../../Domain/Game/GameAggregate';
|
||||
|
||||
export class GenerateBoardCommandHandler {
|
||||
constructor(
|
||||
private readonly boardGenerationService: BoardGenerationService,
|
||||
private readonly redisService: RedisService
|
||||
) {}
|
||||
|
||||
async execute(cmd: GenerateBoardCommand): Promise<void> {
|
||||
try {
|
||||
logOther(`Starting board generation for game ${cmd.gameId}`);
|
||||
const startTime = Date.now();
|
||||
|
||||
// Generate board with 20-30 rule validation
|
||||
const boardData = await this.boardGenerationService.generateBoard(
|
||||
cmd.positiveFieldCount,
|
||||
cmd.negativeFieldCount,
|
||||
cmd.luckFieldCount
|
||||
);
|
||||
|
||||
// Store in Redis
|
||||
const boardDataWithMetadata: BoardData = {
|
||||
...boardData,
|
||||
gameId: cmd.gameId,
|
||||
generatedAt: new Date(),
|
||||
generationComplete: true
|
||||
};
|
||||
|
||||
await this.redisService.setWithExpiry(
|
||||
`game_board_${cmd.gameId}`,
|
||||
JSON.stringify(boardDataWithMetadata),
|
||||
24 * 60 * 60 // 24 hours
|
||||
);
|
||||
|
||||
const executionTime = Date.now() - startTime;
|
||||
logOther(`Board generation completed for game ${cmd.gameId} in ${executionTime}ms using pattern-based approach`);
|
||||
|
||||
} catch (error) {
|
||||
logError(`Board generation failed for game ${cmd.gameId}:`, error as Error);
|
||||
|
||||
// Store error state in Redis
|
||||
const errorData: BoardData = {
|
||||
gameId: cmd.gameId,
|
||||
fields: [],
|
||||
generationComplete: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
generatedAt: new Date()
|
||||
};
|
||||
|
||||
await this.redisService.setWithExpiry(
|
||||
`game_board_${cmd.gameId}`,
|
||||
JSON.stringify(errorData),
|
||||
24 * 60 * 60
|
||||
);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { LoginType } from '../../../Domain/Game/GameAggregate';
|
||||
|
||||
export interface JoinGameCommand {
|
||||
gameCode: string; // 6-character game code
|
||||
playerId?: string; // User ID of the player joining (optional for public games)
|
||||
playerName?: string; // Display name for the player (required for public games)
|
||||
orgId?: string | null; // Organization ID (for organization games)
|
||||
loginType: LoginType; // Type of join being attempted
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { JoinGameCommand } from './JoinGameCommand';
|
||||
import { GameAggregate, GameState, LoginType } from '../../../Domain/Game/GameAggregate';
|
||||
import { IGameRepository } from '../../../Domain/IRepository/IGameRepository';
|
||||
import { DIContainer } from '../../Services/DIContainer';
|
||||
import { RedisService } from '../../Services/RedisService';
|
||||
import { logOther, logError } from '../../Services/Logger';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export interface GamePlayerData {
|
||||
playerId: string;
|
||||
playerName?: string;
|
||||
joinedAt: Date;
|
||||
isOnline: boolean;
|
||||
position?: number; // For game board position (to be used later)
|
||||
}
|
||||
|
||||
export interface ActiveGameData {
|
||||
gameId: string;
|
||||
gameCode: string;
|
||||
hostId?: string;
|
||||
maxPlayers: number;
|
||||
currentPlayers: GamePlayerData[];
|
||||
state: GameState;
|
||||
createdAt: Date;
|
||||
startedAt?: Date;
|
||||
currentTurn?: string; // Player ID whose turn it is
|
||||
websocketRoom: string; // WebSocket room name for real-time updates
|
||||
}
|
||||
|
||||
export class JoinGameCommandHandler {
|
||||
private gameRepository: IGameRepository;
|
||||
private redisService: RedisService;
|
||||
|
||||
constructor() {
|
||||
this.gameRepository = DIContainer.getInstance().gameRepository;
|
||||
this.redisService = RedisService.getInstance();
|
||||
}
|
||||
|
||||
async handle(command: JoinGameCommand): Promise<GameAggregate> {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
logOther('Joining game', `gameCode: ${command.gameCode}, playerId: ${command.playerId || 'anonymous'}, loginType: ${command.loginType}`);
|
||||
|
||||
// Find the game by game code
|
||||
const game = await this.gameRepository.findByGameCode(command.gameCode);
|
||||
if (!game) {
|
||||
throw new Error(`Game with code ${command.gameCode} not found`);
|
||||
}
|
||||
|
||||
// Generate player ID for public games or use provided one
|
||||
const actualPlayerId = command.playerId || uuidv4();
|
||||
|
||||
// Validate game joinability (authentication/org checks done in router)
|
||||
this.validateGameJoinability(game, actualPlayerId, command);
|
||||
|
||||
// Add player to database
|
||||
const updatedGame = await this.gameRepository.addPlayerToGame(game.id, actualPlayerId);
|
||||
if (!updatedGame) {
|
||||
throw new Error('Failed to add player to game');
|
||||
}
|
||||
|
||||
// Update Redis with the new player
|
||||
await this.updateGameInRedis(updatedGame, { ...command, playerId: actualPlayerId });
|
||||
|
||||
const endTime = performance.now();
|
||||
logOther('Player joined game successfully', {
|
||||
gameId: game.id,
|
||||
gameCode: game.gamecode,
|
||||
playerId: actualPlayerId,
|
||||
playerCount: updatedGame.players.length,
|
||||
maxPlayers: updatedGame.maxplayers,
|
||||
loginType: game.logintype,
|
||||
executionTime: Math.round(endTime - startTime)
|
||||
});
|
||||
|
||||
return updatedGame;
|
||||
|
||||
} catch (error) {
|
||||
const endTime = performance.now();
|
||||
logError('Failed to join game', error instanceof Error ? error : new Error(String(error)));
|
||||
logOther('Game join failed', {
|
||||
gameCode: command.gameCode,
|
||||
playerId: command.playerId || 'anonymous',
|
||||
loginType: command.loginType,
|
||||
executionTime: Math.round(endTime - startTime)
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private validateGameJoinability(game: GameAggregate, playerId: string, command: JoinGameCommand): void {
|
||||
// Check if game is in waiting state
|
||||
if (game.state !== GameState.WAITING) {
|
||||
throw new Error('Game is not accepting new players');
|
||||
}
|
||||
|
||||
// Check if player is already in the game
|
||||
if (game.players.includes(playerId)) {
|
||||
throw new Error('Player is already in this game');
|
||||
}
|
||||
|
||||
// Check if game is full
|
||||
if (game.players.length >= game.maxplayers) {
|
||||
throw new Error('Game is full');
|
||||
}
|
||||
|
||||
// Note: Login type validation is now handled in the router before reaching this handler
|
||||
// This ensures proper authentication and organization membership checks are done first
|
||||
|
||||
logOther('Game join validation passed', {
|
||||
gameId: game.id,
|
||||
gameCode: game.gamecode,
|
||||
currentPlayers: game.players.length,
|
||||
maxPlayers: game.maxplayers,
|
||||
gameState: game.state,
|
||||
loginType: game.logintype,
|
||||
playerId: playerId,
|
||||
isAuthenticated: !!command.playerId
|
||||
});
|
||||
}
|
||||
|
||||
private async updateGameInRedis(game: GameAggregate, command: JoinGameCommand & { playerId: string }): Promise<void> {
|
||||
try {
|
||||
const redisKey = `game:${game.id}`;
|
||||
|
||||
// Get existing game data from Redis or create new
|
||||
let gameData: ActiveGameData;
|
||||
const existingData = await this.redisService.get(redisKey);
|
||||
|
||||
if (existingData) {
|
||||
gameData = JSON.parse(existingData) as ActiveGameData;
|
||||
} else {
|
||||
// Create new game data structure
|
||||
gameData = {
|
||||
gameId: game.id,
|
||||
gameCode: game.gamecode,
|
||||
maxPlayers: game.maxplayers,
|
||||
currentPlayers: [],
|
||||
state: game.state,
|
||||
createdAt: game.createdate,
|
||||
websocketRoom: `game_${game.gamecode}`
|
||||
};
|
||||
}
|
||||
|
||||
// Add the new player
|
||||
const newPlayer: GamePlayerData = {
|
||||
playerId: command.playerId,
|
||||
playerName: command.playerName,
|
||||
joinedAt: new Date(),
|
||||
isOnline: true
|
||||
};
|
||||
|
||||
// Update players list (remove if exists, then add)
|
||||
gameData.currentPlayers = gameData.currentPlayers.filter(p => p.playerId !== command.playerId);
|
||||
gameData.currentPlayers.push(newPlayer);
|
||||
|
||||
// Update game state and player count
|
||||
gameData.state = game.state;
|
||||
|
||||
// Store updated data in Redis with TTL (24 hours)
|
||||
await this.redisService.setWithExpiry(redisKey, JSON.stringify(gameData), 24 * 60 * 60);
|
||||
|
||||
// Add player to active players set
|
||||
await this.redisService.setAdd(`active_players:${game.id}`, command.playerId);
|
||||
|
||||
logOther('Game data updated in Redis', {
|
||||
gameId: game.id,
|
||||
gameCode: game.gamecode,
|
||||
redisKey,
|
||||
playerCount: gameData.currentPlayers.length,
|
||||
websocketRoom: gameData.websocketRoom,
|
||||
playerId: command.playerId
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logError('Failed to update game in Redis', error instanceof Error ? error : new Error(String(error)));
|
||||
// Don't throw error here - Redis failure shouldn't prevent game join
|
||||
logOther('Game join completed despite Redis error', {
|
||||
gameId: game.id,
|
||||
playerId: command.playerId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getGameFromRedis(gameId: string): Promise<ActiveGameData | null> {
|
||||
try {
|
||||
const redisKey = `game:${gameId}`;
|
||||
const data = await this.redisService.get(redisKey);
|
||||
return data ? JSON.parse(data) as ActiveGameData : null;
|
||||
} catch (error) {
|
||||
logError('Failed to get game from Redis', error instanceof Error ? error : new Error(String(error)));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async removePlayerFromRedis(gameId: string, playerId: string): Promise<void> {
|
||||
try {
|
||||
const redisKey = `game:${gameId}`;
|
||||
const existingData = await this.redisService.get(redisKey);
|
||||
|
||||
if (existingData) {
|
||||
const gameData = JSON.parse(existingData) as ActiveGameData;
|
||||
gameData.currentPlayers = gameData.currentPlayers.filter(p => p.playerId !== playerId);
|
||||
|
||||
await this.redisService.setWithExpiry(redisKey, JSON.stringify(gameData), 24 * 60 * 60);
|
||||
await this.redisService.setRemove(`active_players:${gameId}`, playerId);
|
||||
}
|
||||
} catch (error) {
|
||||
logError('Failed to remove player from Redis', error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { LoginType } from '../../../Domain/Game/GameAggregate';
|
||||
|
||||
export interface StartGameCommand {
|
||||
deckids: string[]; // Array of deck IDs (3 types, multiple decks per type)
|
||||
maxplayers: number; // Maximum number of players
|
||||
logintype: LoginType; // How players can join the game
|
||||
userid?: string; // Optional user who created the game (becomes game master)
|
||||
orgid?: string | null; // Organization ID (for organization games)
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import { StartGameCommand } from './StartGameCommand';
|
||||
import { GameAggregate, GameDeck, GameCard, DeckType, GameState } from '../../../Domain/Game/GameAggregate';
|
||||
import { DeckAggregate } from '../../../Domain/Deck/DeckAggregate';
|
||||
import { IGameRepository } from '../../../Domain/IRepository/IGameRepository';
|
||||
import { IDeckRepository } from '../../../Domain/IRepository/IDeckRepository';
|
||||
import { DIContainer } from '../../Services/DIContainer';
|
||||
import { RedisService } from '../../Services/RedisService';
|
||||
import { logOther, logError } from '../../Services/Logger';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { GenerateBoardCommand } from './GenerateBoardCommand';
|
||||
|
||||
export interface ActiveGameData {
|
||||
gameId: string;
|
||||
gameCode: string;
|
||||
hostId?: string;
|
||||
maxPlayers: number;
|
||||
currentPlayers: GamePlayerData[];
|
||||
state: GameState;
|
||||
createdAt: Date;
|
||||
startedAt?: Date;
|
||||
currentTurn?: string;
|
||||
websocketRoom: string;
|
||||
}
|
||||
|
||||
export interface GamePlayerData {
|
||||
playerId: string;
|
||||
playerName?: string;
|
||||
joinedAt: Date;
|
||||
isOnline: boolean;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export class StartGameCommandHandler {
|
||||
private gameRepository: IGameRepository;
|
||||
private deckRepository: IDeckRepository;
|
||||
private redisService: RedisService;
|
||||
|
||||
constructor() {
|
||||
this.gameRepository = DIContainer.getInstance().gameRepository;
|
||||
this.deckRepository = DIContainer.getInstance().deckRepository;
|
||||
this.redisService = RedisService.getInstance();
|
||||
}
|
||||
|
||||
async handle(command: StartGameCommand): Promise<GameAggregate> {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
logOther('Starting game creation', `deckCount: ${command.deckids.length}, maxPlayers: ${command.maxplayers}, loginType: ${command.logintype}`);
|
||||
|
||||
// Generate unique game code
|
||||
const gamecode = this.generateGameCode();
|
||||
|
||||
// Fetch all decks by IDs
|
||||
const decks = await this.fetchDecks(command.deckids);
|
||||
|
||||
// Validate we have 3 deck types
|
||||
this.validateDeckTypes(decks);
|
||||
|
||||
// Group decks by type and shuffle cards within each type
|
||||
const gamedecks = await this.createShuffledGameDecks(decks);
|
||||
|
||||
// Create the game aggregate
|
||||
const gameData: Partial<GameAggregate> = {
|
||||
gamecode,
|
||||
maxplayers: command.maxplayers,
|
||||
logintype: command.logintype,
|
||||
createdby: command.userid || null,
|
||||
orgid: command.orgid || null,
|
||||
gamedecks,
|
||||
players: [],
|
||||
started: false,
|
||||
finished: false,
|
||||
winner: null,
|
||||
state: GameState.WAITING,
|
||||
startdate: null,
|
||||
enddate: null
|
||||
};
|
||||
|
||||
// Save the game to database
|
||||
const savedGame = await this.gameRepository.create(gameData);
|
||||
|
||||
// Create Redis object for real-time game management
|
||||
await this.createGameInRedis(savedGame, command.userid);
|
||||
|
||||
// Trigger async board generation (don't block game creation)
|
||||
this.triggerAsyncBoardGeneration(savedGame.id).catch((error: Error) => {
|
||||
logError('Async board generation failed', error);
|
||||
});
|
||||
|
||||
const endTime = performance.now();
|
||||
logOther('Game created successfully', `gameId: ${savedGame.id}, gameCode: ${savedGame.gamecode}, executionTime: ${Math.round(endTime - startTime)}ms`);
|
||||
|
||||
return savedGame;
|
||||
|
||||
} catch (error) {
|
||||
const endTime = performance.now();
|
||||
logError('Failed to create game', error instanceof Error ? error : new Error(String(error)));
|
||||
logOther('Game creation failed', `executionTime: ${Math.round(endTime - startTime)}ms`);
|
||||
throw new Error('Failed to start game: ' + (error instanceof Error ? error.message : String(error)));
|
||||
}
|
||||
}
|
||||
|
||||
private generateGameCode(): string {
|
||||
// Generate a 6-character alphanumeric game code
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
let result = '';
|
||||
const randomBytesArray = randomBytes(6);
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
result += chars[randomBytesArray[i] % chars.length];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async fetchDecks(deckIds: string[]): Promise<DeckAggregate[]> {
|
||||
const decks: DeckAggregate[] = [];
|
||||
|
||||
for (const deckId of deckIds) {
|
||||
const deck = await this.deckRepository.findById(deckId);
|
||||
if (!deck) {
|
||||
throw new Error(`Deck with ID ${deckId} not found`);
|
||||
}
|
||||
decks.push(deck);
|
||||
}
|
||||
|
||||
return decks;
|
||||
}
|
||||
|
||||
private validateDeckTypes(decks: DeckAggregate[]): void {
|
||||
const deckTypes = new Set(decks.map(deck => deck.type));
|
||||
|
||||
// Check if we have all 3 required deck types (LUCK=0, JOKER=1, QUESTION=2)
|
||||
const requiredTypes = [0, 1, 2]; // Based on Type enum in DeckAggregate
|
||||
const missingTypes = requiredTypes.filter(type => !deckTypes.has(type));
|
||||
|
||||
if (missingTypes.length > 0) {
|
||||
throw new Error(`Missing required deck types: ${missingTypes.join(', ')}. Game requires LUCK, JOKER, and QUESTION deck types.`);
|
||||
}
|
||||
|
||||
logOther('Deck types validation passed', `foundTypes: [${Array.from(deckTypes).join(', ')}]`);
|
||||
}
|
||||
|
||||
private async createShuffledGameDecks(decks: DeckAggregate[]): Promise<GameDeck[]> {
|
||||
// Group decks by type
|
||||
const decksByType = new Map<number, DeckAggregate[]>();
|
||||
|
||||
decks.forEach(deck => {
|
||||
if (!decksByType.has(deck.type)) {
|
||||
decksByType.set(deck.type, []);
|
||||
}
|
||||
decksByType.get(deck.type)!.push(deck);
|
||||
});
|
||||
|
||||
const gamedecks: GameDeck[] = [];
|
||||
|
||||
// Process each deck type
|
||||
for (const [deckType, typeDecks] of decksByType) {
|
||||
// Collect all cards from decks of this type
|
||||
const allCards: GameCard[] = [];
|
||||
|
||||
typeDecks.forEach(deck => {
|
||||
deck.cards.forEach(card => {
|
||||
const gameCard: GameCard = {
|
||||
cardid: this.generateCardId(),
|
||||
question: card.text,
|
||||
answer: card.answer || undefined,
|
||||
consequence: card.consequence || null,
|
||||
played: false,
|
||||
playerid: undefined
|
||||
};
|
||||
allCards.push(gameCard);
|
||||
});
|
||||
});
|
||||
|
||||
// Shuffle all cards of this type
|
||||
const shuffledCards = this.shuffleArray(allCards);
|
||||
|
||||
// Create game deck for this type
|
||||
const gameDeck: GameDeck = {
|
||||
deckid: typeDecks[0].id, // Use first deck ID as representative
|
||||
decktype: this.mapDeckTypeToGameDeckType(deckType),
|
||||
cards: shuffledCards
|
||||
};
|
||||
|
||||
gamedecks.push(gameDeck);
|
||||
|
||||
logOther('Created shuffled game deck', `type: ${deckType}, cardCount: ${shuffledCards.length}, sourceDecks: ${typeDecks.length}`);
|
||||
}
|
||||
|
||||
return gamedecks;
|
||||
}
|
||||
|
||||
private mapDeckTypeToGameDeckType(deckType: number): DeckType {
|
||||
// Map DeckAggregate.Type to GameAggregate.DeckType
|
||||
switch (deckType) {
|
||||
case 0: return DeckType.LUCK; // LUCK = 0
|
||||
case 1: return DeckType.JOCKER; // JOKER = 1
|
||||
case 2: return DeckType.QUEST; // QUESTION = 2
|
||||
default: throw new Error(`Unknown deck type: ${deckType}`);
|
||||
}
|
||||
}
|
||||
|
||||
private shuffleArray<T>(array: T[]): T[] {
|
||||
const shuffled = [...array];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
return shuffled;
|
||||
}
|
||||
|
||||
private generateCardId(): string {
|
||||
return randomBytes(8).toString('hex');
|
||||
}
|
||||
|
||||
private async createGameInRedis(game: GameAggregate, hostId?: string): Promise<void> {
|
||||
try {
|
||||
const redisKey = `game:${game.id}`;
|
||||
|
||||
const gameData: ActiveGameData = {
|
||||
gameId: game.id,
|
||||
gameCode: game.gamecode,
|
||||
hostId: hostId,
|
||||
maxPlayers: game.maxplayers,
|
||||
currentPlayers: [],
|
||||
state: game.state,
|
||||
createdAt: game.createdate,
|
||||
websocketRoom: `game_${game.gamecode}`
|
||||
};
|
||||
|
||||
// Store game data in Redis with TTL (24 hours)
|
||||
await this.redisService.setWithExpiry(redisKey, JSON.stringify(gameData), 24 * 60 * 60);
|
||||
|
||||
// Create game room for WebSocket connections
|
||||
await this.redisService.set(`game_room:${game.gamecode}`, game.id);
|
||||
|
||||
logOther('Game created in Redis', {
|
||||
gameId: game.id,
|
||||
gameCode: game.gamecode,
|
||||
hostId: hostId,
|
||||
websocketRoom: gameData.websocketRoom,
|
||||
redisKey
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logError('Failed to create game in Redis', error instanceof Error ? error : new Error(String(error)));
|
||||
// Don't throw error here - Redis failure shouldn't prevent game creation
|
||||
logOther('Game created successfully despite Redis error', {
|
||||
gameId: game.id,
|
||||
gameCode: game.gamecode
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async triggerAsyncBoardGeneration(gameId: string): Promise<void> {
|
||||
try {
|
||||
// Calculate default field counts based on game configuration
|
||||
// For now, use reasonable defaults - this should be configurable by host in the future
|
||||
const maxSpecialFieldsPercentage = parseInt(process.env.MAX_SPECIAL_FIELDS_PERCENTAGE || '67');
|
||||
const maxSpecialFields = Math.floor((100 * maxSpecialFieldsPercentage) / 100);
|
||||
|
||||
// Default distribution: 60% positive, 25% negative, 15% luck
|
||||
const positiveFieldCount = Math.floor(maxSpecialFields * 0.6);
|
||||
const negativeFieldCount = Math.floor(maxSpecialFields * 0.25);
|
||||
const luckFieldCount = Math.floor(maxSpecialFields * 0.15);
|
||||
|
||||
const command: GenerateBoardCommand = {
|
||||
gameId,
|
||||
positiveFieldCount,
|
||||
negativeFieldCount,
|
||||
luckFieldCount
|
||||
};
|
||||
|
||||
logOther(`Triggering async board generation for game ${gameId}`, {
|
||||
positiveFieldCount,
|
||||
negativeFieldCount,
|
||||
luckFieldCount,
|
||||
totalSpecialFields: positiveFieldCount + negativeFieldCount + luckFieldCount
|
||||
});
|
||||
|
||||
// Execute board generation in background
|
||||
await DIContainer.getInstance().generateBoardCommandHandler.execute(command);
|
||||
|
||||
} catch (error) {
|
||||
logError(`Async board generation failed for game ${gameId}`, error as Error);
|
||||
// Don't propagate error - board generation failure shouldn't affect game creation
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface StartGamePlayCommand {
|
||||
gameId: string; // Game ID to start
|
||||
userId?: string; // User who is starting the game (should be game master)
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
import { StartGamePlayCommand } from './StartGamePlayCommand';
|
||||
import { GameAggregate, GameState, BoardData, GameField } from '../../../Domain/Game/GameAggregate';
|
||||
import { IGameRepository } from '../../../Domain/IRepository/IGameRepository';
|
||||
import { DIContainer } from '../../Services/DIContainer';
|
||||
import { RedisService } from '../../Services/RedisService';
|
||||
import { WebSocketService } from '../../Services/WebSocketService';
|
||||
import { logOther, logError } from '../../Services/Logger';
|
||||
|
||||
export interface GamePlayerPosition {
|
||||
playerId: string;
|
||||
playerName?: string;
|
||||
position: number; // Board position (starts at 0)
|
||||
turnOrder: number; // Random number to determine turn sequence
|
||||
isOnline: boolean;
|
||||
joinedAt: Date;
|
||||
}
|
||||
|
||||
export interface ActiveGamePlayData {
|
||||
gameId: string;
|
||||
gameCode: string;
|
||||
hostId?: string;
|
||||
maxPlayers: number;
|
||||
players: GamePlayerPosition[];
|
||||
state: GameState;
|
||||
createdAt: Date;
|
||||
startedAt: Date;
|
||||
currentTurn: number; // Index of current player in turn order
|
||||
turnSequence: string[]; // Ordered array of player IDs based on turnOrder
|
||||
websocketRoom: string;
|
||||
gamePhase: 'starting' | 'playing' | 'paused' | 'finished';
|
||||
boardData: BoardData; // Generated board with fields
|
||||
}
|
||||
|
||||
export interface GameStartResult {
|
||||
game: GameAggregate;
|
||||
boardData: BoardData;
|
||||
}
|
||||
|
||||
export class StartGamePlayCommandHandler {
|
||||
private gameRepository: IGameRepository;
|
||||
private redisService: RedisService;
|
||||
|
||||
constructor() {
|
||||
this.gameRepository = DIContainer.getInstance().gameRepository;
|
||||
this.redisService = RedisService.getInstance();
|
||||
}
|
||||
|
||||
async handle(command: StartGamePlayCommand): Promise<GameStartResult> {
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
logOther('Starting game play', `gameId: ${command.gameId}, userId: ${command.userId || 'system'}`);
|
||||
|
||||
// Find the game
|
||||
const game = await this.gameRepository.findById(command.gameId);
|
||||
if (!game) {
|
||||
throw new Error(`Game with ID ${command.gameId} not found`);
|
||||
}
|
||||
|
||||
// Validate game can be started
|
||||
this.validateGameCanStart(game, command.userId);
|
||||
|
||||
// Wait for board generation to complete (max 20 seconds)
|
||||
const boardData = await this.waitForBoardGeneration(game.id);
|
||||
|
||||
// Update game state in database
|
||||
const updatedGame = await this.gameRepository.update(game.id, {
|
||||
started: true,
|
||||
state: GameState.ACTIVE,
|
||||
startdate: new Date()
|
||||
});
|
||||
|
||||
if (!updatedGame) {
|
||||
throw new Error('Failed to update game state');
|
||||
}
|
||||
|
||||
// Initialize game play in Redis with board data
|
||||
await this.initializeGamePlayInRedis(updatedGame, boardData);
|
||||
|
||||
// Notify all players via WebSocket
|
||||
await this.notifyGameStart(updatedGame);
|
||||
|
||||
const endTime = performance.now();
|
||||
logOther('Game play started successfully', {
|
||||
gameId: updatedGame.id,
|
||||
gameCode: updatedGame.gamecode,
|
||||
playerCount: updatedGame.players.length,
|
||||
executionTime: Math.round(endTime - startTime)
|
||||
});
|
||||
|
||||
return {
|
||||
game: updatedGame,
|
||||
boardData: boardData
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const endTime = performance.now();
|
||||
logError('Failed to start game play', error instanceof Error ? error : new Error(String(error)));
|
||||
logOther('Game start failed', {
|
||||
gameId: command.gameId,
|
||||
userId: command.userId,
|
||||
executionTime: Math.round(endTime - startTime)
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private validateGameCanStart(game: GameAggregate, userId?: string): void {
|
||||
// Check if game is in waiting state
|
||||
if (game.state !== GameState.WAITING) {
|
||||
throw new Error('Game is not in waiting state and cannot be started');
|
||||
}
|
||||
|
||||
// Check if game is already started
|
||||
if (game.started) {
|
||||
throw new Error('Game has already been started');
|
||||
}
|
||||
|
||||
// Check if there are enough players (at least 2)
|
||||
if (game.players.length < 2) {
|
||||
throw new Error('Game needs at least 2 players to start');
|
||||
}
|
||||
|
||||
// For private and organization games, check if user is game master
|
||||
if (game.createdby && userId && game.createdby !== userId) {
|
||||
throw new Error('Only the game master can start this game');
|
||||
}
|
||||
|
||||
logOther('Game start validation passed', {
|
||||
gameId: game.id,
|
||||
gameCode: game.gamecode,
|
||||
playerCount: game.players.length,
|
||||
gameState: game.state,
|
||||
isGameMaster: !game.createdby || (userId && game.createdby === userId)
|
||||
});
|
||||
}
|
||||
|
||||
private async initializeGamePlayInRedis(game: GameAggregate, boardData: BoardData): Promise<void> {
|
||||
try {
|
||||
const redisKey = `gameplay:${game.id}`;
|
||||
|
||||
// Generate random turn orders for all players
|
||||
const playersWithPositions = this.initializePlayerPositions(game.players);
|
||||
|
||||
// Sort by turn order to create turn sequence
|
||||
const turnSequence = [...playersWithPositions]
|
||||
.sort((a, b) => a.turnOrder - b.turnOrder)
|
||||
.map(p => p.playerId);
|
||||
|
||||
const gamePlayData: ActiveGamePlayData = {
|
||||
gameId: game.id,
|
||||
gameCode: game.gamecode,
|
||||
hostId: game.createdby || undefined,
|
||||
maxPlayers: game.maxplayers,
|
||||
players: playersWithPositions,
|
||||
state: GameState.ACTIVE,
|
||||
createdAt: game.createdate,
|
||||
startedAt: new Date(),
|
||||
currentTurn: 0, // Start with first player in sequence
|
||||
turnSequence,
|
||||
websocketRoom: `game_${game.gamecode}`,
|
||||
gamePhase: 'starting',
|
||||
boardData
|
||||
};
|
||||
|
||||
// Store game play data in Redis with TTL (24 hours)
|
||||
await this.redisService.setWithExpiry(redisKey, JSON.stringify(gamePlayData), 24 * 60 * 60);
|
||||
|
||||
// Create turn sequence mapping for quick lookups
|
||||
await this.redisService.setWithExpiry(
|
||||
`game_turns:${game.id}`,
|
||||
JSON.stringify(turnSequence),
|
||||
24 * 60 * 60
|
||||
);
|
||||
|
||||
logOther('Game play initialized in Redis', {
|
||||
gameId: game.id,
|
||||
gameCode: game.gamecode,
|
||||
playerCount: playersWithPositions.length,
|
||||
turnSequence,
|
||||
currentPlayer: turnSequence[0],
|
||||
redisKey
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logError('Failed to initialize game play in Redis', error instanceof Error ? error : new Error(String(error)));
|
||||
throw new Error('Failed to initialize game session');
|
||||
}
|
||||
}
|
||||
|
||||
private initializePlayerPositions(playerIds: string[]): GamePlayerPosition[] {
|
||||
const players: GamePlayerPosition[] = [];
|
||||
|
||||
// Generate random turn orders (1 to playerCount)
|
||||
const turnOrders = this.generateRandomTurnOrders(playerIds.length);
|
||||
|
||||
playerIds.forEach((playerId, index) => {
|
||||
players.push({
|
||||
playerId,
|
||||
position: 0, // All players start at position 0
|
||||
turnOrder: turnOrders[index],
|
||||
isOnline: true, // Assume online when game starts
|
||||
joinedAt: new Date()
|
||||
});
|
||||
});
|
||||
|
||||
logOther('Player positions initialized', {
|
||||
playerCount: players.length,
|
||||
turnOrders: turnOrders,
|
||||
playersData: players.map(p => ({
|
||||
playerId: p.playerId,
|
||||
position: p.position,
|
||||
turnOrder: p.turnOrder
|
||||
}))
|
||||
});
|
||||
|
||||
return players;
|
||||
}
|
||||
|
||||
private generateRandomTurnOrders(playerCount: number): number[] {
|
||||
// Create array [1, 2, 3, ..., playerCount]
|
||||
const orders = Array.from({ length: playerCount }, (_, i) => i + 1);
|
||||
|
||||
// Fisher-Yates shuffle
|
||||
for (let i = orders.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[orders[i], orders[j]] = [orders[j], orders[i]];
|
||||
}
|
||||
|
||||
return orders;
|
||||
}
|
||||
|
||||
private async notifyGameStart(game: GameAggregate): Promise<void> {
|
||||
try {
|
||||
// Note: WebSocket notifications will be handled when WebSocket service is available
|
||||
// For now, just log the game start
|
||||
logOther('Game start notifications prepared', {
|
||||
gameId: game.id,
|
||||
gameCode: game.gamecode,
|
||||
playerCount: game.players.length,
|
||||
websocketRoom: `game_${game.gamecode}`
|
||||
});
|
||||
|
||||
// TODO: Implement WebSocket notifications when service is properly integrated
|
||||
// wsService.notifyGameStart(game.gamecode, game.players);
|
||||
// wsService.broadcastGameStateUpdate(game.gamecode, gameStateData);
|
||||
|
||||
} catch (error) {
|
||||
logError('Failed to prepare game start notifications', error instanceof Error ? error : new Error(String(error)));
|
||||
// Don't throw error here - notification failure shouldn't prevent game start
|
||||
}
|
||||
}
|
||||
|
||||
async getGamePlayFromRedis(gameId: string): Promise<ActiveGamePlayData | null> {
|
||||
try {
|
||||
const redisKey = `gameplay:${gameId}`;
|
||||
const data = await this.redisService.get(redisKey);
|
||||
return data ? JSON.parse(data) as ActiveGamePlayData : null;
|
||||
} catch (error) {
|
||||
logError('Failed to get game play from Redis', error instanceof Error ? error : new Error(String(error)));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async updatePlayerPosition(gameId: string, playerId: string, newPosition: number): Promise<void> {
|
||||
try {
|
||||
const gameData = await this.getGamePlayFromRedis(gameId);
|
||||
if (!gameData) {
|
||||
throw new Error('Game session not found');
|
||||
}
|
||||
|
||||
// Update player position
|
||||
const player = gameData.players.find(p => p.playerId === playerId);
|
||||
if (player) {
|
||||
player.position = newPosition;
|
||||
|
||||
// Save back to Redis
|
||||
const redisKey = `gameplay:${gameId}`;
|
||||
await this.redisService.setWithExpiry(redisKey, JSON.stringify(gameData), 24 * 60 * 60);
|
||||
|
||||
logOther('Player position updated', {
|
||||
gameId,
|
||||
playerId,
|
||||
newPosition
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logError('Failed to update player position', error instanceof Error ? error : new Error(String(error)));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getNextPlayer(gameId: string): Promise<string | null> {
|
||||
try {
|
||||
const gameData = await this.getGamePlayFromRedis(gameId);
|
||||
if (!gameData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextTurnIndex = (gameData.currentTurn + 1) % gameData.turnSequence.length;
|
||||
return gameData.turnSequence[nextTurnIndex];
|
||||
} catch (error) {
|
||||
logError('Failed to get next player', error instanceof Error ? error : new Error(String(error)));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async advanceTurn(gameId: string): Promise<string | null> {
|
||||
try {
|
||||
const gameData = await this.getGamePlayFromRedis(gameId);
|
||||
if (!gameData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Advance to next player
|
||||
gameData.currentTurn = (gameData.currentTurn + 1) % gameData.turnSequence.length;
|
||||
const currentPlayer = gameData.turnSequence[gameData.currentTurn];
|
||||
|
||||
// Save back to Redis
|
||||
const redisKey = `gameplay:${gameId}`;
|
||||
await this.redisService.setWithExpiry(redisKey, JSON.stringify(gameData), 24 * 60 * 60);
|
||||
|
||||
logOther('Turn advanced', {
|
||||
gameId,
|
||||
currentTurn: gameData.currentTurn,
|
||||
currentPlayer
|
||||
});
|
||||
|
||||
return currentPlayer;
|
||||
} catch (error) {
|
||||
logError('Failed to advance turn', error instanceof Error ? error : new Error(String(error)));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async waitForBoardGeneration(gameId: string): Promise<BoardData> {
|
||||
const maxWaitTime = parseInt(process.env.MAX_GENERATION_TIME_SECONDS || '20') * 1000;
|
||||
const pollInterval = 500; // Check every 500ms
|
||||
const startTime = Date.now();
|
||||
|
||||
logOther(`Waiting for board generation for game ${gameId}`, {
|
||||
maxWaitTime: maxWaitTime / 1000,
|
||||
pollInterval,
|
||||
redisKey: `game_board_${gameId}`
|
||||
});
|
||||
|
||||
while (Date.now() - startTime < maxWaitTime) {
|
||||
try {
|
||||
const redisKey = `game_board_${gameId}`;
|
||||
const boardDataStr = await this.redisService.get(redisKey);
|
||||
|
||||
logOther(`Board generation check for game ${gameId}`, {
|
||||
attempt: Math.floor((Date.now() - startTime) / pollInterval) + 1,
|
||||
hasData: !!boardDataStr,
|
||||
dataLength: boardDataStr ? boardDataStr.length : 0,
|
||||
waitTime: Date.now() - startTime
|
||||
});
|
||||
|
||||
if (boardDataStr) {
|
||||
const boardData: BoardData = JSON.parse(boardDataStr);
|
||||
|
||||
logOther(`Board data found for game ${gameId}`, {
|
||||
generationComplete: boardData.generationComplete,
|
||||
hasError: !!boardData.error,
|
||||
fieldsCount: boardData.fields ? boardData.fields.length : 0
|
||||
});
|
||||
|
||||
if (boardData.generationComplete) {
|
||||
if (boardData.error) {
|
||||
logError(`Board generation failed for game ${gameId}`, new Error(boardData.error));
|
||||
throw new Error(`Board generation failed: ${boardData.error}`);
|
||||
}
|
||||
|
||||
logOther(`Board generation completed for game ${gameId}`, {
|
||||
fieldCount: boardData.fields.length,
|
||||
waitTime: Date.now() - startTime
|
||||
});
|
||||
|
||||
return boardData;
|
||||
}
|
||||
} else {
|
||||
// No board data found yet - check if we need to trigger generation
|
||||
logOther(`No board data found yet for game ${gameId}, checking if generation was triggered...`, {
|
||||
waitTime: Date.now() - startTime,
|
||||
redisKey
|
||||
});
|
||||
|
||||
// If we've waited for 2 seconds and still no data, try to trigger generation manually
|
||||
if (Date.now() - startTime > 2000) {
|
||||
await this.ensureBoardGenerationTriggered(gameId);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait before next poll
|
||||
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
||||
|
||||
} catch (error) {
|
||||
logError(`Error checking board generation status for game ${gameId}`, error as Error);
|
||||
throw new Error(`Failed to retrieve board data: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Timeout reached
|
||||
logError(`Board generation timeout for game ${gameId}`, new Error(`Generation took longer than ${maxWaitTime / 1000} seconds`));
|
||||
throw new Error(`Board generation timeout. Game ${gameId} is not ready to start. Please try again later.`);
|
||||
}
|
||||
|
||||
private async ensureBoardGenerationTriggered(gameId: string): Promise<void> {
|
||||
try {
|
||||
logOther(`Ensuring board generation is triggered for game ${gameId}`);
|
||||
|
||||
// Check if generation was already triggered by looking for any board data
|
||||
const redisKey = `game_board_${gameId}`;
|
||||
const existingData = await this.redisService.get(redisKey);
|
||||
|
||||
if (!existingData) {
|
||||
// No data at all - trigger generation manually
|
||||
logOther(`No board generation found for game ${gameId}, triggering manually`);
|
||||
|
||||
// Use DIContainer to trigger board generation
|
||||
const generateBoardCommand = {
|
||||
gameId,
|
||||
positiveFieldCount: Math.floor(67 * 0.6), // Default: 60% positive
|
||||
negativeFieldCount: Math.floor(67 * 0.25), // Default: 25% negative
|
||||
luckFieldCount: Math.floor(67 * 0.15) // Default: 15% luck
|
||||
};
|
||||
|
||||
await DIContainer.getInstance().generateBoardCommandHandler.execute(generateBoardCommand);
|
||||
logOther(`Board generation manually triggered for game ${gameId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logError(`Failed to ensure board generation for game ${gameId}`, error as Error);
|
||||
// Don't throw here - let the main wait loop handle the timeout
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface CreateOrganizationCommand {
|
||||
name: string;
|
||||
contactfname: string;
|
||||
contactlname: string;
|
||||
contactphone: string;
|
||||
contactemail: string;
|
||||
url?: string;
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { IOrganizationRepository } from '../../../Domain/IRepository/IOrganizationRepository';
|
||||
import { CreateOrganizationCommand } from './CreateOrganizationCommand';
|
||||
import { ShortOrganizationDto } from '../../DTOs/OrganizationDto';
|
||||
import { OrganizationAggregate, OrganizationState } from '../../../Domain/Organization/OrganizationAggregate';
|
||||
import { OrganizationMapper } from '../../DTOs/Mappers/OrganizationMapper';
|
||||
|
||||
export class CreateOrganizationCommandHandler {
|
||||
constructor(private readonly orgRepo: IOrganizationRepository) {}
|
||||
|
||||
async execute(cmd: CreateOrganizationCommand): Promise<ShortOrganizationDto> {
|
||||
try {
|
||||
const org = new OrganizationAggregate();
|
||||
org.name = cmd.name;
|
||||
org.contactfname = cmd.contactfname;
|
||||
org.contactlname = cmd.contactlname;
|
||||
org.contactphone = cmd.contactphone;
|
||||
org.contactemail = cmd.contactemail;
|
||||
org.url = cmd.url || null;
|
||||
org.state = OrganizationState.REGISTERED;
|
||||
|
||||
const created = await this.orgRepo.create(org);
|
||||
return OrganizationMapper.toShortDto(created);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('duplicate key value violates unique constraint')) {
|
||||
throw new Error('Organization with this name or contact email already exists');
|
||||
}
|
||||
}
|
||||
throw new Error('Failed to create organization');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface DeleteOrganizationCommand {
|
||||
id: string;
|
||||
soft?: boolean;
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { IOrganizationRepository } from '../../../Domain/IRepository/IOrganizationRepository';
|
||||
import { DeleteOrganizationCommand } from './DeleteOrganizationCommand';
|
||||
|
||||
|
||||
export class DeleteOrganizationCommandHandler {
|
||||
constructor(private readonly orgRepo: IOrganizationRepository) {}
|
||||
|
||||
async execute(cmd: DeleteOrganizationCommand): Promise<boolean> {
|
||||
if (cmd.soft) {
|
||||
await this.orgRepo.softDelete(cmd.id);
|
||||
} else {
|
||||
await this.orgRepo.delete(cmd.id);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
export interface ProcessOrgAuthCallbackCommand {
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
status: 'ok' | 'not_ok';
|
||||
authToken?: string;
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
import { IUserRepository } from '../../../Domain/IRepository/IUserRepository';
|
||||
import { IOrganizationRepository } from '../../../Domain/IRepository/IOrganizationRepository';
|
||||
import { ProcessOrgAuthCallbackCommand } from './ProcessOrgAuthCallbackCommand';
|
||||
import { logAuth, logDatabase, logError, logWarning } from '../../Services/Logger';
|
||||
|
||||
export interface ProcessOrgAuthCallbackResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
updatedFields?: string[];
|
||||
}
|
||||
|
||||
export class ProcessOrgAuthCallbackCommandHandler {
|
||||
constructor(
|
||||
private readonly userRepo: IUserRepository,
|
||||
private readonly orgRepo: IOrganizationRepository
|
||||
) {}
|
||||
|
||||
async execute(cmd: ProcessOrgAuthCallbackCommand): Promise<ProcessOrgAuthCallbackResponse> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
logAuth('Processing organization authentication callback', cmd.userId, {
|
||||
organizationId: cmd.organizationId,
|
||||
status: cmd.status,
|
||||
hasAuthToken: !!cmd.authToken
|
||||
});
|
||||
|
||||
// Verify organization exists
|
||||
const organization = await this.orgRepo.findById(cmd.organizationId);
|
||||
if (!organization) {
|
||||
logWarning('Organization not found for auth callback', {
|
||||
organizationId: cmd.organizationId,
|
||||
userId: cmd.userId
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
message: 'Organization not found'
|
||||
};
|
||||
}
|
||||
|
||||
// Verify user exists
|
||||
const user = await this.userRepo.findById(cmd.userId);
|
||||
if (!user) {
|
||||
logWarning('User not found for auth callback', {
|
||||
organizationId: cmd.organizationId,
|
||||
userId: cmd.userId
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
message: 'User not found'
|
||||
};
|
||||
}
|
||||
|
||||
// Verify user belongs to the organization
|
||||
if (user.orgid !== cmd.organizationId) {
|
||||
logWarning('User does not belong to organization for auth callback', {
|
||||
organizationId: cmd.organizationId,
|
||||
userId: cmd.userId,
|
||||
userOrgId: user.orgid
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
message: 'User does not belong to this organization'
|
||||
};
|
||||
}
|
||||
|
||||
if (cmd.status === 'not_ok') {
|
||||
logAuth('Organization authentication failed', cmd.userId, {
|
||||
organizationId: cmd.organizationId,
|
||||
organizationName: organization.name
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
message: 'Organization authentication failed'
|
||||
};
|
||||
}
|
||||
|
||||
// Update user's organization login date
|
||||
const now = new Date();
|
||||
const updatedUser = await this.userRepo.update(cmd.userId, {
|
||||
Orglogindate: now
|
||||
});
|
||||
|
||||
if (!updatedUser) {
|
||||
logError('Failed to update user organization login date', new Error('User update returned null'));
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to update user login information'
|
||||
};
|
||||
}
|
||||
|
||||
logAuth('Organization authentication successful', cmd.userId, {
|
||||
organizationId: cmd.organizationId,
|
||||
organizationName: organization.name,
|
||||
orgLoginDate: now.toISOString(),
|
||||
executionTime: Date.now() - startTime
|
||||
});
|
||||
|
||||
logDatabase('User organization login date updated',
|
||||
`userId: ${cmd.userId}, orgId: ${cmd.organizationId}`,
|
||||
Date.now() - startTime,
|
||||
{
|
||||
userId: cmd.userId,
|
||||
organizationId: cmd.organizationId,
|
||||
newOrgLoginDate: now.toISOString()
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Organization authentication successful',
|
||||
updatedFields: ['Orglogindate']
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
logError('ProcessOrgAuthCallbackCommandHandler error', error as Error);
|
||||
return {
|
||||
success: false,
|
||||
message: 'Internal error processing authentication callback'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { OrganizationStateType } from '../../../Domain/Organization/OrganizationAggregate';
|
||||
|
||||
export interface UpdateOrganizationCommand {
|
||||
id: string;
|
||||
name?: string;
|
||||
contactfname?: string;
|
||||
contactlname?: string;
|
||||
contactphone?: string;
|
||||
contactemail?: string;
|
||||
url?: string;
|
||||
state?: OrganizationStateType;
|
||||
userinorg?: number;
|
||||
maxOrganizationalDecks?: number | null;
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { IOrganizationRepository } from '../../../Domain/IRepository/IOrganizationRepository';
|
||||
import { UpdateOrganizationCommand } from './UpdateOrganizationCommand';
|
||||
|
||||
import { ShortOrganizationDto } from '../../DTOs/OrganizationDto';
|
||||
import { OrganizationMapper } from '../../DTOs/Mappers/OrganizationMapper';
|
||||
|
||||
export class UpdateOrganizationCommandHandler {
|
||||
constructor(private readonly orgRepo: IOrganizationRepository) {}
|
||||
|
||||
async execute(cmd: UpdateOrganizationCommand): Promise<ShortOrganizationDto | null> {
|
||||
const updated = await this.orgRepo.update(cmd.id, { ...cmd });
|
||||
if (!updated) return null;
|
||||
return OrganizationMapper.toShortDto(updated);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface GetOrganizationByIdQuery {
|
||||
id: string;
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { IOrganizationRepository } from '../../../Domain/IRepository/IOrganizationRepository';
|
||||
import { GetOrganizationByIdQuery } from './GetOrganizationByIdQuery';
|
||||
|
||||
import { ShortOrganizationDto } from '../../DTOs/OrganizationDto';
|
||||
import { OrganizationMapper } from '../../DTOs/Mappers/OrganizationMapper';
|
||||
|
||||
export class GetOrganizationByIdQueryHandler {
|
||||
constructor(private readonly orgRepo: IOrganizationRepository) {}
|
||||
|
||||
async execute(query: GetOrganizationByIdQuery): Promise<ShortOrganizationDto | null> {
|
||||
const org = await this.orgRepo.findById(query.id);
|
||||
if (!org) return null;
|
||||
return OrganizationMapper.toShortDto(org);
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export interface GetOrganizationLoginUrlQuery {
|
||||
organizationId: string;
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import { IOrganizationRepository } from '../../../Domain/IRepository/IOrganizationRepository';
|
||||
import { GetOrganizationLoginUrlQuery } from './GetOrganizationLoginUrlQuery';
|
||||
import { OrganizationLoginUrlDto } from '../../DTOs/OrganizationDto';
|
||||
import { logDatabase, logError, logWarning } from '../../Services/Logger';
|
||||
|
||||
export class GetOrganizationLoginUrlQueryHandler {
|
||||
constructor(private readonly orgRepo: IOrganizationRepository) {}
|
||||
|
||||
async execute(query: GetOrganizationLoginUrlQuery): Promise<OrganizationLoginUrlDto | null> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
logDatabase('Getting organization login URL', `organizationId: ${query.organizationId}`, 0, {
|
||||
organizationId: query.organizationId
|
||||
});
|
||||
|
||||
const organization = await this.orgRepo.findById(query.organizationId);
|
||||
|
||||
if (!organization) {
|
||||
logWarning('Organization not found for login URL request', {
|
||||
organizationId: query.organizationId
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!organization.url) {
|
||||
logWarning('Organization has no configured login URL', {
|
||||
organizationId: query.organizationId,
|
||||
organizationName: organization.name
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const result: OrganizationLoginUrlDto = {
|
||||
organizationId: organization.id,
|
||||
organizationName: organization.name,
|
||||
loginUrl: organization.url
|
||||
};
|
||||
|
||||
logDatabase('Organization login URL retrieved successfully',
|
||||
`organizationId: ${query.organizationId}`,
|
||||
Date.now() - startTime,
|
||||
{
|
||||
organizationId: organization.id,
|
||||
organizationName: organization.name,
|
||||
hasUrl: !!organization.url
|
||||
}
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logError('GetOrganizationLoginUrlQueryHandler error', error as Error);
|
||||
throw new Error('Failed to retrieve organization login URL');
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export interface GetOrganizationsByPageQuery {
|
||||
from: number;
|
||||
to: number;
|
||||
includeDeleted?: boolean;
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
import { IOrganizationRepository } from '../../../Domain/IRepository/IOrganizationRepository';
|
||||
import { GetOrganizationsByPageQuery } from './GetOrganizationsByPageQuery';
|
||||
import { ShortOrganizationDto } from '../../DTOs/OrganizationDto';
|
||||
import { OrganizationMapper } from '../../DTOs/Mappers/OrganizationMapper';
|
||||
import { logError, logRequest } from '../../Services/Logger';
|
||||
|
||||
export class GetOrganizationsByPageQueryHandler {
|
||||
constructor(private readonly orgRepo: IOrganizationRepository) {}
|
||||
|
||||
async execute(query: GetOrganizationsByPageQuery): Promise<{ organizations: ShortOrganizationDto[], totalCount: number }> {
|
||||
try {
|
||||
// Validate pagination parameters
|
||||
if (query.from < 0 || query.to < query.from) {
|
||||
throw new Error('Invalid pagination parameters');
|
||||
}
|
||||
|
||||
const limit = query.to - query.from + 1;
|
||||
if (limit > 100) {
|
||||
throw new Error('Page size too large. Maximum 100 records per request');
|
||||
}
|
||||
|
||||
logRequest('Get organizations by page query started', undefined, undefined, {
|
||||
from: query.from,
|
||||
to: query.to,
|
||||
includeDeleted: query.includeDeleted || false
|
||||
});
|
||||
|
||||
const result = query.includeDeleted
|
||||
? await this.orgRepo.findByPageIncludingDeleted(query.from, query.to)
|
||||
: await this.orgRepo.findByPage(query.from, query.to);
|
||||
|
||||
logRequest('Get organizations by page query completed', undefined, undefined, {
|
||||
from: query.from,
|
||||
to: query.to,
|
||||
returned: result.organizations.length,
|
||||
totalCount: result.totalCount,
|
||||
includeDeleted: query.includeDeleted || false
|
||||
});
|
||||
|
||||
return {
|
||||
organizations: OrganizationMapper.toShortDtoList(result.organizations),
|
||||
totalCount: result.totalCount
|
||||
};
|
||||
} catch (error) {
|
||||
logError('GetOrganizationsByPageQueryHandler error', error instanceof Error ? error : new Error(String(error)));
|
||||
|
||||
// Handle database errors
|
||||
if (error instanceof Error && error.message.includes('database')) {
|
||||
throw new Error('Database connection error');
|
||||
}
|
||||
|
||||
// Re-throw validation errors as-is
|
||||
if (error instanceof Error && (error.message.includes('Invalid pagination') || error.message.includes('Page size'))) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new Error('Failed to retrieve organizations');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { IUserRepository } from '../../Domain/IRepository/IUserRepository';
|
||||
import { IOrganizationRepository } from '../../Domain/IRepository/IOrganizationRepository';
|
||||
import { IDeckRepository } from '../../Domain/IRepository/IDeckRepository';
|
||||
import { SearchQuery, SearchResult } from '../DTOs/SearchDto';
|
||||
import { ShortUserDto, DetailUserDto } from '../DTOs/UserDto';
|
||||
import { ShortOrganizationDto, DetailOrganizationDto } from '../DTOs/OrganizationDto';
|
||||
import { ShortDeckDto, DetailDeckDto } from '../DTOs/DeckDto';
|
||||
import { UserMapper } from '../DTOs/Mappers/UserMapper';
|
||||
import { OrganizationMapper } from '../DTOs/Mappers/OrganizationMapper';
|
||||
import { DeckMapper } from '../DTOs/Mappers/DeckMapper';
|
||||
|
||||
export type SearchType = 'users' | 'organizations' | 'decks';
|
||||
|
||||
export interface IGeneralSearchService {
|
||||
searchUsers(searchQuery: SearchQuery): Promise<SearchResult<ShortUserDto>>;
|
||||
searchOrganizations(searchQuery: SearchQuery): Promise<SearchResult<ShortOrganizationDto>>;
|
||||
searchDecks(searchQuery: SearchQuery): Promise<SearchResult<ShortDeckDto>>;
|
||||
searchByType(searchType: SearchType, searchQuery: SearchQuery): Promise<SearchResult<ShortUserDto | ShortOrganizationDto | ShortDeckDto>>;
|
||||
}
|
||||
|
||||
export class GeneralSearchService implements IGeneralSearchService {
|
||||
constructor(
|
||||
private userRepo: IUserRepository,
|
||||
private organizationRepo: IOrganizationRepository,
|
||||
private deckRepo: IDeckRepository
|
||||
) {}
|
||||
|
||||
static getSearchTypeFromUrl(url: string): SearchType {
|
||||
if (url.includes('/users/') || url.includes('/api/users/')) {
|
||||
return 'users';
|
||||
} else if (url.includes('/organizations/') || url.includes('/api/organizations/')) {
|
||||
return 'organizations';
|
||||
} else if (url.includes('/decks/') || url.includes('/api/decks/')) {
|
||||
return 'decks';
|
||||
}
|
||||
return 'users';
|
||||
}
|
||||
|
||||
async searchUsers(searchQuery: SearchQuery): Promise<SearchResult<ShortUserDto>> {
|
||||
const { query, limit = 20, offset = 0 } = searchQuery;
|
||||
|
||||
if (!query || query.trim().length === 0) {
|
||||
return {
|
||||
results: [],
|
||||
totalCount: 0,
|
||||
hasMore: false,
|
||||
searchQuery: query,
|
||||
searchType: 'users'
|
||||
};
|
||||
}
|
||||
|
||||
// Ensure limit is at least 1 to prevent database issues
|
||||
const effectiveLimit = Math.max(limit || 20, 1);
|
||||
const effectiveOffset = Math.max(offset || 0, 0);
|
||||
|
||||
try {
|
||||
const { users, totalCount } = await this.userRepo.search(query.trim(), effectiveLimit, effectiveOffset);
|
||||
const results = users.map(user => UserMapper.toShortDto(user));
|
||||
const hasMore = (effectiveOffset + effectiveLimit) < totalCount;
|
||||
|
||||
return {
|
||||
results,
|
||||
totalCount,
|
||||
hasMore,
|
||||
searchQuery: query,
|
||||
searchType: 'users'
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error('Failed to search users');
|
||||
}
|
||||
}
|
||||
|
||||
async searchOrganizations(searchQuery: SearchQuery): Promise<SearchResult<ShortOrganizationDto>> {
|
||||
const { query, limit = 20, offset = 0 } = searchQuery;
|
||||
|
||||
if (!query || query.trim().length === 0) {
|
||||
return {
|
||||
results: [],
|
||||
totalCount: 0,
|
||||
hasMore: false,
|
||||
searchQuery: query,
|
||||
searchType: 'organizations'
|
||||
};
|
||||
}
|
||||
|
||||
const { organizations, totalCount } = await this.organizationRepo.search(query.trim(), limit, offset);
|
||||
const results = organizations.map(org => OrganizationMapper.toShortDto(org));
|
||||
const hasMore = (offset + limit) < totalCount;
|
||||
|
||||
return {
|
||||
results,
|
||||
totalCount,
|
||||
hasMore,
|
||||
searchQuery: query,
|
||||
searchType: 'organizations'
|
||||
};
|
||||
}
|
||||
|
||||
async searchDecks(searchQuery: SearchQuery): Promise<SearchResult<ShortDeckDto>> {
|
||||
const { query, limit = 20, offset = 0 } = searchQuery;
|
||||
|
||||
if (!query || query.trim().length === 0) {
|
||||
return {
|
||||
results: [],
|
||||
totalCount: 0,
|
||||
hasMore: false,
|
||||
searchQuery: query,
|
||||
searchType: 'decks'
|
||||
};
|
||||
}
|
||||
|
||||
// Ensure limit is at least 1 to prevent database issues
|
||||
const effectiveLimit = Math.max(limit || 20, 1);
|
||||
const effectiveOffset = Math.max(offset || 0, 0);
|
||||
|
||||
try {
|
||||
const { decks, totalCount } = await this.deckRepo.search(query.trim(), effectiveLimit, effectiveOffset);
|
||||
const results = decks.map(deck => DeckMapper.toShortDto(deck));
|
||||
const hasMore = (effectiveOffset + effectiveLimit) < totalCount;
|
||||
|
||||
return {
|
||||
results,
|
||||
totalCount,
|
||||
hasMore,
|
||||
searchQuery: query,
|
||||
searchType: 'decks'
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error('Failed to search decks');
|
||||
}
|
||||
}
|
||||
|
||||
async searchByType(
|
||||
searchType: SearchType,
|
||||
searchQuery: SearchQuery
|
||||
): Promise<SearchResult<ShortUserDto | ShortOrganizationDto | ShortDeckDto>> {
|
||||
switch (searchType) {
|
||||
case 'users':
|
||||
return await this.searchUsers(searchQuery) as SearchResult<ShortUserDto | ShortOrganizationDto | ShortDeckDto>;
|
||||
case 'organizations':
|
||||
return await this.searchOrganizations(searchQuery) as SearchResult<ShortUserDto | ShortOrganizationDto | ShortDeckDto>;
|
||||
case 'decks':
|
||||
return await this.searchDecks(searchQuery) as SearchResult<ShortUserDto | ShortOrganizationDto | ShortDeckDto>;
|
||||
default:
|
||||
throw new Error(`Unsupported search type: ${searchType}`);
|
||||
}
|
||||
}
|
||||
|
||||
async searchFromUrl(
|
||||
url: string,
|
||||
searchQuery: SearchQuery
|
||||
): Promise<SearchResult<ShortUserDto | ShortOrganizationDto | ShortDeckDto>> {
|
||||
const searchType = GeneralSearchService.getSearchTypeFromUrl(url);
|
||||
return await this.searchByType(searchType, searchQuery);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { UserState } from '../../Domain/User/UserAggregate';
|
||||
import { logAuth } from './Logger';
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
/**
|
||||
* Admin Bypass Service - Centralized admin privilege checking and logging
|
||||
*/
|
||||
export class AdminBypassService {
|
||||
/**
|
||||
* Check if user has admin privileges
|
||||
* @param userState - User's current state
|
||||
* @returns true if user is admin
|
||||
*/
|
||||
static isAdmin(userState: UserState): boolean {
|
||||
return userState === UserState.ADMIN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user should bypass all restrictions
|
||||
* @param userState - User's current state
|
||||
* @returns true if restrictions should be bypassed
|
||||
*/
|
||||
static shouldBypassRestrictions(userState: UserState): boolean {
|
||||
return this.isAdmin(userState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log admin bypass action for audit trail
|
||||
* @param action - Description of the action being bypassed
|
||||
* @param adminUserId - ID of the admin user
|
||||
* @param targetId - ID of the target resource
|
||||
* @param details - Additional details about the bypass
|
||||
* @param req - Optional request object for context
|
||||
* @param res - Optional response object for context
|
||||
*/
|
||||
static logAdminBypass(
|
||||
action: string,
|
||||
adminUserId: string,
|
||||
targetId: string,
|
||||
details?: any,
|
||||
req?: Request,
|
||||
res?: Response
|
||||
): void {
|
||||
logAuth(`ADMIN_BYPASS: ${action}`, adminUserId, {
|
||||
targetId,
|
||||
action,
|
||||
bypassReason: 'Admin privileges',
|
||||
timestamp: new Date().toISOString(),
|
||||
...details
|
||||
}, req, res);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin Audit Service - Enhanced logging for all admin actions
|
||||
*/
|
||||
export class AdminAuditService {
|
||||
/**
|
||||
* Log comprehensive admin action for audit trail
|
||||
* @param action - Action being performed
|
||||
* @param adminUserId - ID of the admin user
|
||||
* @param details - Detailed information about the action
|
||||
* @param req - Request object for context
|
||||
* @param res - Response object for context
|
||||
*/
|
||||
static logAdminAction(
|
||||
action: string,
|
||||
adminUserId: string,
|
||||
details: {
|
||||
targetType: 'user' | 'organization' | 'deck' | 'contact' | 'chat';
|
||||
targetId: string;
|
||||
operation: 'create' | 'read' | 'update' | 'delete' | 'bypass' | 'export' | 'import';
|
||||
changes?: any;
|
||||
sensitive?: boolean;
|
||||
metadata?: any;
|
||||
},
|
||||
req?: Request,
|
||||
res?: Response
|
||||
): void {
|
||||
|
||||
const auditData = {
|
||||
timestamp: new Date().toISOString(),
|
||||
adminUserId,
|
||||
action,
|
||||
...details,
|
||||
ip: req?.ip,
|
||||
userAgent: req?.get('User-Agent'),
|
||||
endpoint: req?.path,
|
||||
method: req?.method,
|
||||
requestId: req?.headers['x-request-id'] || 'unknown'
|
||||
};
|
||||
|
||||
// Enhanced logging for admin actions
|
||||
logAuth(`ADMIN_AUDIT: ${action}`, adminUserId, auditData, req, res);
|
||||
|
||||
// Additional security logging for sensitive operations
|
||||
if (details.sensitive) {
|
||||
logAuth(`ADMIN_SENSITIVE: ${action}`, adminUserId, {
|
||||
...auditData,
|
||||
alertLevel: 'HIGH',
|
||||
requiresReview: true
|
||||
}, req, res);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log bulk admin operations
|
||||
* @param action - Bulk action being performed
|
||||
* @param adminUserId - ID of the admin user
|
||||
* @param affectedCount - Number of resources affected
|
||||
* @param targetType - Type of resources affected
|
||||
* @param req - Request object for context
|
||||
* @param res - Response object for context
|
||||
*/
|
||||
static logBulkAdminAction(
|
||||
action: string,
|
||||
adminUserId: string,
|
||||
affectedCount: number,
|
||||
targetType: string,
|
||||
req?: Request,
|
||||
res?: Response
|
||||
): void {
|
||||
this.logAdminAction(`BULK_${action}`, adminUserId, {
|
||||
targetType: targetType as any,
|
||||
targetId: `bulk-${affectedCount}-items`,
|
||||
operation: 'update' as any,
|
||||
metadata: { affectedCount },
|
||||
sensitive: affectedCount > 10 // Mark large bulk operations as sensitive
|
||||
}, req, res);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { JWTService } from './JWTService';
|
||||
import { RedisService } from './RedisService';
|
||||
import { logAuth, logWarning } from './Logger';
|
||||
|
||||
export const jwtService = new JWTService();
|
||||
const redisService = RedisService.getInstance();
|
||||
|
||||
/**
|
||||
* Check if a token is blacklisted
|
||||
*/
|
||||
async function isTokenBlacklisted(token: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await redisService.get(`blacklist:${token}`);
|
||||
return result === 'true';
|
||||
} catch (error) {
|
||||
// If Redis is down, allow the request to proceed (fail open)
|
||||
logWarning('Failed to check token blacklist - allowing request', { error: (error as Error).message });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract token from request (cookie or Authorization header)
|
||||
*/
|
||||
function extractToken(req: Request): string | null {
|
||||
// First try to get token from cookie
|
||||
const cookieToken = req.cookies['auth_token'];
|
||||
if (cookieToken) {
|
||||
return cookieToken;
|
||||
}
|
||||
|
||||
// Fallback to Authorization header
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
return authHeader.substring(7);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function authRequired(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
// Extract token from request
|
||||
const token = extractToken(req);
|
||||
if (!token) {
|
||||
logAuth('Authentication failed - No token provided', undefined, {
|
||||
ip: req.ip,
|
||||
userAgent: req.get ? req.get('User-Agent') : 'unknown',
|
||||
path: req.path
|
||||
}, req);
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
// Check if token is blacklisted
|
||||
const isBlacklisted = await isTokenBlacklisted(token);
|
||||
if (isBlacklisted) {
|
||||
logAuth('Authentication failed - Token blacklisted', undefined, {
|
||||
ip: req.ip,
|
||||
userAgent: req.get ? req.get('User-Agent') : 'unknown',
|
||||
path: req.path
|
||||
}, req);
|
||||
return res.status(401).json({ error: 'Token has been invalidated' });
|
||||
}
|
||||
|
||||
// Verify token
|
||||
const payload = jwtService.verify(req);
|
||||
if (!payload) {
|
||||
logAuth('Authentication failed - Invalid token', undefined, {
|
||||
ip: req.ip,
|
||||
userAgent: req.get ? req.get('User-Agent') : 'unknown',
|
||||
path: req.path
|
||||
}, req);
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
logAuth('Authentication successful', payload.userId, {
|
||||
authLevel: payload.authLevel,
|
||||
orgId: payload.orgId
|
||||
}, req);
|
||||
|
||||
const refreshed = jwtService.refreshIfNeeded(payload, res);
|
||||
if (refreshed) {
|
||||
logAuth('Token refreshed', payload.userId, undefined, req);
|
||||
}
|
||||
|
||||
(req as any).user = payload;
|
||||
next();
|
||||
} catch (error) {
|
||||
logWarning('Authentication middleware error', { error: (error as Error).message }, req);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
|
||||
export async function adminRequired(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
// Extract token from request
|
||||
const token = extractToken(req);
|
||||
if (!token) {
|
||||
logWarning('Admin access denied - No token provided', {
|
||||
ip: req.ip,
|
||||
path: req.path
|
||||
}, req);
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
// Check if token is blacklisted
|
||||
const isBlacklisted = await isTokenBlacklisted(token);
|
||||
if (isBlacklisted) {
|
||||
logWarning('Admin access denied - Token blacklisted', {
|
||||
ip: req.ip,
|
||||
path: req.path
|
||||
}, req);
|
||||
return res.status(401).json({ error: 'Token has been invalidated' });
|
||||
}
|
||||
|
||||
// Verify token and check admin privileges
|
||||
const payload = jwtService.verify(req);
|
||||
if (!payload || payload.authLevel !== 1) {
|
||||
logWarning('Admin access denied', {
|
||||
hasPayload: !!payload,
|
||||
authLevel: payload?.authLevel,
|
||||
userId: payload?.userId,
|
||||
ip: req.ip,
|
||||
path: req.path
|
||||
}, req);
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
|
||||
logAuth('Admin authentication successful', payload.userId, {
|
||||
authLevel: payload.authLevel,
|
||||
orgId: payload.orgId
|
||||
}, req);
|
||||
|
||||
const refreshed = jwtService.refreshIfNeeded(payload, res);
|
||||
if (refreshed) {
|
||||
logAuth('Admin token refreshed', payload.userId, undefined, req);
|
||||
}
|
||||
|
||||
(req as any).user = payload;
|
||||
next();
|
||||
} catch (error) {
|
||||
logWarning('Admin authentication middleware error', { error: (error as Error).message }, req);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
import { GameAggregate, GameCard, DeckType, GameDeck } from '../../Domain/Game/GameAggregate';
|
||||
import { ConsequenceType } from '../../Domain/Deck/DeckAggregate';
|
||||
import { CardProcessingService, CardClientData, CardValidationResult } from './CardProcessingService';
|
||||
|
||||
export interface CardDrawResult {
|
||||
success: boolean;
|
||||
card?: GameCard;
|
||||
clientData?: CardClientData; // Prepared data for client
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface CardAnswerResult {
|
||||
correct: boolean;
|
||||
consequence: ConsequenceType;
|
||||
description: string;
|
||||
validationDetails?: CardValidationResult; // Detailed validation info
|
||||
}
|
||||
|
||||
export interface PendingCardAnswer {
|
||||
gameId: string;
|
||||
playerId: string;
|
||||
card: GameCard;
|
||||
timeoutId: NodeJS.Timeout;
|
||||
startTime: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service responsible for handling card drawing mechanics during special field landings
|
||||
* Integrates with existing GameCard interface and DeckType enum
|
||||
*/
|
||||
export class CardDrawingService {
|
||||
private pendingAnswers: Map<string, PendingCardAnswer> = new Map();
|
||||
private readonly ANSWER_TIMEOUT_MS = 60000; // 1 minute
|
||||
private cardProcessingService: CardProcessingService;
|
||||
|
||||
constructor() {
|
||||
this.cardProcessingService = new CardProcessingService();
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a card from the appropriate deck based on field type
|
||||
* @param game Game aggregate containing the deck information
|
||||
* @param fieldType Type of field the player landed on
|
||||
* @param playerId ID of the player who needs to draw the card
|
||||
* @returns Card draw result with the drawn card or error
|
||||
*/
|
||||
drawCard(game: GameAggregate, fieldType: 'positive' | 'negative' | 'luck', playerId: string): CardDrawResult {
|
||||
try {
|
||||
// Determine which deck type to use based on field type
|
||||
const deckType = this.getRequiredDeckType(fieldType);
|
||||
|
||||
// Find the appropriate deck in the game
|
||||
const gameDecks: GameDeck[] = typeof game.gamedecks === 'string'
|
||||
? JSON.parse(game.gamedecks)
|
||||
: game.gamedecks;
|
||||
|
||||
const targetDeck = gameDecks.find((deck: GameDeck) => deck.decktype === deckType);
|
||||
|
||||
if (!targetDeck) {
|
||||
return {
|
||||
success: false,
|
||||
error: `No ${this.getDeckTypeName(deckType)} deck found in game`
|
||||
};
|
||||
}
|
||||
|
||||
// Filter available cards (not played by this player yet)
|
||||
const availableCards = targetDeck.cards.filter((card: GameCard) => !card.played || card.playerid !== playerId);
|
||||
|
||||
if (availableCards.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: `No more cards available in ${this.getDeckTypeName(deckType)} deck`
|
||||
};
|
||||
}
|
||||
|
||||
// Randomly select a card
|
||||
const randomIndex = Math.floor(Math.random() * availableCards.length);
|
||||
const drawnCard = availableCards[randomIndex];
|
||||
|
||||
// Mark card as drawn by this player
|
||||
drawnCard.played = true;
|
||||
drawnCard.playerid = playerId;
|
||||
|
||||
// Prepare client data based on card type
|
||||
let clientData: CardClientData | undefined;
|
||||
try {
|
||||
if (drawnCard.type !== undefined) {
|
||||
clientData = this.cardProcessingService.prepareCardForClient(drawnCard);
|
||||
}
|
||||
} catch (error) {
|
||||
// If client data preparation fails, still return the card but log the error
|
||||
console.warn(`Failed to prepare client data for card ${drawnCard.cardid}:`, error);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
card: drawnCard,
|
||||
clientData: clientData
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to draw card: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a joker card for secondary landings on special fields
|
||||
* @param game Game aggregate containing the deck information
|
||||
* @param playerId ID of the player who needs to draw the joker card
|
||||
* @returns Card draw result with the joker card or error
|
||||
*/
|
||||
drawJokerCard(game: GameAggregate, playerId: string): CardDrawResult {
|
||||
try {
|
||||
const gameDecks: GameDeck[] = typeof game.gamedecks === 'string'
|
||||
? JSON.parse(game.gamedecks)
|
||||
: game.gamedecks;
|
||||
|
||||
const jokerDeck = gameDecks.find((deck: GameDeck) => deck.decktype === DeckType.JOCKER);
|
||||
|
||||
if (!jokerDeck) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'No joker deck found in game'
|
||||
};
|
||||
}
|
||||
|
||||
// Filter available joker cards
|
||||
const availableCards = jokerDeck.cards.filter((card: GameCard) => !card.played || card.playerid !== playerId);
|
||||
|
||||
if (availableCards.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'No more joker cards available'
|
||||
};
|
||||
}
|
||||
|
||||
// Randomly select a joker card
|
||||
const randomIndex = Math.floor(Math.random() * availableCards.length);
|
||||
const drawnCard = availableCards[randomIndex];
|
||||
|
||||
// Mark card as drawn by this player
|
||||
drawnCard.played = true;
|
||||
drawnCard.playerid = playerId;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
card: drawnCard
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to draw joker card: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the answer timeout for a question card
|
||||
* @param gameId Game ID
|
||||
* @param playerId Player ID who needs to answer
|
||||
* @param card The card with the question
|
||||
* @param onTimeout Callback function when timeout occurs
|
||||
* @returns Unique key for tracking this pending answer
|
||||
*/
|
||||
startAnswerTimeout(
|
||||
gameId: string,
|
||||
playerId: string,
|
||||
card: GameCard,
|
||||
onTimeout: (gameId: string, playerId: string, card: GameCard) => void
|
||||
): string {
|
||||
const key = `${gameId}:${playerId}`;
|
||||
|
||||
// Clear any existing timeout for this player
|
||||
this.clearAnswerTimeout(key);
|
||||
|
||||
// Set new timeout
|
||||
const timeoutId = setTimeout(() => {
|
||||
onTimeout(gameId, playerId, card);
|
||||
this.pendingAnswers.delete(key);
|
||||
}, this.ANSWER_TIMEOUT_MS);
|
||||
|
||||
// Store pending answer
|
||||
this.pendingAnswers.set(key, {
|
||||
gameId,
|
||||
playerId,
|
||||
card,
|
||||
timeoutId,
|
||||
startTime: new Date()
|
||||
});
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear an answer timeout
|
||||
* @param key The key returned from startAnswerTimeout
|
||||
*/
|
||||
clearAnswerTimeout(key: string): void {
|
||||
const pending = this.pendingAnswers.get(key);
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeoutId);
|
||||
this.pendingAnswers.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process player's answer to a question card
|
||||
* @param card The question card
|
||||
* @param playerAnswer Player's submitted answer
|
||||
* @returns Result indicating if answer was correct and consequence to apply
|
||||
*/
|
||||
processAnswer(card: GameCard, playerAnswer: any): CardAnswerResult {
|
||||
if (!card.answer) {
|
||||
throw new Error('Card has no answer to compare against');
|
||||
}
|
||||
|
||||
let validationResult: CardValidationResult;
|
||||
|
||||
try {
|
||||
// Use CardProcessingService for type-specific validation
|
||||
validationResult = this.cardProcessingService.validateAnswer(card, playerAnswer);
|
||||
} catch (error) {
|
||||
// Fallback to simple string comparison if type-specific validation fails
|
||||
console.warn(`Card validation failed, using fallback: ${error}`);
|
||||
validationResult = this.fallbackValidation(card, playerAnswer);
|
||||
}
|
||||
|
||||
// For question cards, the consequence is applied only if the answer is correct
|
||||
// If wrong, we apply a default negative consequence
|
||||
const consequence = validationResult.isCorrect
|
||||
? (card.consequence?.type || ConsequenceType.EXTRA_TURN)
|
||||
: ConsequenceType.LOSE_TURN; // Default penalty for wrong answer
|
||||
|
||||
return {
|
||||
correct: validationResult.isCorrect,
|
||||
consequence: consequence,
|
||||
description: validationResult.explanation || (validationResult.isCorrect
|
||||
? '✅ Correct!'
|
||||
: '❌ Wrong answer!'),
|
||||
validationDetails: validationResult
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Process automatic wrong answer (timeout occurred)
|
||||
* @param card The question card that timed out
|
||||
* @returns Result with wrong consequence applied
|
||||
*/
|
||||
processTimeoutAnswer(card: GameCard): CardAnswerResult {
|
||||
if (!card.answer) {
|
||||
throw new Error('Card has no answer to compare against');
|
||||
}
|
||||
|
||||
const consequence = ConsequenceType.LOSE_TURN; // Default penalty for timeout
|
||||
|
||||
return {
|
||||
correct: false,
|
||||
consequence: consequence,
|
||||
description: `⏰ Time's up! The correct answer was "${card.answer}". ${this.getConsequenceDescription(consequence, false)}`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Process luck card effect (no answer required)
|
||||
* @param card The luck card
|
||||
* @returns Result with the luck consequence to apply
|
||||
*/
|
||||
processLuckCard(card: GameCard): CardAnswerResult {
|
||||
const consequence = card.consequence?.type || ConsequenceType.EXTRA_TURN;
|
||||
|
||||
return {
|
||||
correct: true, // Luck cards are always "correct" since no answer is needed
|
||||
consequence: consequence,
|
||||
description: `🍀 ${this.getConsequenceDescription(consequence, true)}`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the required deck type based on field type
|
||||
*/
|
||||
private getRequiredDeckType(fieldType: 'positive' | 'negative' | 'luck'): DeckType {
|
||||
switch (fieldType) {
|
||||
case 'positive':
|
||||
case 'negative':
|
||||
return DeckType.QUEST; // Question cards for positive/negative fields
|
||||
case 'luck':
|
||||
return DeckType.LUCK; // Luck cards for luck fields
|
||||
default:
|
||||
throw new Error(`Unsupported field type: ${fieldType}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable deck type name
|
||||
*/
|
||||
private getDeckTypeName(deckType: DeckType): string {
|
||||
switch (deckType) {
|
||||
case DeckType.QUEST:
|
||||
return 'question';
|
||||
case DeckType.LUCK:
|
||||
return 'luck';
|
||||
case DeckType.JOCKER:
|
||||
return 'joker';
|
||||
default:
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable consequence description
|
||||
*/
|
||||
private getConsequenceDescription(consequence: ConsequenceType, isPositive: boolean): string {
|
||||
switch (consequence) {
|
||||
case ConsequenceType.MOVE_FORWARD:
|
||||
return isPositive ? 'Move forward!' : 'Move forward anyway!';
|
||||
case ConsequenceType.MOVE_BACKWARD:
|
||||
return 'Move backward!';
|
||||
case ConsequenceType.LOSE_TURN:
|
||||
return 'Lose your next turn!';
|
||||
case ConsequenceType.EXTRA_TURN:
|
||||
return 'Get an extra turn!';
|
||||
case ConsequenceType.GO_TO_START:
|
||||
return 'Go back to start!';
|
||||
default:
|
||||
return 'Unknown effect!';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remaining time for a pending answer
|
||||
* @param key The key for the pending answer
|
||||
* @returns Remaining time in seconds, or -1 if not found
|
||||
*/
|
||||
getRemainingTime(key: string): number {
|
||||
const pending = this.pendingAnswers.get(key);
|
||||
if (!pending) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - pending.startTime.getTime();
|
||||
const remaining = Math.max(0, this.ANSWER_TIMEOUT_MS - elapsed);
|
||||
return Math.ceil(remaining / 1000); // Return in seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a player has a pending answer
|
||||
* @param gameId Game ID
|
||||
* @param playerId Player ID
|
||||
* @returns True if player has a pending answer
|
||||
*/
|
||||
hasPendingAnswer(gameId: string, playerId: string): boolean {
|
||||
const key = `${gameId}:${playerId}`;
|
||||
return this.pendingAnswers.has(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback validation for cards without proper type information
|
||||
* @param card The card to validate
|
||||
* @param playerAnswer Player's answer
|
||||
* @returns Basic validation result
|
||||
*/
|
||||
private fallbackValidation(card: GameCard, playerAnswer: any): CardValidationResult {
|
||||
if (typeof card.answer !== 'string' || typeof playerAnswer !== 'string') {
|
||||
return {
|
||||
isCorrect: false,
|
||||
submittedAnswer: playerAnswer,
|
||||
explanation: 'Cannot validate non-string answers without card type information'
|
||||
};
|
||||
}
|
||||
|
||||
const cleanPlayerAnswer = playerAnswer.toLowerCase().trim();
|
||||
const cleanCorrectAnswer = card.answer.toLowerCase().trim();
|
||||
const isCorrect = cleanPlayerAnswer === cleanCorrectAnswer;
|
||||
|
||||
return {
|
||||
isCorrect,
|
||||
submittedAnswer: playerAnswer,
|
||||
correctAnswer: card.answer,
|
||||
explanation: isCorrect
|
||||
? '✅ Correct!'
|
||||
: `❌ Wrong! The correct answer was "${card.answer}".`
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import { GameCard } from '../../Domain/Game/GameAggregate';
|
||||
import { CardType } from '../../Domain/Deck/DeckAggregate';
|
||||
|
||||
// Type-specific answer structures
|
||||
export interface QuizOption {
|
||||
answer: string; // A, B, C, D
|
||||
text: string;
|
||||
correct: boolean;
|
||||
}
|
||||
|
||||
export interface CloserAnswer {
|
||||
correct: number;
|
||||
percent: number;
|
||||
}
|
||||
|
||||
export interface CardClientData {
|
||||
cardid: string;
|
||||
question: string;
|
||||
type: CardType;
|
||||
// Type-specific client data
|
||||
options?: QuizOption[]; // For QUIZ
|
||||
words?: string[]; // For SENTENCE_PAIRING (scrambled)
|
||||
acceptableAnswers?: string[]; // For OWN_ANSWER (not sent to client)
|
||||
// CLOSER and TRUE_FALSE send only question
|
||||
}
|
||||
|
||||
export interface CardValidationResult {
|
||||
isCorrect: boolean;
|
||||
submittedAnswer: any;
|
||||
correctAnswer?: any;
|
||||
explanation?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service responsible for handling type-specific card processing
|
||||
* Prepares cards for clients and validates answers based on CardType
|
||||
*/
|
||||
export class CardProcessingService {
|
||||
|
||||
/**
|
||||
* Prepare card data for client based on card type
|
||||
* @param card The game card to prepare
|
||||
* @returns Client-safe card data with type-specific information
|
||||
*/
|
||||
prepareCardForClient(card: GameCard): CardClientData {
|
||||
if (!card.question || card.type === undefined) {
|
||||
throw new Error('Card must have question and type defined');
|
||||
}
|
||||
|
||||
const baseData: CardClientData = {
|
||||
cardid: card.cardid,
|
||||
question: card.question,
|
||||
type: card.type
|
||||
};
|
||||
|
||||
switch (card.type) {
|
||||
case CardType.QUIZ:
|
||||
return this.prepareQuizCard(card, baseData);
|
||||
|
||||
case CardType.SENTENCE_PAIRING:
|
||||
return this.prepareSentencePairingCard(card, baseData);
|
||||
|
||||
case CardType.OWN_ANSWER:
|
||||
return this.prepareOwnAnswerCard(card, baseData);
|
||||
|
||||
case CardType.TRUE_FALSE:
|
||||
return this.prepareTrueFalseCard(card, baseData);
|
||||
|
||||
case CardType.CLOSER:
|
||||
return this.prepareCloserCard(card, baseData);
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported card type: ${card.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate player's answer based on card type
|
||||
* @param card The game card
|
||||
* @param playerAnswer Player's submitted answer
|
||||
* @returns Validation result with correctness and explanation
|
||||
*/
|
||||
validateAnswer(card: GameCard, playerAnswer: any): CardValidationResult {
|
||||
if (card.type === undefined) {
|
||||
throw new Error('Card type is required for validation');
|
||||
}
|
||||
|
||||
switch (card.type) {
|
||||
case CardType.QUIZ:
|
||||
return this.validateQuizAnswer(card, playerAnswer);
|
||||
|
||||
case CardType.SENTENCE_PAIRING:
|
||||
return this.validateSentencePairingAnswer(card, playerAnswer);
|
||||
|
||||
case CardType.OWN_ANSWER:
|
||||
return this.validateOwnAnswerAnswer(card, playerAnswer);
|
||||
|
||||
case CardType.TRUE_FALSE:
|
||||
return this.validateTrueFalseAnswer(card, playerAnswer);
|
||||
|
||||
case CardType.CLOSER:
|
||||
return this.validateCloserAnswer(card, playerAnswer);
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported card type for validation: ${card.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare QUIZ card with multiple choice options
|
||||
*/
|
||||
private prepareQuizCard(card: GameCard, baseData: CardClientData): CardClientData {
|
||||
if (!Array.isArray(card.answer)) {
|
||||
throw new Error('Quiz card answer must be an array of options');
|
||||
}
|
||||
|
||||
return {
|
||||
...baseData,
|
||||
options: card.answer as QuizOption[]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare SENTENCE_PAIRING card with scrambled words
|
||||
*/
|
||||
private prepareSentencePairingCard(card: GameCard, baseData: CardClientData): CardClientData {
|
||||
if (typeof card.answer !== 'string') {
|
||||
throw new Error('Sentence pairing card answer must be a string');
|
||||
}
|
||||
|
||||
const words = card.answer.split(' ').filter(word => word.trim() !== '');
|
||||
const scrambledWords = this.scrambleArray([...words]);
|
||||
|
||||
return {
|
||||
...baseData,
|
||||
words: scrambledWords
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare OWN_ANSWER card (only question, acceptable answers hidden)
|
||||
*/
|
||||
private prepareOwnAnswerCard(card: GameCard, baseData: CardClientData): CardClientData {
|
||||
// Don't send acceptable answers to client
|
||||
return baseData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare TRUE_FALSE card (only question)
|
||||
*/
|
||||
private prepareTrueFalseCard(card: GameCard, baseData: CardClientData): CardClientData {
|
||||
return baseData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare CLOSER card (only question)
|
||||
*/
|
||||
private prepareCloserCard(card: GameCard, baseData: CardClientData): CardClientData {
|
||||
return baseData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate QUIZ answer (A, B, C, D)
|
||||
*/
|
||||
private validateQuizAnswer(card: GameCard, playerAnswer: string): CardValidationResult {
|
||||
if (!Array.isArray(card.answer)) {
|
||||
throw new Error('Quiz card answer must be an array');
|
||||
}
|
||||
|
||||
const options = card.answer as QuizOption[];
|
||||
const correctOption = options.find(opt => opt.correct);
|
||||
|
||||
if (!correctOption) {
|
||||
throw new Error('Quiz card must have one correct answer');
|
||||
}
|
||||
|
||||
const isCorrect = playerAnswer.toUpperCase() === correctOption.answer.toUpperCase();
|
||||
|
||||
return {
|
||||
isCorrect,
|
||||
submittedAnswer: playerAnswer,
|
||||
correctAnswer: correctOption.answer,
|
||||
explanation: isCorrect
|
||||
? `✅ Correct! ${correctOption.text}`
|
||||
: `❌ Wrong! Correct answer was ${correctOption.answer}: ${correctOption.text}`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate SENTENCE_PAIRING answer (reconstructed sentence)
|
||||
*/
|
||||
private validateSentencePairingAnswer(card: GameCard, playerAnswer: string[] | string): CardValidationResult {
|
||||
if (typeof card.answer !== 'string') {
|
||||
throw new Error('Sentence pairing card answer must be a string');
|
||||
}
|
||||
|
||||
// Handle both array of words and joined string
|
||||
const reconstructed = Array.isArray(playerAnswer)
|
||||
? playerAnswer.join(' ').toLowerCase().trim()
|
||||
: playerAnswer.toLowerCase().trim();
|
||||
|
||||
const correctSentence = card.answer.toLowerCase().trim();
|
||||
const isCorrect = reconstructed === correctSentence;
|
||||
|
||||
return {
|
||||
isCorrect,
|
||||
submittedAnswer: reconstructed,
|
||||
correctAnswer: card.answer,
|
||||
explanation: isCorrect
|
||||
? '✅ Perfect! You arranged the sentence correctly!'
|
||||
: `❌ Wrong order! Correct sentence: "${card.answer}"`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate OWN_ANSWER (check against acceptable answers array)
|
||||
*/
|
||||
private validateOwnAnswerAnswer(card: GameCard, playerAnswer: string): CardValidationResult {
|
||||
if (!Array.isArray(card.answer)) {
|
||||
throw new Error('Own answer card must have array of acceptable answers');
|
||||
}
|
||||
|
||||
const acceptableAnswers = card.answer as string[];
|
||||
const cleanPlayerAnswer = playerAnswer.toLowerCase().trim();
|
||||
|
||||
const isCorrect = acceptableAnswers.some(acceptable =>
|
||||
acceptable.toLowerCase().trim() === cleanPlayerAnswer
|
||||
);
|
||||
|
||||
return {
|
||||
isCorrect,
|
||||
submittedAnswer: playerAnswer,
|
||||
correctAnswer: acceptableAnswers,
|
||||
explanation: isCorrect
|
||||
? '✅ Correct! Your answer is acceptable.'
|
||||
: `❌ Your answer doesn't match any acceptable responses.`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate TRUE_FALSE answer
|
||||
*/
|
||||
private validateTrueFalseAnswer(card: GameCard, playerAnswer: string): CardValidationResult {
|
||||
if (typeof card.answer !== 'boolean' && typeof card.answer !== 'string') {
|
||||
throw new Error('True/false card answer must be boolean or string');
|
||||
}
|
||||
|
||||
// Convert player answer to boolean
|
||||
const playerBool = this.convertToBoolean(playerAnswer);
|
||||
const correctBool = typeof card.answer === 'boolean'
|
||||
? card.answer
|
||||
: this.convertToBoolean(card.answer);
|
||||
|
||||
const isCorrect = playerBool === correctBool;
|
||||
|
||||
return {
|
||||
isCorrect,
|
||||
submittedAnswer: playerAnswer,
|
||||
correctAnswer: correctBool ? 'True' : 'False',
|
||||
explanation: isCorrect
|
||||
? '✅ Correct!'
|
||||
: `❌ Wrong! The correct answer is ${correctBool ? 'True' : 'False'}.`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate CLOSER answer (numerical proximity)
|
||||
*/
|
||||
private validateCloserAnswer(card: GameCard, playerAnswer: string | number): CardValidationResult {
|
||||
if (typeof card.answer !== 'object' || !card.answer.correct || !card.answer.percent) {
|
||||
throw new Error('Closer card answer must have correct and percent fields');
|
||||
}
|
||||
|
||||
const closerAnswer = card.answer as CloserAnswer;
|
||||
const playerNumber = typeof playerAnswer === 'number'
|
||||
? playerAnswer
|
||||
: parseFloat(playerAnswer.toString());
|
||||
|
||||
if (isNaN(playerNumber)) {
|
||||
return {
|
||||
isCorrect: false,
|
||||
submittedAnswer: playerAnswer,
|
||||
correctAnswer: closerAnswer.correct,
|
||||
explanation: '❌ Invalid number! Please enter a valid numeric answer.'
|
||||
};
|
||||
}
|
||||
|
||||
const tolerance = closerAnswer.correct * (closerAnswer.percent / 100);
|
||||
const minValue = closerAnswer.correct - tolerance;
|
||||
const maxValue = closerAnswer.correct + tolerance;
|
||||
|
||||
const isCorrect = playerNumber >= minValue && playerNumber <= maxValue;
|
||||
|
||||
return {
|
||||
isCorrect,
|
||||
submittedAnswer: playerNumber,
|
||||
correctAnswer: closerAnswer.correct,
|
||||
explanation: isCorrect
|
||||
? `✅ Close enough! Correct answer: ${closerAnswer.correct}`
|
||||
: `❌ Not close enough! Correct answer: ${closerAnswer.correct} (±${closerAnswer.percent}%)`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert string to boolean for TRUE_FALSE validation
|
||||
*/
|
||||
private convertToBoolean(value: string): boolean {
|
||||
const lowerValue = value.toLowerCase().trim();
|
||||
return ['true', 'yes', '1', 'correct', 'right'].includes(lowerValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scramble array elements randomly
|
||||
*/
|
||||
private scrambleArray<T>(array: T[]): T[] {
|
||||
const scrambled = [...array];
|
||||
for (let i = scrambled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[scrambled[i], scrambled[j]] = [scrambled[j], scrambled[i]];
|
||||
}
|
||||
return scrambled;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { IContactRepository } from '../../Domain/IRepository/IContactRepository';
|
||||
import { EmailService } from './EmailService';
|
||||
import { ContactType } from '../../Domain/Contact/ContactAggregate';
|
||||
import { logOther, logError } from './Logger';
|
||||
import { EmailTemplateHelper, LocalizedSubjects } from './EmailTemplateHelper';
|
||||
|
||||
export interface EmailResponseData {
|
||||
to: string;
|
||||
message: string;
|
||||
contactId: string;
|
||||
adminUserId: string;
|
||||
contactName: string;
|
||||
contactType: ContactType;
|
||||
originalMessage: string;
|
||||
language?: 'en' | 'hu' | 'de'; // Default to 'en' if not specified
|
||||
}
|
||||
|
||||
export class ContactEmailService {
|
||||
constructor(
|
||||
private readonly contactRepo: IContactRepository,
|
||||
private readonly emailService: EmailService
|
||||
) {}
|
||||
|
||||
async sendResponse(responseData: EmailResponseData): Promise<void> {
|
||||
try {
|
||||
// First update the contact with the response
|
||||
await this.contactRepo.update(responseData.contactId, {
|
||||
adminResponse: responseData.message,
|
||||
responseDate: new Date(),
|
||||
respondedBy: responseData.adminUserId,
|
||||
});
|
||||
|
||||
// Determine language and template
|
||||
const language = responseData.language || 'en';
|
||||
const templateName = language === 'en' ? 'contact-response' : `contact-response-${language}`;
|
||||
|
||||
// Prepare template data
|
||||
const templateData = {
|
||||
contactName: responseData.contactName,
|
||||
contactTypeString: this.getContactTypeString(responseData.contactType, language),
|
||||
contactTypeBadge: this.getContactTypeBadge(responseData.contactType),
|
||||
originalMessage: responseData.originalMessage,
|
||||
adminResponse: responseData.message,
|
||||
companyName: 'SerpentRace',
|
||||
supportEmail: 'support@serpentrace.com'
|
||||
};
|
||||
|
||||
// Send email using EmailService with template
|
||||
const emailSent = await this.emailService.sendEmail({
|
||||
to: responseData.to,
|
||||
subject: this.getLocalizedContactResponseSubject(language),
|
||||
template: templateName,
|
||||
templateData
|
||||
});
|
||||
|
||||
if (emailSent) {
|
||||
logOther('Contact response email sent successfully', {
|
||||
to: responseData.to,
|
||||
subject: this.getLocalizedContactResponseSubject(language),
|
||||
contactId: responseData.contactId,
|
||||
respondedBy: responseData.adminUserId,
|
||||
language
|
||||
});
|
||||
} else {
|
||||
throw new Error('Email service failed to send email');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logError('Failed to send contact response email', error instanceof Error ? error : new Error(String(error)));
|
||||
throw new Error('Failed to send email response');
|
||||
}
|
||||
}
|
||||
|
||||
private getLocalizedContactResponseSubject(language: 'en' | 'hu' | 'de'): string {
|
||||
const subjects: LocalizedSubjects = {
|
||||
contactResponse: {
|
||||
en: 'SerpentRace - Response to Your Message',
|
||||
hu: 'SerpentRace - Válasz az üzenetére',
|
||||
de: 'SerpentRace - Antwort auf Ihre Nachricht'
|
||||
}
|
||||
};
|
||||
return EmailTemplateHelper.getLocalizedSubject('contactResponse', subjects, language);
|
||||
}
|
||||
|
||||
private getContactTypeString(type: ContactType, language: 'en' | 'hu' | 'de' = 'en'): string {
|
||||
const translations = {
|
||||
[ContactType.BUG]: {
|
||||
en: 'Bug Report',
|
||||
hu: 'Hiba bejelentés',
|
||||
de: 'Fehlerbericht'
|
||||
},
|
||||
[ContactType.PROBLEM]: {
|
||||
en: 'Problem',
|
||||
hu: 'Probléma',
|
||||
de: 'Problem'
|
||||
},
|
||||
[ContactType.QUESTION]: {
|
||||
en: 'Question',
|
||||
hu: 'Kérdés',
|
||||
de: 'Frage'
|
||||
},
|
||||
[ContactType.SALES]: {
|
||||
en: 'Sales Inquiry',
|
||||
hu: 'Értékesítési kérdés',
|
||||
de: 'Verkaufsanfrage'
|
||||
},
|
||||
[ContactType.OTHER]: {
|
||||
en: 'General Inquiry',
|
||||
hu: 'Általános kérdés',
|
||||
de: 'Allgemeine Anfrage'
|
||||
}
|
||||
};
|
||||
|
||||
return translations[type]?.[language] || translations[type]?.['en'] || 'Contact';
|
||||
}
|
||||
|
||||
private getContactTypeBadge(type: ContactType): string {
|
||||
switch (type) {
|
||||
case ContactType.BUG:
|
||||
return 'bug';
|
||||
case ContactType.PROBLEM:
|
||||
return 'problem';
|
||||
case ContactType.QUESTION:
|
||||
return 'question';
|
||||
case ContactType.SALES:
|
||||
return 'sales';
|
||||
case ContactType.OTHER:
|
||||
return 'other';
|
||||
default:
|
||||
return 'other';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
// Repository Interfaces
|
||||
import { IUserRepository } from '../../Domain/IRepository/IUserRepository';
|
||||
import { IChatRepository } from '../../Domain/IRepository/IChatRepository';
|
||||
import { IChatArchiveRepository } from '../../Domain/IRepository/IChatArchiveRepository';
|
||||
import { IDeckRepository } from '../../Domain/IRepository/IDeckRepository';
|
||||
import { IOrganizationRepository } from '../../Domain/IRepository/IOrganizationRepository';
|
||||
import { IContactRepository } from '../../Domain/IRepository/IContactRepository';
|
||||
import { IGameRepository } from '../../Domain/IRepository/IGameRepository';
|
||||
|
||||
// Repository Implementations
|
||||
import { UserRepository } from '../../Infrastructure/Repository/UserRepository';
|
||||
import { ChatRepository } from '../../Infrastructure/Repository/ChatRepository';
|
||||
import { ChatArchiveRepository } from '../../Infrastructure/Repository/ChatArchiveRepository';
|
||||
import { DeckRepository } from '../../Infrastructure/Repository/DeckRepository';
|
||||
import { OrganizationRepository } from '../../Infrastructure/Repository/OrganizationRepository';
|
||||
import { ContactRepository } from '../../Infrastructure/Repository/ContactRepository';
|
||||
import { GameRepository } from '../../Infrastructure/Repository/GameRepository';
|
||||
|
||||
// Command Handlers
|
||||
import { CreateUserCommandHandler } from '../User/commands/CreateUserCommandHandler';
|
||||
import { LoginCommandHandler } from '../User/commands/LoginCommandHandler';
|
||||
import { LogoutCommandHandler } from '../User/commands/LogoutCommandHandler';
|
||||
import { UpdateUserCommandHandler } from '../User/commands/UpdateUserCommandHandler';
|
||||
import { DeactivateUserCommandHandler } from '../User/commands/DeactivateUserCommandHandler';
|
||||
import { DeleteUserCommandHandler } from '../User/commands/DeleteUserCommandHandler';
|
||||
import { VerifyEmailCommandHandler } from '../User/commands/VerifyEmailCommandHandler';
|
||||
import { RequestPasswordResetCommandHandler } from '../User/commands/RequestPasswordResetCommandHandler';
|
||||
import { ResetPasswordCommandHandler } from '../User/commands/ResetPasswordCommandHandler';
|
||||
import { CreateChatCommandHandler } from '../Chat/commands/CreateChatCommandHandler';
|
||||
import { SendMessageCommandHandler } from '../Chat/commands/SendMessageCommandHandler';
|
||||
import { ArchiveChatCommandHandler, RestoreChatCommandHandler } from '../Chat/commands/ChatArchiveCommandHandlers';
|
||||
import { CreateDeckCommandHandler } from '../Deck/commands/CreateDeckCommandHandler';
|
||||
import { UpdateDeckCommandHandler } from '../Deck/commands/UpdateDeckCommandHandler';
|
||||
import { DeleteDeckCommandHandler } from '../Deck/commands/DeleteDeckCommandHandler';
|
||||
import { CreateOrganizationCommandHandler } from '../Organization/commands/CreateOrganizationCommandHandler';
|
||||
import { UpdateOrganizationCommandHandler } from '../Organization/commands/UpdateOrganizationCommandHandler';
|
||||
import { DeleteOrganizationCommandHandler } from '../Organization/commands/DeleteOrganizationCommandHandler';
|
||||
import { ProcessOrgAuthCallbackCommandHandler } from '../Organization/commands/ProcessOrgAuthCallbackCommandHandler';
|
||||
import { CreateContactCommandHandler } from '../Contact/commands/CreateContactCommandHandler';
|
||||
import { UpdateContactCommandHandler } from '../Contact/commands/UpdateContactCommandHandler';
|
||||
import { DeleteContactCommandHandler } from '../Contact/commands/DeleteContactCommandHandler';
|
||||
|
||||
// Query Handlers
|
||||
import { GetUserByIdQueryHandler } from '../User/queries/GetUserByIdQueryHandler';
|
||||
import { GetUsersByPageQueryHandler } from '../User/queries/GetUsersByPageQueryHandler';
|
||||
import { GetUserChatsQueryHandler } from '../Chat/queries/GetUserChatsQueryHandler';
|
||||
import { GetChatHistoryQueryHandler, GetArchivedChatsQueryHandler } from '../Chat/queries/ChatHistoryQueryHandlers';
|
||||
import { GetChatsByPageQueryHandler } from '../Chat/queries/GetChatsByPageQueryHandler';
|
||||
import { GetDeckByIdQueryHandler } from '../Deck/queries/GetDeckByIdQueryHandler';
|
||||
import { GetDecksByPageQueryHandler } from '../Deck/queries/GetDecksByPageQueryHandler';
|
||||
import { GetOrganizationByIdQueryHandler } from '../Organization/queries/GetOrganizationByIdQueryHandler';
|
||||
import { GetOrganizationsByPageQueryHandler } from '../Organization/queries/GetOrganizationsByPageQueryHandler';
|
||||
import { GetOrganizationLoginUrlQueryHandler } from '../Organization/queries/GetOrganizationLoginUrlQueryHandler';
|
||||
import { GetContactByIdQueryHandler } from '../Contact/queries/GetContactByIdQueryHandler';
|
||||
import { GetContactsByPageQueryHandler } from '../Contact/queries/GetContactsByPageQueryHandler';
|
||||
|
||||
// Services
|
||||
import { JWTService } from './JWTService';
|
||||
import { EmailService } from './EmailService';
|
||||
import { GameTokenService } from './GameTokenService';
|
||||
import { ContactEmailService } from './ContactEmailService';
|
||||
import { DeckImportExportService } from './DeckImportExportService';
|
||||
import { FieldEffectService } from './FieldEffectService';
|
||||
import { CardDrawingService } from './CardDrawingService';
|
||||
import { GamemasterService } from './GamemasterService';
|
||||
import { RedisService } from './RedisService';
|
||||
import { GameService } from '../Game/GameService';
|
||||
import { BoardGenerationService } from '../Game/BoardGenerationService';
|
||||
import { GenerateBoardCommandHandler } from '../Game/commands/GenerateBoardCommandHandler';
|
||||
|
||||
/**
|
||||
* Central Dependency Injection Container
|
||||
* Manages all repositories, command handlers, and query handlers as singletons
|
||||
*/
|
||||
export class DIContainer {
|
||||
private static instance: DIContainer;
|
||||
|
||||
// Repositories - Using interfaces for better abstraction
|
||||
private _userRepository: IUserRepository | null = null;
|
||||
private _chatRepository: IChatRepository | null = null;
|
||||
private _chatArchiveRepository: IChatArchiveRepository | null = null;
|
||||
private _deckRepository: IDeckRepository | null = null;
|
||||
private _organizationRepository: IOrganizationRepository | null = null;
|
||||
private _contactRepository: IContactRepository | null = null;
|
||||
private _gameRepository: IGameRepository | null = null;
|
||||
|
||||
// Services
|
||||
private _jwtService: JWTService | null = null;
|
||||
private _emailService: EmailService | null = null;
|
||||
private _gameTokenService: GameTokenService | null = null;
|
||||
private _contactEmailService: ContactEmailService | null = null;
|
||||
private _deckImportExportService: DeckImportExportService | null = null;
|
||||
private _cardDrawingService: CardDrawingService | null = null;
|
||||
private _gamemasterService: GamemasterService | null = null;
|
||||
private _fieldEffectService: FieldEffectService | null = null;
|
||||
private _gameService: GameService | null = null;
|
||||
private _boardGenerationService: BoardGenerationService | null = null;
|
||||
|
||||
// Command Handlers
|
||||
private _createUserCommandHandler: CreateUserCommandHandler | null = null;
|
||||
private _loginCommandHandler: LoginCommandHandler | null = null;
|
||||
private _logoutCommandHandler: LogoutCommandHandler | null = null;
|
||||
private _updateUserCommandHandler: UpdateUserCommandHandler | null = null;
|
||||
private _deactivateUserCommandHandler: DeactivateUserCommandHandler | null = null;
|
||||
private _deleteUserCommandHandler: DeleteUserCommandHandler | null = null;
|
||||
private _verifyEmailCommandHandler: VerifyEmailCommandHandler | null = null;
|
||||
private _requestPasswordResetCommandHandler: RequestPasswordResetCommandHandler | null = null;
|
||||
private _resetPasswordCommandHandler: ResetPasswordCommandHandler | null = null;
|
||||
private _createChatCommandHandler: CreateChatCommandHandler | null = null;
|
||||
private _sendMessageCommandHandler: SendMessageCommandHandler | null = null;
|
||||
private _archiveChatCommandHandler: ArchiveChatCommandHandler | null = null;
|
||||
private _restoreChatCommandHandler: RestoreChatCommandHandler | null = null;
|
||||
private _createDeckCommandHandler: CreateDeckCommandHandler | null = null;
|
||||
private _updateDeckCommandHandler: UpdateDeckCommandHandler | null = null;
|
||||
private _deleteDeckCommandHandler: DeleteDeckCommandHandler | null = null;
|
||||
private _createOrganizationCommandHandler: CreateOrganizationCommandHandler | null = null;
|
||||
private _updateOrganizationCommandHandler: UpdateOrganizationCommandHandler | null = null;
|
||||
private _deleteOrganizationCommandHandler: DeleteOrganizationCommandHandler | null = null;
|
||||
private _processOrgAuthCallbackCommandHandler: ProcessOrgAuthCallbackCommandHandler | null = null;
|
||||
private _createContactCommandHandler: CreateContactCommandHandler | null = null;
|
||||
private _updateContactCommandHandler: UpdateContactCommandHandler | null = null;
|
||||
private _deleteContactCommandHandler: DeleteContactCommandHandler | null = null;
|
||||
private _generateBoardCommandHandler: GenerateBoardCommandHandler | null = null;
|
||||
|
||||
// Query Handlers
|
||||
private _getUserByIdQueryHandler: GetUserByIdQueryHandler | null = null;
|
||||
private _getUsersByPageQueryHandler: GetUsersByPageQueryHandler | null = null;
|
||||
private _getUserChatsQueryHandler: GetUserChatsQueryHandler | null = null;
|
||||
private _getChatHistoryQueryHandler: GetChatHistoryQueryHandler | null = null;
|
||||
private _getArchivedChatsQueryHandler: GetArchivedChatsQueryHandler | null = null;
|
||||
private _getChatsByPageQueryHandler: GetChatsByPageQueryHandler | null = null;
|
||||
private _getDeckByIdQueryHandler: GetDeckByIdQueryHandler | null = null;
|
||||
private _getDecksByPageQueryHandler: GetDecksByPageQueryHandler | null = null;
|
||||
private _getOrganizationByIdQueryHandler: GetOrganizationByIdQueryHandler | null = null;
|
||||
private _getOrganizationsByPageQueryHandler: GetOrganizationsByPageQueryHandler | null = null;
|
||||
private _getOrganizationLoginUrlQueryHandler: GetOrganizationLoginUrlQueryHandler | null = null;
|
||||
private _getContactByIdQueryHandler: GetContactByIdQueryHandler | null = null;
|
||||
private _getContactsByPageQueryHandler: GetContactsByPageQueryHandler | null = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): DIContainer {
|
||||
if (!DIContainer.instance) {
|
||||
DIContainer.instance = new DIContainer();
|
||||
}
|
||||
return DIContainer.instance;
|
||||
}
|
||||
|
||||
// Repository getters - Return interfaces for better abstraction
|
||||
public get userRepository(): IUserRepository {
|
||||
if (!this._userRepository) {
|
||||
this._userRepository = new UserRepository();
|
||||
}
|
||||
return this._userRepository;
|
||||
}
|
||||
|
||||
public get chatRepository(): IChatRepository {
|
||||
if (!this._chatRepository) {
|
||||
this._chatRepository = new ChatRepository();
|
||||
}
|
||||
return this._chatRepository;
|
||||
}
|
||||
|
||||
public get chatArchiveRepository(): IChatArchiveRepository {
|
||||
if (!this._chatArchiveRepository) {
|
||||
this._chatArchiveRepository = new ChatArchiveRepository();
|
||||
}
|
||||
return this._chatArchiveRepository;
|
||||
}
|
||||
|
||||
public get deckRepository(): IDeckRepository {
|
||||
if (!this._deckRepository) {
|
||||
this._deckRepository = new DeckRepository();
|
||||
}
|
||||
return this._deckRepository;
|
||||
}
|
||||
|
||||
public get organizationRepository(): IOrganizationRepository {
|
||||
if (!this._organizationRepository) {
|
||||
this._organizationRepository = new OrganizationRepository();
|
||||
}
|
||||
return this._organizationRepository;
|
||||
}
|
||||
|
||||
public get contactRepository(): IContactRepository {
|
||||
if (!this._contactRepository) {
|
||||
this._contactRepository = new ContactRepository();
|
||||
}
|
||||
return this._contactRepository;
|
||||
}
|
||||
|
||||
public get gameRepository(): IGameRepository {
|
||||
if (!this._gameRepository) {
|
||||
this._gameRepository = new GameRepository();
|
||||
}
|
||||
return this._gameRepository;
|
||||
}
|
||||
|
||||
// Services getters
|
||||
public get jwtService(): JWTService {
|
||||
if (!this._jwtService) {
|
||||
this._jwtService = new JWTService();
|
||||
}
|
||||
return this._jwtService;
|
||||
}
|
||||
|
||||
public get emailService(): EmailService {
|
||||
if (!this._emailService) {
|
||||
this._emailService = new EmailService();
|
||||
}
|
||||
return this._emailService;
|
||||
}
|
||||
|
||||
public get gameTokenService(): GameTokenService {
|
||||
if (!this._gameTokenService) {
|
||||
this._gameTokenService = new GameTokenService();
|
||||
}
|
||||
return this._gameTokenService;
|
||||
}
|
||||
|
||||
public get contactEmailService(): ContactEmailService {
|
||||
if (!this._contactEmailService) {
|
||||
this._contactEmailService = new ContactEmailService(this.contactRepository, this.emailService);
|
||||
}
|
||||
return this._contactEmailService;
|
||||
}
|
||||
|
||||
public get deckImportExportService(): DeckImportExportService {
|
||||
if (!this._deckImportExportService) {
|
||||
this._deckImportExportService = new DeckImportExportService(this.deckRepository);
|
||||
}
|
||||
return this._deckImportExportService;
|
||||
}
|
||||
|
||||
public get cardDrawingService(): CardDrawingService {
|
||||
if (!this._cardDrawingService) {
|
||||
this._cardDrawingService = new CardDrawingService();
|
||||
}
|
||||
return this._cardDrawingService;
|
||||
}
|
||||
|
||||
public get gamemasterService(): GamemasterService {
|
||||
if (!this._gamemasterService) {
|
||||
this._gamemasterService = new GamemasterService();
|
||||
}
|
||||
return this._gamemasterService;
|
||||
}
|
||||
|
||||
public get fieldEffectService(): FieldEffectService {
|
||||
if (!this._fieldEffectService) {
|
||||
this._fieldEffectService = new FieldEffectService(
|
||||
this.boardGenerationService,
|
||||
this.gamemasterService
|
||||
);
|
||||
}
|
||||
return this._fieldEffectService;
|
||||
}
|
||||
|
||||
public get gameService(): GameService {
|
||||
if (!this._gameService) {
|
||||
this._gameService = new GameService();
|
||||
}
|
||||
return this._gameService;
|
||||
}
|
||||
|
||||
public get boardGenerationService(): BoardGenerationService {
|
||||
if (!this._boardGenerationService) {
|
||||
this._boardGenerationService = new BoardGenerationService();
|
||||
}
|
||||
return this._boardGenerationService;
|
||||
}
|
||||
|
||||
// Command Handler getters
|
||||
public get createUserCommandHandler(): CreateUserCommandHandler {
|
||||
if (!this._createUserCommandHandler) {
|
||||
this._createUserCommandHandler = new CreateUserCommandHandler(this.userRepository, this.emailService);
|
||||
}
|
||||
return this._createUserCommandHandler;
|
||||
}
|
||||
|
||||
public get loginCommandHandler(): LoginCommandHandler {
|
||||
if (!this._loginCommandHandler) {
|
||||
this._loginCommandHandler = new LoginCommandHandler(this.userRepository, this.jwtService, this.organizationRepository);
|
||||
}
|
||||
return this._loginCommandHandler;
|
||||
}
|
||||
|
||||
public get logoutCommandHandler(): LogoutCommandHandler {
|
||||
if (!this._logoutCommandHandler) {
|
||||
this._logoutCommandHandler = new LogoutCommandHandler(this.userRepository);
|
||||
}
|
||||
return this._logoutCommandHandler;
|
||||
}
|
||||
|
||||
public get updateUserCommandHandler(): UpdateUserCommandHandler {
|
||||
if (!this._updateUserCommandHandler) {
|
||||
this._updateUserCommandHandler = new UpdateUserCommandHandler(this.userRepository);
|
||||
}
|
||||
return this._updateUserCommandHandler;
|
||||
}
|
||||
|
||||
public get deactivateUserCommandHandler(): DeactivateUserCommandHandler {
|
||||
if (!this._deactivateUserCommandHandler) {
|
||||
this._deactivateUserCommandHandler = new DeactivateUserCommandHandler(this.userRepository);
|
||||
}
|
||||
return this._deactivateUserCommandHandler;
|
||||
}
|
||||
|
||||
public get deleteUserCommandHandler(): DeleteUserCommandHandler {
|
||||
if (!this._deleteUserCommandHandler) {
|
||||
this._deleteUserCommandHandler = new DeleteUserCommandHandler(this.userRepository);
|
||||
}
|
||||
return this._deleteUserCommandHandler;
|
||||
}
|
||||
|
||||
public get verifyEmailCommandHandler(): VerifyEmailCommandHandler {
|
||||
if (!this._verifyEmailCommandHandler) {
|
||||
this._verifyEmailCommandHandler = new VerifyEmailCommandHandler(this.userRepository);
|
||||
}
|
||||
return this._verifyEmailCommandHandler;
|
||||
}
|
||||
|
||||
public get requestPasswordResetCommandHandler(): RequestPasswordResetCommandHandler {
|
||||
if (!this._requestPasswordResetCommandHandler) {
|
||||
this._requestPasswordResetCommandHandler = new RequestPasswordResetCommandHandler(this.userRepository, this.emailService);
|
||||
}
|
||||
return this._requestPasswordResetCommandHandler;
|
||||
}
|
||||
|
||||
public get resetPasswordCommandHandler(): ResetPasswordCommandHandler {
|
||||
if (!this._resetPasswordCommandHandler) {
|
||||
this._resetPasswordCommandHandler = new ResetPasswordCommandHandler(this.userRepository);
|
||||
}
|
||||
return this._resetPasswordCommandHandler;
|
||||
}
|
||||
|
||||
public get createChatCommandHandler(): CreateChatCommandHandler {
|
||||
if (!this._createChatCommandHandler) {
|
||||
this._createChatCommandHandler = new CreateChatCommandHandler(this.chatRepository, this.userRepository);
|
||||
}
|
||||
return this._createChatCommandHandler;
|
||||
}
|
||||
|
||||
public get sendMessageCommandHandler(): SendMessageCommandHandler {
|
||||
if (!this._sendMessageCommandHandler) {
|
||||
this._sendMessageCommandHandler = new SendMessageCommandHandler(this.chatRepository);
|
||||
}
|
||||
return this._sendMessageCommandHandler;
|
||||
}
|
||||
|
||||
public get archiveChatCommandHandler(): ArchiveChatCommandHandler {
|
||||
if (!this._archiveChatCommandHandler) {
|
||||
this._archiveChatCommandHandler = new ArchiveChatCommandHandler(this.chatRepository);
|
||||
}
|
||||
return this._archiveChatCommandHandler;
|
||||
}
|
||||
|
||||
public get restoreChatCommandHandler(): RestoreChatCommandHandler {
|
||||
if (!this._restoreChatCommandHandler) {
|
||||
this._restoreChatCommandHandler = new RestoreChatCommandHandler(this.chatRepository);
|
||||
}
|
||||
return this._restoreChatCommandHandler;
|
||||
}
|
||||
|
||||
public get createDeckCommandHandler(): CreateDeckCommandHandler {
|
||||
if (!this._createDeckCommandHandler) {
|
||||
this._createDeckCommandHandler = new CreateDeckCommandHandler(
|
||||
this.deckRepository,
|
||||
this.userRepository,
|
||||
this.organizationRepository
|
||||
);
|
||||
}
|
||||
return this._createDeckCommandHandler;
|
||||
}
|
||||
|
||||
public get updateDeckCommandHandler(): UpdateDeckCommandHandler {
|
||||
if (!this._updateDeckCommandHandler) {
|
||||
this._updateDeckCommandHandler = new UpdateDeckCommandHandler(this.deckRepository);
|
||||
}
|
||||
return this._updateDeckCommandHandler;
|
||||
}
|
||||
|
||||
public get deleteDeckCommandHandler(): DeleteDeckCommandHandler {
|
||||
if (!this._deleteDeckCommandHandler) {
|
||||
this._deleteDeckCommandHandler = new DeleteDeckCommandHandler(this.deckRepository);
|
||||
}
|
||||
return this._deleteDeckCommandHandler;
|
||||
}
|
||||
|
||||
public get createOrganizationCommandHandler(): CreateOrganizationCommandHandler {
|
||||
if (!this._createOrganizationCommandHandler) {
|
||||
this._createOrganizationCommandHandler = new CreateOrganizationCommandHandler(this.organizationRepository);
|
||||
}
|
||||
return this._createOrganizationCommandHandler;
|
||||
}
|
||||
|
||||
public get updateOrganizationCommandHandler(): UpdateOrganizationCommandHandler {
|
||||
if (!this._updateOrganizationCommandHandler) {
|
||||
this._updateOrganizationCommandHandler = new UpdateOrganizationCommandHandler(this.organizationRepository);
|
||||
}
|
||||
return this._updateOrganizationCommandHandler;
|
||||
}
|
||||
|
||||
public get deleteOrganizationCommandHandler(): DeleteOrganizationCommandHandler {
|
||||
if (!this._deleteOrganizationCommandHandler) {
|
||||
this._deleteOrganizationCommandHandler = new DeleteOrganizationCommandHandler(this.organizationRepository);
|
||||
}
|
||||
return this._deleteOrganizationCommandHandler;
|
||||
}
|
||||
|
||||
public get processOrgAuthCallbackCommandHandler(): ProcessOrgAuthCallbackCommandHandler {
|
||||
if (!this._processOrgAuthCallbackCommandHandler) {
|
||||
this._processOrgAuthCallbackCommandHandler = new ProcessOrgAuthCallbackCommandHandler(this.userRepository, this.organizationRepository);
|
||||
}
|
||||
return this._processOrgAuthCallbackCommandHandler;
|
||||
}
|
||||
|
||||
public get createContactCommandHandler(): CreateContactCommandHandler {
|
||||
if (!this._createContactCommandHandler) {
|
||||
this._createContactCommandHandler = new CreateContactCommandHandler(this.contactRepository);
|
||||
}
|
||||
return this._createContactCommandHandler;
|
||||
}
|
||||
|
||||
public get updateContactCommandHandler(): UpdateContactCommandHandler {
|
||||
if (!this._updateContactCommandHandler) {
|
||||
this._updateContactCommandHandler = new UpdateContactCommandHandler(this.contactRepository);
|
||||
}
|
||||
return this._updateContactCommandHandler;
|
||||
}
|
||||
|
||||
public get deleteContactCommandHandler(): DeleteContactCommandHandler {
|
||||
if (!this._deleteContactCommandHandler) {
|
||||
this._deleteContactCommandHandler = new DeleteContactCommandHandler(this.contactRepository);
|
||||
}
|
||||
return this._deleteContactCommandHandler;
|
||||
}
|
||||
|
||||
public get generateBoardCommandHandler(): GenerateBoardCommandHandler {
|
||||
if (!this._generateBoardCommandHandler) {
|
||||
this._generateBoardCommandHandler = new GenerateBoardCommandHandler(this.boardGenerationService, RedisService.getInstance());
|
||||
}
|
||||
return this._generateBoardCommandHandler;
|
||||
}
|
||||
|
||||
// Query Handler getters
|
||||
public get getUserByIdQueryHandler(): GetUserByIdQueryHandler {
|
||||
if (!this._getUserByIdQueryHandler) {
|
||||
this._getUserByIdQueryHandler = new GetUserByIdQueryHandler(this.userRepository);
|
||||
}
|
||||
return this._getUserByIdQueryHandler;
|
||||
}
|
||||
|
||||
public get getUserChatsQueryHandler(): GetUserChatsQueryHandler {
|
||||
if (!this._getUserChatsQueryHandler) {
|
||||
this._getUserChatsQueryHandler = new GetUserChatsQueryHandler(this.chatRepository, this.chatArchiveRepository);
|
||||
}
|
||||
return this._getUserChatsQueryHandler;
|
||||
}
|
||||
|
||||
public get getChatHistoryQueryHandler(): GetChatHistoryQueryHandler {
|
||||
if (!this._getChatHistoryQueryHandler) {
|
||||
this._getChatHistoryQueryHandler = new GetChatHistoryQueryHandler(this.chatRepository, this.chatArchiveRepository);
|
||||
}
|
||||
return this._getChatHistoryQueryHandler;
|
||||
}
|
||||
|
||||
public get getArchivedChatsQueryHandler(): GetArchivedChatsQueryHandler {
|
||||
if (!this._getArchivedChatsQueryHandler) {
|
||||
this._getArchivedChatsQueryHandler = new GetArchivedChatsQueryHandler(this.chatArchiveRepository);
|
||||
}
|
||||
return this._getArchivedChatsQueryHandler;
|
||||
}
|
||||
|
||||
public get getDeckByIdQueryHandler(): GetDeckByIdQueryHandler {
|
||||
if (!this._getDeckByIdQueryHandler) {
|
||||
this._getDeckByIdQueryHandler = new GetDeckByIdQueryHandler(this.deckRepository);
|
||||
}
|
||||
return this._getDeckByIdQueryHandler;
|
||||
}
|
||||
|
||||
public get getOrganizationByIdQueryHandler(): GetOrganizationByIdQueryHandler {
|
||||
if (!this._getOrganizationByIdQueryHandler) {
|
||||
this._getOrganizationByIdQueryHandler = new GetOrganizationByIdQueryHandler(this.organizationRepository);
|
||||
}
|
||||
return this._getOrganizationByIdQueryHandler;
|
||||
}
|
||||
|
||||
public get getOrganizationLoginUrlQueryHandler(): GetOrganizationLoginUrlQueryHandler {
|
||||
if (!this._getOrganizationLoginUrlQueryHandler) {
|
||||
this._getOrganizationLoginUrlQueryHandler = new GetOrganizationLoginUrlQueryHandler(this.organizationRepository);
|
||||
}
|
||||
return this._getOrganizationLoginUrlQueryHandler;
|
||||
}
|
||||
|
||||
public get getContactByIdQueryHandler(): GetContactByIdQueryHandler {
|
||||
if (!this._getContactByIdQueryHandler) {
|
||||
this._getContactByIdQueryHandler = new GetContactByIdQueryHandler(this.contactRepository);
|
||||
}
|
||||
return this._getContactByIdQueryHandler;
|
||||
}
|
||||
|
||||
public get getContactsByPageQueryHandler(): GetContactsByPageQueryHandler {
|
||||
if (!this._getContactsByPageQueryHandler) {
|
||||
this._getContactsByPageQueryHandler = new GetContactsByPageQueryHandler(this.contactRepository);
|
||||
}
|
||||
return this._getContactsByPageQueryHandler;
|
||||
}
|
||||
|
||||
// New paginated query handlers
|
||||
public get getUsersByPageQueryHandler(): GetUsersByPageQueryHandler {
|
||||
if (!this._getUsersByPageQueryHandler) {
|
||||
this._getUsersByPageQueryHandler = new GetUsersByPageQueryHandler(this.userRepository);
|
||||
}
|
||||
return this._getUsersByPageQueryHandler;
|
||||
}
|
||||
|
||||
public get getDecksByPageQueryHandler(): GetDecksByPageQueryHandler {
|
||||
if (!this._getDecksByPageQueryHandler) {
|
||||
this._getDecksByPageQueryHandler = new GetDecksByPageQueryHandler(this.deckRepository);
|
||||
}
|
||||
return this._getDecksByPageQueryHandler;
|
||||
}
|
||||
|
||||
public get getOrganizationsByPageQueryHandler(): GetOrganizationsByPageQueryHandler {
|
||||
if (!this._getOrganizationsByPageQueryHandler) {
|
||||
this._getOrganizationsByPageQueryHandler = new GetOrganizationsByPageQueryHandler(this.organizationRepository);
|
||||
}
|
||||
return this._getOrganizationsByPageQueryHandler;
|
||||
}
|
||||
|
||||
public get getChatsByPageQueryHandler(): GetChatsByPageQueryHandler {
|
||||
if (!this._getChatsByPageQueryHandler) {
|
||||
this._getChatsByPageQueryHandler = new GetChatsByPageQueryHandler(this.chatRepository);
|
||||
}
|
||||
return this._getChatsByPageQueryHandler;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const container = DIContainer.getInstance();
|
||||
@@ -0,0 +1,208 @@
|
||||
import * as crypto from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import { DeckAggregate, State, CType } from '../../Domain/Deck/DeckAggregate';
|
||||
import { IDeckRepository } from '../../Domain/IRepository/IDeckRepository';
|
||||
import { logError, logAuth } from './Logger';
|
||||
|
||||
export interface SprDeckData {
|
||||
name: string;
|
||||
type: number;
|
||||
cards: any[];
|
||||
ctype: number;
|
||||
exportDate: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface ImportDeckCommand {
|
||||
name: string;
|
||||
type: number;
|
||||
cards: any[];
|
||||
ctype?: number;
|
||||
userid: string;
|
||||
}
|
||||
|
||||
export class DeckImportExportService {
|
||||
private readonly encryptionKey: string;
|
||||
private readonly algorithm = 'aes-256-gcm';
|
||||
|
||||
constructor(private readonly deckRepo: IDeckRepository) {
|
||||
this.encryptionKey = process.env.DECK_ENCRYPTION_KEY || 'your-32-byte-encryption-key-here!!';
|
||||
|
||||
if (this.encryptionKey.length !== 32) {
|
||||
throw new Error('DECK_ENCRYPTION_KEY must be exactly 32 characters long');
|
||||
}
|
||||
}
|
||||
|
||||
async exportDeckToSpr(deckId: string, userId: string): Promise<Buffer> {
|
||||
try {
|
||||
const deck = await this.deckRepo.findByIdIncludingDeleted(deckId);
|
||||
|
||||
if (!deck) {
|
||||
throw new Error('Deck not found');
|
||||
}
|
||||
|
||||
if (deck.userid !== userId) {
|
||||
throw new Error('Unauthorized: You can only export your own decks');
|
||||
}
|
||||
|
||||
const deckData: SprDeckData = {
|
||||
name: deck.name,
|
||||
type: deck.type,
|
||||
cards: deck.cards,
|
||||
ctype: deck.ctype,
|
||||
exportDate: new Date().toISOString(),
|
||||
version: '1.0'
|
||||
};
|
||||
|
||||
const jsonString = JSON.stringify(deckData);
|
||||
const encrypted = this.encrypt(jsonString);
|
||||
|
||||
logAuth('Deck exported to SPR format', userId, {
|
||||
deckId: deck.id,
|
||||
deckName: deck.name,
|
||||
cardCount: deck.cards.length
|
||||
});
|
||||
|
||||
return encrypted;
|
||||
} catch (error) {
|
||||
logError('Failed to export deck to SPR', error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async importDeckFromSpr(sprData: Buffer, userId: string): Promise<DeckAggregate> {
|
||||
try {
|
||||
const decrypted = this.decrypt(sprData);
|
||||
const deckData: SprDeckData = JSON.parse(decrypted);
|
||||
|
||||
// Validate required fields
|
||||
if (!deckData.name || !deckData.cards || deckData.type === undefined) {
|
||||
throw new Error('Invalid SPR file format: missing required fields');
|
||||
}
|
||||
|
||||
// Create new deck
|
||||
const newDeck = new DeckAggregate();
|
||||
newDeck.name = deckData.name;
|
||||
newDeck.type = deckData.type;
|
||||
newDeck.userid = userId;
|
||||
newDeck.cards = deckData.cards;
|
||||
newDeck.ctype = deckData.ctype || CType.PUBLIC;
|
||||
newDeck.state = State.ACTIVE;
|
||||
|
||||
const createdDeck = await this.deckRepo.create(newDeck);
|
||||
|
||||
logAuth('Deck imported from SPR format', userId, {
|
||||
deckId: createdDeck.id,
|
||||
deckName: createdDeck.name,
|
||||
cardCount: createdDeck.cards.length,
|
||||
originalExportDate: deckData.exportDate
|
||||
});
|
||||
|
||||
return createdDeck;
|
||||
} catch (error) {
|
||||
logError('Failed to import deck from SPR', error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async importDeckFromJson(jsonData: any, userId: string): Promise<DeckAggregate> {
|
||||
try {
|
||||
// Validate required fields
|
||||
if (!jsonData.name || !jsonData.cards || jsonData.type === undefined) {
|
||||
throw new Error('Invalid JSON format: missing required fields (name, cards, type)');
|
||||
}
|
||||
|
||||
// Create new deck
|
||||
const newDeck = new DeckAggregate();
|
||||
newDeck.name = jsonData.name;
|
||||
newDeck.type = jsonData.type;
|
||||
newDeck.userid = userId;
|
||||
newDeck.cards = jsonData.cards;
|
||||
newDeck.ctype = jsonData.ctype || CType.PUBLIC;
|
||||
newDeck.state = State.ACTIVE;
|
||||
|
||||
const createdDeck = await this.deckRepo.create(newDeck);
|
||||
|
||||
logAuth('Deck imported from JSON format', userId, {
|
||||
deckId: createdDeck.id,
|
||||
deckName: createdDeck.name,
|
||||
cardCount: createdDeck.cards.length
|
||||
});
|
||||
|
||||
return createdDeck;
|
||||
} catch (error) {
|
||||
logError('Failed to import deck from JSON', error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Admin-only function to import JSON without encryption
|
||||
async adminImportFromJson(jsonData: any, targetUserId: string, adminUserId: string): Promise<DeckAggregate> {
|
||||
try {
|
||||
if (!jsonData.name || !jsonData.cards || jsonData.type === undefined) {
|
||||
throw new Error('Invalid JSON format: missing required fields (name, cards, type)');
|
||||
}
|
||||
|
||||
const newDeck = new DeckAggregate();
|
||||
newDeck.name = jsonData.name;
|
||||
newDeck.type = jsonData.type;
|
||||
newDeck.userid = targetUserId;
|
||||
newDeck.cards = jsonData.cards;
|
||||
newDeck.ctype = jsonData.ctype || CType.PUBLIC;
|
||||
newDeck.state = jsonData.state || State.ACTIVE;
|
||||
|
||||
const createdDeck = await this.deckRepo.create(newDeck);
|
||||
|
||||
logAuth('Deck imported by admin from JSON', adminUserId, {
|
||||
deckId: createdDeck.id,
|
||||
deckName: createdDeck.name,
|
||||
cardCount: createdDeck.cards.length,
|
||||
targetUserId: targetUserId
|
||||
});
|
||||
|
||||
return createdDeck;
|
||||
} catch (error) {
|
||||
logError('Failed to admin import deck from JSON', error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private encrypt(text: string): Buffer {
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv(this.algorithm, this.encryptionKey, iv);
|
||||
cipher.setAAD(Buffer.from('SerpentRace-Deck', 'utf8'));
|
||||
|
||||
let encrypted = cipher.update(text, 'utf8');
|
||||
encrypted = Buffer.concat([encrypted, cipher.final()]);
|
||||
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
return Buffer.concat([iv, authTag, encrypted]);
|
||||
}
|
||||
|
||||
private decrypt(encryptedData: Buffer): string {
|
||||
if (encryptedData.length < 32) {
|
||||
throw new Error('Invalid SPR file: file too short');
|
||||
}
|
||||
|
||||
const iv = encryptedData.slice(0, 16);
|
||||
const authTag = encryptedData.slice(16, 32);
|
||||
const encrypted = encryptedData.slice(32);
|
||||
|
||||
const decipher = crypto.createDecipheriv(this.algorithm, this.encryptionKey, iv);
|
||||
decipher.setAAD(Buffer.from('SerpentRace-Deck', 'utf8'));
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
let decrypted = decipher.update(encrypted, undefined, 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
generateFilename(deckName: string): string {
|
||||
// Sanitize deck name for filename
|
||||
const sanitized = deckName.replace(/[^a-zA-Z0-9\-_]/g, '_');
|
||||
const timestamp = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
return `${sanitized}_${timestamp}.spr`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import * as nodemailer from 'nodemailer';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { logError, logAuth, logStartup } from './Logger';
|
||||
import { EmailTemplateHelper, LocalizedSubjects } from './EmailTemplateHelper';
|
||||
|
||||
export interface EmailOptions {
|
||||
to: string;
|
||||
subject: string;
|
||||
html?: string;
|
||||
text?: string;
|
||||
template?: string;
|
||||
templateData?: any;
|
||||
}
|
||||
|
||||
export interface EmailConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
auth: {
|
||||
user: string;
|
||||
pass: string;
|
||||
};
|
||||
from: string;
|
||||
}
|
||||
|
||||
export class EmailService {
|
||||
private transporter!: nodemailer.Transporter;
|
||||
private config: EmailConfig;
|
||||
private templatesPath: string;
|
||||
|
||||
constructor() {
|
||||
this.templatesPath = path.join(__dirname, '../../Templates');
|
||||
|
||||
this.config = {
|
||||
host: process.env.EMAIL_HOST || 'smtp.gmail.com',
|
||||
port: parseInt(process.env.EMAIL_PORT || '587'),
|
||||
secure: process.env.EMAIL_SECURE === 'true',
|
||||
auth: {
|
||||
user: process.env.EMAIL_USER || '',
|
||||
pass: process.env.EMAIL_PASS || ''
|
||||
},
|
||||
from: process.env.EMAIL_FROM || 'noreply@serpentrace.com'
|
||||
};
|
||||
|
||||
this.initializeTransporter();
|
||||
}
|
||||
|
||||
private initializeTransporter(): void {
|
||||
try {
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host: this.config.host,
|
||||
port: this.config.port,
|
||||
secure: this.config.secure,
|
||||
auth: {
|
||||
user: this.config.auth.user,
|
||||
pass: this.config.auth.pass
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logError('EmailService initialization failed', error instanceof Error ? error : new Error(String(error)));
|
||||
throw new Error('Failed to initialize email service');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send email with template
|
||||
* @param options - Email options including template and data
|
||||
*/
|
||||
async sendEmail(options: EmailOptions): Promise<boolean> {
|
||||
try {
|
||||
let htmlContent = options.html;
|
||||
let textContent = options.text;
|
||||
|
||||
if (options.template) {
|
||||
const templateResult = await this.loadTemplate(options.template, options.templateData || {});
|
||||
htmlContent = templateResult.html;
|
||||
textContent = templateResult.text;
|
||||
}
|
||||
|
||||
const mailOptions = {
|
||||
from: this.config.from,
|
||||
to: options.to,
|
||||
subject: options.subject,
|
||||
html: htmlContent,
|
||||
text: textContent
|
||||
};
|
||||
|
||||
const result = await this.transporter.sendMail(mailOptions);
|
||||
logAuth('Email sent successfully', undefined, {
|
||||
messageId: result.messageId,
|
||||
to: options.to,
|
||||
subject: options.subject
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
logError('Email sending failed', error instanceof Error ? error : new Error(String(error)));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send verification email to user
|
||||
* @param userEmail - User's email address
|
||||
* @param userName - User's name
|
||||
* @param verificationToken - Verification token
|
||||
* @param verificationUrl - Complete verification URL
|
||||
* @param language - Language code ('en', 'hu', 'de')
|
||||
*/
|
||||
async sendVerificationEmail(
|
||||
userEmail: string,
|
||||
userName: string,
|
||||
verificationToken: string,
|
||||
verificationUrl: string,
|
||||
language: 'en' | 'hu' | 'de' = 'en'
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const templateName = language === 'en' ? 'verification' : `verification-${language}`;
|
||||
const subject = this.getLocalizedVerificationSubject(language);
|
||||
|
||||
return await this.sendEmail({
|
||||
to: userEmail,
|
||||
subject,
|
||||
template: templateName,
|
||||
templateData: {
|
||||
userName,
|
||||
verificationToken,
|
||||
verificationUrl,
|
||||
companyName: 'SerpentRace',
|
||||
supportEmail: 'support@serpentrace.com'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logError('Verification email sending failed', error instanceof Error ? error : new Error(String(error)));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send password reset email
|
||||
* @param userEmail - User's email address
|
||||
* @param userName - User's name
|
||||
* @param resetToken - Password reset token
|
||||
* @param resetUrl - Complete password reset URL
|
||||
* @param language - Language code ('en', 'hu', 'de')
|
||||
*/
|
||||
async sendPasswordResetEmail(
|
||||
userEmail: string,
|
||||
userName: string,
|
||||
resetToken: string,
|
||||
resetUrl: string,
|
||||
language: 'en' | 'hu' | 'de' = 'en'
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const templateName = language === 'en' ? 'password-reset' : `password-reset-${language}`;
|
||||
const subject = this.getLocalizedPasswordResetSubject(language);
|
||||
|
||||
return await this.sendEmail({
|
||||
to: userEmail,
|
||||
subject,
|
||||
template: templateName,
|
||||
templateData: {
|
||||
userName,
|
||||
resetToken,
|
||||
resetUrl,
|
||||
companyName: 'SerpentRace',
|
||||
supportEmail: 'support@serpentrace.com'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logError('Password reset email sending failed', error instanceof Error ? error : new Error(String(error)));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and compile email template with language support
|
||||
* @param templateName - Name of the template file (with or without language suffix)
|
||||
* @param data - Data to replace placeholders in the template
|
||||
*/
|
||||
private async loadTemplate(templateName: string, data: any): Promise<{ html: string; text: string }> {
|
||||
try {
|
||||
// Try the specified template first
|
||||
let htmlTemplatePath = path.join(this.templatesPath, `${templateName}.html`);
|
||||
let textTemplatePath = path.join(this.templatesPath, `${templateName}.txt`);
|
||||
|
||||
let htmlTemplate = '';
|
||||
let textTemplate = '';
|
||||
|
||||
// Load HTML template if it exists
|
||||
if (fs.existsSync(htmlTemplatePath)) {
|
||||
htmlTemplate = fs.readFileSync(htmlTemplatePath, 'utf8');
|
||||
} else {
|
||||
// If language-specific template doesn't exist, try fallback to English
|
||||
const baseName = templateName.replace(/-[a-z]{2}$/, ''); // Remove language suffix
|
||||
const fallbackHtmlPath = path.join(this.templatesPath, `${baseName}.html`);
|
||||
if (fs.existsSync(fallbackHtmlPath)) {
|
||||
htmlTemplate = fs.readFileSync(fallbackHtmlPath, 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
// Load text template if it exists
|
||||
if (fs.existsSync(textTemplatePath)) {
|
||||
textTemplate = fs.readFileSync(textTemplatePath, 'utf8');
|
||||
} else {
|
||||
// If language-specific template doesn't exist, try fallback to English
|
||||
const baseName = templateName.replace(/-[a-z]{2}$/, ''); // Remove language suffix
|
||||
const fallbackTextPath = path.join(this.templatesPath, `${baseName}.txt`);
|
||||
if (fs.existsSync(fallbackTextPath)) {
|
||||
textTemplate = fs.readFileSync(fallbackTextPath, 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
// If no templates found, throw error
|
||||
if (!htmlTemplate && !textTemplate) {
|
||||
throw new Error(`Template '${templateName}' not found`);
|
||||
}
|
||||
|
||||
// Replace placeholders in templates
|
||||
const processedTemplate = EmailTemplateHelper.processTemplate(
|
||||
{ html: htmlTemplate, text: textTemplate },
|
||||
data
|
||||
);
|
||||
|
||||
return {
|
||||
html: processedTemplate.html,
|
||||
text: processedTemplate.text
|
||||
};
|
||||
} catch (error) {
|
||||
logError('Email template loading failed', error instanceof Error ? error : new Error(String(error)));
|
||||
throw new Error(`Failed to load email template: ${templateName}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get localized verification email subject
|
||||
* @param language - Language code ('en', 'hu', 'de')
|
||||
*/
|
||||
private getLocalizedVerificationSubject(language: 'en' | 'hu' | 'de'): string {
|
||||
const subjects: LocalizedSubjects = {
|
||||
verification: {
|
||||
en: 'SerpentRace - Verify Your Account',
|
||||
hu: 'SerpentRace - Fiók megerősítése',
|
||||
de: 'SerpentRace - Konto verifizieren'
|
||||
}
|
||||
};
|
||||
return EmailTemplateHelper.getLocalizedSubject('verification', subjects, language);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get localized password reset email subject
|
||||
* @param language - Language code ('en', 'hu', 'de')
|
||||
*/
|
||||
private getLocalizedPasswordResetSubject(language: 'en' | 'hu' | 'de'): string {
|
||||
const subjects: LocalizedSubjects = {
|
||||
passwordReset: {
|
||||
en: 'SerpentRace - Password Reset Request',
|
||||
hu: 'SerpentRace - Jelszó visszaállítás kérése',
|
||||
de: 'SerpentRace - Passwort zurücksetzen'
|
||||
}
|
||||
};
|
||||
return EmailTemplateHelper.getLocalizedSubject('passwordReset', subjects, language);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
export interface LocalizedSubjects {
|
||||
[key: string]: {
|
||||
en: string;
|
||||
hu: string;
|
||||
de: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TemplateData {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface EmailTemplate {
|
||||
html: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export class EmailTemplateHelper {
|
||||
public static getLocalizedSubject(
|
||||
subjectKey: string,
|
||||
subjects: LocalizedSubjects,
|
||||
language: 'en' | 'hu' | 'de'
|
||||
): string {
|
||||
return subjects[subjectKey]?.[language] || subjects[subjectKey]?.['en'] || 'SerpentRace';
|
||||
}
|
||||
|
||||
public static replaceTemplatePlaceholders(template: string, data: TemplateData): string {
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (match, key) => {
|
||||
return data[key] !== undefined ? String(data[key]) : match;
|
||||
});
|
||||
}
|
||||
|
||||
public static processTemplate(templateContent: EmailTemplate, data: TemplateData): EmailTemplate {
|
||||
return {
|
||||
html: this.replaceTemplatePlaceholders(templateContent.html, data),
|
||||
text: this.replaceTemplatePlaceholders(templateContent.text, data)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Response } from 'express';
|
||||
|
||||
export class ErrorResponseService {
|
||||
static sendError(res: Response, statusCode: number, message: string, details?: any): Response {
|
||||
const errorResponse: any = { error: message };
|
||||
if (details) {
|
||||
errorResponse.details = details;
|
||||
}
|
||||
return res.status(statusCode).json(errorResponse);
|
||||
}
|
||||
|
||||
static sendInternalServerError(res: Response): Response {
|
||||
return this.sendError(res, 500, 'Internal server error');
|
||||
}
|
||||
|
||||
static sendBadRequest(res: Response, message: string = 'Bad request', details?: any): Response {
|
||||
return this.sendError(res, 400, message, details);
|
||||
}
|
||||
|
||||
static sendUnauthorized(res: Response, message: string = 'Unauthorized'): Response {
|
||||
return this.sendError(res, 401, message);
|
||||
}
|
||||
|
||||
static sendForbidden(res: Response, message: string = 'Forbidden'): Response {
|
||||
return this.sendError(res, 403, message);
|
||||
}
|
||||
|
||||
static sendNotFound(res: Response, message: string = 'Not found'): Response {
|
||||
return this.sendError(res, 404, message);
|
||||
}
|
||||
|
||||
static sendConflict(res: Response, message: string = 'Conflict'): Response {
|
||||
return this.sendError(res, 409, message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
import { GameCard, GameField } from '../../Domain/Game/GameAggregate';
|
||||
import { Consequence } from '../../Domain/Deck/DeckAggregate';
|
||||
import { BoardGenerationService } from '../Game/BoardGenerationService';
|
||||
import { GamemasterService, GamemasterDecisionResult } from './GamemasterService';
|
||||
import { FieldEffectRequest, FieldEffectResult } from './Interfaces/GameInterfaces';
|
||||
|
||||
// Interfaces for different card processing results
|
||||
export interface GuessResult {
|
||||
guessedPosition: number;
|
||||
actualPosition: number;
|
||||
isCorrect: boolean;
|
||||
penaltyApplied: boolean; // true if moved back 2 fields
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface TurnEffect {
|
||||
type: 'LOSE_TURN' | 'EXTRA_TURN';
|
||||
playerId: string;
|
||||
value: number; // Number of turns to lose/gain
|
||||
}
|
||||
|
||||
export interface CardProcessingResult {
|
||||
finalPosition: number;
|
||||
stepValue: number;
|
||||
dice: number;
|
||||
patternModifier: number;
|
||||
consequenceModifier: number;
|
||||
guessResult?: GuessResult;
|
||||
gamemasterResult?: GamemasterDecisionResult;
|
||||
turnEffect?: TurnEffect; // Turn-based consequences that need game state changes
|
||||
description: string;
|
||||
effects: string[]; // Array of all effects applied
|
||||
}
|
||||
|
||||
/**
|
||||
* Service responsible for processing card-based field effects with step calculations
|
||||
* Integrates pattern-based movement with test/guess mechanism and gamemaster decisions
|
||||
*/
|
||||
export class FieldEffectService {
|
||||
constructor(
|
||||
private boardGenerationService: BoardGenerationService,
|
||||
private gamemasterService: GamemasterService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Process a card-based field effect with complete movement calculation
|
||||
* @param request The field effect request containing all necessary data
|
||||
* @returns Promise with complete processing result
|
||||
*/
|
||||
async processFieldEffect(request: FieldEffectRequest): Promise<CardProcessingResult> {
|
||||
const { currentPosition, card, field, dice } = request;
|
||||
|
||||
// Ensure stepValue is defined
|
||||
const stepValue = field.stepValue || 1;
|
||||
|
||||
// Calculate base movement using pattern-based system
|
||||
const finalPosition = this.boardGenerationService.calculatePatternBasedMovement(
|
||||
currentPosition,
|
||||
stepValue,
|
||||
dice
|
||||
);
|
||||
|
||||
// Calculate pattern modifier manually for tracking
|
||||
const patternModifier = this.getPatternModifier(currentPosition);
|
||||
|
||||
let result: CardProcessingResult = {
|
||||
finalPosition,
|
||||
stepValue,
|
||||
dice,
|
||||
patternModifier,
|
||||
consequenceModifier: 0,
|
||||
description: `Moved from position ${currentPosition} to ${finalPosition}`,
|
||||
effects: []
|
||||
};
|
||||
|
||||
// Process card based on type
|
||||
if (this.isQuestionCard(card.type)) {
|
||||
result = await this.processQuestionCard(request, result);
|
||||
} else if (this.isJokerCard(card.type)) {
|
||||
result = await this.processJokerCard(request, result);
|
||||
} else if (this.isLuckCard(card.type)) {
|
||||
result = await this.processLuckCard(request, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pattern modifier based on position (duplicated from BoardGenerationService)
|
||||
* @param position Current position
|
||||
* @returns Pattern modifier value
|
||||
*/
|
||||
private getPatternModifier(position: number): number {
|
||||
// Pattern modifiers for strategic complexity:
|
||||
// Positions 1-20: +2 bonus (easier start)
|
||||
// Positions 21-40: -1 penalty (early game challenge)
|
||||
// Positions 41-60: +1 bonus (mid-game boost)
|
||||
// Positions 61-80: -2 penalty (late game challenge)
|
||||
// Positions 81-100: +3 bonus (final stretch boost)
|
||||
|
||||
if (position <= 20) {
|
||||
return 2;
|
||||
} else if (position <= 40) {
|
||||
return -1;
|
||||
} else if (position <= 60) {
|
||||
return 1;
|
||||
} else if (position <= 80) {
|
||||
return -2;
|
||||
} else {
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if card is a question card (types 0-4)
|
||||
* @param cardType Card type
|
||||
* @returns True if question card
|
||||
*/
|
||||
private isQuestionCard(cardType?: number): boolean {
|
||||
return cardType !== undefined && cardType >= 0 && cardType <= 4;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if card is a joker card
|
||||
* @param cardType Card type
|
||||
* @returns True if joker card
|
||||
*/
|
||||
private isJokerCard(cardType?: number): boolean {
|
||||
return cardType === 5; // Assuming joker cards have type 5
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if card is a luck card
|
||||
* @param cardType Card type
|
||||
* @returns True if luck card
|
||||
*/
|
||||
private isLuckCard(cardType?: number): boolean {
|
||||
return cardType === 6; // Assuming luck cards have type 6
|
||||
}
|
||||
|
||||
/**
|
||||
* Process question card with test/guess mechanism
|
||||
* @param request The field effect request
|
||||
* @param baseResult The base movement calculation result
|
||||
* @returns Updated result with guess processing
|
||||
*/
|
||||
private async processQuestionCard(
|
||||
request: FieldEffectRequest,
|
||||
baseResult: CardProcessingResult
|
||||
): Promise<CardProcessingResult> {
|
||||
const { guessedPosition } = request;
|
||||
|
||||
if (guessedPosition === undefined) {
|
||||
throw new Error('Question cards require a position guess');
|
||||
}
|
||||
|
||||
// Apply test/guess mechanism
|
||||
const guessResult = this.processGuess(
|
||||
guessedPosition,
|
||||
baseResult.finalPosition,
|
||||
baseResult.finalPosition
|
||||
);
|
||||
|
||||
let finalPosition = baseResult.finalPosition;
|
||||
let effects = [...baseResult.effects];
|
||||
|
||||
if (!guessResult.isCorrect) {
|
||||
// Apply guess penalty: move back exactly 2 fields
|
||||
finalPosition = Math.max(1, baseResult.finalPosition - 2);
|
||||
effects.push(`Wrong guess penalty: moved back 2 fields`);
|
||||
} else {
|
||||
effects.push(`Correct guess: no penalty`);
|
||||
}
|
||||
|
||||
return {
|
||||
...baseResult,
|
||||
finalPosition,
|
||||
guessResult,
|
||||
effects,
|
||||
description: `Question card: ${guessResult.description}`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Process joker card with same guess mechanism as question cards + gamemaster decision
|
||||
* @param request The field effect request
|
||||
* @param baseResult The base movement calculation result
|
||||
* @returns Updated result with guess processing and gamemaster decision
|
||||
*/
|
||||
private async processJokerCard(
|
||||
request: FieldEffectRequest,
|
||||
baseResult: CardProcessingResult
|
||||
): Promise<CardProcessingResult> {
|
||||
const { guessedPosition, gameId, playerId, playerName, card } = request;
|
||||
|
||||
if (guessedPosition === undefined) {
|
||||
throw new Error('Joker cards require a position guess');
|
||||
}
|
||||
|
||||
// Joker cards always use dice = 6, recalculate with correct dice value
|
||||
const jokerDice = 6;
|
||||
const correctBasePosition = this.boardGenerationService.calculatePatternBasedMovement(
|
||||
request.currentPosition,
|
||||
baseResult.stepValue,
|
||||
jokerDice
|
||||
);
|
||||
|
||||
let finalPosition = correctBasePosition;
|
||||
let effects = [`Joker card: dice counted as 6`];
|
||||
|
||||
// Step 1: Process guess penalty (same as question cards)
|
||||
const guessResult = this.processGuess(
|
||||
guessedPosition,
|
||||
correctBasePosition,
|
||||
correctBasePosition
|
||||
);
|
||||
|
||||
if (!guessResult.isCorrect) {
|
||||
// Apply guess penalty: move back exactly 2 fields
|
||||
finalPosition = Math.max(1, correctBasePosition - 2);
|
||||
effects.push(`Wrong guess penalty: moved back 2 fields`);
|
||||
} else {
|
||||
effects.push(`Correct guess: no penalty`);
|
||||
}
|
||||
|
||||
// Step 2: Process gamemaster decision (replaces player answer effect)
|
||||
const gamemasterResult = await this.requestGamemasterDecision(
|
||||
gameId,
|
||||
playerId,
|
||||
playerName,
|
||||
card
|
||||
);
|
||||
|
||||
let consequenceModifier = 0;
|
||||
let turnEffect: TurnEffect | undefined;
|
||||
|
||||
if (gamemasterResult.consequence && card.consequence) {
|
||||
// Apply consequence based on gamemaster decision using new processing method
|
||||
const consequenceResult = this.processConsequence(playerId, finalPosition, card.consequence);
|
||||
finalPosition = consequenceResult.newPosition;
|
||||
consequenceModifier = consequenceResult.positionChange;
|
||||
turnEffect = consequenceResult.turnEffect;
|
||||
|
||||
effects.push(`Gamemaster decision: ${gamemasterResult.description}`);
|
||||
effects.push(`Consequence applied: ${this.getConsequenceDescription(card.consequence)}`);
|
||||
} else {
|
||||
effects.push(`No consequence applied: ${gamemasterResult.description}`);
|
||||
}
|
||||
|
||||
return {
|
||||
...baseResult,
|
||||
finalPosition,
|
||||
dice: jokerDice, // Update to show dice was 6
|
||||
consequenceModifier,
|
||||
turnEffect,
|
||||
guessResult,
|
||||
gamemasterResult,
|
||||
effects,
|
||||
description: `Joker card: ${guessResult.description} | ${gamemasterResult.description}`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Process luck card with immediate effects
|
||||
* @param request The field effect request
|
||||
* @param baseResult The base movement calculation result
|
||||
* @returns Updated result with luck card effects
|
||||
*/
|
||||
private async processLuckCard(
|
||||
request: FieldEffectRequest,
|
||||
baseResult: CardProcessingResult
|
||||
): Promise<CardProcessingResult> {
|
||||
const { card, playerId } = request;
|
||||
|
||||
let finalPosition = baseResult.finalPosition;
|
||||
let consequenceModifier = 0;
|
||||
let turnEffect: TurnEffect | undefined;
|
||||
let effects = [...baseResult.effects];
|
||||
|
||||
if (card.consequence) {
|
||||
// Apply immediate consequence using new processing method
|
||||
const consequenceResult = this.processConsequence(playerId, finalPosition, card.consequence);
|
||||
finalPosition = consequenceResult.newPosition;
|
||||
consequenceModifier = consequenceResult.positionChange;
|
||||
turnEffect = consequenceResult.turnEffect;
|
||||
effects.push(`Luck effect: ${this.getConsequenceDescription(card.consequence)}`);
|
||||
}
|
||||
|
||||
return {
|
||||
...baseResult,
|
||||
finalPosition,
|
||||
consequenceModifier,
|
||||
turnEffect,
|
||||
effects,
|
||||
description: `Luck card: immediate effect applied`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Process position guess and determine if penalty should be applied
|
||||
* @param guessedPosition Player's position guess
|
||||
* @param actualPosition The calculated final position
|
||||
* @param basePosition The position before guess penalty
|
||||
* @returns Guess processing result
|
||||
*/
|
||||
private processGuess(
|
||||
guessedPosition: number,
|
||||
actualPosition: number,
|
||||
basePosition: number
|
||||
): GuessResult {
|
||||
// Validate guess range
|
||||
if (guessedPosition < 1 || guessedPosition > 100) {
|
||||
throw new Error('Position guess must be between 1 and 100');
|
||||
}
|
||||
|
||||
const isCorrect = guessedPosition === actualPosition;
|
||||
const penaltyApplied = !isCorrect;
|
||||
|
||||
return {
|
||||
guessedPosition,
|
||||
actualPosition,
|
||||
isCorrect,
|
||||
penaltyApplied,
|
||||
description: isCorrect
|
||||
? `Correct guess (${guessedPosition})!`
|
||||
: `Wrong guess (${guessedPosition} ≠ ${actualPosition})`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Request gamemaster decision for joker card
|
||||
* @param gameId Game ID
|
||||
* @param playerId Player ID
|
||||
* @param playerName Player name
|
||||
* @param card Joker card
|
||||
* @returns Promise with gamemaster decision result
|
||||
*/
|
||||
private async requestGamemasterDecision(
|
||||
gameId: string,
|
||||
playerId: string,
|
||||
playerName: string,
|
||||
card: GameCard
|
||||
): Promise<GamemasterDecisionResult> {
|
||||
// For now, return a default decision - this will be replaced with actual async gamemaster interaction
|
||||
// TODO: Implement proper WebSocket-based gamemaster decision flow
|
||||
return {
|
||||
decision: 'reject' as any,
|
||||
consequence: false,
|
||||
description: '🎭 Gamemaster decision pending...'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Process consequence and separate position changes from turn effects
|
||||
* @param playerId Player ID who drew the card
|
||||
* @param currentPosition Current position before consequence
|
||||
* @param consequence Card consequence
|
||||
* @returns Object with position changes and turn effects
|
||||
*/
|
||||
private processConsequence(playerId: string, currentPosition: number, consequence: Consequence): {
|
||||
newPosition: number;
|
||||
positionChange: number;
|
||||
turnEffect?: TurnEffect;
|
||||
} {
|
||||
// Handle position-affecting consequences
|
||||
if (consequence.type === 0 || consequence.type === 1 || consequence.type === 5) {
|
||||
const newPosition = this.applyConsequenceToPosition(currentPosition, consequence);
|
||||
return {
|
||||
newPosition,
|
||||
positionChange: newPosition - currentPosition
|
||||
};
|
||||
}
|
||||
|
||||
// Handle turn-based consequences
|
||||
if (consequence.type === 2 || consequence.type === 3) {
|
||||
const turnEffect: TurnEffect = {
|
||||
type: consequence.type === 2 ? 'LOSE_TURN' : 'EXTRA_TURN',
|
||||
playerId,
|
||||
value: consequence.value || 1
|
||||
};
|
||||
return {
|
||||
newPosition: currentPosition, // No position change
|
||||
positionChange: 0,
|
||||
turnEffect
|
||||
};
|
||||
}
|
||||
|
||||
// Unknown consequence type
|
||||
return {
|
||||
newPosition: currentPosition,
|
||||
positionChange: 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply consequence to position with proper boundary handling
|
||||
* @param currentPosition Current position
|
||||
* @param consequence Card consequence
|
||||
* @returns New position after consequence
|
||||
*/
|
||||
private applyConsequenceToPosition(currentPosition: number, consequence: Consequence): number {
|
||||
switch (consequence.type) {
|
||||
case 0: // MOVE_FORWARD
|
||||
return Math.min(100, currentPosition + (consequence.value || 1));
|
||||
case 1: // MOVE_BACKWARD
|
||||
return Math.max(1, currentPosition - (consequence.value || 1));
|
||||
case 5: // GO_TO_START
|
||||
return 1;
|
||||
default:
|
||||
return currentPosition; // Other consequences don't change position
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable description for consequence
|
||||
* @param consequence Card consequence
|
||||
* @returns Description string
|
||||
*/
|
||||
private getConsequenceDescription(consequence: Consequence): string {
|
||||
switch (consequence.type) {
|
||||
case 0: // MOVE_FORWARD
|
||||
return `Move forward ${consequence.value || 1} steps`;
|
||||
case 1: // MOVE_BACKWARD
|
||||
return `Move backward ${consequence.value || 1} steps`;
|
||||
case 2: // LOSE_TURN
|
||||
const lostTurns = consequence.value || 1;
|
||||
return lostTurns === 1 ? 'Lose next turn' : `Lose next ${lostTurns} turns`;
|
||||
case 3: // EXTRA_TURN
|
||||
const extraTurns = consequence.value || 1;
|
||||
return extraTurns === 1 ? 'Get extra turn' : `Get ${extraTurns} extra turns`;
|
||||
case 5: // GO_TO_START
|
||||
return 'Go back to start';
|
||||
default:
|
||||
return 'Unknown effect';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import jwt, { SignOptions } from 'jsonwebtoken';
|
||||
import { Request } from 'express';
|
||||
|
||||
export interface GameTokenPayload {
|
||||
gameId: string;
|
||||
gameCode: string;
|
||||
playerName: string;
|
||||
isAuthenticated: boolean;
|
||||
userId?: string; // Optional - only for authenticated players
|
||||
tokenType: 'game_session';
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
export class GameTokenService {
|
||||
private readonly secretKey: string;
|
||||
private readonly gameTokenExpiry: number;
|
||||
|
||||
constructor() {
|
||||
this.secretKey = process.env.JWT_SECRET || 'your-secret-key';
|
||||
|
||||
// Game tokens expire after 24 hours (or configured duration)
|
||||
// This should be longer than typical game duration
|
||||
this.gameTokenExpiry = parseInt(process.env.GAME_TOKEN_EXPIRY || '86400'); // 24 hours default
|
||||
|
||||
if (process.env.NODE_ENV === 'production' && (!process.env.JWT_SECRET || process.env.JWT_SECRET === 'your-secret-key')) {
|
||||
throw new Error('JWT_SECRET environment variable must be set in production');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a game session token for a player
|
||||
* @param gameId - The database ID of the game
|
||||
* @param gameCode - The public game code (e.g., ABC123)
|
||||
* @param playerName - The player's name in the game
|
||||
* @param userId - Optional user ID for authenticated players
|
||||
* @returns Game session JWT token
|
||||
*/
|
||||
createGameToken(gameId: string, gameCode: string, playerName: string, userId?: string): string {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const payload: GameTokenPayload = {
|
||||
gameId,
|
||||
gameCode,
|
||||
playerName,
|
||||
isAuthenticated: !!userId,
|
||||
userId,
|
||||
tokenType: 'game_session',
|
||||
iat: now,
|
||||
exp: now + this.gameTokenExpiry
|
||||
};
|
||||
|
||||
const options: SignOptions = {};
|
||||
const token = jwt.sign(payload, this.secretKey, options);
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify and decode a game session token
|
||||
* @param token - The game session JWT token
|
||||
* @returns Decoded payload or null if invalid
|
||||
*/
|
||||
verifyGameToken(token: string): GameTokenPayload | null {
|
||||
try {
|
||||
const decoded = jwt.verify(token, this.secretKey) as GameTokenPayload;
|
||||
|
||||
// Verify it's actually a game token
|
||||
if (decoded.tokenType !== 'game_session') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return decoded;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract game token from request headers or query params
|
||||
* @param req - Express request object
|
||||
* @returns Game token string or null
|
||||
*/
|
||||
extractGameTokenFromRequest(req: Request): string | null {
|
||||
// Check Authorization header
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
return authHeader.substring(7);
|
||||
}
|
||||
|
||||
// Check query parameter (for WebSocket handshake)
|
||||
if (req.query && req.query.gameToken && typeof req.query.gameToken === 'string') {
|
||||
return req.query.gameToken;
|
||||
}
|
||||
|
||||
// Check game_token cookie
|
||||
if (req.cookies && req.cookies.game_token) {
|
||||
return req.cookies.game_token;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify game token from request and return payload
|
||||
* @param req - Express request object
|
||||
* @returns Decoded game token payload or null
|
||||
*/
|
||||
verifyGameTokenFromRequest(req: Request): GameTokenPayload | null {
|
||||
const token = this.extractGameTokenFromRequest(req);
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.verifyGameToken(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a game token is valid for a specific game
|
||||
* @param token - The game session token
|
||||
* @param gameCode - The game code to validate against
|
||||
* @param playerName - Optional player name to validate
|
||||
* @returns True if token is valid for the game
|
||||
*/
|
||||
isValidForGame(token: string, gameCode: string, playerName?: string): boolean {
|
||||
const payload = this.verifyGameToken(token);
|
||||
if (!payload) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check game code matches
|
||||
if (payload.gameCode !== gameCode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check player name if provided
|
||||
if (playerName && payload.playerName !== playerName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh a game token (extend expiry)
|
||||
* @param currentToken - The current game token
|
||||
* @returns New token with extended expiry or null if invalid
|
||||
*/
|
||||
refreshGameToken(currentToken: string): string | null {
|
||||
const payload = this.verifyGameToken(currentToken);
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create new token with same data but fresh expiry
|
||||
return this.createGameToken(
|
||||
payload.gameId,
|
||||
payload.gameCode,
|
||||
payload.playerName,
|
||||
payload.userId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remaining time before token expires
|
||||
* @param token - The game session token
|
||||
* @returns Seconds until expiry or -1 if invalid/expired
|
||||
*/
|
||||
getTimeUntilExpiry(token: string): number {
|
||||
const payload = this.verifyGameToken(token);
|
||||
if (!payload || !payload.exp) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const remaining = payload.exp - now;
|
||||
|
||||
return remaining > 0 ? remaining : -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a game token response object for API responses
|
||||
* @param gameId - The database ID of the game
|
||||
* @param gameCode - The public game code
|
||||
* @param playerName - The player's name
|
||||
* @param userId - Optional user ID for authenticated players
|
||||
* @returns Object with token and metadata
|
||||
*/
|
||||
createGameTokenResponse(gameId: string, gameCode: string, playerName: string, userId?: string) {
|
||||
const token = this.createGameToken(gameId, gameCode, playerName, userId);
|
||||
const expiresIn = this.gameTokenExpiry;
|
||||
|
||||
return {
|
||||
gameToken: token,
|
||||
gameCode,
|
||||
playerName,
|
||||
isAuthenticated: !!userId,
|
||||
expiresIn,
|
||||
expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString(),
|
||||
tokenType: 'game_session'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default GameTokenService;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,284 @@
|
||||
import { GameAggregate, GameCard } from '../../Domain/Game/GameAggregate';
|
||||
|
||||
export interface GamemasterDecisionRequest {
|
||||
gameId: string;
|
||||
playerId: string;
|
||||
playerName: string;
|
||||
card: GameCard;
|
||||
requestId: string;
|
||||
timeoutId: NodeJS.Timeout;
|
||||
startTime: Date;
|
||||
}
|
||||
|
||||
export enum GamemasterDecision {
|
||||
APPROVE = 'approve',
|
||||
REJECT = 'reject'
|
||||
}
|
||||
|
||||
export interface GamemasterDecisionResult {
|
||||
decision: GamemasterDecision;
|
||||
consequence: boolean; // true = apply consequence, false = don't apply
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service responsible for handling gamemaster decisions on joker cards
|
||||
* Integrates with existing gamemaster role identification system
|
||||
*/
|
||||
export class GamemasterService {
|
||||
private pendingDecisions: Map<string, GamemasterDecisionRequest> = new Map();
|
||||
private readonly DECISION_TIMEOUT_MS = 120000; // 2 minutes for gamemaster to decide
|
||||
|
||||
/**
|
||||
* Request gamemaster decision for a joker card
|
||||
* @param gameId Game ID
|
||||
* @param playerId Player ID who drew the joker card
|
||||
* @param playerName Player name for display
|
||||
* @param card The joker card that needs decision
|
||||
* @param onTimeout Callback when gamemaster doesn't respond in time
|
||||
* @returns Request ID for tracking this decision
|
||||
*/
|
||||
requestGamemasterDecision(
|
||||
gameId: string,
|
||||
playerId: string,
|
||||
playerName: string,
|
||||
card: GameCard,
|
||||
onTimeout: (requestId: string) => void
|
||||
): string {
|
||||
const requestId = `${gameId}:${playerId}:${Date.now()}`;
|
||||
|
||||
// Clear any existing decision for this player
|
||||
this.clearExistingDecision(gameId, playerId);
|
||||
|
||||
// Set timeout for gamemaster decision
|
||||
const timeoutId = setTimeout(() => {
|
||||
onTimeout(requestId);
|
||||
this.pendingDecisions.delete(requestId);
|
||||
}, this.DECISION_TIMEOUT_MS);
|
||||
|
||||
// Store pending decision
|
||||
this.pendingDecisions.set(requestId, {
|
||||
gameId,
|
||||
playerId,
|
||||
playerName,
|
||||
card,
|
||||
requestId,
|
||||
timeoutId,
|
||||
startTime: new Date()
|
||||
});
|
||||
|
||||
return requestId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process gamemaster's decision on a joker card
|
||||
* @param requestId The request ID returned from requestGamemasterDecision
|
||||
* @param decision The gamemaster's decision
|
||||
* @returns Result with consequence application info
|
||||
*/
|
||||
processGamemasterDecision(requestId: string, decision: GamemasterDecision): GamemasterDecisionResult | null {
|
||||
const pendingRequest = this.pendingDecisions.get(requestId);
|
||||
|
||||
if (!pendingRequest) {
|
||||
return null; // Request not found or already processed
|
||||
}
|
||||
|
||||
// Clear the timeout since decision was made
|
||||
clearTimeout(pendingRequest.timeoutId);
|
||||
this.pendingDecisions.delete(requestId);
|
||||
|
||||
// Determine if consequence should be applied based on its nature and decision
|
||||
const consequence = pendingRequest.card.consequence;
|
||||
const isNegativeConsequence = this.isNegativeConsequence(consequence?.type);
|
||||
|
||||
let applyConsequence: boolean;
|
||||
if (isNegativeConsequence) {
|
||||
// Negative consequences applied when gamemaster REJECTS
|
||||
applyConsequence = decision === GamemasterDecision.REJECT;
|
||||
} else {
|
||||
// Positive consequences applied when gamemaster APPROVES
|
||||
applyConsequence = decision === GamemasterDecision.APPROVE;
|
||||
}
|
||||
|
||||
return {
|
||||
decision,
|
||||
consequence: applyConsequence,
|
||||
description: this.getDecisionDescription(decision, applyConsequence, pendingRequest.card, isNegativeConsequence)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Process automatic decision when gamemaster times out
|
||||
* @param requestId The request ID that timed out
|
||||
* @returns Result with default rejection applied
|
||||
*/
|
||||
processTimeoutDecision(requestId: string): GamemasterDecisionResult | null {
|
||||
const pendingRequest = this.pendingDecisions.get(requestId);
|
||||
|
||||
if (!pendingRequest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.pendingDecisions.delete(requestId);
|
||||
|
||||
return {
|
||||
decision: GamemasterDecision.REJECT,
|
||||
consequence: false,
|
||||
description: `🎭 Gamemaster didn't respond in time. No effect applied.`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending decision by request ID
|
||||
* @param requestId The request ID
|
||||
* @returns Pending decision request or undefined
|
||||
*/
|
||||
getPendingDecision(requestId: string): GamemasterDecisionRequest | undefined {
|
||||
return this.pendingDecisions.get(requestId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pending decisions for a game
|
||||
* @param gameId Game ID
|
||||
* @returns Array of pending decisions for the game
|
||||
*/
|
||||
getPendingDecisionsForGame(gameId: string): GamemasterDecisionRequest[] {
|
||||
return Array.from(this.pendingDecisions.values())
|
||||
.filter(request => request.gameId === gameId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if gamemaster is the correct user for a game
|
||||
* @param game Game aggregate
|
||||
* @param userId User ID to check
|
||||
* @returns True if user is the gamemaster
|
||||
*/
|
||||
isGamemaster(game: GameAggregate, userId: string): boolean {
|
||||
return game.createdby === userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a pending decision (e.g., if player leaves game)
|
||||
* @param requestId Request ID to cancel
|
||||
* @returns True if decision was cancelled
|
||||
*/
|
||||
cancelDecision(requestId: string): boolean {
|
||||
const pendingRequest = this.pendingDecisions.get(requestId);
|
||||
|
||||
if (!pendingRequest) {
|
||||
return false;
|
||||
}
|
||||
|
||||
clearTimeout(pendingRequest.timeoutId);
|
||||
this.pendingDecisions.delete(requestId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear any existing pending decision for a player in a game
|
||||
* @param gameId Game ID
|
||||
* @param playerId Player ID
|
||||
*/
|
||||
private clearExistingDecision(gameId: string, playerId: string): void {
|
||||
for (const [requestId, request] of this.pendingDecisions.entries()) {
|
||||
if (request.gameId === gameId && request.playerId === playerId) {
|
||||
clearTimeout(request.timeoutId);
|
||||
this.pendingDecisions.delete(requestId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable description for joker card effect
|
||||
* @param card The joker card
|
||||
* @param applied Whether the effect will be applied
|
||||
* @returns Description string
|
||||
*/
|
||||
private getJokerDescription(card: GameCard, applied: boolean): string {
|
||||
if (!applied) {
|
||||
return 'No effect applied.';
|
||||
}
|
||||
|
||||
if (!card.consequence) {
|
||||
return 'Apply joker effect!';
|
||||
}
|
||||
|
||||
switch (card.consequence.type) {
|
||||
case 0: // MOVE_FORWARD
|
||||
return `Move forward ${card.consequence.value || 1} steps!`;
|
||||
case 1: // MOVE_BACKWARD
|
||||
return `Move backward ${card.consequence.value || 1} steps!`;
|
||||
case 2: // LOSE_TURN
|
||||
return 'Lose your next turn!';
|
||||
case 3: // EXTRA_TURN
|
||||
return 'Get an extra turn!';
|
||||
case 5: // GO_TO_START
|
||||
return 'Go back to start!';
|
||||
default:
|
||||
return 'Apply joker effect!';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remaining time for a pending decision
|
||||
* @param requestId Request ID
|
||||
* @returns Remaining time in seconds, or -1 if not found
|
||||
*/
|
||||
getRemainingTime(requestId: string): number {
|
||||
const pending = this.pendingDecisions.get(requestId);
|
||||
if (!pending) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - pending.startTime.getTime();
|
||||
const remaining = Math.max(0, this.DECISION_TIMEOUT_MS - elapsed);
|
||||
return Math.ceil(remaining / 1000); // Return in seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of pending decisions for a game
|
||||
* @param gameId Game ID
|
||||
* @returns Number of pending decisions
|
||||
*/
|
||||
getPendingDecisionCount(gameId: string): number {
|
||||
return Array.from(this.pendingDecisions.values())
|
||||
.filter(request => request.gameId === gameId).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a consequence type is negative
|
||||
* @param consequenceType The consequence type to check
|
||||
* @returns True if consequence is negative
|
||||
*/
|
||||
private isNegativeConsequence(consequenceType?: number): boolean {
|
||||
if (consequenceType === undefined) return false;
|
||||
|
||||
// Negative consequences: MOVE_BACKWARD, LOSE_TURN, GO_TO_START
|
||||
return [1, 2, 5].includes(consequenceType); // MOVE_BACKWARD=1, LOSE_TURN=2, GO_TO_START=5
|
||||
}
|
||||
|
||||
/**
|
||||
* Get description for gamemaster decision result
|
||||
* @param decision Gamemaster's decision
|
||||
* @param applyConsequence Whether consequence will be applied
|
||||
* @param card The joker card
|
||||
* @param isNegative Whether the consequence is negative
|
||||
* @returns Description string
|
||||
*/
|
||||
private getDecisionDescription(decision: GamemasterDecision, applyConsequence: boolean, card: GameCard, isNegative: boolean): string {
|
||||
if (decision === GamemasterDecision.APPROVE) {
|
||||
if (isNegative) {
|
||||
return '🎭 Gamemaster approved - no penalty applied!';
|
||||
} else {
|
||||
return `🎭 Gamemaster approved! ${this.getJokerDescription(card, true)}`;
|
||||
}
|
||||
} else {
|
||||
if (isNegative) {
|
||||
return `🎭 Gamemaster rejected! ${this.getJokerDescription(card, true)}`;
|
||||
} else {
|
||||
return '🎭 Gamemaster rejected - no bonus applied.';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Shared interfaces for game-related WebSocket communications
|
||||
* Used by both WebSocketService and GameWebSocketService
|
||||
*/
|
||||
|
||||
export interface JoinGameRoomData {
|
||||
gameCode: string;
|
||||
}
|
||||
|
||||
export interface LeaveGameRoomData {
|
||||
gameCode: string;
|
||||
}
|
||||
|
||||
export interface GameStateUpdateData {
|
||||
gameId: string;
|
||||
gameCode: string;
|
||||
players: string[];
|
||||
state: string;
|
||||
currentTurn?: string;
|
||||
}
|
||||
|
||||
export interface GameActionData {
|
||||
gameId: string;
|
||||
gameCode: string;
|
||||
playerId: string;
|
||||
action: 'pick_card' | 'play_card' | 'end_turn' | 'leave_game' | 'roll-dice' | 'move' | 'use-field';
|
||||
data?: any;
|
||||
}
|
||||
|
||||
// Field Effect Service WebSocket interfaces
|
||||
export interface FieldEffectCalculationData {
|
||||
gameId: string;
|
||||
gameCode: string;
|
||||
playerId: string;
|
||||
currentPosition: number;
|
||||
card: any; // GameCard
|
||||
field: any; // GameField
|
||||
dice: number;
|
||||
guessedPosition?: number;
|
||||
}
|
||||
|
||||
export interface FieldEffectResultData {
|
||||
gameId: string;
|
||||
gameCode: string;
|
||||
playerId: string;
|
||||
result: {
|
||||
finalPosition: number;
|
||||
stepValue: number;
|
||||
dice: number;
|
||||
patternModifier: number;
|
||||
consequenceModifier: number;
|
||||
guessResult?: any;
|
||||
gamemasterResult?: any;
|
||||
description: string;
|
||||
effects: string[];
|
||||
turnEffect?: {
|
||||
type: 'LOSE_TURN' | 'EXTRA_TURN';
|
||||
value: number;
|
||||
playerId: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface GamemasterDecisionRequestData {
|
||||
gameId: string;
|
||||
gameCode: string;
|
||||
requestId: string;
|
||||
playerId: string;
|
||||
playerName: string;
|
||||
card: any; // GameCard
|
||||
timeRemaining: number;
|
||||
}
|
||||
|
||||
export interface GamemasterDecisionResponseData {
|
||||
requestId: string;
|
||||
decision: 'approve' | 'reject';
|
||||
}
|
||||
|
||||
// Game-specific interfaces for GameWebSocketService
|
||||
export interface JoinGameData {
|
||||
gameToken: string; // Required game session token
|
||||
}
|
||||
|
||||
export interface LeaveGameData {
|
||||
gameCode: string;
|
||||
}
|
||||
|
||||
export interface DiceRollData {
|
||||
gameCode: string;
|
||||
diceValue: number; // Value from frontend (1-6)
|
||||
}
|
||||
|
||||
export interface PlayerPosition {
|
||||
playerId: string;
|
||||
playerName: string;
|
||||
boardPosition: number;
|
||||
turnOrder: number;
|
||||
}
|
||||
|
||||
export interface GameChatData {
|
||||
gameCode: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Field Effect related types
|
||||
export interface FieldEffectRequest {
|
||||
gameId: string;
|
||||
playerId: string;
|
||||
playerName: string;
|
||||
currentPosition: number;
|
||||
card: any;
|
||||
field: any;
|
||||
dice: number;
|
||||
guessedPosition?: number;
|
||||
}
|
||||
|
||||
export interface FieldEffectResult {
|
||||
finalPosition: number;
|
||||
stepValue: number;
|
||||
dice: number;
|
||||
patternModifier: number;
|
||||
consequenceModifier: number;
|
||||
guessResult?: any;
|
||||
gamemasterResult?: any;
|
||||
description: string;
|
||||
effects: string[];
|
||||
turnEffect?: {
|
||||
type: 'LOSE_TURN' | 'EXTRA_TURN';
|
||||
value: number;
|
||||
playerId: string;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import jwt, { SignOptions } from 'jsonwebtoken';
|
||||
import { Request, Response } from 'express';
|
||||
import { UserState } from '../../Domain/User/UserAggregate';
|
||||
|
||||
export interface TokenPayload {
|
||||
userId: string;
|
||||
authLevel: 0 | 1;
|
||||
userStatus: UserState;
|
||||
orgId: string;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
export class JWTService {
|
||||
private readonly secretKey: string;
|
||||
private readonly tokenExpiry: number;
|
||||
private readonly cookieName: string;
|
||||
|
||||
constructor() {
|
||||
this.secretKey = process.env.JWT_SECRET || 'your-secret-key';
|
||||
|
||||
let expiry = 86400;
|
||||
|
||||
if (process.env.JWT_EXPIRY) {
|
||||
expiry = parseInt(process.env.JWT_EXPIRY);
|
||||
} else if (process.env.JWT_EXPIRATION) {
|
||||
expiry = this.parseDuration(process.env.JWT_EXPIRATION);
|
||||
}
|
||||
|
||||
this.tokenExpiry = expiry;
|
||||
this.cookieName = 'auth_token';
|
||||
|
||||
if (process.env.NODE_ENV === 'production' && (!process.env.JWT_SECRET || process.env.JWT_SECRET === 'your-secret-key')) {
|
||||
throw new Error('JWT_SECRET environment variable must be set in production');
|
||||
}
|
||||
}
|
||||
|
||||
create(payload: TokenPayload, res: Response): string {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const payloadWithTimestamps: TokenPayload = {
|
||||
...payload,
|
||||
iat: now,
|
||||
exp: now + this.tokenExpiry
|
||||
};
|
||||
|
||||
// Don't use expiresIn option since we're manually setting exp in payload
|
||||
const options: SignOptions = {};
|
||||
const token = jwt.sign(payloadWithTimestamps, this.secretKey, options);
|
||||
|
||||
res.cookie(this.cookieName, token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
maxAge: this.tokenExpiry * 1000, // Convert to milliseconds
|
||||
});
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
verify(req: Request): TokenPayload | null {
|
||||
try {
|
||||
const token = req.cookies[this.cookieName];
|
||||
if (!token) return null;
|
||||
|
||||
const decoded = jwt.verify(token, this.secretKey) as TokenPayload;
|
||||
return decoded;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if token needs refresh (within 25% of expiry time)
|
||||
shouldRefreshToken(payload: TokenPayload): boolean {
|
||||
if (!payload.exp || !payload.iat) return false;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const tokenAge = now - payload.iat;
|
||||
const tokenLifetime = payload.exp - payload.iat;
|
||||
const refreshThreshold = tokenLifetime * 0.75; // Refresh when 75% of lifetime has passed
|
||||
|
||||
return tokenAge >= refreshThreshold;
|
||||
}
|
||||
|
||||
// Conditionally refresh token only if needed
|
||||
refreshIfNeeded(payload: TokenPayload, res: Response): boolean {
|
||||
if (this.shouldRefreshToken(payload)) {
|
||||
// Create new token with fresh timestamps, but same user data
|
||||
const freshPayload: Omit<TokenPayload, 'iat' | 'exp'> = {
|
||||
userId: payload.userId,
|
||||
authLevel: payload.authLevel,
|
||||
userStatus: payload.userStatus,
|
||||
orgId: payload.orgId
|
||||
};
|
||||
this.create(freshPayload, res);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse duration string to seconds (e.g., "24h", "7d", "30m")
|
||||
* @param duration Duration string
|
||||
* @returns Duration in seconds
|
||||
*/
|
||||
private parseDuration(duration: string): number {
|
||||
const match = duration.match(/^(\d+)([smhd])$/);
|
||||
if (!match) {
|
||||
throw new Error(`Invalid duration format: ${duration}. Use format like '24h', '7d', '30m'`);
|
||||
}
|
||||
|
||||
const [, value, unit] = match;
|
||||
const num = parseInt(value);
|
||||
|
||||
switch (unit) {
|
||||
case 's': return num; // seconds
|
||||
case 'm': return num * 60; // minutes
|
||||
case 'h': return num * 60 * 60; // hours
|
||||
case 'd': return num * 60 * 60 * 24; // days
|
||||
default:
|
||||
throw new Error(`Unsupported duration unit: ${unit}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { LoggingService, LogLevel } from './LoggingService';
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
// Singleton instance
|
||||
const logger = LoggingService.getInstance();
|
||||
|
||||
// Convenience functions for each log level
|
||||
export const logRequest = (message: string, req?: Request, res?: Response, metadata?: any) => {
|
||||
logger.log(LogLevel.REQUEST, message, metadata, req, res);
|
||||
};
|
||||
|
||||
export const logError = (message: string, error?: Error, req?: Request, res?: Response) => {
|
||||
const metadata = error ? {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
} : undefined;
|
||||
logger.log(LogLevel.ERROR, message, metadata, req, res);
|
||||
};
|
||||
|
||||
export const logWarning = (message: string, metadata?: any, req?: Request, res?: Response) => {
|
||||
logger.log(LogLevel.WARNING, message, metadata, req, res);
|
||||
};
|
||||
|
||||
export const logAuth = (message: string, userId?: string, metadata?: any, req?: Request, res?: Response) => {
|
||||
const authMetadata = {
|
||||
userId,
|
||||
...metadata
|
||||
};
|
||||
logger.log(LogLevel.AUTH, message, authMetadata, req, res);
|
||||
};
|
||||
|
||||
export const logDatabase = (message: string, query?: string, executionTime?: number, metadata?: any) => {
|
||||
const dbMetadata = {
|
||||
query: query ? query.substring(0, 200) : undefined,
|
||||
executionTime,
|
||||
...metadata
|
||||
};
|
||||
logger.log(LogLevel.DATABASE, message, dbMetadata);
|
||||
};
|
||||
|
||||
export const logStartup = (message: string, metadata?: any) => {
|
||||
logger.log(LogLevel.STARTUP, message, metadata);
|
||||
};
|
||||
|
||||
export const logConnection = (message: string, type: string, status: 'success' | 'failure' | 'attempt', metadata?: any) => {
|
||||
const connectionMetadata = {
|
||||
connectionType: type,
|
||||
status,
|
||||
...metadata
|
||||
};
|
||||
logger.log(LogLevel.CONNECTION, message, connectionMetadata);
|
||||
};
|
||||
|
||||
export const logOther = (message: string, metadata?: any, req?: Request, res?: Response) => {
|
||||
logger.log(LogLevel.OTHER, message, metadata, req, res);
|
||||
};
|
||||
|
||||
// Export the main service
|
||||
export { LoggingService, LogLevel };
|
||||
export default logger;
|
||||
@@ -0,0 +1,401 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import * as Minio from 'minio';
|
||||
|
||||
export enum LogLevel {
|
||||
REQUEST = 'REQUEST',
|
||||
ERROR = 'ERROR',
|
||||
WARNING = 'WARNING',
|
||||
AUTH = 'AUTH',
|
||||
DATABASE = 'DATABASE',
|
||||
STARTUP = 'STARTUP',
|
||||
CONNECTION = 'CONNECTION',
|
||||
OTHER = 'OTHER'
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: string;
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
metadata?: any;
|
||||
requestId?: string;
|
||||
userId?: string;
|
||||
ip?: string;
|
||||
userAgent?: string;
|
||||
method?: string;
|
||||
url?: string;
|
||||
statusCode?: number;
|
||||
responseTime?: number;
|
||||
}
|
||||
|
||||
export class LoggingService {
|
||||
private static instance: LoggingService;
|
||||
private minioClient: Minio.Client | null = null;
|
||||
private logBuffer: LogEntry[] = [];
|
||||
private currentLogFile: string | null = null;
|
||||
private logCount = 0;
|
||||
private readonly maxLogsPerFile = parseInt(process.env.MAX_LOGS_PER_FILE || '10000');
|
||||
private readonly logsDir = path.join(process.cwd(), 'logs');
|
||||
private readonly bucketName = process.env.MINIO_BUCKET_NAME || 'serpentrace-logs';
|
||||
private uploadInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
private constructor() {
|
||||
this.initializeLogsDirectory();
|
||||
this.initializeMinioClient();
|
||||
this.createNewLogFile();
|
||||
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
this.startPeriodicUpload();
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => this.shutdown());
|
||||
process.on('SIGINT', () => this.shutdown());
|
||||
process.on('beforeExit', () => this.shutdown());
|
||||
}
|
||||
|
||||
static getInstance(): LoggingService {
|
||||
if (!LoggingService.instance) {
|
||||
LoggingService.instance = new LoggingService();
|
||||
}
|
||||
return LoggingService.instance;
|
||||
}
|
||||
|
||||
private initializeLogsDirectory(): void {
|
||||
try {
|
||||
if (!fs.existsSync(this.logsDir)) {
|
||||
fs.mkdirSync(this.logsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Create monthly subdirectory
|
||||
const monthlyDir = this.getMonthlyDirectory();
|
||||
if (!fs.existsSync(monthlyDir)) {
|
||||
fs.mkdirSync(monthlyDir, { recursive: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize logs directory:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private initializeMinioClient(): void {
|
||||
try {
|
||||
// Check if in production or development
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
if (process.env.MINIO_ENDPOINT && process.env.MINIO_ACCESS_KEY && process.env.MINIO_SECRET_KEY) {
|
||||
this.minioClient = new Minio.Client({
|
||||
endPoint: process.env.MINIO_ENDPOINT,
|
||||
port: parseInt(process.env.MINIO_PORT || '9000'),
|
||||
useSSL: process.env.MINIO_USE_SSL === 'true',
|
||||
accessKey: process.env.MINIO_ACCESS_KEY,
|
||||
secretKey: process.env.MINIO_SECRET_KEY
|
||||
});
|
||||
|
||||
this.ensureBucketExists();
|
||||
} else {
|
||||
console.warn('Minio configuration not found. Logs will only be stored locally and in console.');
|
||||
}
|
||||
} else {
|
||||
// Development 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();
|
||||
} else {
|
||||
console.log('Development mode: MinIO disabled. Set ENABLE_MINIO=true to enable MinIO logging.');
|
||||
this.minioClient = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize Minio client:', error);
|
||||
this.minioClient = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureBucketExists(): Promise<void> {
|
||||
if (!this.minioClient) return;
|
||||
|
||||
try {
|
||||
const exists = await this.minioClient.bucketExists(this.bucketName);
|
||||
if (!exists) {
|
||||
await this.minioClient.makeBucket(this.bucketName);
|
||||
this.log(LogLevel.STARTUP, `Created Minio bucket: ${this.bucketName}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('MinIO connection failed - disabling MinIO logging:', (error as Error).message);
|
||||
// Disable MinIO client if connection fails
|
||||
this.minioClient = null;
|
||||
}
|
||||
}
|
||||
|
||||
private startPeriodicUpload(): void {
|
||||
// Upload current log file to Minio every 2 minutes
|
||||
this.uploadInterval = setInterval(async () => {
|
||||
if (this.currentLogFile && this.minioClient) {
|
||||
await this.uploadToMinio(this.currentLogFile);
|
||||
}
|
||||
}, 2 * 60 * 1000); // 2 minutes
|
||||
}
|
||||
|
||||
private getMonthlyDirectory(): string {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
return path.join(this.logsDir, `${year}-${month}`);
|
||||
}
|
||||
|
||||
private getMonthlyMinioPrefix(): string {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
return `${year}-${month}/`;
|
||||
}
|
||||
|
||||
private createNewLogFile(): void {
|
||||
const now = new Date();
|
||||
const timestamp = now.toISOString().replace(/[:.]/g, '-');
|
||||
const fileName = `serpentrace-${timestamp}.log`;
|
||||
|
||||
this.currentLogFile = path.join(this.getMonthlyDirectory(), fileName);
|
||||
this.logCount = 0;
|
||||
|
||||
// Write log file header
|
||||
const header = `# SerpentRace Backend Logs\n# Started: ${now.toISOString()}\n# Max entries per file: ${this.maxLogsPerFile}\n\n`;
|
||||
try {
|
||||
fs.writeFileSync(this.currentLogFile, header);
|
||||
} catch (error) {
|
||||
console.error('Failed to create log file:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private formatLogEntry(entry: LogEntry): string {
|
||||
const parts = [
|
||||
entry.timestamp,
|
||||
`[${entry.level}]`,
|
||||
entry.message
|
||||
];
|
||||
|
||||
if (entry.requestId) parts.push(`ReqId:${entry.requestId}`);
|
||||
if (entry.userId) parts.push(`UserId:${entry.userId}`);
|
||||
if (entry.ip) parts.push(`IP:${entry.ip}`);
|
||||
if (entry.method && entry.url) parts.push(`${entry.method} ${entry.url}`);
|
||||
if (entry.statusCode) parts.push(`Status:${entry.statusCode}`);
|
||||
if (entry.responseTime) parts.push(`Time:${entry.responseTime}ms`);
|
||||
if (entry.userAgent) parts.push(`UA:${entry.userAgent.substring(0, 50)}`);
|
||||
if (entry.metadata) parts.push(`Meta:${JSON.stringify(entry.metadata)}`);
|
||||
|
||||
return parts.join(' | ');
|
||||
}
|
||||
|
||||
private async writeToLocalFile(entry: LogEntry): Promise<void> {
|
||||
if (!this.currentLogFile) return;
|
||||
|
||||
try {
|
||||
const logLine = this.formatLogEntry(entry) + '\n';
|
||||
fs.appendFileSync(this.currentLogFile, logLine);
|
||||
|
||||
this.logCount++;
|
||||
|
||||
// Check if we need to rotate the log file
|
||||
if (this.logCount >= this.maxLogsPerFile) {
|
||||
await this.rotateLogFile();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to write to log file:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async rotateLogFile(): Promise<void> {
|
||||
if (!this.currentLogFile) return;
|
||||
|
||||
try {
|
||||
// Upload current file to Minio before rotating
|
||||
await this.uploadToMinio(this.currentLogFile);
|
||||
|
||||
// Create new log file
|
||||
this.createNewLogFile();
|
||||
|
||||
this.log(LogLevel.OTHER, 'Log file rotated due to size limit');
|
||||
} catch (error) {
|
||||
console.error('Failed to rotate log file:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadToMinio(filePath: string): Promise<void> {
|
||||
if (!this.minioClient) {
|
||||
console.warn('Minio client not initialized, skipping upload');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.warn(`Log file does not exist: ${filePath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const fileName = path.basename(filePath);
|
||||
const objectName = this.getMonthlyMinioPrefix() + fileName;
|
||||
|
||||
console.log(`Attempting to upload log file to Minio: ${objectName}`);
|
||||
await this.minioClient.fPutObject(this.bucketName, objectName, filePath);
|
||||
console.log(`Successfully uploaded log file to Minio: ${objectName}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to upload to Minio:', error);
|
||||
console.error('Minio config:', {
|
||||
endpoint: this.minioClient ? 'configured' : 'not configured',
|
||||
bucket: this.bucketName
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private logToConsole(entry: LogEntry): void {
|
||||
const formattedEntry = this.formatLogEntry(entry);
|
||||
|
||||
switch (entry.level) {
|
||||
case LogLevel.ERROR:
|
||||
console.error(formattedEntry);
|
||||
break;
|
||||
case LogLevel.WARNING:
|
||||
console.warn(formattedEntry);
|
||||
break;
|
||||
case LogLevel.REQUEST:
|
||||
case LogLevel.AUTH:
|
||||
case LogLevel.DATABASE:
|
||||
case LogLevel.CONNECTION:
|
||||
console.info(formattedEntry);
|
||||
break;
|
||||
case LogLevel.STARTUP:
|
||||
console.log(formattedEntry);
|
||||
break;
|
||||
default:
|
||||
console.log(formattedEntry);
|
||||
}
|
||||
}
|
||||
|
||||
public log(
|
||||
level: LogLevel,
|
||||
message: string,
|
||||
metadata?: any,
|
||||
req?: Request,
|
||||
res?: Response,
|
||||
responseTime?: number
|
||||
): void {
|
||||
const entry: LogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
message,
|
||||
metadata
|
||||
};
|
||||
|
||||
// Add request context if available
|
||||
if (req) {
|
||||
entry.requestId = (req as any).requestId || this.generateRequestId();
|
||||
entry.userId = (req as any).user?.userId;
|
||||
entry.ip = req.ip || req.socket?.remoteAddress || 'unknown';
|
||||
entry.userAgent = req.get ? req.get('User-Agent') : 'unknown';
|
||||
entry.method = req.method;
|
||||
entry.url = req.originalUrl || req.url;
|
||||
}
|
||||
|
||||
if (res) {
|
||||
entry.statusCode = res.statusCode;
|
||||
}
|
||||
|
||||
if (responseTime !== undefined) {
|
||||
entry.responseTime = responseTime;
|
||||
}
|
||||
|
||||
// Log to all three destinations
|
||||
this.logToConsole(entry);
|
||||
this.writeToLocalFile(entry);
|
||||
|
||||
// Add to buffer for potential batch processing
|
||||
this.logBuffer.push(entry);
|
||||
|
||||
// Limit buffer size
|
||||
if (this.logBuffer.length > 1000) {
|
||||
this.logBuffer = this.logBuffer.slice(-500);
|
||||
}
|
||||
}
|
||||
|
||||
private generateRequestId(): string {
|
||||
return Math.random().toString(36).substr(2, 9);
|
||||
}
|
||||
|
||||
public async shutdown(): Promise<void> {
|
||||
try {
|
||||
// Clear the upload interval
|
||||
if (this.uploadInterval) {
|
||||
clearInterval(this.uploadInterval);
|
||||
this.uploadInterval = null;
|
||||
}
|
||||
|
||||
// Upload current log file to Minio
|
||||
if (this.currentLogFile) {
|
||||
await this.uploadToMinio(this.currentLogFile);
|
||||
}
|
||||
|
||||
this.log(LogLevel.STARTUP, 'Logging service shutting down gracefully');
|
||||
|
||||
// Give time for final logs to be written
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
} catch (error) {
|
||||
console.error('Error during logging service shutdown:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware factory methods
|
||||
public requestLoggingMiddleware() {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Generate request ID
|
||||
(req as any).requestId = this.generateRequestId();
|
||||
|
||||
// Log request start
|
||||
this.log(LogLevel.REQUEST, `Incoming request`, undefined, req);
|
||||
|
||||
// Override res.end to log response
|
||||
const originalEnd = res.end.bind(res);
|
||||
res.end = (...args: any[]): Response => {
|
||||
const responseTime = Date.now() - startTime;
|
||||
LoggingService.getInstance().log(
|
||||
LogLevel.REQUEST,
|
||||
`Request completed`,
|
||||
undefined,
|
||||
req,
|
||||
res,
|
||||
responseTime
|
||||
);
|
||||
return originalEnd(...args);
|
||||
};
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
public errorLoggingMiddleware() {
|
||||
return (error: Error, req: Request, res: Response, next: NextFunction) => {
|
||||
this.log(
|
||||
LogLevel.ERROR,
|
||||
`Unhandled error: ${error.message}`,
|
||||
{
|
||||
stack: error.stack,
|
||||
name: error.name
|
||||
},
|
||||
req,
|
||||
res
|
||||
);
|
||||
next(error);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default LoggingService;
|
||||
@@ -0,0 +1,99 @@
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { logError } from './Logger';
|
||||
|
||||
export class PasswordService {
|
||||
private static readonly SALT_ROUNDS = 12;
|
||||
|
||||
/**
|
||||
* Hashes a plain text password using bcrypt
|
||||
* @param password - The plain text password to hash
|
||||
* @returns Promise<string> - The hashed password
|
||||
*/
|
||||
static async hashPassword(password: string): Promise<string> {
|
||||
try {
|
||||
if (!password || typeof password !== 'string') {
|
||||
throw new Error('Password must be a non-empty string');
|
||||
}
|
||||
|
||||
return await bcrypt.hash(password, this.SALT_ROUNDS);
|
||||
} catch (error) {
|
||||
logError('PasswordService.hashPassword error', error instanceof Error ? error : new Error(String(error)));
|
||||
|
||||
if (error instanceof Error && error.message === 'Password must be a non-empty string') {
|
||||
throw error; // Re-throw validation errors as-is
|
||||
}
|
||||
|
||||
throw new Error('Failed to hash password');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a plain text password against a hashed password
|
||||
* @param password - The plain text password to verify
|
||||
* @param hashedPassword - The hashed password to compare against
|
||||
* @returns Promise<boolean> - True if password matches, false otherwise
|
||||
*/
|
||||
static async verifyPassword(password: string, hashedPassword: string): Promise<boolean> {
|
||||
try {
|
||||
if (!password || typeof password !== 'string') {
|
||||
return false; // Invalid input should return false, not throw
|
||||
}
|
||||
|
||||
if (!hashedPassword || typeof hashedPassword !== 'string') {
|
||||
return false; // Invalid input should return false, not throw
|
||||
}
|
||||
|
||||
return await bcrypt.compare(password, hashedPassword);
|
||||
} catch (error) {
|
||||
logError('PasswordService.verifyPassword error', error instanceof Error ? error : new Error(String(error)));
|
||||
return false; // Return false on error instead of throwing
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates password strength requirements
|
||||
* @param password - The password to validate
|
||||
* @returns object - Object containing isValid boolean and error messages
|
||||
*/
|
||||
static validatePasswordStrength(password: string): { isValid: boolean; errors: string[] } {
|
||||
try {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!password || typeof password !== 'string') {
|
||||
errors.push('Password must be provided as a string');
|
||||
return { isValid: false, errors };
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
errors.push('Password must be at least 8 characters long');
|
||||
}
|
||||
|
||||
if (!/[A-Z]/.test(password)) {
|
||||
errors.push('Password must contain at least one uppercase letter');
|
||||
}
|
||||
|
||||
if (!/[a-z]/.test(password)) {
|
||||
errors.push('Password must contain at least one lowercase letter');
|
||||
}
|
||||
|
||||
if (!/\d/.test(password)) {
|
||||
errors.push('Password must contain at least one number');
|
||||
}
|
||||
|
||||
if (!/[!@#$%^&*(),.?":{}|<>]/.test(password)) {
|
||||
errors.push('Password must contain at least one special character');
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
} catch (error) {
|
||||
logError('PasswordService.validatePasswordStrength error', error instanceof Error ? error : new Error(String(error)));
|
||||
return {
|
||||
isValid: false,
|
||||
errors: ['Password validation failed due to internal error']
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
import { createClient, RedisClientType } from 'redis';
|
||||
import { logError, logStartup, logWarning } from './Logger';
|
||||
|
||||
export interface ActiveChatData {
|
||||
chatId: string;
|
||||
participants: string[];
|
||||
lastActivity: Date;
|
||||
messageCount: number;
|
||||
chatType: 'direct' | 'group' | 'game';
|
||||
gameId?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface ActiveUserData {
|
||||
userId: string;
|
||||
activeChatIds: string[];
|
||||
lastActivity: Date;
|
||||
isOnline: boolean;
|
||||
}
|
||||
|
||||
export class RedisService {
|
||||
private static instance: RedisService;
|
||||
private client: RedisClientType;
|
||||
private isConnected: boolean = false;
|
||||
|
||||
private constructor() {
|
||||
const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379';
|
||||
this.client = createClient({
|
||||
url: redisUrl,
|
||||
socket: {
|
||||
reconnectStrategy: (retries) => Math.min(retries * 50, 500)
|
||||
}
|
||||
});
|
||||
|
||||
this.client.on('error', (err) => {
|
||||
logError('Redis connection error', err);
|
||||
this.isConnected = false;
|
||||
});
|
||||
|
||||
this.client.on('connect', () => {
|
||||
logStartup('Redis client connected successfully');
|
||||
this.isConnected = true;
|
||||
});
|
||||
|
||||
this.client.on('disconnect', () => {
|
||||
logWarning('Redis client disconnected');
|
||||
this.isConnected = false;
|
||||
});
|
||||
}
|
||||
|
||||
public static getInstance(): RedisService {
|
||||
if (!RedisService.instance) {
|
||||
RedisService.instance = new RedisService();
|
||||
}
|
||||
return RedisService.instance;
|
||||
}
|
||||
|
||||
public async connect(): Promise<void> {
|
||||
try {
|
||||
if (!this.isConnected) {
|
||||
await this.client.connect();
|
||||
}
|
||||
} catch (error) {
|
||||
logError('Failed to connect to Redis', error as Error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async disconnect(): Promise<void> {
|
||||
try {
|
||||
if (this.isConnected) {
|
||||
await this.client.disconnect();
|
||||
}
|
||||
} catch (error) {
|
||||
logError('Failed to disconnect from Redis', error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
public async setActiveChat(chatId: string, chatData: ActiveChatData): Promise<void> {
|
||||
try {
|
||||
const key = `active_chat:${chatId}`;
|
||||
await this.client.hSet(key, {
|
||||
chatId: chatData.chatId,
|
||||
participants: JSON.stringify(chatData.participants),
|
||||
lastActivity: chatData.lastActivity.toISOString(),
|
||||
messageCount: chatData.messageCount.toString(),
|
||||
chatType: chatData.chatType,
|
||||
gameId: chatData.gameId || '',
|
||||
name: chatData.name || ''
|
||||
});
|
||||
|
||||
// Set expiration for 1 hour of inactivity
|
||||
await this.client.expire(key, 3600);
|
||||
} catch (error) {
|
||||
logError(`Failed to set active chat ${chatId}`, error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
public async getActiveChat(chatId: string): Promise<ActiveChatData | null> {
|
||||
try {
|
||||
const key = `active_chat:${chatId}`;
|
||||
const data = await this.client.hGetAll(key);
|
||||
|
||||
if (!data.chatId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
chatId: data.chatId,
|
||||
participants: JSON.parse(data.participants),
|
||||
lastActivity: new Date(data.lastActivity),
|
||||
messageCount: parseInt(data.messageCount, 10),
|
||||
chatType: data.chatType as 'direct' | 'group' | 'game',
|
||||
gameId: data.gameId || undefined,
|
||||
name: data.name || undefined
|
||||
};
|
||||
} catch (error) {
|
||||
logError(`Failed to get active chat ${chatId}`, error as Error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async removeActiveChat(chatId: string): Promise<void> {
|
||||
try {
|
||||
const key = `active_chat:${chatId}`;
|
||||
await this.client.del(key);
|
||||
} catch (error) {
|
||||
logError(`Failed to remove active chat ${chatId}`, error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
public async getAllActiveChats(): Promise<ActiveChatData[]> {
|
||||
try {
|
||||
const pattern = 'active_chat:*';
|
||||
const keys = await this.client.keys(pattern);
|
||||
const chats: ActiveChatData[] = [];
|
||||
|
||||
for (const key of keys) {
|
||||
const data = await this.client.hGetAll(key);
|
||||
if (data.chatId) {
|
||||
chats.push({
|
||||
chatId: data.chatId,
|
||||
participants: JSON.parse(data.participants),
|
||||
lastActivity: new Date(data.lastActivity),
|
||||
messageCount: parseInt(data.messageCount, 10),
|
||||
chatType: data.chatType as 'direct' | 'group' | 'game',
|
||||
gameId: data.gameId || undefined,
|
||||
name: data.name || undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return chats;
|
||||
} catch (error) {
|
||||
logError('Failed to get all active chats', error as Error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async setActiveUser(userId: string, userData: ActiveUserData): Promise<void> {
|
||||
try {
|
||||
const key = `active_user:${userId}`;
|
||||
await this.client.hSet(key, {
|
||||
userId: userData.userId,
|
||||
activeChatIds: JSON.stringify(userData.activeChatIds),
|
||||
lastActivity: userData.lastActivity.toISOString(),
|
||||
isOnline: userData.isOnline.toString()
|
||||
});
|
||||
|
||||
// Set expiration for 2 hours
|
||||
await this.client.expire(key, 7200);
|
||||
} catch (error) {
|
||||
logError(`Failed to set active user ${userId}`, error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
public async getActiveUser(userId: string): Promise<ActiveUserData | null> {
|
||||
try {
|
||||
const key = `active_user:${userId}`;
|
||||
const data = await this.client.hGetAll(key);
|
||||
|
||||
if (!data.userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
userId: data.userId,
|
||||
activeChatIds: JSON.parse(data.activeChatIds),
|
||||
lastActivity: new Date(data.lastActivity),
|
||||
isOnline: data.isOnline === 'true'
|
||||
};
|
||||
} catch (error) {
|
||||
logError(`Failed to get active user ${userId}`, error as Error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async removeActiveUser(userId: string): Promise<void> {
|
||||
try {
|
||||
const key = `active_user:${userId}`;
|
||||
await this.client.del(key);
|
||||
} catch (error) {
|
||||
logError(`Failed to remove active user ${userId}`, error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
public async addUserToChat(userId: string, chatId: string): Promise<void> {
|
||||
try {
|
||||
const userData = await this.getActiveUser(userId) || {
|
||||
userId,
|
||||
activeChatIds: [],
|
||||
lastActivity: new Date(),
|
||||
isOnline: true
|
||||
};
|
||||
|
||||
if (!userData.activeChatIds.includes(chatId)) {
|
||||
userData.activeChatIds.push(chatId);
|
||||
userData.lastActivity = new Date();
|
||||
await this.setActiveUser(userId, userData);
|
||||
}
|
||||
} catch (error) {
|
||||
logError(`Failed to add user ${userId} to chat ${chatId}`, error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
public async removeUserFromChat(userId: string, chatId: string): Promise<void> {
|
||||
try {
|
||||
const userData = await this.getActiveUser(userId);
|
||||
if (userData) {
|
||||
userData.activeChatIds = userData.activeChatIds.filter(id => id !== chatId);
|
||||
userData.lastActivity = new Date();
|
||||
await this.setActiveUser(userId, userData);
|
||||
}
|
||||
} catch (error) {
|
||||
logError(`Failed to remove user ${userId} from chat ${chatId}`, error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
public async getUserActiveChats(userId: string): Promise<string[]> {
|
||||
try {
|
||||
const userData = await this.getActiveUser(userId);
|
||||
return userData?.activeChatIds || [];
|
||||
} catch (error) {
|
||||
logError(`Failed to get active chats for user ${userId}`, error as Error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async updateChatActivity(chatId: string, messageCount?: number): Promise<void> {
|
||||
try {
|
||||
const chatData = await this.getActiveChat(chatId);
|
||||
if (chatData) {
|
||||
chatData.lastActivity = new Date();
|
||||
if (messageCount !== undefined) {
|
||||
chatData.messageCount = messageCount;
|
||||
}
|
||||
await this.setActiveChat(chatId, chatData);
|
||||
}
|
||||
} catch (error) {
|
||||
logError(`Failed to update chat activity ${chatId}`, error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
public async getInactiveChats(inactivityMinutes: number): Promise<string[]> {
|
||||
try {
|
||||
const cutoffTime = new Date(Date.now() - inactivityMinutes * 60 * 1000);
|
||||
const allChats = await this.getAllActiveChats();
|
||||
|
||||
return allChats
|
||||
.filter(chat => chat.lastActivity < cutoffTime)
|
||||
.map(chat => chat.chatId);
|
||||
} catch (error) {
|
||||
logError('Failed to get inactive chats', error as Error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async cleanupInactiveChats(inactivityMinutes: number): Promise<string[]> {
|
||||
try {
|
||||
const inactiveChats = await this.getInactiveChats(inactivityMinutes);
|
||||
|
||||
for (const chatId of inactiveChats) {
|
||||
await this.removeActiveChat(chatId);
|
||||
}
|
||||
|
||||
return inactiveChats;
|
||||
} catch (error) {
|
||||
logError('Failed to cleanup inactive chats', error as Error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async ping(): Promise<boolean> {
|
||||
try {
|
||||
const result = await this.client.ping();
|
||||
return result === 'PONG';
|
||||
} catch (error) {
|
||||
logError('Redis ping failed', error as Error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public isRedisConnected(): boolean {
|
||||
return this.isConnected;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import * as crypto from 'crypto';
|
||||
import { logError } from './Logger';
|
||||
|
||||
export interface VerificationToken {
|
||||
token: string;
|
||||
expiresAt: Date;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface PasswordResetToken {
|
||||
token: string;
|
||||
expiresAt: Date;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export class TokenService {
|
||||
private static readonly VERIFICATION_TOKEN_EXPIRES_HOURS = 24;
|
||||
private static readonly PASSWORD_RESET_TOKEN_EXPIRES_HOURS = 1;
|
||||
private static readonly TOKEN_LENGTH = 32;
|
||||
|
||||
/**
|
||||
* Generate a secure random token
|
||||
* @param length - Length of the token in bytes (default: 32)
|
||||
* @returns Hexadecimal string token
|
||||
*/
|
||||
static generateSecureToken(length: number = TokenService.TOKEN_LENGTH): string {
|
||||
try {
|
||||
return crypto.randomBytes(length).toString('hex');
|
||||
} catch (error) {
|
||||
logError('TokenService.generateSecureToken error', error instanceof Error ? error : new Error(String(error)));
|
||||
throw new Error('Failed to generate secure token');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate email verification token with expiration
|
||||
* @returns VerificationToken object with token and expiration info
|
||||
*/
|
||||
static generateVerificationToken(): VerificationToken {
|
||||
try {
|
||||
const token = this.generateSecureToken();
|
||||
const createdAt = new Date();
|
||||
const expiresAt = new Date(createdAt.getTime() + (this.VERIFICATION_TOKEN_EXPIRES_HOURS * 60 * 60 * 1000));
|
||||
|
||||
return {
|
||||
token,
|
||||
createdAt,
|
||||
expiresAt
|
||||
};
|
||||
} catch (error) {
|
||||
logError('TokenService.generateVerificationToken error', error instanceof Error ? error : new Error(String(error)));
|
||||
throw new Error('Failed to generate verification token');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate password reset token with expiration
|
||||
* @returns PasswordResetToken object with token and expiration info
|
||||
*/
|
||||
static generatePasswordResetToken(): PasswordResetToken {
|
||||
try {
|
||||
const token = this.generateSecureToken();
|
||||
const createdAt = new Date();
|
||||
const expiresAt = new Date(createdAt.getTime() + (this.PASSWORD_RESET_TOKEN_EXPIRES_HOURS * 60 * 60 * 1000));
|
||||
|
||||
return {
|
||||
token,
|
||||
createdAt,
|
||||
expiresAt
|
||||
};
|
||||
} catch (error) {
|
||||
logError('TokenService.generatePasswordResetToken error', error instanceof Error ? error : new Error(String(error)));
|
||||
throw new Error('Failed to generate password reset token');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a token has expired
|
||||
* @param expiresAt - Expiration date of the token
|
||||
* @returns True if token has expired, false otherwise
|
||||
*/
|
||||
static isTokenExpired(expiresAt: Date): boolean {
|
||||
try {
|
||||
return new Date() > expiresAt;
|
||||
} catch (error) {
|
||||
logError('TokenService.isTokenExpired error', error instanceof Error ? error : new Error(String(error)));
|
||||
return true; // Assume expired on error for security
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate token format (basic validation)
|
||||
* @param token - Token to validate
|
||||
* @returns True if token format is valid, false otherwise
|
||||
*/
|
||||
static isValidTokenFormat(token: string): boolean {
|
||||
try {
|
||||
if (!token || typeof token !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if token is hexadecimal and has expected length
|
||||
const hexRegex = /^[a-f0-9]+$/i;
|
||||
const expectedLength = this.TOKEN_LENGTH * 2; // Each byte becomes 2 hex characters
|
||||
|
||||
return hexRegex.test(token) && token.length === expectedLength;
|
||||
} catch (error) {
|
||||
logError('TokenService.isValidTokenFormat error', error instanceof Error ? error : new Error(String(error)));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a verification URL with token
|
||||
* @param baseUrl - Base URL of the application
|
||||
* @param token - Verification token
|
||||
* @returns Complete verification URL
|
||||
*/
|
||||
static generateVerificationUrl(baseUrl: string, token: string): string {
|
||||
try {
|
||||
// Remove trailing slash from baseUrl if present
|
||||
const cleanBaseUrl = baseUrl.replace(/\/$/, '');
|
||||
return `${cleanBaseUrl}/api/auth/verify-email?token=${encodeURIComponent(token)}`;
|
||||
} catch (error) {
|
||||
logError('TokenService.generateVerificationUrl error', error instanceof Error ? error : new Error(String(error)));
|
||||
throw new Error('Failed to generate verification URL');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a password reset URL with token
|
||||
* @param baseUrl - Base URL of the application
|
||||
* @param token - Password reset token
|
||||
* @returns Complete password reset URL
|
||||
*/
|
||||
static generatePasswordResetUrl(baseUrl: string, token: string): string {
|
||||
try {
|
||||
// Remove trailing slash from baseUrl if present
|
||||
const cleanBaseUrl = baseUrl.replace(/\/$/, '');
|
||||
return `${cleanBaseUrl}/api/auth/reset-password?token=${encodeURIComponent(token)}`;
|
||||
} catch (error) {
|
||||
logError('TokenService.generatePasswordResetUrl error', error instanceof Error ? error : new Error(String(error)));
|
||||
throw new Error('Failed to generate password reset URL');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a token for secure storage in database
|
||||
* @param token - Plain text token to hash
|
||||
* @returns Hashed token
|
||||
*/
|
||||
static async hashToken(token: string): Promise<string> {
|
||||
try {
|
||||
if (!token || typeof token !== 'string') {
|
||||
throw new Error('Token must be a non-empty string');
|
||||
}
|
||||
|
||||
return crypto.createHash('sha256').update(token).digest('hex');
|
||||
} catch (error) {
|
||||
logError('TokenService.hashToken error', error instanceof Error ? error : new Error(String(error)));
|
||||
throw new Error('Failed to hash token');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a plain text token against a hashed token
|
||||
* @param plainToken - Plain text token to verify
|
||||
* @param hashedToken - Hashed token to compare against
|
||||
* @returns True if tokens match, false otherwise
|
||||
*/
|
||||
static async verifyToken(plainToken: string, hashedToken: string): Promise<boolean> {
|
||||
try {
|
||||
if (!plainToken || !hashedToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hashedPlainToken = await this.hashToken(plainToken);
|
||||
return hashedPlainToken === hashedToken;
|
||||
} catch (error) {
|
||||
logError('TokenService.verifyToken error', error instanceof Error ? error : new Error(String(error)));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get token expiration info in human-readable format
|
||||
* @param expiresAt - Expiration date
|
||||
* @returns Human-readable expiration info
|
||||
*/
|
||||
static getExpirationInfo(expiresAt: Date): { expired: boolean; timeLeft: string } {
|
||||
try {
|
||||
const now = new Date();
|
||||
const expired = now > expiresAt;
|
||||
|
||||
if (expired) {
|
||||
const timeAgo = Math.floor((now.getTime() - expiresAt.getTime()) / (1000 * 60));
|
||||
return {
|
||||
expired: true,
|
||||
timeLeft: `Expired ${timeAgo} minute(s) ago`
|
||||
};
|
||||
}
|
||||
|
||||
const timeLeft = Math.floor((expiresAt.getTime() - now.getTime()) / (1000 * 60));
|
||||
const hours = Math.floor(timeLeft / 60);
|
||||
const minutes = timeLeft % 60;
|
||||
|
||||
let timeString = '';
|
||||
if (hours > 0) {
|
||||
timeString = `${hours} hour(s)`;
|
||||
if (minutes > 0) {
|
||||
timeString += ` and ${minutes} minute(s)`;
|
||||
}
|
||||
} else {
|
||||
timeString = `${minutes} minute(s)`;
|
||||
}
|
||||
|
||||
return {
|
||||
expired: false,
|
||||
timeLeft: `Expires in ${timeString}`
|
||||
};
|
||||
} catch (error) {
|
||||
logError('TokenService.getExpirationInfo error', error instanceof Error ? error : new Error(String(error)));
|
||||
return {
|
||||
expired: true,
|
||||
timeLeft: 'Unable to determine expiration'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { ErrorResponseService } from './ErrorResponseService';
|
||||
import { logError, logWarning } from './Logger';
|
||||
|
||||
/**
|
||||
* Common validation middleware functions for request validation
|
||||
*/
|
||||
export class ValidationMiddleware {
|
||||
|
||||
/**
|
||||
* Validates required fields in request body
|
||||
* @param requiredFields Array of required field names
|
||||
*/
|
||||
static validateRequiredFields(requiredFields: string[]) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const missingFields: string[] = [];
|
||||
|
||||
for (const field of requiredFields) {
|
||||
if (!req.body || req.body[field] === undefined || req.body[field] === null || req.body[field] === '') {
|
||||
missingFields.push(field);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
logWarning('Validation failed - missing required fields', {
|
||||
missingFields,
|
||||
endpoint: req.path
|
||||
}, req, res);
|
||||
return ErrorResponseService.sendBadRequest(
|
||||
res,
|
||||
'Missing required fields',
|
||||
{ missingFields }
|
||||
);
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates field types in request body
|
||||
* @param fieldTypes Object mapping field names to expected types
|
||||
*/
|
||||
static validateFieldTypes(fieldTypes: Record<string, 'string' | 'number' | 'boolean' | 'array' | 'object'>) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const typeErrors: string[] = [];
|
||||
|
||||
for (const [field, expectedType] of Object.entries(fieldTypes)) {
|
||||
if (req.body && req.body[field] !== undefined) {
|
||||
const actualType = Array.isArray(req.body[field]) ? 'array' : typeof req.body[field];
|
||||
|
||||
if (actualType !== expectedType) {
|
||||
typeErrors.push(`Field '${field}' should be ${expectedType}, got ${actualType}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeErrors.length > 0) {
|
||||
logWarning('Validation failed - invalid field types', {
|
||||
typeErrors,
|
||||
endpoint: req.path
|
||||
}, req, res);
|
||||
return ErrorResponseService.sendBadRequest(
|
||||
res,
|
||||
'Invalid field types',
|
||||
{ errors: typeErrors }
|
||||
);
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates string field length constraints
|
||||
* @param constraints Object mapping field names to min/max length
|
||||
*/
|
||||
static validateStringLength(constraints: Record<string, { min?: number; max?: number }>) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const lengthErrors: string[] = [];
|
||||
|
||||
for (const [field, constraint] of Object.entries(constraints)) {
|
||||
if (req.body && typeof req.body[field] === 'string') {
|
||||
const value = req.body[field];
|
||||
|
||||
if (constraint.min !== undefined && value.length < constraint.min) {
|
||||
lengthErrors.push(`Field '${field}' must be at least ${constraint.min} characters`);
|
||||
}
|
||||
|
||||
if (constraint.max !== undefined && value.length > constraint.max) {
|
||||
lengthErrors.push(`Field '${field}' must not exceed ${constraint.max} characters`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lengthErrors.length > 0) {
|
||||
logWarning('Validation failed - string length constraints', {
|
||||
lengthErrors,
|
||||
endpoint: req.path
|
||||
}, req, res);
|
||||
return ErrorResponseService.sendBadRequest(
|
||||
res,
|
||||
'String length validation failed',
|
||||
{ errors: lengthErrors }
|
||||
);
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates email format
|
||||
* @param emailFields Array of field names that should contain valid emails
|
||||
*/
|
||||
static validateEmailFormat(emailFields: string[]) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const emailErrors: string[] = [];
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
for (const field of emailFields) {
|
||||
if (req.body && req.body[field] && typeof req.body[field] === 'string') {
|
||||
if (!emailRegex.test(req.body[field])) {
|
||||
emailErrors.push(`Field '${field}' must contain a valid email address`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (emailErrors.length > 0) {
|
||||
logWarning('Validation failed - invalid email format', {
|
||||
emailErrors,
|
||||
endpoint: req.path
|
||||
}, req, res);
|
||||
return ErrorResponseService.sendBadRequest(
|
||||
res,
|
||||
'Email format validation failed',
|
||||
{ errors: emailErrors }
|
||||
);
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates UUIDs format
|
||||
* @param uuidFields Array of field names that should contain valid UUIDs
|
||||
*/
|
||||
static validateUUIDFormat(uuidFields: string[]) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const uuidErrors: string[] = [];
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
for (const field of uuidFields) {
|
||||
const value = field.includes('.')
|
||||
? this.getNestedValue(req, field)
|
||||
: req.body?.[field] || req.params?.[field] || req.query?.[field];
|
||||
|
||||
if (value && typeof value === 'string') {
|
||||
if (!uuidRegex.test(value)) {
|
||||
uuidErrors.push(`Field '${field}' must contain a valid UUID`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (uuidErrors.length > 0) {
|
||||
logWarning('Validation failed - invalid UUID format', {
|
||||
uuidErrors,
|
||||
endpoint: req.path
|
||||
}, req, res);
|
||||
return ErrorResponseService.sendBadRequest(
|
||||
res,
|
||||
'UUID format validation failed',
|
||||
{ errors: uuidErrors }
|
||||
);
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates numeric constraints
|
||||
* @param constraints Object mapping field names to min/max values
|
||||
*/
|
||||
static validateNumericConstraints(constraints: Record<string, { min?: number; max?: number }>) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const numericErrors: string[] = [];
|
||||
|
||||
for (const [field, constraint] of Object.entries(constraints)) {
|
||||
if (req.body && typeof req.body[field] === 'number') {
|
||||
const value = req.body[field];
|
||||
|
||||
if (constraint.min !== undefined && value < constraint.min) {
|
||||
numericErrors.push(`Field '${field}' must be at least ${constraint.min}`);
|
||||
}
|
||||
|
||||
if (constraint.max !== undefined && value > constraint.max) {
|
||||
numericErrors.push(`Field '${field}' must not exceed ${constraint.max}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (numericErrors.length > 0) {
|
||||
logWarning('Validation failed - numeric constraints', {
|
||||
numericErrors,
|
||||
endpoint: req.path
|
||||
}, req, res);
|
||||
return ErrorResponseService.sendBadRequest(
|
||||
res,
|
||||
'Numeric validation failed',
|
||||
{ errors: numericErrors }
|
||||
);
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that arrays are not empty
|
||||
* @param arrayFields Array of field names that should contain non-empty arrays
|
||||
*/
|
||||
static validateNonEmptyArrays(arrayFields: string[]) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const arrayErrors: string[] = [];
|
||||
|
||||
for (const field of arrayFields) {
|
||||
if (req.body && Array.isArray(req.body[field])) {
|
||||
if (req.body[field].length === 0) {
|
||||
arrayErrors.push(`Field '${field}' must not be empty`);
|
||||
}
|
||||
} else if (req.body && req.body[field] !== undefined) {
|
||||
arrayErrors.push(`Field '${field}' must be an array`);
|
||||
}
|
||||
}
|
||||
|
||||
if (arrayErrors.length > 0) {
|
||||
logWarning('Validation failed - empty arrays', {
|
||||
arrayErrors,
|
||||
endpoint: req.path
|
||||
}, req, res);
|
||||
return ErrorResponseService.sendBadRequest(
|
||||
res,
|
||||
'Array validation failed',
|
||||
{ errors: arrayErrors }
|
||||
);
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates allowed values for enum-like fields
|
||||
* @param allowedValues Object mapping field names to arrays of allowed values
|
||||
*/
|
||||
static validateAllowedValues(allowedValues: Record<string, any[]>) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const valueErrors: string[] = [];
|
||||
|
||||
for (const [field, allowed] of Object.entries(allowedValues)) {
|
||||
if (req.body && req.body[field] !== undefined) {
|
||||
if (!allowed.includes(req.body[field])) {
|
||||
valueErrors.push(`Field '${field}' must be one of: ${allowed.join(', ')}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (valueErrors.length > 0) {
|
||||
logWarning('Validation failed - disallowed values', {
|
||||
valueErrors,
|
||||
endpoint: req.path
|
||||
}, req, res);
|
||||
return ErrorResponseService.sendBadRequest(
|
||||
res,
|
||||
'Value validation failed',
|
||||
{ errors: valueErrors }
|
||||
);
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines multiple validation middlewares
|
||||
* @param validations Array of validation middleware functions
|
||||
*/
|
||||
static combine(validations: Array<(req: Request, res: Response, next: NextFunction) => void>) {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
let currentIndex = 0;
|
||||
|
||||
const runNext = (error?: any) => {
|
||||
if (error) {
|
||||
return next(error);
|
||||
}
|
||||
|
||||
if (currentIndex >= validations.length) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const currentValidation = validations[currentIndex++];
|
||||
|
||||
try {
|
||||
currentValidation(req, res, (err?: any) => {
|
||||
if (res.headersSent) {
|
||||
return; // Response already sent, don't continue
|
||||
}
|
||||
runNext(err);
|
||||
});
|
||||
} catch (error) {
|
||||
logError('Validation middleware error', error as Error, req, res);
|
||||
ErrorResponseService.sendInternalServerError(res);
|
||||
}
|
||||
};
|
||||
|
||||
runNext();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to get nested values from request
|
||||
* @param req Request object
|
||||
* @param path Dot-notation path like 'body.user.id'
|
||||
*/
|
||||
private static getNestedValue(req: Request, path: string): any {
|
||||
const parts = path.split('.');
|
||||
let current: any = req;
|
||||
|
||||
for (const part of parts) {
|
||||
if (current && typeof current === 'object') {
|
||||
current = current[part];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
export interface CreateUserCommand {
|
||||
username: string;
|
||||
password: string;
|
||||
email: string;
|
||||
fname: string;
|
||||
lname: string;
|
||||
code?: string;
|
||||
orgid?: string;
|
||||
type: string;
|
||||
phone?: string;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { IUserRepository } from '../../../Domain/IRepository/IUserRepository';
|
||||
import { CreateUserCommand } from './CreateUserCommand';
|
||||
import { ShortUserDto } from '../../DTOs/UserDto';
|
||||
import { UserAggregate, UserState } from '../../../Domain/User/UserAggregate';
|
||||
import { UserMapper } from '../../DTOs/Mappers/UserMapper';
|
||||
import { PasswordService } from '../../Services/PasswordService';
|
||||
import { EmailService } from '../../Services/EmailService';
|
||||
import { TokenService } from '../../Services/TokenService';
|
||||
import { logDatabase, logError, logAuth, logWarning } from '../../Services/Logger';
|
||||
|
||||
export class CreateUserCommandHandler {
|
||||
constructor(
|
||||
private readonly userRepo: IUserRepository,
|
||||
private readonly emailService: EmailService
|
||||
) {}
|
||||
|
||||
async execute(cmd: CreateUserCommand): Promise<ShortUserDto> {
|
||||
try {
|
||||
// Validate password strength
|
||||
const passwordValidation = PasswordService.validatePasswordStrength(cmd.password);
|
||||
if (!passwordValidation.isValid) {
|
||||
throw new Error(`Password validation failed: ${passwordValidation.errors.join(', ')}`);
|
||||
}
|
||||
|
||||
const user = new UserAggregate();
|
||||
user.username = cmd.username;
|
||||
|
||||
// Hash the password before storing
|
||||
user.password = await PasswordService.hashPassword(cmd.password);
|
||||
|
||||
// Generate verification token
|
||||
const verificationTokenData = TokenService.generateVerificationToken();
|
||||
user.token = await TokenService.hashToken(verificationTokenData.token);
|
||||
user.TokenExpires = verificationTokenData.expiresAt;
|
||||
|
||||
user.email = cmd.email;
|
||||
user.fname = cmd.fname;
|
||||
user.lname = cmd.lname;
|
||||
user.orgid = cmd.orgid || null;
|
||||
user.phone = cmd.phone || null;
|
||||
user.state = UserState.REGISTERED_NOT_VERIFIED;
|
||||
|
||||
const created = await this.userRepo.create(user);
|
||||
|
||||
// Send verification email (non-blocking)
|
||||
this.sendVerificationEmailAsync(created, verificationTokenData.token);
|
||||
|
||||
return UserMapper.toShortDto(created);
|
||||
} catch (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 (don't log as these are user input errors)
|
||||
if (errorMessage.includes('Password validation failed')) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Handle database constraint errors
|
||||
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');
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface DeactivateUserCommand {
|
||||
id: string;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { IUserRepository } from '../../../Domain/IRepository/IUserRepository';
|
||||
import { DeactivateUserCommand } from './DeactivateUserCommand';
|
||||
|
||||
|
||||
export class DeactivateUserCommandHandler {
|
||||
constructor(private readonly userRepo: IUserRepository) {}
|
||||
|
||||
async execute(cmd: DeactivateUserCommand): Promise<boolean> {
|
||||
await this.userRepo.deactivate(cmd.id);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface DeleteUserCommand {
|
||||
id: string;
|
||||
soft?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { IUserRepository } from '../../../Domain/IRepository/IUserRepository';
|
||||
import { DeleteUserCommand } from './DeleteUserCommand';
|
||||
|
||||
|
||||
export class DeleteUserCommandHandler {
|
||||
constructor(private readonly userRepo: IUserRepository) {}
|
||||
|
||||
async execute(cmd: DeleteUserCommand): Promise<boolean> {
|
||||
if (cmd.soft) {
|
||||
await this.userRepo.softDelete(cmd.id);
|
||||
} else {
|
||||
await this.userRepo.delete(cmd.id);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface LoginCommand {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { IUserRepository } from '../../../Domain/IRepository/IUserRepository';
|
||||
import { IOrganizationRepository } from '../../../Domain/IRepository/IOrganizationRepository';
|
||||
import { LoginCommand } from './LoginCommand';
|
||||
import { ShortUserDto } from '../../DTOs/UserDto';
|
||||
import { UserMapper } from '../../DTOs/Mappers/UserMapper';
|
||||
import { PasswordService } from '../../Services/PasswordService';
|
||||
import { JWTService } from '../../Services/JWTService';
|
||||
import { UserState } from '../../../Domain/User/UserAggregate';
|
||||
import { logAuth, logDatabase, logError, logWarning } from '../../Services/Logger';
|
||||
import { Response } from 'express';
|
||||
|
||||
export interface LoginResponse {
|
||||
user: ShortUserDto;
|
||||
token: string;
|
||||
requiresOrgReauth?: boolean;
|
||||
orgLoginUrl?: string;
|
||||
organizationName?: string;
|
||||
}
|
||||
|
||||
export class LoginCommandHandler {
|
||||
constructor(
|
||||
private readonly userRepo: IUserRepository,
|
||||
private readonly jwtService: JWTService,
|
||||
private readonly orgRepo: IOrganizationRepository
|
||||
) {}
|
||||
|
||||
async execute(cmd: LoginCommand, res?: Response): Promise<LoginResponse | null> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
logAuth('Login attempt', undefined, { username: cmd.username });
|
||||
|
||||
const user = await this.userRepo.findByUsername(cmd.username) ||
|
||||
await this.userRepo.findByEmail(cmd.username);
|
||||
|
||||
logDatabase('User lookup completed', undefined, Date.now() - startTime, {
|
||||
found: !!user,
|
||||
searchBy: cmd.username.includes('@') ? 'email' : 'username'
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
logAuth('Login failed - User not found', undefined, { username: cmd.username });
|
||||
throw new Error('Invalid username');
|
||||
}
|
||||
|
||||
// Check if user account state allows login
|
||||
const restrictedStates = [
|
||||
UserState.REGISTERED_NOT_VERIFIED,
|
||||
UserState.SOFT_DELETE,
|
||||
UserState.DEACTIVATED
|
||||
];
|
||||
|
||||
if (restrictedStates.includes(user.state)) {
|
||||
let stateDescription = '';
|
||||
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;
|
||||
}
|
||||
|
||||
logAuth('Login failed - Account state restriction', user.id, {
|
||||
username: cmd.username,
|
||||
userState: user.state,
|
||||
stateDescription
|
||||
});
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
try {
|
||||
const passwordStartTime = Date.now();
|
||||
const isPasswordValid = await PasswordService.verifyPassword(cmd.password, user.password);
|
||||
|
||||
logAuth('Password verification completed', user.id, {
|
||||
valid: isPasswordValid,
|
||||
verificationTime: Date.now() - passwordStartTime
|
||||
});
|
||||
|
||||
if (!isPasswordValid) {
|
||||
logWarning('Login failed - Invalid password', {
|
||||
userId: user.id,
|
||||
username: cmd.username
|
||||
});
|
||||
throw new Error('Invalid password');
|
||||
}
|
||||
} catch (error) {
|
||||
logError('Password verification error', error as Error);
|
||||
throw new Error('Invalid password');
|
||||
}
|
||||
|
||||
const mockRes = {
|
||||
cookie: () => {}
|
||||
} as any;
|
||||
|
||||
const tokenPayload = {
|
||||
userId: user.id,
|
||||
authLevel: (user.state === UserState.ADMIN ? 1 : 0) as 0 | 1,
|
||||
userStatus: user.state,
|
||||
orgId: user.orgid || ''
|
||||
};
|
||||
|
||||
try {
|
||||
// Use the real response object if provided, otherwise use mock
|
||||
const responseObj = res || mockRes;
|
||||
const token = this.jwtService.create(tokenPayload, responseObj);
|
||||
|
||||
// Check if user belongs to an organization and needs reauthentication
|
||||
let requiresOrgReauth = false;
|
||||
let orgLoginUrl: string | undefined;
|
||||
let organizationName: string | undefined;
|
||||
|
||||
if (user.orgid) {
|
||||
const organization = await this.orgRepo.findById(user.orgid);
|
||||
if (organization) {
|
||||
organizationName = organization.name;
|
||||
|
||||
// Check if user has logged in to organization within the last month
|
||||
const oneMonthAgo = new Date();
|
||||
oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
|
||||
|
||||
const needsReauth = !user.Orglogindate || user.Orglogindate < oneMonthAgo;
|
||||
|
||||
if (needsReauth && organization.url) {
|
||||
requiresOrgReauth = true;
|
||||
orgLoginUrl = organization.url;
|
||||
|
||||
logAuth('User requires organization reauthentication', user.id, {
|
||||
organizationId: user.orgid,
|
||||
organizationName: organization.name,
|
||||
lastOrgLogin: user.Orglogindate?.toISOString() || 'never',
|
||||
orgLoginUrl: organization.url
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logAuth('Login successful', user.id, {
|
||||
authLevel: tokenPayload.authLevel,
|
||||
userStatus: tokenPayload.userStatus,
|
||||
orgId: tokenPayload.orgId,
|
||||
requiresOrgReauth,
|
||||
organizationName,
|
||||
totalLoginTime: Date.now() - startTime
|
||||
});
|
||||
|
||||
const response: LoginResponse = {
|
||||
user: UserMapper.toShortDto(user),
|
||||
token
|
||||
};
|
||||
|
||||
if (requiresOrgReauth) {
|
||||
response.requiresOrgReauth = true;
|
||||
response.orgLoginUrl = orgLoginUrl;
|
||||
response.organizationName = organizationName;
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
logError('Token creation failed during login', error as Error);
|
||||
throw new Error('Login failed due to internal error');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
logError('Login handler error', error);
|
||||
|
||||
// Handle database connection errors
|
||||
if (error.message.includes('database connection')) {
|
||||
logDatabase('Database connection error during login', undefined, Date.now() - startTime);
|
||||
throw new Error('Database connection error');
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
// Default database error handling
|
||||
logDatabase('Unexpected database error during login', undefined, Date.now() - startTime);
|
||||
throw new Error('Database connection 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user