feat: Fase 1 — Auth JWT+MFA, portales CRUD, UI completa
API: - AuthModule: register, login, refresh, logout, MFA/TOTP setup+verify - JwtStrategy + JwtAuthGuard + RolesGuard + CurrentUser decorator - PackagesModule: CRUD paquetes + historial de estados - PreAlertsModule: pre-alertas por usuario - UsersModule: gestión de usuarios + roles + activación - B2BModule: solicitudes de carga pesada/cotización - ValidationPipe global + CORS configurado Web (Next.js 15): - globals.css completo (design system + utility classes) - Layout raíz con WhatsApp flotante - /login + /registro funcionales con JWT y redirección por rol - /portal: dashboard, mi-casillero, mis-paquetes, pre-alerta, calculadora, perfil - /admin: dashboard, usuarios (gestión roles/activación), tarifas, reportes, auditoría - /bodega: dashboard, paquetes (crear+actualizar estado), verificación, despacho - /tracking: tracking real con progreso visual + historial - /calculadora: calculadora interactiva real (API SENAE §15) - /como-funciona, /tarifas, /quienes-somos, /casillero - /carga-pesada + /carga-pesada/cotizacion (formulario B2B) - lib/api.ts: cliente HTTP con auto-refresh de token Roles sincronizados con schema: SUPER_ADMIN, ADMIN_EMPRESA, OPERADOR_BODEGA, AGENTE_ADUANERO, CLIENTE, SOPORTE
This commit is contained in:
@@ -4,6 +4,11 @@ import { HealthModule } from "./health/health.module";
|
||||
import { PrismaModule } from "./prisma/prisma.module";
|
||||
import { CalculatorModule } from "./calculator/calculator.module";
|
||||
import { TrackingModule } from "./tracking/tracking.module";
|
||||
import { AuthModule } from "./auth/auth.module";
|
||||
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";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -15,6 +20,11 @@ import { TrackingModule } from "./tracking/tracking.module";
|
||||
HealthModule,
|
||||
CalculatorModule,
|
||||
TrackingModule,
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
PackagesModule,
|
||||
PreAlertsModule,
|
||||
B2BModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
Controller, Post, Get, Body, Req, UseGuards, HttpCode, HttpStatus,
|
||||
} from "@nestjs/common";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { RegisterDto, LoginDto, RefreshDto, SetupMfaDto } from "./dto/auth.dto";
|
||||
import { JwtAuthGuard } from "./guards/auth.guard";
|
||||
import { CurrentUser } from "./decorators/current-user.decorator";
|
||||
|
||||
@Controller("auth")
|
||||
export class AuthController {
|
||||
constructor(private auth: AuthService) {}
|
||||
|
||||
/** POST /api/auth/register — Registro público (doc §09 paso 1) */
|
||||
@Post("register")
|
||||
register(@Body() dto: RegisterDto) {
|
||||
return this.auth.register(dto);
|
||||
}
|
||||
|
||||
/** POST /api/auth/login — Login con JWT + MFA opcional */
|
||||
@Post("login")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
login(@Body() dto: LoginDto, @Req() req: any) {
|
||||
return this.auth.login(dto, req.ip);
|
||||
}
|
||||
|
||||
/** POST /api/auth/refresh — Rotar refresh token */
|
||||
@Post("refresh")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
refresh(@Body() dto: RefreshDto) {
|
||||
return this.auth.refresh(dto.refreshToken);
|
||||
}
|
||||
|
||||
/** POST /api/auth/logout */
|
||||
@Post("logout")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
logout(@Body() dto: RefreshDto, @CurrentUser() user: any) {
|
||||
return this.auth.logout(dto.refreshToken, user.id);
|
||||
}
|
||||
|
||||
/** GET /api/auth/me — Perfil del usuario autenticado */
|
||||
@Get("me")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
me(@CurrentUser() user: any) {
|
||||
return this.auth.getProfile(user.id);
|
||||
}
|
||||
|
||||
/** POST /api/auth/mfa/setup — Genera QR para TOTP */
|
||||
@Post("mfa/setup")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
setupMfa(@CurrentUser() user: any) {
|
||||
return this.auth.setupMfa(user.id);
|
||||
}
|
||||
|
||||
/** POST /api/auth/mfa/verify — Activa MFA con primer código TOTP */
|
||||
@Post("mfa/verify")
|
||||
@UseGuards(JwtAuthGuard)
|
||||
verifyMfa(@Body() dto: SetupMfaDto, @CurrentUser() user: any) {
|
||||
return this.auth.verifyMfa(user.id, dto.totpCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { JwtModule } from "@nestjs/jwt";
|
||||
import { PassportModule } from "@nestjs/passport";
|
||||
import { ConfigModule, ConfigService } from "@nestjs/config";
|
||||
import { AuthController } from "./auth.controller";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { JwtStrategy } from "./jwt.strategy";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.get<string>("JWT_SECRET", "change-me"),
|
||||
signOptions: { expiresIn: config.get("JWT_EXPIRES_IN", "15m") },
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
exports: [AuthService, JwtModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,209 @@
|
||||
import {
|
||||
Injectable, UnauthorizedException, ConflictException, BadRequestException,
|
||||
} from "@nestjs/common";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { generateSuiteCode } from "../common/utils/suite-code.util";
|
||||
import { RegisterDto, LoginDto } from "./dto/auth.dto";
|
||||
import * as bcrypt from "bcrypt";
|
||||
import * as crypto from "crypto";
|
||||
import { TOTP, generateSecret, generateURI, verify as totpVerify } from "otplib";
|
||||
|
||||
const TENANT_SLUG = "moraworld";
|
||||
const BCRYPT_ROUNDS = 10;
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private jwt: JwtService,
|
||||
private config: ConfigService,
|
||||
) {}
|
||||
|
||||
// ─── Register ────────────────────────────────────────────────
|
||||
async register(dto: RegisterDto): Promise<any> {
|
||||
const tenant = await this.prisma.client.tenant.findUnique({ where: { slug: TENANT_SLUG } });
|
||||
if (!tenant) throw new BadRequestException("Tenant no encontrado.");
|
||||
|
||||
const existing = await this.prisma.client.user.findUnique({
|
||||
where: { tenantId_email: { tenantId: tenant.id, email: dto.email.toLowerCase() } },
|
||||
});
|
||||
if (existing) throw new ConflictException("Ya existe una cuenta con ese email.");
|
||||
|
||||
const passwordHash = await bcrypt.hash(dto.password, BCRYPT_ROUNDS);
|
||||
|
||||
const user = await this.prisma.client.user.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
email: dto.email.toLowerCase(),
|
||||
passwordHash,
|
||||
firstName: dto.firstName,
|
||||
lastName: dto.lastName,
|
||||
phone: dto.phone ?? null,
|
||||
role: "CLIENTE",
|
||||
},
|
||||
});
|
||||
|
||||
// Asignar Suite automáticamente (doc §09)
|
||||
const suiteCount = await this.prisma.client.suite.count({ where: { tenantId: tenant.id } });
|
||||
const suiteCode = generateSuiteCode(suiteCount + 1);
|
||||
await this.prisma.client.suite.create({
|
||||
data: { tenantId: tenant.id, userId: user.id, code: suiteCode },
|
||||
});
|
||||
|
||||
await this.audit(tenant.id, user.id, "USER_REGISTER", "User", user.id);
|
||||
|
||||
const tokens = await this.generateTokens(user.id, user.email, user.role, tenant.id);
|
||||
return {
|
||||
user: this.sanitizeUser(user),
|
||||
suiteCode,
|
||||
suiteAddress: `150 N Day St, Suite ${suiteCode}, City of Orange, NJ 07050, EE.UU.`,
|
||||
...tokens,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Login ───────────────────────────────────────────────────
|
||||
async login(dto: LoginDto, ip?: string): Promise<any> {
|
||||
const tenant = await this.prisma.client.tenant.findUnique({ where: { slug: TENANT_SLUG } });
|
||||
if (!tenant) throw new UnauthorizedException();
|
||||
|
||||
const user = await this.prisma.client.user.findUnique({
|
||||
where: { tenantId_email: { tenantId: tenant.id, email: dto.email.toLowerCase() } },
|
||||
});
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
await this.audit(tenant.id, null, "LOGIN_FAILED", "User", dto.email);
|
||||
throw new UnauthorizedException("Credenciales inválidas.");
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(dto.password, user.passwordHash);
|
||||
if (!valid) {
|
||||
await this.audit(tenant.id, user.id, "LOGIN_FAILED", "User", user.id);
|
||||
throw new UnauthorizedException("Credenciales inválidas.");
|
||||
}
|
||||
|
||||
// MFA
|
||||
if (user.mfaEnabled) {
|
||||
if (!dto.totpCode) return { requiresMfa: true, userId: user.id };
|
||||
const ok = totpVerify({ token: dto.totpCode, secret: user.mfaSecret! });
|
||||
if (!ok) {
|
||||
await this.audit(tenant.id, user.id, "MFA_FAILED", "User", user.id);
|
||||
throw new UnauthorizedException("Código MFA inválido.");
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.client.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
|
||||
await this.audit(tenant.id, user.id, "LOGIN_SUCCESS", "User", user.id);
|
||||
|
||||
const suite = await this.prisma.client.suite.findUnique({ where: { userId: user.id } });
|
||||
const tokens = await this.generateTokens(user.id, user.email, user.role, tenant.id);
|
||||
return {
|
||||
user: this.sanitizeUser(user),
|
||||
suite: suite ? {
|
||||
code: suite.code,
|
||||
address: `150 N Day St, Suite ${suite.code}, City of Orange, NJ 07050, EE.UU.`,
|
||||
} : null,
|
||||
...tokens,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Refresh ─────────────────────────────────────────────────
|
||||
async refresh(refreshToken: string): Promise<any> {
|
||||
const stored = await this.prisma.client.refreshToken.findUnique({ where: { token: refreshToken } });
|
||||
if (!stored || stored.revokedAt || stored.expiresAt < new Date()) {
|
||||
throw new UnauthorizedException("Refresh token inválido o expirado.");
|
||||
}
|
||||
const user = await this.prisma.client.user.findUnique({ where: { id: stored.userId } });
|
||||
if (!user || !user.isActive) throw new UnauthorizedException();
|
||||
|
||||
await this.prisma.client.refreshToken.update({
|
||||
where: { id: stored.id },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
return this.generateTokens(user.id, user.email, user.role, user.tenantId);
|
||||
}
|
||||
|
||||
// ─── Logout ──────────────────────────────────────────────────
|
||||
async logout(refreshToken: string, userId: string): Promise<void> {
|
||||
await this.prisma.client.refreshToken.updateMany({
|
||||
where: { token: refreshToken, userId },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
await this.audit(null, userId, "LOGOUT", "User", userId);
|
||||
}
|
||||
|
||||
// ─── MFA Setup ───────────────────────────────────────────────
|
||||
async setupMfa(userId: string): Promise<any> {
|
||||
const user = await this.prisma.client.user.findUnique({ where: { id: userId } });
|
||||
if (!user) throw new UnauthorizedException();
|
||||
|
||||
const secret = generateSecret();
|
||||
const otpAuthUrl = generateURI({
|
||||
issuer: "Moraworld Imports",
|
||||
label: user.email,
|
||||
secret,
|
||||
});
|
||||
|
||||
await this.prisma.client.user.update({
|
||||
where: { id: userId },
|
||||
data: { mfaSecret: secret, mfaEnabled: false },
|
||||
});
|
||||
|
||||
return { secret, otpAuthUrl };
|
||||
}
|
||||
|
||||
async verifyMfa(userId: string, totpCode: string): Promise<any> {
|
||||
const user = await this.prisma.client.user.findUnique({ where: { id: userId } });
|
||||
if (!user?.mfaSecret) throw new BadRequestException("Primero genera el secreto MFA.");
|
||||
|
||||
const ok = totpVerify({ token: totpCode, secret: user.mfaSecret });
|
||||
if (!ok) throw new BadRequestException("Código TOTP inválido.");
|
||||
|
||||
await this.prisma.client.user.update({ where: { id: userId }, data: { mfaEnabled: true } });
|
||||
await this.audit(user.tenantId, userId, "MFA_ENABLED", "User", userId);
|
||||
return { mfaEnabled: true };
|
||||
}
|
||||
|
||||
// ─── Profile ─────────────────────────────────────────────────
|
||||
async getProfile(userId: string): Promise<any> {
|
||||
const user = await this.prisma.client.user.findUnique({ where: { id: userId } });
|
||||
if (!user) throw new UnauthorizedException();
|
||||
const suite = await this.prisma.client.suite.findUnique({ where: { userId } });
|
||||
return {
|
||||
...this.sanitizeUser(user),
|
||||
suite: suite ? {
|
||||
code: suite.code,
|
||||
address: `150 N Day St, Suite ${suite.code}, City of Orange, NJ 07050, EE.UU.`,
|
||||
} : null,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────
|
||||
private async generateTokens(userId: string, email: string, role: string, tenantId: string): Promise<any> {
|
||||
const payload = { sub: userId, email, role, tenantId };
|
||||
const accessToken = this.jwt.sign(payload, { expiresIn: this.config.get("JWT_EXPIRES_IN", "15m") });
|
||||
const refreshToken = crypto.randomBytes(64).toString("hex");
|
||||
const refreshExpires = new Date();
|
||||
refreshExpires.setDate(refreshExpires.getDate() + 7);
|
||||
await this.prisma.client.refreshToken.create({
|
||||
data: { userId, token: refreshToken, expiresAt: refreshExpires },
|
||||
});
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
private sanitizeUser(user: any): any {
|
||||
const { passwordHash, mfaSecret, ...safe } = user;
|
||||
return safe;
|
||||
}
|
||||
|
||||
private async audit(tenantId: string | null, userId: string | null, action: string, resource?: string, resourceId?: string): Promise<void> {
|
||||
await this.prisma.client.auditLog.create({
|
||||
data: { tenantId, userId, action, resource, resourceId },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_data: unknown, ctx: ExecutionContext) => {
|
||||
return ctx.switchToHttp().getRequest().user;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,44 @@
|
||||
import { IsEmail, IsString, MinLength, IsOptional, Matches } from "class-validator";
|
||||
|
||||
export class RegisterDto {
|
||||
@IsEmail({}, { message: "Email inválido" })
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
firstName!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
lastName!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export class LoginDto {
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
password!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
totpCode?: string;
|
||||
}
|
||||
|
||||
export class RefreshDto {
|
||||
@IsString()
|
||||
refreshToken!: string;
|
||||
}
|
||||
|
||||
export class SetupMfaDto {
|
||||
@IsString()
|
||||
totpCode!: string;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard("jwt") {}
|
||||
|
||||
export const ROLES_KEY = "roles";
|
||||
export function Roles(...roles: string[]) {
|
||||
return (target: any, key?: string, descriptor?: any) => {
|
||||
Reflect.defineMetadata(ROLES_KEY, roles, descriptor?.value ?? target);
|
||||
return descriptor ?? target;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(ctx: ExecutionContext): boolean {
|
||||
const required = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
|
||||
ctx.getHandler(), ctx.getClass(),
|
||||
]);
|
||||
if (!required || required.length === 0) return true;
|
||||
const { user } = ctx.switchToHttp().getRequest();
|
||||
if (!user || !required.includes(user.role)) {
|
||||
throw new ForbiddenException("No tienes permisos para esta acción.");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import { ExtractJwt, Strategy } from "passport-jwt";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(config: ConfigService, private prisma: PrismaService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: config.get<string>("JWT_SECRET", "change-me"),
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: { sub: string; email: string; role: string; tenantId: string }): Promise<any> {
|
||||
const user = await this.prisma.client.user.findUnique({ where: { id: payload.sub } });
|
||||
if (!user || !user.isActive) throw new UnauthorizedException();
|
||||
return { id: user.id, email: user.email, role: user.role, tenantId: user.tenantId };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Controller, Get, Post, Patch, Body, Param, UseGuards, HttpCode, HttpStatus } from "@nestjs/common";
|
||||
import { B2BService, CreateB2BDto, UpdateB2BStatusDto } from "./b2b.service";
|
||||
import { JwtAuthGuard, RolesGuard, Roles } from "../auth/guards/auth.guard";
|
||||
import { CurrentUser } from "../auth/decorators/current-user.decorator";
|
||||
|
||||
const TENANT_FALLBACK = "moraworld"; // B2B público usa tenantId del slug
|
||||
|
||||
@Controller("b2b")
|
||||
export class B2BController {
|
||||
constructor(private svc: B2BService) {}
|
||||
|
||||
/** POST /api/b2b — público */
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
async create(@Body() dto: CreateB2BDto, @CurrentUser() user: any): Promise<any> {
|
||||
// Si hay usuario autenticado usa su tenantId, sino carga el tenant por slug
|
||||
const tenantId = user?.tenantId ?? TENANT_FALLBACK;
|
||||
return this.svc.create(dto, tenantId);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles("ADMIN_EMPRESA", "SUPER_ADMIN")
|
||||
findAll(@CurrentUser() user: any): Promise<any[]> {
|
||||
return this.svc.findAll(user.tenantId);
|
||||
}
|
||||
|
||||
@Patch(":id/status")
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles("ADMIN_EMPRESA", "SUPER_ADMIN")
|
||||
updateStatus(@Param("id") id: string, @Body() dto: UpdateB2BStatusDto): Promise<any> {
|
||||
return this.svc.updateStatus(id, dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { B2BController } from "./b2b.controller";
|
||||
import { B2BService } from "./b2b.service";
|
||||
|
||||
@Module({ controllers: [B2BController], providers: [B2BService] })
|
||||
export class B2BModule {}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { IsString, IsOptional, IsNumber, Min } from "class-validator";
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { generateTrackingId } from "../common/utils/tracking-id.util";
|
||||
|
||||
export class CreateB2BDto {
|
||||
@IsString() contactName!: string;
|
||||
@IsString() contactEmail!: string;
|
||||
@IsOptional() @IsString() contactPhone?: string;
|
||||
@IsOptional() @IsString() companyName?: string;
|
||||
@IsString() merchandiseType!: string;
|
||||
@IsString() description!: string;
|
||||
@IsOptional() @IsNumber() @Min(0) commercialValue?: number;
|
||||
}
|
||||
|
||||
export class UpdateB2BStatusDto {
|
||||
@IsString() status!: string;
|
||||
@IsOptional() @IsString() quotationNotes?: string;
|
||||
@IsOptional() @IsNumber() @Min(0) quotationAmount?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class B2BService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async findAll(tenantId: string): Promise<any[]> {
|
||||
return this.prisma.client.b2BRequest.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
async create(dto: CreateB2BDto, tenantId: string): Promise<any> {
|
||||
const count = await this.prisma.client.b2BRequest.count();
|
||||
const trackingId = `B2B-${String(count + 1).padStart(6, "0")}`;
|
||||
return this.prisma.client.b2BRequest.create({
|
||||
data: {
|
||||
tenantId,
|
||||
trackingId,
|
||||
contactName: dto.contactName,
|
||||
contactEmail: dto.contactEmail,
|
||||
contactPhone: dto.contactPhone,
|
||||
companyName: dto.companyName,
|
||||
merchandiseType: dto.merchandiseType,
|
||||
description: dto.description,
|
||||
commercialValue: dto.commercialValue ?? null,
|
||||
status: "PENDIENTE",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateStatus(id: string, dto: UpdateB2BStatusDto): Promise<any> {
|
||||
const req = await this.prisma.client.b2BRequest.findUnique({ where: { id } });
|
||||
if (!req) throw new NotFoundException("Solicitud B2B no encontrada.");
|
||||
return this.prisma.client.b2BRequest.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: dto.status as any,
|
||||
quotationNotes: dto.quotationNotes,
|
||||
quotationAmount: dto.quotationAmount ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { ValidationPipe } from "@nestjs/common";
|
||||
import { AppModule } from "./app.module";
|
||||
|
||||
async function bootstrap() {
|
||||
@@ -15,6 +16,16 @@ async function bootstrap() {
|
||||
|
||||
app.setGlobalPrefix("api");
|
||||
|
||||
// Validación global de DTOs (class-validator)
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: false,
|
||||
transform: true,
|
||||
transformOptions: { enableImplicitConversion: true },
|
||||
}),
|
||||
);
|
||||
|
||||
const port = process.env.API_PORT ?? process.env.PORT ?? 3001;
|
||||
await app.listen(port, "0.0.0.0");
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { IsString, IsOptional, IsNumber, Min } from "class-validator";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export class UpdateStatusDto {
|
||||
@IsString()
|
||||
status!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Controller, Get, Post, Patch, Body, Param, Query, UseGuards } from "@nestjs/common";
|
||||
import { PackagesService } from "./packages.service";
|
||||
import { CreatePackageDto, UpdateStatusDto } from "./dto/package.dto";
|
||||
import { JwtAuthGuard, RolesGuard, Roles } from "../auth/guards/auth.guard";
|
||||
import { CurrentUser } from "../auth/decorators/current-user.decorator";
|
||||
|
||||
@Controller("packages")
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
export class PackagesController {
|
||||
constructor(private svc: PackagesService) {}
|
||||
|
||||
@Get()
|
||||
findAll(@CurrentUser() user: any, @Query("status") status?: string, @Query("search") search?: string): Promise<any[]> {
|
||||
return this.svc.findAll(user, { status, search });
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
findOne(@Param("id") id: string, @CurrentUser() user: any): Promise<any> {
|
||||
return this.svc.findOne(id, user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Roles("OPERADOR_BODEGA", "ADMIN_EMPRESA", "SUPER_ADMIN")
|
||||
create(@Body() dto: CreatePackageDto, @CurrentUser() user: any): Promise<any> {
|
||||
return this.svc.create(dto, user.id, user.tenantId);
|
||||
}
|
||||
|
||||
@Patch(":id/status")
|
||||
@Roles("OPERADOR_BODEGA", "AGENTE_ADUANERO", "ADMIN_EMPRESA", "SUPER_ADMIN")
|
||||
updateStatus(@Param("id") id: string, @Body() dto: UpdateStatusDto, @CurrentUser() user: any): Promise<any> {
|
||||
return this.svc.updateStatus(id, dto, user.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PackagesController } from "./packages.controller";
|
||||
import { PackagesService } from "./packages.service";
|
||||
|
||||
@Module({
|
||||
controllers: [PackagesController],
|
||||
providers: [PackagesService],
|
||||
})
|
||||
export class PackagesModule {}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Injectable, NotFoundException, ForbiddenException } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { generateTrackingId } from "../common/utils/tracking-id.util";
|
||||
import { CreatePackageDto, UpdateStatusDto } from "./dto/package.dto";
|
||||
|
||||
@Injectable()
|
||||
export class PackagesService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async findAll(user: any, filters?: { status?: string; search?: string }): Promise<any[]> {
|
||||
const where: any = { tenantId: user.tenantId };
|
||||
|
||||
if (user.role === "CLIENTE") {
|
||||
where.userId = user.id;
|
||||
}
|
||||
|
||||
if (filters?.status) where.status = filters.status;
|
||||
if (filters?.search) {
|
||||
where.OR = [
|
||||
{ trackingId: { contains: filters.search, mode: "insensitive" } },
|
||||
{ vendorTracking: { contains: filters.search, mode: "insensitive" } },
|
||||
{ description: { contains: filters.search, mode: "insensitive" } },
|
||||
];
|
||||
}
|
||||
|
||||
return this.prisma.client.package.findMany({
|
||||
where,
|
||||
include: {
|
||||
user: { select: { firstName: true, lastName: true, email: true } },
|
||||
statusHistory: { orderBy: { createdAt: "desc" }, take: 1 },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: string, user: any): Promise<any> {
|
||||
const pkg = await this.prisma.client.package.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
user: { select: { firstName: true, lastName: true, email: true } },
|
||||
statusHistory: { orderBy: { createdAt: "desc" } },
|
||||
preAlert: true,
|
||||
},
|
||||
});
|
||||
if (!pkg) throw new NotFoundException("Paquete no encontrado.");
|
||||
if (user.role === "CLIENTE" && pkg.userId !== user.id) throw new ForbiddenException();
|
||||
return pkg;
|
||||
}
|
||||
|
||||
async create(dto: CreatePackageDto, operatorId: string, tenantId: string): Promise<any> {
|
||||
const trackingId = generateTrackingId();
|
||||
|
||||
const pkg = await this.prisma.client.package.create({
|
||||
data: {
|
||||
trackingId,
|
||||
tenantId,
|
||||
userId: dto.userId,
|
||||
description: dto.description,
|
||||
store: dto.store,
|
||||
vendorTracking: dto.vendorTracking,
|
||||
declaredValue: dto.declaredValue ?? 0,
|
||||
declaredWeight: dto.declaredWeightLb ?? null,
|
||||
lengthCm: dto.lengthCm ?? null,
|
||||
widthCm: dto.widthCm ?? null,
|
||||
heightCm: dto.heightCm ?? null,
|
||||
status: "REGISTRADO",
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.client.packageStatusHistory.create({
|
||||
data: {
|
||||
packageId: pkg.id,
|
||||
status: "REGISTRADO",
|
||||
createdBy: operatorId,
|
||||
note: "Paquete registrado al recibirse en bodega NJ",
|
||||
},
|
||||
});
|
||||
|
||||
return pkg;
|
||||
}
|
||||
|
||||
async updateStatus(id: string, dto: UpdateStatusDto, operatorId: string): Promise<any> {
|
||||
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: { status: dto.status as any },
|
||||
});
|
||||
|
||||
await this.prisma.client.packageStatusHistory.create({
|
||||
data: {
|
||||
packageId: id,
|
||||
status: dto.status as any,
|
||||
createdBy: operatorId,
|
||||
note: dto.note,
|
||||
},
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { IsString, IsOptional, IsNumber, Min } from "class-validator";
|
||||
|
||||
export class CreatePreAlertDto {
|
||||
@IsString()
|
||||
store!: string;
|
||||
|
||||
@IsString()
|
||||
description!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
declaredValue?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
vendorTracking?: string;
|
||||
}
|
||||
|
||||
export class UpdatePreAlertStatusDto {
|
||||
@IsString()
|
||||
status!: string;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Controller, Get, Post, Patch, Delete, Body, Param, UseGuards } from "@nestjs/common";
|
||||
import { PreAlertsService } from "./pre-alerts.service";
|
||||
import { CreatePreAlertDto, UpdatePreAlertStatusDto } from "./dto/pre-alert.dto";
|
||||
import { JwtAuthGuard, RolesGuard, Roles } from "../auth/guards/auth.guard";
|
||||
import { CurrentUser } from "../auth/decorators/current-user.decorator";
|
||||
|
||||
@Controller("pre-alerts")
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
export class PreAlertsController {
|
||||
constructor(private svc: PreAlertsService) {}
|
||||
|
||||
@Get()
|
||||
findAll(@CurrentUser() user: any) {
|
||||
return this.svc.findAll(user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Roles("CLIENTE")
|
||||
create(@Body() dto: CreatePreAlertDto, @CurrentUser() user: any) {
|
||||
return this.svc.create(dto, user);
|
||||
}
|
||||
|
||||
@Patch(":id/status")
|
||||
@Roles("OPERADOR_BODEGA", "ADMIN_EMPRESA", "SUPER_ADMIN")
|
||||
updateStatus(@Param("id") id: string, @Body() dto: UpdatePreAlertStatusDto): Promise<any> {
|
||||
return this.svc.updateStatus(id, dto);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
remove(@Param("id") id: string, @CurrentUser() user: any) {
|
||||
return this.svc.remove(id, user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PreAlertsController } from "./pre-alerts.controller";
|
||||
import { PreAlertsService } from "./pre-alerts.service";
|
||||
|
||||
@Module({ controllers: [PreAlertsController], providers: [PreAlertsService] })
|
||||
export class PreAlertsModule {}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Injectable, NotFoundException, ForbiddenException } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { CreatePreAlertDto, UpdatePreAlertStatusDto } from "./dto/pre-alert.dto";
|
||||
|
||||
@Injectable()
|
||||
export class PreAlertsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async findAll(user: any): Promise<any[]> {
|
||||
const where: any = { tenantId: user.tenantId };
|
||||
if (user.role === "CLIENTE") where.userId = user.id;
|
||||
return this.prisma.client.preAlert.findMany({
|
||||
where,
|
||||
include: { user: { select: { firstName: true, lastName: true, email: true } } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
async create(dto: CreatePreAlertDto, user: any): Promise<any> {
|
||||
return this.prisma.client.preAlert.create({
|
||||
data: {
|
||||
tenantId: user.tenantId,
|
||||
userId: user.id,
|
||||
store: dto.store,
|
||||
description: dto.description,
|
||||
declaredValue: dto.declaredValue ?? 0,
|
||||
vendorTracking: dto.vendorTracking,
|
||||
status: "PENDIENTE",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateStatus(id: string, dto: UpdatePreAlertStatusDto): Promise<any> {
|
||||
const alert = await this.prisma.client.preAlert.findUnique({ where: { id } });
|
||||
if (!alert) throw new NotFoundException("Pre-alerta no encontrada.");
|
||||
return this.prisma.client.preAlert.update({ where: { id }, data: { status: dto.status as any } });
|
||||
}
|
||||
|
||||
async remove(id: string, user: any): Promise<any> {
|
||||
const alert = await this.prisma.client.preAlert.findUnique({ where: { id } });
|
||||
if (!alert) throw new NotFoundException("Pre-alerta no encontrada.");
|
||||
if (user.role === "CLIENTE" && alert.userId !== user.id) throw new ForbiddenException();
|
||||
return this.prisma.client.preAlert.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Controller, Get, Patch, Body, Param, Query, UseGuards } from "@nestjs/common";
|
||||
import { UsersService } from "./users.service";
|
||||
import { JwtAuthGuard, RolesGuard, Roles } from "../auth/guards/auth.guard";
|
||||
import { CurrentUser } from "../auth/decorators/current-user.decorator";
|
||||
import { IsEnum, IsBoolean } from "class-validator";
|
||||
|
||||
class UpdateRoleDto {
|
||||
@IsEnum(["CLIENTE","OPERADOR_BODEGA","AGENTE_ADUANERO","ADMIN_EMPRESA","SUPER_ADMIN","SOPORTE"])
|
||||
role!: string;
|
||||
}
|
||||
class SetActiveDto {
|
||||
@IsBoolean() isActive!: boolean;
|
||||
}
|
||||
|
||||
@Controller("users")
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
export class UsersController {
|
||||
constructor(private svc: UsersService) {}
|
||||
|
||||
@Get()
|
||||
@Roles("ADMIN_EMPRESA", "SUPER_ADMIN")
|
||||
findAll(@CurrentUser() user: any, @Query("search") search?: string): Promise<any[]> {
|
||||
return this.svc.findAll(user.tenantId, search);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@Roles("ADMIN_EMPRESA", "SUPER_ADMIN")
|
||||
findOne(@Param("id") id: string): Promise<any> {
|
||||
return this.svc.findOne(id);
|
||||
}
|
||||
|
||||
@Patch(":id/role")
|
||||
@Roles("ADMIN_EMPRESA", "SUPER_ADMIN")
|
||||
updateRole(@Param("id") id: string, @Body() dto: UpdateRoleDto): Promise<any> {
|
||||
return this.svc.updateRole(id, dto.role);
|
||||
}
|
||||
|
||||
@Patch(":id/active")
|
||||
@Roles("ADMIN_EMPRESA", "SUPER_ADMIN")
|
||||
setActive(@Param("id") id: string, @Body() dto: SetActiveDto): Promise<any> {
|
||||
return this.svc.setActive(id, dto.isActive);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { UsersController } from "./users.controller";
|
||||
import { UsersService } from "./users.service";
|
||||
|
||||
@Module({ controllers: [UsersController], providers: [UsersService] })
|
||||
export class UsersModule {}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async findAll(tenantId: string, search?: string): Promise<any[]> {
|
||||
return this.prisma.client.user.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
...(search ? {
|
||||
OR: [
|
||||
{ email: { contains: search, mode: "insensitive" } },
|
||||
{ firstName: { contains: search, mode: "insensitive" } },
|
||||
{ lastName: { contains: search, mode: "insensitive" } },
|
||||
],
|
||||
} : {}),
|
||||
},
|
||||
select: {
|
||||
id: true, email: true, firstName: true, lastName: true, phone: true,
|
||||
role: true, isActive: true, mfaEnabled: true, lastLoginAt: true, createdAt: true,
|
||||
suite: { select: { code: true } },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<any> {
|
||||
const user = await this.prisma.client.user.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true, email: true, firstName: true, lastName: true, phone: true,
|
||||
role: true, isActive: true, mfaEnabled: true, lastLoginAt: true, createdAt: true,
|
||||
suite: { select: { code: true } },
|
||||
},
|
||||
});
|
||||
if (!user) throw new NotFoundException("Usuario no encontrado.");
|
||||
return user;
|
||||
}
|
||||
|
||||
async updateRole(id: string, role: string): Promise<any> {
|
||||
return this.prisma.client.user.update({ where: { id }, data: { role: role as any } });
|
||||
}
|
||||
|
||||
async setActive(id: string, isActive: boolean): Promise<any> {
|
||||
return this.prisma.client.user.update({ where: { id }, data: { isActive } });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user