import { Body, Controller, Delete, Get, Param, Post, Req, Res, StreamableFile, UploadedFile, UseGuards, UseInterceptors, } from "@nestjs/common"; import { FileInterceptor } from "@nestjs/platform-express"; import { Request, Response } from "express"; import { AuthenticatedGuard } from "../auth/authenticated.guard"; import { AbilityGuard } from "../auth/ability.guard"; import { RequireAbility } from "../auth/require-ability.decorator"; import { AuditService } from "../common/audit.service"; import { OpsService } from "./ops.service"; import { ReplicationService } from "./replication.service"; import { StartJobDto } from "./start-job.dto"; /** Every route is ADMIN-only (ability "db:manage"). */ @UseGuards(AuthenticatedGuard, AbilityGuard) @RequireAbility("db:manage") @Controller("ops") export class OpsController { constructor( private readonly ops: OpsService, private readonly replication: ReplicationService, private readonly audit: AuditService, ) {} private actingId(req: Request): string { return (req.user as { id: string }).id; } /* ------------------------------------------------------------- ingest */ @Get("ingest") listIngest() { return this.ops.listIngest(); } @Post("ingest/:name") @UseInterceptors( FileInterceptor("file", { limits: { fileSize: 2 * 1024 * 1024 * 1024 } }), ) async uploadIngest( @Param("name") name: string, @UploadedFile() file: { buffer: Buffer; size: number } | undefined, @Req() req: Request, ) { if (!file) throw new Error("No se recibió ningún archivo."); await this.ops.saveIngest(name, file.buffer); void this.audit.log(this.actingId(req), "ops.ingest.upload", { name, size: file.size, }); return { ok: true }; } @Delete("ingest/:name") async deleteIngest(@Param("name") name: string, @Req() req: Request) { await this.ops.deleteIngest(name); void this.audit.log(this.actingId(req), "ops.ingest.delete", { name }); return { ok: true }; } /* ------------------------------------------------------------ backups */ @Get("backups") listBackups() { return this.ops.listBackups(); } @Get("backups/:name/download") download( @Param("name") name: string, @Res({ passthrough: true }) res: Response, ): StreamableFile { const { stream, name: safe } = this.ops.backupStream(name); res.set({ "Content-Type": "application/gzip", "Content-Disposition": `attachment; filename="${safe}"`, }); return new StreamableFile(stream); } @Delete("backups/:name") async deleteBackup(@Param("name") name: string, @Req() req: Request) { await this.ops.deleteBackup(name); void this.audit.log(this.actingId(req), "ops.backup.delete", { name }); return { ok: true }; } /* --------------------------------------------------------------- jobs */ /** Health of the my.jorgecuadros.com read replica. Read-only, no audit entry. */ @Get("replication") replicationStatus() { return this.replication.status(); } /** * Full row-by-row comparison of the customer-visible tables against the master. * * POST rather than GET despite reading nothing: it is a full scan of both * servers and must not be something a browser prefetch, a retry, or a refresh * can set off. Audited for the same reason — it is a deliberate, costly act, * and "who ran this while the site was slow" is a question worth answering. */ @Post("replication/verify") async verifyReplication(@Req() req: Request) { const result = await this.replication.verify(); void this.audit.log(this.actingId(req), "ops.replication.verify", { identical: result.identical, elapsedMs: result.elapsedMs, }); return result; } @Get("jobs") listJobs() { return this.ops.listJobs(); } @Get("jobs/:id") getJob(@Param("id") id: string) { return this.ops.getJob(id); } @Post("jobs") async startJob(@Body() dto: StartJobDto, @Req() req: Request) { const userId = this.actingId(req); const job = await this.ops.startJob( dto.kind, { file: dto.file, forceFull: dto.forceFull }, userId, ); void this.audit.log(userId, "ops.job.start", { jobId: job.id, kind: dto.kind, file: dto.file, // Recorded because this is the flag that authorised deleting native rows. forceFull: dto.forceFull, }); return job; } }