import { Response, NextFunction } from 'express'; import { z } from 'zod'; import { partnersService, CreatePartnerDto, UpdatePartnerDto, PartnerFilters, PartnerType } from './partners.service.js'; import { ApiResponse, AuthenticatedRequest, ValidationError } from '../../shared/types/index.js'; // Validation schemas (accept both snake_case and camelCase from frontend) const createPartnerSchema = z.object({ code: z.string().min(1, 'El código es requerido').max(20), display_name: z.string().min(1).max(200).optional(), displayName: z.string().min(1, 'El nombre es requerido').max(200).optional(), legal_name: z.string().max(200).optional(), legalName: z.string().max(200).optional(), partner_type: z.enum(['customer', 'supplier', 'both']).default('customer'), partnerType: z.enum(['customer', 'supplier', 'both']).default('customer'), email: z.string().email('Email inválido').max(255).optional(), phone: z.string().max(30).optional(), mobile: z.string().max(30).optional(), website: z.string().max(500).optional(), tax_id: z.string().max(20).optional(), taxId: z.string().max(20).optional(), tax_regime: z.string().max(100).optional(), taxRegime: z.string().max(100).optional(), cfdi_use: z.string().max(10).optional(), cfdiUse: z.string().max(10).optional(), payment_term_days: z.coerce.number().int().default(0), paymentTermDays: z.coerce.number().int().default(0), credit_limit: z.coerce.number().default(0), creditLimit: z.coerce.number().default(0), price_list_id: z.string().uuid().optional(), priceListId: z.string().uuid().optional(), discount_percent: z.coerce.number().default(0), discountPercent: z.coerce.number().default(0), category: z.string().max(50).optional(), tags: z.array(z.string()).optional(), notes: z.string().optional(), sales_rep_id: z.string().uuid().optional(), salesRepId: z.string().uuid().optional(), }); const updatePartnerSchema = z.object({ display_name: z.string().min(1).max(200).optional(), displayName: z.string().min(1).max(200).optional(), legal_name: z.string().max(200).optional().nullable(), legalName: z.string().max(200).optional().nullable(), partner_type: z.enum(['customer', 'supplier', 'both']).optional(), partnerType: z.enum(['customer', 'supplier', 'both']).optional(), email: z.string().email('Email inválido').max(255).optional().nullable(), phone: z.string().max(30).optional().nullable(), mobile: z.string().max(30).optional().nullable(), website: z.string().max(500).optional().nullable(), tax_id: z.string().max(20).optional().nullable(), taxId: z.string().max(20).optional().nullable(), tax_regime: z.string().max(100).optional().nullable(), taxRegime: z.string().max(100).optional().nullable(), cfdi_use: z.string().max(10).optional().nullable(), cfdiUse: z.string().max(10).optional().nullable(), payment_term_days: z.coerce.number().int().optional(), paymentTermDays: z.coerce.number().int().optional(), credit_limit: z.coerce.number().optional(), creditLimit: z.coerce.number().optional(), price_list_id: z.string().uuid().optional().nullable(), priceListId: z.string().uuid().optional().nullable(), discount_percent: z.coerce.number().optional(), discountPercent: z.coerce.number().optional(), category: z.string().max(50).optional().nullable(), tags: z.array(z.string()).optional(), notes: z.string().optional().nullable(), is_active: z.boolean().optional(), isActive: z.boolean().optional(), is_verified: z.boolean().optional(), isVerified: z.boolean().optional(), sales_rep_id: z.string().uuid().optional().nullable(), salesRepId: z.string().uuid().optional().nullable(), }); const querySchema = z.object({ search: z.string().optional(), partner_type: z.enum(['customer', 'supplier', 'both']).optional(), partnerType: z.enum(['customer', 'supplier', 'both']).optional(), category: z.string().optional(), is_active: z.coerce.boolean().optional(), isActive: z.coerce.boolean().optional(), is_verified: z.coerce.boolean().optional(), isVerified: z.coerce.boolean().optional(), page: z.coerce.number().int().positive().default(1), limit: z.coerce.number().int().positive().max(100).default(20), }); class PartnersController { async findAll(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { try { const queryResult = querySchema.safeParse(req.query); if (!queryResult.success) { throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.issues); } const data = queryResult.data; const tenantId = req.user!.tenantId; const filters: PartnerFilters = { search: data.search, partnerType: (data.partnerType || data.partner_type) as PartnerType | undefined, category: data.category, isActive: data.isActive ?? data.is_active, isVerified: data.isVerified ?? data.is_verified, page: data.page, limit: data.limit, }; const result = await partnersService.findAll(tenantId, filters); const response: ApiResponse = { success: true, data: result.data, meta: { total: result.total, page: filters.page || 1, limit: filters.limit || 20, totalPages: Math.ceil(result.total / (filters.limit || 20)), }, }; res.json(response); } catch (error) { next(error); } } async findCustomers(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { try { const queryResult = querySchema.safeParse(req.query); if (!queryResult.success) { throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.issues); } const data = queryResult.data; const tenantId = req.user!.tenantId; const filters = { search: data.search, category: data.category, isActive: data.isActive ?? data.is_active, isVerified: data.isVerified ?? data.is_verified, page: data.page, limit: data.limit, }; const result = await partnersService.findCustomers(tenantId, filters); const response: ApiResponse = { success: true, data: result.data, meta: { total: result.total, page: filters.page || 1, limit: filters.limit || 20, totalPages: Math.ceil(result.total / (filters.limit || 20)), }, }; res.json(response); } catch (error) { next(error); } } async findSuppliers(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { try { const queryResult = querySchema.safeParse(req.query); if (!queryResult.success) { throw new ValidationError('Parámetros de consulta inválidos', queryResult.error.issues); } const data = queryResult.data; const tenantId = req.user!.tenantId; const filters = { search: data.search, category: data.category, isActive: data.isActive ?? data.is_active, isVerified: data.isVerified ?? data.is_verified, page: data.page, limit: data.limit, }; const result = await partnersService.findSuppliers(tenantId, filters); const response: ApiResponse = { success: true, data: result.data, meta: { total: result.total, page: filters.page || 1, limit: filters.limit || 20, totalPages: Math.ceil(result.total / (filters.limit || 20)), }, }; res.json(response); } catch (error) { next(error); } } async findById(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { try { const { id } = req.params; const tenantId = req.user!.tenantId; const partner = await partnersService.findById(id, tenantId); const response: ApiResponse = { success: true, data: partner, }; res.json(response); } catch (error) { next(error); } } async create(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { try { const parseResult = createPartnerSchema.safeParse(req.body); if (!parseResult.success) { throw new ValidationError('Datos de contacto inválidos', parseResult.error.issues); } const data = parseResult.data; const tenantId = req.user!.tenantId; const userId = req.user!.userId; // Transform to camelCase DTO const dto: CreatePartnerDto = { code: data.code, displayName: data.displayName || data.display_name || data.code, legalName: data.legalName || data.legal_name, partnerType: (data.partnerType || data.partner_type) as PartnerType, email: data.email, phone: data.phone, mobile: data.mobile, website: data.website, taxId: data.taxId || data.tax_id, taxRegime: data.taxRegime || data.tax_regime, cfdiUse: data.cfdiUse || data.cfdi_use, paymentTermDays: data.paymentTermDays || data.payment_term_days, creditLimit: data.creditLimit || data.credit_limit, priceListId: data.priceListId || data.price_list_id, discountPercent: data.discountPercent || data.discount_percent, category: data.category, tags: data.tags, notes: data.notes, salesRepId: data.salesRepId || data.sales_rep_id, }; const partner = await partnersService.create(dto, tenantId, userId); const response: ApiResponse = { success: true, data: partner, message: 'Contacto creado exitosamente', }; res.status(201).json(response); } catch (error) { next(error); } } async update(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { try { const { id } = req.params; const parseResult = updatePartnerSchema.safeParse(req.body); if (!parseResult.success) { throw new ValidationError('Datos de contacto inválidos', parseResult.error.issues); } const data = parseResult.data; const tenantId = req.user!.tenantId; const userId = req.user!.userId; // Transform to camelCase DTO const dto: UpdatePartnerDto = {}; if (data.displayName !== undefined || data.display_name !== undefined) { dto.displayName = data.displayName ?? data.display_name; } if (data.legalName !== undefined || data.legal_name !== undefined) { dto.legalName = data.legalName ?? data.legal_name; } if (data.partnerType !== undefined || data.partner_type !== undefined) { dto.partnerType = (data.partnerType ?? data.partner_type) as PartnerType; } if (data.email !== undefined) dto.email = data.email; if (data.phone !== undefined) dto.phone = data.phone; if (data.mobile !== undefined) dto.mobile = data.mobile; if (data.website !== undefined) dto.website = data.website; if (data.taxId !== undefined || data.tax_id !== undefined) { dto.taxId = data.taxId ?? data.tax_id; } if (data.taxRegime !== undefined || data.tax_regime !== undefined) { dto.taxRegime = data.taxRegime ?? data.tax_regime; } if (data.cfdiUse !== undefined || data.cfdi_use !== undefined) { dto.cfdiUse = data.cfdiUse ?? data.cfdi_use; } if (data.paymentTermDays !== undefined || data.payment_term_days !== undefined) { dto.paymentTermDays = data.paymentTermDays ?? data.payment_term_days; } if (data.creditLimit !== undefined || data.credit_limit !== undefined) { dto.creditLimit = data.creditLimit ?? data.credit_limit; } if (data.priceListId !== undefined || data.price_list_id !== undefined) { dto.priceListId = data.priceListId ?? data.price_list_id; } if (data.discountPercent !== undefined || data.discount_percent !== undefined) { dto.discountPercent = data.discountPercent ?? data.discount_percent; } if (data.category !== undefined) dto.category = data.category; if (data.tags !== undefined) dto.tags = data.tags; if (data.notes !== undefined) dto.notes = data.notes; if (data.isActive !== undefined || data.is_active !== undefined) { dto.isActive = data.isActive ?? data.is_active; } if (data.isVerified !== undefined || data.is_verified !== undefined) { dto.isVerified = data.isVerified ?? data.is_verified; } if (data.salesRepId !== undefined || data.sales_rep_id !== undefined) { dto.salesRepId = data.salesRepId ?? data.sales_rep_id; } const partner = await partnersService.update(id, dto, tenantId, userId); const response: ApiResponse = { success: true, data: partner, message: 'Contacto actualizado exitosamente', }; res.json(response); } catch (error) { next(error); } } async delete(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise { try { const { id } = req.params; const tenantId = req.user!.tenantId; const userId = req.user!.userId; await partnersService.delete(id, tenantId, userId); const response: ApiResponse = { success: true, message: 'Contacto eliminado exitosamente', }; res.json(response); } catch (error) { next(error); } } } export const partnersController = new PartnersController();