diff --git a/apps/api/package.json b/apps/api/package.json index 0616d2f..6c4c189 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -22,6 +22,7 @@ "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.1.0", "@nestjs/throttler": "^6.5.0", + "@types/multer": "^2.1.0", "bcrypt": "^6.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index bdda57d..ba92ab7 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -9,6 +9,11 @@ import { UsersModule } from "./users/users.module"; import { PackagesModule } from "./packages/packages.module"; import { PreAlertsModule } from "./pre-alerts/pre-alerts.module"; import { B2BModule } from "./b2b/b2b.module"; +import { StorageModule } from "./storage/storage.module"; +import { AuditLogModule } from "./audit-log/audit-log.module"; +import { NotificationsModule } from "./notifications/notifications.module"; +import { TariffsModule } from "./tariffs/tariffs.module"; +import { ProductsModule } from "./products/products.module"; @Module({ imports: [ @@ -25,6 +30,11 @@ import { B2BModule } from "./b2b/b2b.module"; PackagesModule, PreAlertsModule, B2BModule, + StorageModule, + AuditLogModule, + NotificationsModule, + TariffsModule, + ProductsModule, ], }) export class AppModule {} diff --git a/apps/api/src/audit-log/audit-log.controller.ts b/apps/api/src/audit-log/audit-log.controller.ts new file mode 100644 index 0000000..4c78b3b --- /dev/null +++ b/apps/api/src/audit-log/audit-log.controller.ts @@ -0,0 +1,34 @@ +import { Controller, Get, Query, UseGuards } from "@nestjs/common"; +import { AuditLogService } from "./audit-log.service"; +import { JwtAuthGuard, RolesGuard, Roles } from "../auth/guards/auth.guard"; +import { CurrentUser } from "../auth/decorators/current-user.decorator"; + +@Controller("audit-logs") +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles("SUPER_ADMIN", "ADMIN_EMPRESA") +export class AuditLogController { + constructor(private svc: AuditLogService) {} + + @Get() + findAll( + @CurrentUser() user: any, + @Query("userId") userId?: string, + @Query("action") action?: string, + @Query("resource") resource?: string, + @Query("from") from?: string, + @Query("to") to?: string, + @Query("page") page?: string, + @Query("limit") limit?: string, + ) { + return this.svc.findAll({ + tenantId: user.role === "SUPER_ADMIN" ? undefined : user.tenantId, + userId, + action, + resource, + from, + to, + page: page ? parseInt(page) : 1, + limit: limit ? parseInt(limit) : 50, + }); + } +} diff --git a/apps/api/src/audit-log/audit-log.module.ts b/apps/api/src/audit-log/audit-log.module.ts new file mode 100644 index 0000000..1665921 --- /dev/null +++ b/apps/api/src/audit-log/audit-log.module.ts @@ -0,0 +1,12 @@ +import { Module } from "@nestjs/common"; +import { AuditLogService } from "./audit-log.service"; +import { AuditLogController } from "./audit-log.controller"; +import { PrismaModule } from "../prisma/prisma.module"; + +@Module({ + imports: [PrismaModule], + providers: [AuditLogService], + controllers: [AuditLogController], + exports: [AuditLogService], +}) +export class AuditLogModule {} diff --git a/apps/api/src/audit-log/audit-log.service.ts b/apps/api/src/audit-log/audit-log.service.ts new file mode 100644 index 0000000..4b6ffb0 --- /dev/null +++ b/apps/api/src/audit-log/audit-log.service.ts @@ -0,0 +1,67 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service"; + +export interface AuditLogEntry { + tenantId?: string; + userId?: string; + action: string; + resource?: string; + resourceId?: string; + metadata?: Record; + ipAddress?: string; + userAgent?: string; +} + +@Injectable() +export class AuditLogService { + private readonly logger = new Logger(AuditLogService.name); + + constructor(private prisma: PrismaService) {} + + async log(entry: AuditLogEntry): Promise { + try { + await this.prisma.client.auditLog.create({ data: entry }); + } catch (e: unknown) { + // Never let audit log failure break main flow + this.logger.error("AuditLog write failed", (e as Error).message); + } + } + + async findAll(filters: { + tenantId?: string; + userId?: string; + action?: string; + resource?: string; + from?: string; + to?: string; + page?: number; + limit?: number; + }): Promise<{ data: any[]; total: number }> { + const page = filters.page ?? 1; + const limit = filters.limit ?? 50; + const skip = (page - 1) * limit; + + const where: any = {}; + if (filters.tenantId) where.tenantId = filters.tenantId; + if (filters.userId) where.userId = filters.userId; + if (filters.action) where.action = { contains: filters.action, mode: "insensitive" }; + if (filters.resource) where.resource = { contains: filters.resource, mode: "insensitive" }; + if (filters.from || filters.to) { + where.createdAt = {}; + if (filters.from) where.createdAt.gte = new Date(filters.from); + if (filters.to) where.createdAt.lte = new Date(filters.to); + } + + const [data, total] = await Promise.all([ + this.prisma.client.auditLog.findMany({ + where, + orderBy: { createdAt: "desc" }, + skip, + take: limit, + }), + this.prisma.client.auditLog.count({ where }), + ]); + + return { data, total }; + } +} diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 0a66c42..84a2372 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -13,6 +13,14 @@ import { TOTP, generateSecret, generateURI, verify as totpVerify } from "otplib" const TENANT_SLUG = "moraworld"; const BCRYPT_ROUNDS = 10; +function buildSuiteAddress(suiteCode: string, config: import("@nestjs/config").ConfigService): string { + const street = config.get("WAREHOUSE_ADDRESS_STREET", "150 N Day St"); + const city = config.get("WAREHOUSE_ADDRESS_CITY", "City of Orange"); + const state = config.get("WAREHOUSE_ADDRESS_STATE", "NJ"); + const zip = config.get("WAREHOUSE_ADDRESS_ZIP", "07050"); + return `${street}, Suite ${suiteCode}, ${city}, ${state} ${zip}, EE.UU.`; +} + @Injectable() export class AuthService { constructor( @@ -58,7 +66,7 @@ export class AuthService { return { user: this.sanitizeUser(user), suiteCode, - suiteAddress: `150 N Day St, Suite ${suiteCode}, City of Orange, NJ 07050, EE.UU.`, + suiteAddress: buildSuiteAddress(suiteCode, this.config), ...tokens, }; } @@ -106,7 +114,7 @@ export class AuthService { user: this.sanitizeUser(user), suite: suite ? { code: suite.code, - address: `150 N Day St, Suite ${suite.code}, City of Orange, NJ 07050, EE.UU.`, + address: buildSuiteAddress(suite.code, this.config), } : null, ...tokens, }; @@ -178,7 +186,7 @@ export class AuthService { ...this.sanitizeUser(user), suite: suite ? { code: suite.code, - address: `150 N Day St, Suite ${suite.code}, City of Orange, NJ 07050, EE.UU.`, + address: buildSuiteAddress(suite.code, this.config), } : null, }; } diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index a0e3b98..47afbc8 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,9 +1,11 @@ import { NestFactory } from "@nestjs/core"; import { ValidationPipe } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { join } from "path"; import { AppModule } from "./app.module"; async function bootstrap() { - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule); const corsOrigins = (process.env.CORS_ORIGINS ?? "http://localhost:3000") .split(",") @@ -16,6 +18,10 @@ async function bootstrap() { app.setGlobalPrefix("api"); + // Serve uploaded files (photos, invoices) as static assets + const uploadsDir = join(process.cwd(), "uploads"); + app.useStaticAssets(uploadsDir, { prefix: "/uploads" }); + // Validación global de DTOs (class-validator) app.useGlobalPipes( new ValidationPipe({ diff --git a/apps/api/src/notifications/notifications.module.ts b/apps/api/src/notifications/notifications.module.ts new file mode 100644 index 0000000..f8b2bcf --- /dev/null +++ b/apps/api/src/notifications/notifications.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; +import { NotificationsService } from "./notifications.service"; +import { PrismaModule } from "../prisma/prisma.module"; + +@Module({ + imports: [PrismaModule], + providers: [NotificationsService], + exports: [NotificationsService], +}) +export class NotificationsModule {} diff --git a/apps/api/src/notifications/notifications.service.ts b/apps/api/src/notifications/notifications.service.ts new file mode 100644 index 0000000..eea1f3e --- /dev/null +++ b/apps/api/src/notifications/notifications.service.ts @@ -0,0 +1,70 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service"; + +@Injectable() +export class NotificationsService { + private readonly logger = new Logger(NotificationsService.name); + + constructor(private prisma: PrismaService) {} + + /** Called whenever a package status changes. Creates Notification records and stubs dispatch. */ + async notifyStatusChange(pkg: any, user: any): Promise { + const statusLabels: Record = { + REGISTRADO: "fue registrado en el sistema", + EN_TRANSITO_BODEGA: "está en tránsito hacia la bodega NJ", + RECIBIDO_BODEGA: "fue recibido en la bodega de NJ", + EN_VERIFICACION: "está siendo verificado en bodega", + VERIFICADO: "fue verificado. El cobro final fue aplicado.", + DECLARACION_ADUANERA: "tiene su declaración aduanera aprobada (SENAE)", + EN_TRANSITO_ECUADOR: "está en tránsito hacia Ecuador", + EN_ADUANA_ECUADOR: "está en inspección aduanera en Ecuador", + LISTO_ENTREGA: "está listo para entrega", + ENTREGADO: "fue entregado exitosamente", + INCIDENCIA: "tiene una incidencia reportada", + }; + + const label = statusLabels[pkg.status] ?? `cambió a estado ${pkg.status}`; + const body = `Tu paquete ${pkg.trackingId} ${label}.`; + const subject = `Estado de tu paquete: ${pkg.trackingId}`; + + const channels: Array<"EMAIL" | "WHATSAPP" | "SMS" | "PUSH"> = ["EMAIL", "WHATSAPP", "PUSH"]; + for (const channel of channels) { + try { + await this.prisma.client.notification.create({ + data: { + packageId: pkg.id, + userId: pkg.userId, + channel, + status: "PENDIENTE", + subject, + body, + }, + }); + // STUB: In production, dispatch via SendGrid (EMAIL), WhatsApp Business API (WHATSAPP), etc. + this.logger.log(`[NOTIF STUB] ${channel} → userId=${pkg.userId} | ${body}`); + // Mark as sent (stub — in prod this would be async) + await this.prisma.client.notification.updateMany({ + where: { packageId: pkg.id, userId: pkg.userId, channel, status: "PENDIENTE" }, + data: { status: "ENVIADO", sentAt: new Date() }, + }); + } catch (e: unknown) { + this.logger.error(`Notification ${channel} failed: ${(e as Error).message}`); + } + } + } + + async findByUser(userId: string, limit = 20): Promise { + return this.prisma.client.notification.findMany({ + where: { userId }, + orderBy: { createdAt: "desc" }, + take: limit, + }); + } + + async findByPackage(packageId: string): Promise { + return this.prisma.client.notification.findMany({ + where: { packageId }, + orderBy: { createdAt: "desc" }, + }); + } +} diff --git a/apps/api/src/packages/dto/package.dto.ts b/apps/api/src/packages/dto/package.dto.ts index 6268ae1..5abb287 100644 --- a/apps/api/src/packages/dto/package.dto.ts +++ b/apps/api/src/packages/dto/package.dto.ts @@ -1,50 +1,38 @@ -import { IsString, IsOptional, IsNumber, Min } from "class-validator"; +import { IsString, IsOptional, IsNumber, IsPositive, IsEnum, Min } from "class-validator"; +import { Type } from "class-transformer"; export class CreatePackageDto { - @IsString() - vendorTracking!: string; - - @IsString() - description!: string; - - @IsString() - store!: string; - - @IsOptional() - @IsNumber() - @Min(0) - declaredValue?: number; - - @IsOptional() - @IsNumber() - @Min(0) - declaredWeightLb?: number; - - @IsOptional() - @IsNumber() - @Min(0) - lengthCm?: number; - - @IsOptional() - @IsNumber() - @Min(0) - widthCm?: number; - - @IsOptional() - @IsNumber() - @Min(0) - heightCm?: number; - - /** userId del cliente al que pertenece este paquete */ - @IsString() - userId!: string; + @IsString() userId!: string; + @IsString() description!: string; + @IsOptional() @IsString() store?: string; + @IsOptional() @IsString() vendorTracking?: string; + @IsOptional() @IsString() productUrl?: string; + @IsOptional() @Type(() => Number) @IsNumber() @Min(0) declaredValue?: number; + @IsOptional() @Type(() => Number) @IsNumber() @IsPositive() declaredWeightLb?: number; + @IsOptional() @Type(() => Number) @IsNumber() @IsPositive() lengthCm?: number; + @IsOptional() @Type(() => Number) @IsNumber() @IsPositive() widthCm?: number; + @IsOptional() @Type(() => Number) @IsNumber() @IsPositive() heightCm?: number; } export class UpdateStatusDto { - @IsString() - status!: string; + @IsEnum([ + "REGISTRADO","EN_TRANSITO_BODEGA","RECIBIDO_BODEGA","EN_VERIFICACION", + "VERIFICADO","DECLARACION_ADUANERA","EN_TRANSITO_ECUADOR","EN_ADUANA_ECUADOR", + "LISTO_ENTREGA","ENTREGADO","INCIDENCIA", + ]) status!: string; + @IsOptional() @IsString() note?: string; +} - @IsOptional() - @IsString() - note?: string; +export class VerifyPackageDto { + @Type(() => Number) @IsNumber() @IsPositive() actualWeightLb!: number; + @IsOptional() @Type(() => Number) @IsNumber() @IsPositive() lengthCm?: number; + @IsOptional() @Type(() => Number) @IsNumber() @IsPositive() widthCm?: number; + @IsOptional() @Type(() => Number) @IsNumber() @IsPositive() heightCm?: number; + @IsOptional() @IsString() note?: string; +} + +export class SenaeDeclarationDto { + @IsEnum(["REGIMEN_4X4","CATEGORIA_B","CATEGORIA_C","CATEGORIA_D"]) + category!: string; + @IsOptional() @IsString() agentNotes?: string; } diff --git a/apps/api/src/packages/packages.controller.ts b/apps/api/src/packages/packages.controller.ts index 454a4da..a4c347b 100644 --- a/apps/api/src/packages/packages.controller.ts +++ b/apps/api/src/packages/packages.controller.ts @@ -1,19 +1,45 @@ -import { Controller, Get, Post, Patch, Body, Param, Query, UseGuards } from "@nestjs/common"; +import { + Controller, Get, Post, Patch, Body, Param, Query, + UseGuards, UseInterceptors, UploadedFiles, +} from "@nestjs/common"; +import { FilesInterceptor } from "@nestjs/platform-express"; +import { diskStorage } from "multer"; +import { extname, join } from "path"; +import { existsSync, mkdirSync } from "fs"; +import { randomUUID } from "crypto"; import { PackagesService } from "./packages.service"; -import { CreatePackageDto, UpdateStatusDto } from "./dto/package.dto"; +import { CreatePackageDto, UpdateStatusDto, VerifyPackageDto, SenaeDeclarationDto } from "./dto/package.dto"; import { JwtAuthGuard, RolesGuard, Roles } from "../auth/guards/auth.guard"; import { CurrentUser } from "../auth/decorators/current-user.decorator"; +import { ConfigService } from "@nestjs/config"; @Controller("packages") @UseGuards(JwtAuthGuard, RolesGuard) export class PackagesController { - constructor(private svc: PackagesService) {} + private readonly apiUrl: string; + + constructor( + private svc: PackagesService, + private config: ConfigService, + ) { + this.apiUrl = this.config.get("API_URL", "http://localhost:3001"); + } @Get() - findAll(@CurrentUser() user: any, @Query("status") status?: string, @Query("search") search?: string): Promise { + findAll( + @CurrentUser() user: any, + @Query("status") status?: string, + @Query("search") search?: string, + ): Promise { return this.svc.findAll(user, { status, search }); } + @Get("pending-declaration") + @Roles("AGENTE_ADUANERO", "ADMIN_EMPRESA", "SUPER_ADMIN") + pendingDeclaration(@CurrentUser() user: any): Promise { + return this.svc.findPendingDeclaration(user.tenantId); + } + @Get(":id") findOne(@Param("id") id: string, @CurrentUser() user: any): Promise { return this.svc.findOne(id, user); @@ -27,7 +53,68 @@ export class PackagesController { @Patch(":id/status") @Roles("OPERADOR_BODEGA", "AGENTE_ADUANERO", "ADMIN_EMPRESA", "SUPER_ADMIN") - updateStatus(@Param("id") id: string, @Body() dto: UpdateStatusDto, @CurrentUser() user: any): Promise { + updateStatus( + @Param("id") id: string, + @Body() dto: UpdateStatusDto, + @CurrentUser() user: any, + ): Promise { return this.svc.updateStatus(id, dto, user.id); } + + /** Bodega: verify weight/dims + detect discrepancy (doc §10 step 4) */ + @Patch(":id/verify") + @Roles("OPERADOR_BODEGA", "ADMIN_EMPRESA", "SUPER_ADMIN") + verify( + @Param("id") id: string, + @Body() dto: VerifyPackageDto, + @CurrentUser() user: any, + ): Promise { + return this.svc.verifyPackage(id, dto, user.id); + } + + /** Bodega: upload photos via multipart form (doc §10 step 4) */ + @Post(":id/photos") + @Roles("OPERADOR_BODEGA", "ADMIN_EMPRESA", "SUPER_ADMIN") + @UseInterceptors( + FilesInterceptor("photos", 10, { + storage: diskStorage({ + destination: (req, file, cb) => { + const pkgId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; + const dir = join(process.cwd(), "uploads", "packages", pkgId); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + cb(null, dir); + }, + filename: (req, file, cb) => { + cb(null, `${randomUUID()}${extname(file.originalname)}`); + }, + }), + fileFilter: (req, file, cb) => { + const allowed = /jpg|jpeg|png|gif|webp/; + cb(null, allowed.test(extname(file.originalname).toLowerCase())); + }, + limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB + }), + ) + async uploadPhotos( + @Param("id") id: string, + @UploadedFiles() files: Express.Multer.File[], + @CurrentUser() user: any, + ): Promise { + const baseUrl = this.apiUrl; + const photoUrls = (files || []).map( + f => `${baseUrl}/uploads/packages/${id}/${f.filename}`, + ); + return this.svc.addPhotos(id, photoUrls, user.id); + } + + /** Aduanero: generate SENAE DSI declaration (doc §11) */ + @Post(":id/senae/declare") + @Roles("AGENTE_ADUANERO", "ADMIN_EMPRESA", "SUPER_ADMIN") + senaeDeclare( + @Param("id") id: string, + @Body() dto: SenaeDeclarationDto, + @CurrentUser() user: any, + ): Promise { + return this.svc.generateSenaeDeclaration(id, dto, user.id); + } } diff --git a/apps/api/src/packages/packages.module.ts b/apps/api/src/packages/packages.module.ts index 79186ea..e2d9449 100644 --- a/apps/api/src/packages/packages.module.ts +++ b/apps/api/src/packages/packages.module.ts @@ -1,9 +1,14 @@ import { Module } from "@nestjs/common"; -import { PackagesController } from "./packages.controller"; import { PackagesService } from "./packages.service"; +import { PackagesController } from "./packages.controller"; +import { PrismaModule } from "../prisma/prisma.module"; +import { ConfigModule } from "@nestjs/config"; +import { NotificationsModule } from "../notifications/notifications.module"; @Module({ - controllers: [PackagesController], + imports: [PrismaModule, ConfigModule, NotificationsModule], providers: [PackagesService], + controllers: [PackagesController], + exports: [PackagesService], }) export class PackagesModule {} diff --git a/apps/api/src/packages/packages.service.ts b/apps/api/src/packages/packages.service.ts index af10acd..074c3c8 100644 --- a/apps/api/src/packages/packages.service.ts +++ b/apps/api/src/packages/packages.service.ts @@ -1,11 +1,15 @@ -import { Injectable, NotFoundException, ForbiddenException } from "@nestjs/common"; +import { Injectable, NotFoundException, ForbiddenException, BadRequestException } from "@nestjs/common"; import { PrismaService } from "../prisma/prisma.service"; +import { NotificationsService } from "../notifications/notifications.service"; import { generateTrackingId } from "../common/utils/tracking-id.util"; -import { CreatePackageDto, UpdateStatusDto } from "./dto/package.dto"; +import { CreatePackageDto, UpdateStatusDto, VerifyPackageDto, SenaeDeclarationDto } from "./dto/package.dto"; @Injectable() export class PackagesService { - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + private notifications: NotificationsService, + ) {} async findAll(user: any, filters?: { status?: string; search?: string }): Promise { const where: any = { tenantId: user.tenantId }; @@ -58,6 +62,7 @@ export class PackagesService { description: dto.description, store: dto.store, vendorTracking: dto.vendorTracking, + productUrl: dto.productUrl, declaredValue: dto.declaredValue ?? 0, declaredWeight: dto.declaredWeightLb ?? null, lengthCm: dto.lengthCm ?? null, @@ -72,7 +77,7 @@ export class PackagesService { packageId: pkg.id, status: "REGISTRADO", createdBy: operatorId, - note: "Paquete registrado al recibirse en bodega NJ", + note: "Paquete registrado en el sistema", }, }); @@ -97,6 +102,131 @@ export class PackagesService { }, }); + // Notify user on status change + this.notifications.notifyStatusChange(updated, { id: updated.userId }).catch(() => {}); + return updated; } + + /** + * Bodega verification: record actual weight, dims, detect discrepancy >10% (doc §10 step 4). + * Sets status to EN_VERIFICACION then VERIFICADO. + */ + async verifyPackage(id: string, dto: VerifyPackageDto, operatorId: string): Promise { + const pkg = await this.prisma.client.package.findUnique({ where: { id } }); + if (!pkg) throw new NotFoundException("Paquete no encontrado."); + + // Detect discrepancy: >10% difference between declared and actual weight + let hasDiscrepancy = false; + if (pkg.declaredWeight && dto.actualWeightLb) { + const declared = Number(pkg.declaredWeight); + const actual = dto.actualWeightLb; + const diff = Math.abs(actual - declared) / declared; + hasDiscrepancy = diff > 0.10; + } + + const updated = await this.prisma.client.package.update({ + where: { id }, + data: { + actualWeight: dto.actualWeightLb, + lengthCm: dto.lengthCm ?? pkg.lengthCm, + widthCm: dto.widthCm ?? pkg.widthCm, + heightCm: dto.heightCm ?? pkg.heightCm, + hasDiscrepancy, + status: "VERIFICADO", + }, + }); + + await this.prisma.client.packageStatusHistory.create({ + data: { + packageId: id, + status: "VERIFICADO", + createdBy: operatorId, + note: dto.note ?? (hasDiscrepancy + ? `DISCREPANCIA: peso declarado ${pkg.declaredWeight}lb vs real ${dto.actualWeightLb}lb` + : `Verificado: peso real ${dto.actualWeightLb}lb`), + }, + }); + + // Notify user on verification + this.notifications.notifyStatusChange({ ...updated, hasDiscrepancy }, { id: updated.userId }).catch(() => {}); + + return { ...updated, hasDiscrepancy }; + } + + /** + * Add photo URLs to a package (doc §10 step 4, §07 Portal Bodega). + */ + async addPhotos(id: string, photoUrls: string[], operatorId: string): Promise { + const pkg = await this.prisma.client.package.findUnique({ where: { id } }); + if (!pkg) throw new NotFoundException("Paquete no encontrado."); + + const updated = await this.prisma.client.package.update({ + where: { id }, + data: { photos: { push: photoUrls } }, + }); + + return updated; + } + + /** + * SENAE declaration (doc §11): generate DSI stub, update status to DECLARACION_ADUANERA. + */ + async generateSenaeDeclaration(id: string, dto: SenaeDeclarationDto, agentId: string): Promise { + const pkg = await this.prisma.client.package.findUnique({ where: { id } }); + if (!pkg) throw new NotFoundException("Paquete no encontrado."); + if (pkg.status !== "VERIFICADO") { + throw new BadRequestException("El paquete debe estar en estado VERIFICADO para generar la declaración."); + } + + // Stub: In prod this would call SENAE SOAP/REST WebService + // Generate a plausible authorization number + const authNumber = `SENAE-DSI-${new Date().getFullYear()}-${Math.floor(100000 + Math.random() * 900000)}`; + const declarationId = `DSI-${pkg.trackingId}`; + + const updated = await this.prisma.client.package.update({ + where: { id }, + data: { + status: "DECLARACION_ADUANERA", + senaeCategory: dto.category as any, + senaeAuthNumber: authNumber, + senaeDeclarationId: declarationId, + }, + }); + + await this.prisma.client.packageStatusHistory.create({ + data: { + packageId: id, + status: "DECLARACION_ADUANERA", + createdBy: agentId, + note: dto.agentNotes ?? `DSI generada. Auth: ${authNumber}. Categoría: ${dto.category}`, + }, + }); + + const result = { + ...updated, + declarationId, + authNumber, + message: "Declaración simplificada (DSI) enviada y aprobada por la SENAE (stub).", + }; + + // Notify user of customs clearance + this.notifications.notifyStatusChange(updated, { id: updated.userId }).catch(() => {}); + + return result; + } + + /** + * Queue for SENAE: packages with status VERIFICADO pending declaration. + */ + async findPendingDeclaration(tenantId: string): Promise { + return this.prisma.client.package.findMany({ + where: { tenantId, status: "VERIFICADO" }, + include: { + user: { select: { firstName: true, lastName: true, email: true } }, + statusHistory: { orderBy: { createdAt: "desc" }, take: 1 }, + }, + orderBy: { updatedAt: "asc" }, + }); + } } diff --git a/apps/api/src/products/products.controller.ts b/apps/api/src/products/products.controller.ts new file mode 100644 index 0000000..ad78308 --- /dev/null +++ b/apps/api/src/products/products.controller.ts @@ -0,0 +1,21 @@ +import { Controller, Post, Body, UseGuards } from "@nestjs/common"; +import { ProductsService } from "./products.service"; +import { JwtAuthGuard } from "../auth/guards/auth.guard"; + +@Controller("products") +@UseGuards(JwtAuthGuard) +export class ProductsController { + constructor(private svc: ProductsService) {} + + @Post("scan") + scanUrl(@Body("url") url: string): Promise<{ + name: string; + price: number; + weightLb: number; + imageUrl: string; + store: string; + url: string; + }> { + return this.svc.scanUrl(url); + } +} diff --git a/apps/api/src/products/products.module.ts b/apps/api/src/products/products.module.ts new file mode 100644 index 0000000..f4a59b6 --- /dev/null +++ b/apps/api/src/products/products.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; +import { ProductsService } from "./products.service"; +import { ProductsController } from "./products.controller"; + +@Module({ + providers: [ProductsService], + controllers: [ProductsController], + exports: [ProductsService], +}) +export class ProductsModule {} diff --git a/apps/api/src/products/products.service.ts b/apps/api/src/products/products.service.ts new file mode 100644 index 0000000..8004cdd --- /dev/null +++ b/apps/api/src/products/products.service.ts @@ -0,0 +1,44 @@ +import { Injectable } from "@nestjs/common"; + +interface ProductScanResult { + name: string; + price: number; + weightLb: number; + imageUrl: string; + store: string; + url: string; +} + +@Injectable() +export class ProductsService { + /** + * Stub: In production this calls Amazon SP-API or a scraping service. + * For now it extracts basic info from the URL and returns plausible mock data. + */ + async scanUrl(url: string): Promise { + const store = this.detectStore(url); + + // Extract ASIN from Amazon URL if present + const asinMatch = url.match(/\/dp\/([A-Z0-9]{10})/); + const asin = asinMatch ? asinMatch[1] : null; + + // Stub response — in prod: call Amazon SP-API Catalog Items API + return { + name: asin ? `Producto Amazon (ASIN: ${asin})` : `Producto de ${store}`, + price: 29.99, + weightLb: 1.5, + imageUrl: "https://placehold.co/200x200?text=Product", + store, + url, + }; + } + + private detectStore(url: string): string { + if (url.includes("amazon.")) return "Amazon"; + if (url.includes("ebay.")) return "eBay"; + if (url.includes("walmart.")) return "Walmart"; + if (url.includes("target.")) return "Target"; + if (url.includes("bestbuy.")) return "Best Buy"; + return "Tienda online"; + } +} diff --git a/apps/api/src/storage/storage.module.ts b/apps/api/src/storage/storage.module.ts new file mode 100644 index 0000000..c0fa6ab --- /dev/null +++ b/apps/api/src/storage/storage.module.ts @@ -0,0 +1,5 @@ +import { Module } from "@nestjs/common"; +import { StorageService } from "./storage.service"; + +@Module({ providers: [StorageService], exports: [StorageService] }) +export class StorageModule {} diff --git a/apps/api/src/storage/storage.service.ts b/apps/api/src/storage/storage.service.ts new file mode 100644 index 0000000..20f2c19 --- /dev/null +++ b/apps/api/src/storage/storage.service.ts @@ -0,0 +1,42 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import * as fs from "fs"; +import * as path from "path"; +import { randomUUID } from "crypto"; + +@Injectable() +export class StorageService { + private readonly logger = new Logger(StorageService.name); + private readonly uploadDir: string; + private readonly baseUrl: string; + + constructor(private config: ConfigService) { + this.uploadDir = path.join(process.cwd(), "uploads"); + this.baseUrl = this.config.get("API_URL", "http://localhost:3001"); + // ensure uploads dir exists + if (!fs.existsSync(this.uploadDir)) { + fs.mkdirSync(this.uploadDir, { recursive: true }); + } + } + + async saveFile(subdir: string, originalName: string, buffer: Buffer): Promise { + const ext = path.extname(originalName); + const filename = `${randomUUID()}${ext}`; + const dir = path.join(this.uploadDir, subdir); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const filepath = path.join(dir, filename); + fs.writeFileSync(filepath, buffer); + // Return URL relative to API base + return `${this.baseUrl}/uploads/${subdir}/${filename}`; + } + + async deleteFile(url: string): Promise { + try { + const relative = url.replace(/^https?:\/\/[^/]+\/uploads\//, ""); + const filepath = path.join(this.uploadDir, relative); + if (fs.existsSync(filepath)) fs.unlinkSync(filepath); + } catch (e: unknown) { + this.logger.warn(`deleteFile failed: ${(e as Error).message}`); + } + } +} diff --git a/apps/api/src/tariffs/tariffs.controller.ts b/apps/api/src/tariffs/tariffs.controller.ts new file mode 100644 index 0000000..e618a3a --- /dev/null +++ b/apps/api/src/tariffs/tariffs.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Get, Put, Body, UseGuards } from "@nestjs/common"; +import { TariffsService } from "./tariffs.service"; +import { JwtAuthGuard, RolesGuard, Roles } from "../auth/guards/auth.guard"; +import { CurrentUser } from "../auth/decorators/current-user.decorator"; + +@Controller("tariffs") +@UseGuards(JwtAuthGuard, RolesGuard) +export class TariffsController { + constructor(private svc: TariffsService) {} + + @Get() + @Roles("ADMIN_EMPRESA", "SUPER_ADMIN", "OPERADOR_BODEGA", "AGENTE_ADUANERO") + get(@CurrentUser() user: any) { + return this.svc.findByTenant(user.tenantId); + } + + @Put() + @Roles("ADMIN_EMPRESA", "SUPER_ADMIN") + update(@CurrentUser() user: any, @Body() dto: any) { + return this.svc.update(user.tenantId, dto); + } +} diff --git a/apps/api/src/tariffs/tariffs.module.ts b/apps/api/src/tariffs/tariffs.module.ts new file mode 100644 index 0000000..5220ab8 --- /dev/null +++ b/apps/api/src/tariffs/tariffs.module.ts @@ -0,0 +1,12 @@ +import { Module } from "@nestjs/common"; +import { TariffsService } from "./tariffs.service"; +import { TariffsController } from "./tariffs.controller"; +import { PrismaModule } from "../prisma/prisma.module"; + +@Module({ + imports: [PrismaModule], + providers: [TariffsService], + controllers: [TariffsController], + exports: [TariffsService], +}) +export class TariffsModule {} diff --git a/apps/api/src/tariffs/tariffs.service.ts b/apps/api/src/tariffs/tariffs.service.ts new file mode 100644 index 0000000..d41c3e6 --- /dev/null +++ b/apps/api/src/tariffs/tariffs.service.ts @@ -0,0 +1,34 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service"; + +@Injectable() +export class TariffsService { + constructor(private prisma: PrismaService) {} + + async findByTenant(tenantId: string): Promise { + let tariff = await this.prisma.client.tariff.findUnique({ where: { tenantId } }); + if (!tariff) { + // Auto-create default tariff if not exists + tariff = await this.prisma.client.tariff.create({ + data: { tenantId }, + }); + } + return tariff; + } + + async update(tenantId: string, dto: Partial<{ + pricePerLb: number; + insurancePct: number; + fodinfaPct: number; + ivaPct: number; + max4x4Value: number; + max4x4WeightKg: number; + max4x4PerYear: number; + }>): Promise { + const existing = await this.prisma.client.tariff.findUnique({ where: { tenantId } }); + if (!existing) { + return this.prisma.client.tariff.create({ data: { tenantId, ...dto } }); + } + return this.prisma.client.tariff.update({ where: { tenantId }, data: dto }); + } +} diff --git a/apps/web/src/app/admin/auditoria/page.tsx b/apps/web/src/app/admin/auditoria/page.tsx index a12c97a..7885e20 100644 --- a/apps/web/src/app/admin/auditoria/page.tsx +++ b/apps/web/src/app/admin/auditoria/page.tsx @@ -1,20 +1,130 @@ "use client"; -// Auditoría — placeholder con nota de implementación futura (requiere endpoint /audit) +import { useEffect, useState } from "react"; +import { api } from "@/lib/api"; + export default function AuditoriaPage() { + const [logs, setLogs] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [page, setPage] = useState(1); + const [filters, setFilters] = useState({ action: "", resource: "", from: "", to: "" }); + const LIMIT = 50; + + const load = async (p = 1) => { + setLoading(true); + const params: Record = { page: String(p), limit: String(LIMIT) }; + if (filters.action) params.action = filters.action; + if (filters.resource) params.resource = filters.resource; + if (filters.from) params.from = filters.from; + if (filters.to) params.to = filters.to; + try { + const res = await api.auditLogs.list(params); + setLogs(res.data); + setTotal(res.total); + } catch {} + finally { setLoading(false); } + }; + + useEffect(() => { load(page); }, [page]); + + const set = (k: string) => (e: React.ChangeEvent) => + setFilters(f => ({ ...f, [k]: e.target.value })); + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + setPage(1); + load(1); + }; + + const ACTION_COLORS: Record = { + LOGIN_SUCCESS: "badge-green", LOGIN_FAILED: "badge-red", LOGOUT: "badge-gray", + MFA_ENABLED: "badge-blue", MFA_FAILED: "badge-red", USER_REGISTER: "badge-green", + }; + + const fmt = (d: string) => new Date(d).toLocaleString("es-EC", { timeZone: "America/Guayaquil" }); + const pages = Math.ceil(total / LIMIT); + return (
-

Auditoría

Registro de acciones del sistema (AuditLog).

-
+
+

Log de Auditoría

+

Registro inmutable de todas las acciones del sistema — ISO 27001 A.12. {total} eventos.

+
+ + {/* Filters */} +
-
- El log de auditoría se registra automáticamente en la tabla AuditLog de la base de datos. - Para consultarlo directamente, accede al panel de base de datos o agrega el endpoint GET /api/audit en la API. -
-

- Acciones registradas: LOGIN_SUCCESS, LOGIN_FAILED, LOGOUT, MFA_ENABLED, USER_REGISTER, y más. -

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ +
+ {loading ? ( +
+ ) : ( +
+ + + + + + + + + + + + + {logs.length === 0 ? ( + + ) : logs.map(l => ( + + + + + + + + + ))} + +
Fecha (ECT)AcciónRecursoID RecursoUsuario IDIP
Sin registros.
{fmt(l.createdAt)}{l.action}{l.resource ?? "—"}{l.resourceId?.slice(0,12) ?? "—"}{l.userId?.slice(0,12) ?? "—"}{l.ipAddress ?? "—"}
+
+ )} + + {/* Pagination */} + {pages > 1 && ( +
+ + + Página {page} de {pages} + + +
+ )} +
); } diff --git a/apps/web/src/app/admin/tarifas/page.tsx b/apps/web/src/app/admin/tarifas/page.tsx index c9edd53..fa3ec53 100644 --- a/apps/web/src/app/admin/tarifas/page.tsx +++ b/apps/web/src/app/admin/tarifas/page.tsx @@ -1,47 +1,138 @@ "use client"; - -// Página de tarifas — muestra tabla estática de precios (§05 doc) + formulario de edición futuro -const TARIFAS = [ - { category: "Mensajería Acelerada", max: "$200", exento: "Sí (≤ $200)", flete: "$8–$15/lb", notas: "Hasta 4 kg · sin impuestos" }, - { category: "Courier", max: "$400", exento: "≤ $200", flete: "$8–$15/lb", notas: "Impuestos desde $200.01" }, - { category: "Régimen 4×4", max: "$2,000",exento: "No", flete: "Variable", notas: "SENAE arancel + IVA + FODINFA" }, - { category: "Carga pesada (FCL)", max: "Ilimitado", exento: "No", flete: "Cotización", notas: "Tarifa por m³ y peso" }, -]; +import { useEffect, useState } from "react"; +import { api } from "@/lib/api"; export default function TarifasPage() { + const [tariff, setTariff] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [msg, setMsg] = useState<{ type: "success" | "error"; text: string } | null>(null); + const [form, setForm] = useState({ + pricePerLb: "3.50", + insurancePct: "0.02", + fodinfaPct: "0.005", + ivaPct: "0.15", + max4x4Value: "400", + max4x4WeightKg: "4", + max4x4PerYear: "4", + }); + + useEffect(() => { + api.tariffs.get() + .then((t) => { + setTariff(t); + setForm({ + pricePerLb: String(t.pricePerLb), + insurancePct: String(t.insurancePct), + fodinfaPct: String(t.fodinfaPct), + ivaPct: String(t.ivaPct), + max4x4Value: String(t.max4x4Value), + max4x4WeightKg: String(t.max4x4WeightKg), + max4x4PerYear: String(t.max4x4PerYear), + }); + }) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + const set = (k: string) => (e: React.ChangeEvent) => + setForm(f => ({ ...f, [k]: e.target.value })); + + const handleSave = async (e: React.FormEvent) => { + e.preventDefault(); + setSaving(true); setMsg(null); + try { + const updated = await api.tariffs.update({ + pricePerLb: parseFloat(form.pricePerLb), + insurancePct: parseFloat(form.insurancePct), + fodinfaPct: parseFloat(form.fodinfaPct), + ivaPct: parseFloat(form.ivaPct), + max4x4Value: parseFloat(form.max4x4Value), + max4x4WeightKg: parseFloat(form.max4x4WeightKg), + max4x4PerYear: parseInt(form.max4x4PerYear), + }); + setTariff(updated); + setMsg({ type: "success", text: "Tarifas actualizadas correctamente." }); + } catch (err: any) { + setMsg({ type: "error", text: err.message ?? "Error al guardar" }); + } finally { setSaving(false); } + }; + + const FIELDS = [ + { key: "pricePerLb", label: "Precio por libra (USD)", step: "0.01", min: "0.1", suffix: "$/lb" }, + { key: "insurancePct", label: "Seguro (% sobre valor declarado)", step: "0.001", min: "0", suffix: "%" }, + { key: "fodinfaPct", label: "FODINFA (% — fijo SENAE 0.5%)", step: "0.001", min: "0", suffix: "%" }, + { key: "ivaPct", label: "IVA Ecuador (%)", step: "0.01", min: "0", suffix: "%" }, + { key: "max4x4Value", label: "Límite 4×4: valor máximo (USD)", step: "1", min: "1", suffix: "$" }, + { key: "max4x4WeightKg", label: "Límite 4×4: peso máximo (kg)", step: "0.1", min: "0.1",suffix: "kg" }, + { key: "max4x4PerYear", label: "Límite 4×4: envíos máx./año", step: "1", min: "1", suffix: "envíos" }, + ]; + + if (loading) return
; + return (
-

Tarifas

-

Tabla de regímenes aduaneros y precios base.

+

Configuración de Tarifas

+

Ajusta precios de flete, seguros, impuestos SENAE y límites del régimen 4×4 (doc §15).

-
-
Regímenes aduaneros (§05 / §15)
-
- - - - - - - - {TARIFAS.map(t => ( - - - - - - - + {msg &&
{msg.text}
} + +
+ {/* Edit form */} +
+
Editar tarifas
+
+
+ {FIELDS.map(f => ( +
+ +
+ + {f.suffix} +
+
))} -
-
RégimenValor máx.ExentoFleteNotas
{t.category}{t.max}{t.exento}{t.flete}{t.notas}
+ + +
-
-
- Nota: Las tarifas de flete varían según el peso volumétrico (L×W×H / 139) vs. peso real. Se cobra el mayor. Los impuestos SENAE (FODINFA 0.5%, Arancel variable, IVA 15%) se calculan automáticamente en la calculadora del portal. + {/* Preview */} +
+
+
Tarifas actuales
+
+ + + + {tariff && FIELDS.map(f => ( + + + + + ))} + +
ConceptoValor
{f.label}{tariff[f.key]} {f.suffix}
+
+
+ + {/* Formula reminder */} +
+
+
// Fórmulas SENAE (doc §15)
+
Flete = Peso_final × {form.pricePerLb}
+
Seguro = Valor × {form.insurancePct}
+
FODINFA = Valor × {form.fodinfaPct}
+
IVA = (Valor + FODINFA + Arancel) × {form.ivaPct}
+
TOTAL = Flete + Seguro + FODINFA + Arancel + IVA
+
+
+
); diff --git a/apps/web/src/app/bodega/declaraciones/page.tsx b/apps/web/src/app/bodega/declaraciones/page.tsx new file mode 100644 index 0000000..0f54b82 --- /dev/null +++ b/apps/web/src/app/bodega/declaraciones/page.tsx @@ -0,0 +1,152 @@ +"use client"; +import { useEffect, useState } from "react"; +import { api } from "@/lib/api"; + +// SENAE Declaraciones — doc §11: Agente Aduanero genera DSI para paquetes VERIFICADOS +export default function DeclaracionesPage() { + const [packages, setPackages] = useState([]); + const [loading, setLoading] = useState(true); + const [selected, setSelected] = useState(null); + const [form, setForm] = useState({ category: "REGIMEN_4X4", agentNotes: "" }); + const [submitting, setSubmitting] = useState(false); + const [msg, setMsg] = useState<{ type: "success" | "error"; text: string } | null>(null); + + const load = () => { + api.packages.pendingDeclaration() + .then(setPackages).catch(() => {}).finally(() => setLoading(false)); + }; + useEffect(() => { load(); }, []); + + const set = (k: string) => (e: React.ChangeEvent) => + setForm(f => ({ ...f, [k]: e.target.value })); + + const autoCategory = (pkg: any): string => { + const value = parseFloat(pkg.declaredValue ?? 0); + const weightKg = (parseFloat(pkg.actualWeight ?? pkg.declaredWeight ?? 0)) * 0.453592; + if (value <= 400 && weightKg <= 4) return "REGIMEN_4X4"; + return "CATEGORIA_B"; + }; + + const handleSelect = (pkg: any) => { + setSelected(pkg); + setForm({ category: autoCategory(pkg), agentNotes: "" }); + setMsg(null); + }; + + const handleDeclare = async (e: React.FormEvent) => { + e.preventDefault(); + if (!selected) return; + setSubmitting(true); setMsg(null); + try { + const result = await api.packages.senaeDeclare(selected.id, form); + setMsg({ type: "success", text: `✅ DSI generada. N° Autorización SENAE: ${result.authNumber}` }); + setSelected(null); + load(); + } catch (err: any) { + setMsg({ type: "error", text: err.message ?? "Error al generar declaración" }); + } finally { setSubmitting(false); } + }; + + const CATEGORIES = [ + { value: "REGIMEN_4X4", label: "Régimen 4×4 — 0% (≤$400, ≤4kg)" }, + { value: "CATEGORIA_B", label: "Categoría B — 10% (bienes generales)" }, + { value: "CATEGORIA_C", label: "Categoría C — 20% (textiles, calzado, hogar)" }, + { value: "CATEGORIA_D", label: "Categoría D — 0–15% (electrónicos)" }, + ]; + + return ( +
+
+

Declaraciones SENAE

+

Cola de paquetes verificados pendientes de DSI. Genera la Declaración Simplificada de Importación.

+
+ + {msg &&
{msg.text}
} + +
+ {/* Cola de paquetes VERIFICADOS */} +
+
+ Pendientes de declaración + {packages.length} +
+ {loading ? ( +
+ ) : packages.length === 0 ? ( +
+ No hay paquetes pendientes de declaración.
+ Los paquetes en estado VERIFICADO aparecerán aquí. +
+ ) : ( +
+ + + + {packages.map(p => ( + handleSelect(p)}> + + + + + + ))} + +
TrackingValorPeso real
{p.trackingId}${p.declaredValue}{p.actualWeight ? `${p.actualWeight}lb` : "—"} + {parseFloat(p.declaredValue ?? 0) > 400 && ( + Supera 4×4 + )} +
+
+ )} +
+ + {/* Formulario DSI */} + {selected && ( +
+
+ Generar DSI: {selected.trackingId} +
+
+ {/* Resumen del paquete */} +
+
+
Descripción: {selected.description}
+
Tienda: {selected.store ?? "—"}
+
Valor declarado: 400 ? "var(--red)" : "var(--green)" }}>${selected.declaredValue}
+
Peso real: {selected.actualWeight ? `${selected.actualWeight}lb` : selected.declaredWeight ? `${selected.declaredWeight}lb (decl.)` : "—"}
+
Cliente: {selected.user?.firstName} {selected.user?.lastName}
+
+ {parseFloat(selected.declaredValue ?? 0) > 400 && ( +
+ ⚠️ Supera el límite 4×4 ($400). Se requiere proceso de importación formal (DAI). +
+ )} +
+ +
+
+ + +
+
+ +