Spaces:
Runtime error
Runtime error
| import { Controller, Get, Post, Delete, Param, Query, Body, HttpCode, HttpStatus, ParseUUIDPipe } from '@nestjs/common'; | |
| import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger'; | |
| import { SessionService } from './session.service'; | |
| import { | |
| CreateSessionDto, | |
| SessionResponseDto, | |
| QRCodeResponseDto, | |
| MarkChatReadDto, | |
| DeleteChatDto, | |
| SendChatStateDto, | |
| RequestPairingCodeDto, | |
| PairingCodeResponseDto, | |
| } from './dto'; | |
| import { Session } from './entities/session.entity'; | |
| import { ChatSummary } from '../../engine/interfaces/whatsapp-engine.interface'; | |
| import { AuditService } from '../audit/audit.service'; | |
| import { AuditAction } from '../audit/entities/audit-log.entity'; | |
| import { RequireRole, CurrentApiKey, SessionScoped } from '../auth/decorators/auth.decorators'; | |
| import { ApiKey, ApiKeyRole } from '../auth/entities/api-key.entity'; | |
| ('sessions') | |
| ('sessions') | |
| // The `:id` route param here is a WhatsApp session id, so the ApiKeyGuard enforces a key's | |
| // allowedSessions scope against it (other controllers' `:id` is an unrelated resource id). | |
| () | |
| export class SessionController { | |
| constructor( | |
| private readonly sessionService: SessionService, | |
| private readonly auditService: AuditService, | |
| ) {} | |
| private transformSession(session: Session): SessionResponseDto { | |
| return SessionResponseDto.fromEntity(session); | |
| } | |
| () | |
| (ApiKeyRole.OPERATOR) | |
| ({ summary: 'Create a new WhatsApp session' }) | |
| ({ | |
| status: 201, | |
| description: 'Session created', | |
| type: SessionResponseDto, | |
| }) | |
| ({ status: 409, description: 'Session name already exists' }) | |
| async create(() dto: CreateSessionDto): Promise<Session> { | |
| const session = await this.sessionService.create(dto); | |
| await this.auditService.logInfo(AuditAction.SESSION_CREATED, { | |
| sessionId: session.id, | |
| sessionName: session.name, | |
| }); | |
| return session; | |
| } | |
| () | |
| ({ summary: 'List all sessions' }) | |
| ({ | |
| status: 200, | |
| description: 'List of sessions', | |
| type: [SessionResponseDto], | |
| }) | |
| ({ name: 'limit', required: false, description: 'Max sessions to return (1-1000, default 1000)' }) | |
| ({ name: 'offset', required: false, description: 'Number of sessions to skip (for paging)' }) | |
| async findAll( | |
| () apiKey?: ApiKey, | |
| ('limit') limit?: string, | |
| ('offset') offset?: string, | |
| ): Promise<SessionResponseDto[]> { | |
| // Scope to the key's allowedSessions so a session-restricted key cannot enumerate every | |
| // session. A null/empty allowlist (e.g. ADMIN) still lists all. | |
| const sessions = await this.sessionService.findAll(apiKey?.allowedSessions, { | |
| limit: limit ? parseInt(limit, 10) : undefined, | |
| offset: offset ? parseInt(offset, 10) : undefined, | |
| }); | |
| return sessions.map(s => this.transformSession(s)); | |
| } | |
| (':id') | |
| ({ summary: 'Get session by ID' }) | |
| ({ name: 'id', description: 'Session ID' }) | |
| ({ | |
| status: 200, | |
| description: 'Session details', | |
| type: SessionResponseDto, | |
| }) | |
| ({ status: 404, description: 'Session not found' }) | |
| async findOne(('id', ParseUUIDPipe) id: string): Promise<SessionResponseDto> { | |
| const session = await this.sessionService.findOne(id); | |
| return this.transformSession(session); | |
| } | |
| (':id') | |
| (ApiKeyRole.OPERATOR) | |
| (HttpStatus.NO_CONTENT) | |
| ({ summary: 'Delete a session' }) | |
| ({ name: 'id', description: 'Session ID' }) | |
| ({ status: 204, description: 'Session deleted' }) | |
| ({ status: 404, description: 'Session not found' }) | |
| async delete(('id', ParseUUIDPipe) id: string): Promise<void> { | |
| const session = await this.sessionService.findOne(id); | |
| await this.sessionService.delete(id); | |
| await this.auditService.logInfo(AuditAction.SESSION_DELETED, { | |
| sessionId: id, | |
| sessionName: session.name, | |
| }); | |
| } | |
| (':id/start') | |
| (ApiKeyRole.OPERATOR) | |
| (HttpStatus.OK) | |
| ({ | |
| summary: 'Start a session and initialize WhatsApp connection', | |
| }) | |
| ({ name: 'id', description: 'Session ID' }) | |
| ({ | |
| status: 200, | |
| description: 'Session started', | |
| type: SessionResponseDto, | |
| }) | |
| ({ status: 400, description: 'Session already started' }) | |
| ({ status: 404, description: 'Session not found' }) | |
| async start(('id', ParseUUIDPipe) id: string): Promise<SessionResponseDto> { | |
| const session = await this.sessionService.start(id); | |
| await this.auditService.logInfo(AuditAction.SESSION_STARTED, { | |
| sessionId: session.id, | |
| sessionName: session.name, | |
| }); | |
| return this.transformSession(session); | |
| } | |
| (':id/stop') | |
| (ApiKeyRole.OPERATOR) | |
| (HttpStatus.OK) | |
| ({ summary: 'Stop a session and disconnect WhatsApp' }) | |
| ({ name: 'id', description: 'Session ID' }) | |
| ({ | |
| status: 200, | |
| description: 'Session stopped', | |
| type: SessionResponseDto, | |
| }) | |
| ({ status: 404, description: 'Session not found' }) | |
| async stop(('id', ParseUUIDPipe) id: string): Promise<SessionResponseDto> { | |
| const session = await this.sessionService.stop(id); | |
| await this.auditService.logInfo(AuditAction.SESSION_STOPPED, { | |
| sessionId: session.id, | |
| sessionName: session.name, | |
| }); | |
| return this.transformSession(session); | |
| } | |
| (':id/force-kill') | |
| (ApiKeyRole.OPERATOR) | |
| (HttpStatus.OK) | |
| ({ summary: 'Force-kill a stuck session (SIGKILL its wedged engine, then tear it down)' }) | |
| ({ name: 'id', description: 'Session ID' }) | |
| ({ | |
| status: 200, | |
| description: 'Session force-killed', | |
| type: SessionResponseDto, | |
| }) | |
| ({ status: 404, description: 'Session not found' }) | |
| async forceKill(('id', ParseUUIDPipe) id: string): Promise<SessionResponseDto> { | |
| const session = await this.sessionService.forceKill(id); | |
| await this.auditService.logInfo(AuditAction.SESSION_FORCE_KILLED, { | |
| sessionId: session.id, | |
| sessionName: session.name, | |
| }); | |
| return this.transformSession(session); | |
| } | |
| (':id/qr') | |
| (ApiKeyRole.OPERATOR) | |
| ({ summary: 'Get QR code for session authentication' }) | |
| ({ name: 'id', description: 'Session ID' }) | |
| ({ | |
| status: 200, | |
| description: 'QR code data', | |
| type: QRCodeResponseDto, | |
| }) | |
| ({ | |
| status: 400, | |
| description: 'QR code not ready or session already authenticated', | |
| }) | |
| ({ status: 404, description: 'Session not found' }) | |
| async getQRCode(('id', ParseUUIDPipe) id: string): Promise<QRCodeResponseDto> { | |
| const qrCode = await this.sessionService.getQRCode(id); | |
| await this.auditService.logInfo(AuditAction.SESSION_QR_GENERATED, { | |
| sessionId: id, | |
| }); | |
| return qrCode; | |
| } | |
| (':id/pairing-code') | |
| (ApiKeyRole.OPERATOR) | |
| ({ summary: 'Request an 8-char pairing code to link via phone number (alternative to QR)' }) | |
| ({ name: 'id', description: 'Session ID' }) | |
| ({ status: 201, description: 'Pairing code generated', type: PairingCodeResponseDto }) | |
| ({ status: 400, description: 'Session not started or already authenticated' }) | |
| ({ status: 404, description: 'Session not found' }) | |
| async requestPairingCode( | |
| ('id', ParseUUIDPipe) id: string, | |
| () dto: RequestPairingCodeDto, | |
| ): Promise<PairingCodeResponseDto> { | |
| return this.sessionService.requestPairingCode(id, dto.phoneNumber); | |
| } | |
| (':id/groups') | |
| ({ summary: 'Get all groups for a session' }) | |
| ({ name: 'id', description: 'Session ID' }) | |
| ({ | |
| status: 200, | |
| description: 'List of groups the session is a member of', | |
| }) | |
| ({ status: 400, description: 'Session not ready' }) | |
| ({ status: 404, description: 'Session not found' }) | |
| ({ name: 'limit', required: false, description: 'Max groups to return (1–1000, default 1000)' }) | |
| ({ name: 'offset', required: false, description: 'Number of groups to skip (for paging)' }) | |
| async getGroups( | |
| ('id', ParseUUIDPipe) id: string, | |
| ('limit') limit?: string, | |
| ('offset') offset?: string, | |
| ): Promise<{ id: string; name: string; linkedParentJID?: string | null }[]> { | |
| return this.sessionService.getGroups(id, { | |
| limit: limit ? parseInt(limit, 10) : undefined, | |
| offset: offset ? parseInt(offset, 10) : undefined, | |
| }); | |
| } | |
| (':id/chats') | |
| ({ summary: 'Get active chats for a session' }) | |
| ({ name: 'id', description: 'Session ID' }) | |
| ({ status: 200, description: 'List of active chats (most recent first)' }) | |
| ({ status: 400, description: 'Session not ready' }) | |
| ({ status: 404, description: 'Session not found' }) | |
| ({ name: 'limit', required: false, description: 'Max chats to return (1–1000, default 1000)' }) | |
| ({ name: 'offset', required: false, description: 'Number of chats to skip (for paging)' }) | |
| async getChats( | |
| ('id', ParseUUIDPipe) id: string, | |
| ('limit') limit?: string, | |
| ('offset') offset?: string, | |
| ): Promise<ChatSummary[]> { | |
| return this.sessionService.getChats(id, { | |
| limit: limit ? parseInt(limit, 10) : undefined, | |
| offset: offset ? parseInt(offset, 10) : undefined, | |
| }); | |
| } | |
| (':id/chats/read') | |
| (ApiKeyRole.OPERATOR) | |
| (HttpStatus.OK) | |
| ({ summary: 'Mark a chat as read/seen' }) | |
| ({ name: 'id', description: 'Session ID' }) | |
| ({ status: 200, description: 'Chat marked as read successfully' }) | |
| ({ status: 400, description: 'Session not ready' }) | |
| ({ status: 404, description: 'Session not found' }) | |
| async markChatRead( | |
| ('id', ParseUUIDPipe) id: string, | |
| () dto: MarkChatReadDto, | |
| ): Promise<{ success: boolean }> { | |
| const success = await this.sessionService.sendSeen(id, dto.chatId); | |
| return { success }; | |
| } | |
| (':id/chats/unread') | |
| (ApiKeyRole.OPERATOR) | |
| (HttpStatus.OK) | |
| ({ summary: 'Mark a chat as unread' }) | |
| ({ name: 'id', description: 'Session ID' }) | |
| ({ status: 200, description: 'Chat marked as unread successfully' }) | |
| ({ status: 400, description: 'Session not ready' }) | |
| ({ status: 404, description: 'Session not found' }) | |
| async markChatUnread( | |
| ('id', ParseUUIDPipe) id: string, | |
| () dto: MarkChatReadDto, | |
| ): Promise<{ success: boolean }> { | |
| const success = await this.sessionService.markUnread(id, dto.chatId); | |
| return { success }; | |
| } | |
| (':id/chats/delete') | |
| (ApiKeyRole.OPERATOR) | |
| (HttpStatus.OK) | |
| ({ summary: 'Delete a chat from the chat list (e.g. a group you have left)' }) | |
| ({ name: 'id', description: 'Session ID' }) | |
| ({ status: 200, description: 'Chat deleted successfully' }) | |
| ({ status: 400, description: 'Session not ready' }) | |
| ({ status: 404, description: 'Session not found' }) | |
| async deleteChat(('id', ParseUUIDPipe) id: string, () dto: DeleteChatDto): Promise<{ success: boolean }> { | |
| const success = await this.sessionService.deleteChat(id, dto.chatId); | |
| return { success }; | |
| } | |
| (':id/chats/typing') | |
| (ApiKeyRole.OPERATOR) | |
| (HttpStatus.OK) | |
| ({ summary: "Send a typing/recording presence indicator to a chat (or clear it with 'paused')" }) | |
| ({ name: 'id', description: 'Session ID' }) | |
| ({ status: 200, description: 'Presence sent' }) | |
| ({ status: 404, description: 'Session not found' }) | |
| async sendChatState( | |
| ('id', ParseUUIDPipe) id: string, | |
| () dto: SendChatStateDto, | |
| ): Promise<{ success: boolean }> { | |
| await this.sessionService.sendChatState(id, dto.chatId, dto.state); | |
| return { success: true }; | |
| } | |
| ('stats/overview') | |
| ({ | |
| summary: 'Get session statistics for multi-session monitoring', | |
| }) | |
| ({ | |
| status: 200, | |
| description: 'Session statistics including counts and memory usage', | |
| }) | |
| async getStats(() apiKey?: ApiKey): Promise<{ | |
| total: number; | |
| active: number; | |
| ready: number; | |
| disconnected: number; | |
| byStatus: Record<string, number>; | |
| memoryUsage: { heapUsed: number; heapTotal: number; rss: number }; | |
| }> { | |
| // Scope aggregate stats to the key's allowedSessions so a session-restricted key cannot enumerate | |
| // global session counts/status (the route carries no :id for the guard to scope against). | |
| return this.sessionService.getStats(apiKey?.allowedSessions); | |
| } | |
| } | |