import { Controller, Get, Post, Patch, Delete, Body, Param, Query, UseGuards, Request, UseInterceptors, UploadedFile, Logger, Inject, forwardRef, BadRequestException, } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { FileInterceptor } from '@nestjs/platform-express'; import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes } from '@nestjs/swagger'; import { Throttle } from '@nestjs/throttler'; import { ApplicationsService } from './applications.service'; import { DomainService } from './domain.service'; import { CreateApplicationDto, UpdateApplicationDto, ScaleResourcesDto, SetCustomDomainDto, CheckDnsDto } from './dto/application.dto'; import { RolesGuard } from '../common/guards/roles.guard'; import { Roles } from '../common/decorators/roles.decorator'; import { UserRole, DatabaseType, ServiceAccessTarget } from '../common/enums'; import { KubernetesService } from '../kubernetes/kubernetes.service'; import { DeploymentsService } from '../deployments/deployments.service'; import { AccessService } from '../access/access.service'; import { CreateServiceAccessDto } from '../access/dto/service-access.dto'; import { SnapshotsService } from '../snapshots/snapshots.service'; import { BillingService } from '../billing/billing.service'; @ApiTags('Applications') @ApiBearerAuth() @Controller('applications') @UseGuards(AuthGuard('jwt'), RolesGuard) export class ApplicationsController { private readonly logger = new Logger(ApplicationsController.name); constructor( private readonly applicationsService: ApplicationsService, private readonly domainService: DomainService, private readonly kubernetesService: KubernetesService, @Inject(forwardRef(() => DeploymentsService)) private readonly deploymentsService: DeploymentsService, private readonly accessService: AccessService, @Inject(forwardRef(() => SnapshotsService)) private readonly snapshotsService: SnapshotsService, @Inject(forwardRef(() => BillingService)) private readonly billingService: BillingService, ) {} private isStaff(role: string): boolean { return role === UserRole.ADMIN || role === UserRole.TECHNICAL; } private staffUserIdFilter(req: any): string | undefined { return this.isStaff(req.user.role) ? undefined : req.user.id; } @Post() @ApiOperation({ summary: 'Create a new application' }) async create(@Request() req: any, @Body() dto: CreateApplicationDto) { return this.applicationsService.create(req.user.id, dto); } @Post(':id/upload') @Throttle({ default: { limit: 10, ttl: 60_000 } }) @ApiOperation({ summary: 'Upload application code (zip file)' }) @ApiConsumes('multipart/form-data') @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10 * 1024 * 1024 * 1024 }, // 10 GiB max application archive })) async uploadCode( @Param('id') id: string, @Request() req: any, @UploadedFile() file: Express.Multer.File, ) { return this.applicationsService.uploadCode(id, req.user.id, file); } @Post(':id/db-upload') @ApiOperation({ summary: 'Upload a SQL dump file for later restore during deployment' }) @ApiConsumes('multipart/form-data') @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 500 * 1024 * 1024 }, // 500MB for DB dumps })) async uploadDbDump( @Param('id') id: string, @Request() req: any, @UploadedFile() file: Express.Multer.File, ) { if (!file) { throw new BadRequestException('No file uploaded'); } const isStaff = req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL; const app = await this.applicationsService.findOne(id, isStaff ? undefined : req.user.id); if (app.databaseType === DatabaseType.NONE) { throw new BadRequestException('This application does not have a database configured'); } this.logger.log(`DB dump upload for ${app.name} — ${(file.size / 1024).toFixed(1)} KB`); // Save to disk (restore happens after deploy when namespace exists) await this.applicationsService.uploadDbDump(id, isStaff ? app.userId : req.user.id, file); return { success: true, message: 'Database dump uploaded. It will be restored automatically after deployment.', }; } @Get(':id/db-storage') @ApiOperation({ summary: 'Get current database PVC storage size' }) async getDbStorage(@Param('id') id: string, @Request() req: any) { const isStaff = req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL; const app = await this.applicationsService.findOne(id, isStaff ? undefined : req.user.id); if (app.databaseType === DatabaseType.NONE) { throw new BadRequestException('This application does not have a database configured'); } const currentSize = await this.kubernetesService.getDatabasePvcSize(app); return { currentSize, savedSize: app.dbStorageSize || '1Gi' }; } @Patch(':id/db-storage') @ApiOperation({ summary: 'Resize (expand) database PVC storage' }) async resizeDbStorage( @Param('id') id: string, @Request() req: any, @Body() body: { size: string }, ) { const isStaff = req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL; const app = await this.applicationsService.findOne(id, isStaff ? undefined : req.user.id); if (app.databaseType === DatabaseType.NONE) { throw new BadRequestException('This application does not have a database configured'); } if (!body.size || !/^\d+Gi$/.test(body.size)) { throw new BadRequestException('Size must be in format like "1Gi", "5Gi", "10Gi"'); } const result = await this.kubernetesService.resizeDatabasePvc(app, body.size); if (result.success) { // Update the saved size in the DB await this.applicationsService.update(id, app.userId, { dbStorageSize: body.size } as any); } return result; } @Get(':id/storage') @ApiOperation({ summary: 'Get comprehensive storage usage (database + app storage)' }) async getStorageUsage(@Param('id') id: string, @Request() req: any) { const isStaff = req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL; const app = await this.applicationsService.findOne(id, isStaff ? undefined : req.user.id); const usage = await this.kubernetesService.getStorageUsage(app); return { applicationId: app.id, applicationName: app.name, ...usage, // Configured sizes from entity configured: { dbStorageSize: app.dbStorageSize || '1Gi', appStorageSize: app.appStorageSize || '2Gi', }, }; } @Patch(':id/app-storage') @ApiOperation({ summary: 'Resize (expand) app storage PVC' }) async resizeAppStorage( @Param('id') id: string, @Request() req: any, @Body() body: { size: string }, ) { const isStaff = req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL; const app = await this.applicationsService.findOne(id, isStaff ? undefined : req.user.id); if (!body.size || !/^\d+Gi$/.test(body.size)) { throw new BadRequestException('Size must be in format like "2Gi", "5Gi", "10Gi"'); } const result = await this.kubernetesService.resizeAppStoragePvc(app, body.size); if (result.success) { // Update the saved size in the DB await this.applicationsService.update(id, app.userId, { appStorageSize: body.size } as any); } return result; } @Patch(':id/redis-storage') @ApiOperation({ summary: 'Resize (expand) Redis PVC storage' }) async resizeRedisStorage( @Param('id') id: string, @Request() req: any, @Body() body: { size: string }, ) { const isStaff = req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL; const app = await this.applicationsService.findOne(id, isStaff ? undefined : req.user.id); if (!app.enableRedis) { throw new BadRequestException('Redis is not enabled for this application'); } if (!body.size || !/^\d+Gi$/.test(body.size)) { throw new BadRequestException('Size must be in format like "1Gi", "5Gi", "10Gi"'); } const result = await this.kubernetesService.resizeRedisStoragePvc(app, body.size); if (result.success) { const prev = app.optionalServiceResources?.redis; const storageGi = parseInt(body.size.replace('Gi', ''), 10) || 1; await this.applicationsService.update(id, app.userId, { optionalServiceResources: { ...app.optionalServiceResources, redis: { cpuRequest: prev?.cpuRequest, cpuLimit: prev?.cpuLimit ?? '200m', memoryRequest: prev?.memoryRequest, memoryLimit: prev?.memoryLimit ?? '256Mi', storageGi, }, }, } as any); } return result; } @Patch(':id/rabbitmq-storage') @ApiOperation({ summary: 'Resize (expand) RabbitMQ PVC storage' }) async resizeRabbitmqStorage( @Param('id') id: string, @Request() req: any, @Body() body: { size: string }, ) { const isStaff = req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL; const app = await this.applicationsService.findOne(id, isStaff ? undefined : req.user.id); if (!app.enableRabbitmq) { throw new BadRequestException('RabbitMQ is not enabled for this application'); } if (!body.size || !/^\d+Gi$/.test(body.size)) { throw new BadRequestException('Size must be in format like "1Gi", "5Gi", "10Gi"'); } const result = await this.kubernetesService.resizeRabbitmqStoragePvc(app, body.size); if (result.success) { const prev = app.optionalServiceResources?.rabbitmq; const storageGi = parseInt(body.size.replace('Gi', ''), 10) || 2; await this.applicationsService.update(id, app.userId, { optionalServiceResources: { ...app.optionalServiceResources, rabbitmq: { cpuRequest: prev?.cpuRequest, cpuLimit: prev?.cpuLimit ?? '500m', memoryRequest: prev?.memoryRequest, memoryLimit: prev?.memoryLimit ?? '512Mi', storageGi, }, }, } as any); } return result; } @Get() @ApiOperation({ summary: 'List my applications or managed services' }) async findAll( @Request() req: any, @Query('productType') productType?: 'application' | 'managed', ) { return this.applicationsService.findAllByUser(req.user.id, { productType }); } @Get('all') @Roles(UserRole.ADMIN, UserRole.TECHNICAL) @ApiOperation({ summary: 'List all applications (admin/technical)' }) async findAllAdmin(@Request() req: any, @Query('search') search?: string) { return this.applicationsService.findAll(search); } @Get(':id/service-credentials') @ApiOperation({ summary: 'Get internal credentials for enabled optional services' }) async getServiceCredentials(@Param('id') id: string, @Request() req: any) { const app = await this.applicationsService.findOne(id, this.staffUserIdFilter(req)); const credentials: Record = {}; if (app.enableRedis) { const redis = await this.kubernetesService.readAccessCredentials(app, ServiceAccessTarget.REDIS); credentials.redis = { host: `${app.name}-redis`, port: 6379, password: redis.password, url: redis.password ? `redis://:${redis.password}@${app.name}-redis:6379` : `redis://${app.name}-redis:6379`, }; } if (app.enableRabbitmq) { const rabbitmq = await this.kubernetesService.readAccessCredentials(app, ServiceAccessTarget.RABBITMQ_AMQP); const username = rabbitmq.username || 'appuser'; credentials.rabbitmq = { host: `${app.name}-rabbitmq`, amqpPort: 5672, managementPort: 15672, username, password: rabbitmq.password, amqpUrl: `amqp://${username}:${rabbitmq.password}@${app.name}-rabbitmq:5672`, managementUrl: `http://${app.name}-rabbitmq:15672`, }; } return credentials; } @Get(':id') @ApiOperation({ summary: 'Get application details' }) async findOne(@Param('id') id: string, @Request() req: any) { if (req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL) { return this.applicationsService.findOne(id); } return this.applicationsService.findOne(id, req.user.id); } @Patch(':id') @ApiOperation({ summary: 'Update application configuration' }) async update( @Param('id') id: string, @Request() req: any, @Body() dto: UpdateApplicationDto, ) { const isStaff = req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL; if (isStaff) { const app = await this.applicationsService.findOne(id); return this.applicationsService.update(id, app.userId, dto); } return this.applicationsService.update(id, req.user.id, dto); } @Get(':id/resources') @ApiOperation({ summary: 'Get real-time resource usage for an application' }) async getResources(@Param('id') id: string, @Request() req: any) { const app = await this.applicationsService.findOne( id, (req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL) ? undefined : req.user.id, ); return this.kubernetesService.getResourceUsage(app); } @Patch(':id/resources') @ApiOperation({ summary: 'Update application resources (CPU/Memory/Replicas)' }) async updateResources( @Param('id') id: string, @Request() req: any, @Body() dto: ScaleResourcesDto, ) { const isStaff = req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL; const app = await this.applicationsService.findOne(id, isStaff ? undefined : req.user.id); const workload = dto.workload || 'app'; if (dto.replicas !== undefined && workload !== 'app') { throw new BadRequestException('Replicas can only be changed for the main application workload.'); } // Update in K8s (live) await this.kubernetesService.updateResources(app, dto, workload); const updateFields: Record = {}; if (workload === 'app') { if (dto.cpuRequest) updateFields.cpuRequest = dto.cpuRequest; if (dto.cpuLimit) updateFields.cpuLimit = dto.cpuLimit; if (dto.memoryRequest) updateFields.memoryRequest = dto.memoryRequest; if (dto.memoryLimit) updateFields.memoryLimit = dto.memoryLimit; if (dto.replicas !== undefined) updateFields.replicas = dto.replicas; } else if (workload === 'redis' || workload === 'rabbitmq') { const prev = app.optionalServiceResources?.[workload]; updateFields.optionalServiceResources = { ...app.optionalServiceResources, [workload]: { cpuRequest: dto.cpuRequest ?? prev?.cpuRequest, cpuLimit: dto.cpuLimit ?? prev?.cpuLimit ?? '200m', memoryRequest: dto.memoryRequest ?? prev?.memoryRequest, memoryLimit: dto.memoryLimit ?? prev?.memoryLimit ?? '256Mi', storageGi: prev?.storageGi ?? (workload === 'redis' ? 1 : 2), }, }; } const updated = Object.keys(updateFields).length > 0 ? await this.applicationsService.update(id, app.userId, updateFields as any) : app; this.logger.log(`Updated resources for ${app.name}: ${JSON.stringify(dto)}`); return updated; } @Get(':id/preview') @ApiOperation({ summary: 'Get preview URL for the deployed application' }) async getPreview(@Param('id') id: string, @Request() req: any) { const app = await this.applicationsService.findOne(id, this.staffUserIdFilter(req)); const deployments = await this.deploymentsService.findByApplication(app.id); const latest = deployments.find((d) => d.status === 'running') || deployments[0]; const previewNumber = latest?.previewSubdomain ?? null; return this.kubernetesService.getPreviewInfo(app, previewNumber); } @Post(':id/access') @ApiOperation({ summary: 'Open temporary external access to database, Redis, or RabbitMQ' }) async createAccess( @Param('id') id: string, @Request() req: any, @Body() dto: CreateServiceAccessDto, ) { await this.applicationsService.findOne(id, this.staffUserIdFilter(req)); return this.accessService.createGrant(id, this.staffUserIdFilter(req), dto); } @Get(':id/access') @ApiOperation({ summary: 'List active temporary access grants for an application' }) async listAccess(@Param('id') id: string, @Request() req: any) { await this.applicationsService.findOne(id, this.staffUserIdFilter(req)); return this.accessService.listGrants(id, this.staffUserIdFilter(req)); } @Delete(':id/access/:grantId') @ApiOperation({ summary: 'Revoke temporary external access' }) async revokeAccess( @Param('id') id: string, @Param('grantId') grantId: string, @Request() req: any, ) { const app = await this.applicationsService.findOne(id, this.staffUserIdFilter(req)); await this.accessService.findGrantForApp(grantId, id, this.staffUserIdFilter(req)); await this.accessService.revokeGrant(grantId, this.staffUserIdFilter(req)); return { message: 'Access revoked' }; } // ── Custom Domain ───────────────────────────────────────────── @Post('domain/check-dns') @ApiOperation({ summary: 'Standalone DNS check (no app needed — for deploy wizard)' }) async checkDnsStandalone(@Body() dto: CheckDnsDto) { return this.domainService.checkDnsStandalone(dto.domain, dto.appName); } @Get(':id/domain') @ApiOperation({ summary: 'Get custom domain info and DNS setup instructions' }) async getDomainInfo(@Param('id') id: string, @Request() req: any) { const userId = (req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL) ? (await this.applicationsService.findOne(id)).userId : req.user.id; return this.domainService.getDomainInfo(id, userId); } @Post(':id/domain') @ApiOperation({ summary: 'Set a custom domain for the application' }) async setCustomDomain( @Param('id') id: string, @Request() req: any, @Body() dto: SetCustomDomainDto, ) { const isStaff = req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL; const userId = isStaff ? (await this.applicationsService.findOne(id)).userId : req.user.id; return this.domainService.setCustomDomain(id, userId, dto.domain); } @Post(':id/domain/verify') @ApiOperation({ summary: 'Verify DNS records for the custom domain' }) async verifyDomainDns(@Param('id') id: string, @Request() req: any) { const isStaff = req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL; const userId = isStaff ? (await this.applicationsService.findOne(id)).userId : req.user.id; const result = await this.domainService.verifyDns(id, userId); if (result.verified && result.application) { try { await this.kubernetesService.updateIngress(result.application); } catch (e: any) { this.logger.warn(`Failed to update ingress for ${id} after DNS verification: ${e.message}`); } } return result; } @Delete(':id/domain') @ApiOperation({ summary: 'Remove custom domain from the application' }) async removeCustomDomain(@Param('id') id: string, @Request() req: any) { const isStaff = req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL; const userId = isStaff ? (await this.applicationsService.findOne(id)).userId : req.user.id; const app = await this.domainService.removeCustomDomain(id, userId); try { await this.kubernetesService.updateIngress(app); } catch (e: any) { this.logger.warn(`Failed to update ingress for ${id} after domain removal: ${e.message}`); } return { message: 'Custom domain removed successfully' }; } @Delete(':id') @ApiOperation({ summary: 'Permanently delete an application and all its resources' }) async delete(@Param('id') id: string, @Request() req: any) { const isStaff = req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL; const app = await this.applicationsService.findOne(id, isStaff ? undefined : req.user.id); const credit = await this.billingService.createCreditFromDeletedApp(app); try { await this.accessService.revokeAllForApplication(app.id); } catch (e: any) { this.logger.warn(`Access grant cleanup failed for ${app.name}: ${e.message}`); } try { await this.kubernetesService.deleteApplication(app); this.logger.log(`Deleted K8s resources for ${app.name}`); } catch (e: any) { this.logger.warn(`K8s cleanup failed for ${app.name}: ${e.message}`); } try { await this.deploymentsService.deleteAllForApplication(app.id); } catch (e: any) { this.logger.warn(`Deployment records cleanup failed for ${app.name}: ${e.message}`); } try { await this.snapshotsService.deleteAllForApplication(app.id); } catch (e: any) { this.logger.warn(`Snapshot cleanup failed for ${app.name}: ${e.message}`); } await this.applicationsService.delete(id, isStaff ? app.userId : req.user.id); return { message: `Application "${app.name}" and all resources deleted`, resourceCredit: credit ? this.billingService.formatCreditForApi(credit) : null, }; } }