[MCH-BE] feat: Add settings API endpoints

Implement settings module with endpoints for tenant configuration:
- GET /api/v1/settings - Get tenant settings
- PATCH /api/v1/settings - Update tenant settings
- GET /api/v1/settings/whatsapp - WhatsApp connection status
- POST /api/v1/settings/whatsapp/test - Test WhatsApp connection
- GET /api/v1/settings/subscription - Subscription information

Includes:
- TenantSettings entity for fiado, WhatsApp and notification preferences
- DTOs for business, fiado, WhatsApp and notification settings
- Service with full CRUD operations
- Integration with existing Tenant, Subscription and WhatsApp entities

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2026-01-20 02:28:32 -06:00
parent 45ec3ec09a
commit c936f447cf
6 changed files with 698 additions and 0 deletions

View File

@ -22,6 +22,8 @@ import { NotificationsModule } from './modules/notifications/notifications.modul
import { DeliveryModule } from './modules/delivery/delivery.module';
import { TemplatesModule } from './modules/templates/templates.module';
import { OnboardingModule } from './modules/onboarding/onboarding.module';
import { SettingsModule } from './modules/settings/settings.module';
import { ExportsModule } from './modules/exports/exports.module';
@Module({
imports: [
@ -72,6 +74,7 @@ import { OnboardingModule } from './modules/onboarding/onboarding.module';
DeliveryModule,
TemplatesModule,
OnboardingModule,
SettingsModule,
],
})
export class AppModule {}

View File

@ -0,0 +1,271 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsString,
IsOptional,
IsBoolean,
IsNumber,
IsEmail,
MaxLength,
Min,
Max,
} from 'class-validator';
// ==================== BUSINESS SETTINGS ====================
export class BusinessSettingsDto {
@ApiProperty({ description: 'Nombre del negocio', example: 'Mi Tiendita' })
@IsString()
@MaxLength(100)
name: string;
@ApiPropertyOptional({ description: 'Tipo de negocio', example: 'tienda' })
@IsOptional()
@IsString()
@MaxLength(50)
businessType?: string;
@ApiPropertyOptional({ description: 'Telefono', example: '5512345678' })
@IsOptional()
@IsString()
@MaxLength(20)
phone?: string;
@ApiPropertyOptional({ description: 'Email', example: 'tienda@email.com' })
@IsOptional()
@IsEmail()
@MaxLength(100)
email?: string;
@ApiPropertyOptional({ description: 'Direccion' })
@IsOptional()
@IsString()
address?: string;
@ApiPropertyOptional({ description: 'Ciudad' })
@IsOptional()
@IsString()
@MaxLength(50)
city?: string;
@ApiPropertyOptional({ description: 'Estado' })
@IsOptional()
@IsString()
@MaxLength(50)
state?: string;
@ApiPropertyOptional({ description: 'Codigo postal' })
@IsOptional()
@IsString()
@MaxLength(10)
zipCode?: string;
@ApiPropertyOptional({ description: 'Zona horaria', default: 'America/Mexico_City' })
@IsOptional()
@IsString()
@MaxLength(50)
timezone?: string;
@ApiPropertyOptional({ description: 'Moneda', default: 'MXN' })
@IsOptional()
@IsString()
@MaxLength(3)
currency?: string;
@ApiPropertyOptional({ description: 'Tasa de impuesto', default: 16 })
@IsOptional()
@IsNumber()
@Min(0)
@Max(100)
taxRate?: number;
@ApiPropertyOptional({ description: 'Impuesto incluido en precios', default: true })
@IsOptional()
@IsBoolean()
taxIncluded?: boolean;
}
// ==================== FIADO SETTINGS ====================
export class FiadoSettingsDto {
@ApiPropertyOptional({ description: 'Fiado habilitado globalmente', default: true })
@IsOptional()
@IsBoolean()
enabled?: boolean;
@ApiPropertyOptional({ description: 'Limite de credito global por defecto', example: 500 })
@IsOptional()
@IsNumber()
@Min(0)
defaultCreditLimit?: number;
@ApiPropertyOptional({ description: 'Dias de vencimiento por defecto', example: 15 })
@IsOptional()
@IsNumber()
@Min(1)
@Max(365)
defaultDueDays?: number;
}
// ==================== WHATSAPP SETTINGS ====================
export class WhatsAppSettingsDto {
@ApiPropertyOptional({ description: 'Numero de WhatsApp configurado' })
@IsOptional()
@IsString()
@MaxLength(20)
phoneNumber?: string;
@ApiPropertyOptional({ description: 'Usar numero de plataforma', default: true })
@IsOptional()
@IsBoolean()
usePlatformNumber?: boolean;
@ApiPropertyOptional({ description: 'Respuestas automaticas habilitadas', default: true })
@IsOptional()
@IsBoolean()
autoRepliesEnabled?: boolean;
@ApiPropertyOptional({ description: 'Notificar pedidos nuevos por WhatsApp', default: true })
@IsOptional()
@IsBoolean()
orderNotificationsEnabled?: boolean;
}
// ==================== NOTIFICATION SETTINGS ====================
export class NotificationSettingsDto {
@ApiPropertyOptional({ description: 'Alerta de stock bajo', default: true })
@IsOptional()
@IsBoolean()
lowStockAlert?: boolean;
@ApiPropertyOptional({ description: 'Alerta de fiados vencidos', default: true })
@IsOptional()
@IsBoolean()
overdueDebtsAlert?: boolean;
@ApiPropertyOptional({ description: 'Notificacion de nuevos pedidos', default: true })
@IsOptional()
@IsBoolean()
newOrdersAlert?: boolean;
@ApiPropertyOptional({ description: 'Sonido en nuevos pedidos', default: true })
@IsOptional()
@IsBoolean()
newOrdersSound?: boolean;
}
// ==================== UPDATE SETTINGS DTO ====================
export class UpdateSettingsDto {
@ApiPropertyOptional({ description: 'Configuracion del negocio' })
@IsOptional()
business?: BusinessSettingsDto;
@ApiPropertyOptional({ description: 'Configuracion de fiado' })
@IsOptional()
fiado?: FiadoSettingsDto;
@ApiPropertyOptional({ description: 'Configuracion de WhatsApp' })
@IsOptional()
whatsapp?: WhatsAppSettingsDto;
@ApiPropertyOptional({ description: 'Configuracion de notificaciones' })
@IsOptional()
notifications?: NotificationSettingsDto;
}
// ==================== RESPONSE DTOs ====================
export class WhatsAppStatusResponseDto {
@ApiProperty({ description: 'Estado de conexion' })
connected: boolean;
@ApiProperty({ description: 'Numero configurado', nullable: true })
phoneNumber: string | null;
@ApiProperty({ description: 'Nombre para mostrar', nullable: true })
displayName: string | null;
@ApiProperty({ description: 'Verificado' })
verified: boolean;
@ApiProperty({ description: 'Usa numero de plataforma' })
usesPlatformNumber: boolean;
}
export class SubscriptionInfoResponseDto {
@ApiProperty({ description: 'Nombre del plan' })
planName: string;
@ApiProperty({ description: 'Codigo del plan' })
planCode: string;
@ApiProperty({ description: 'Precio mensual' })
priceMonthly: number;
@ApiProperty({ description: 'Moneda' })
currency: string;
@ApiProperty({ description: 'Estado de suscripcion' })
status: string;
@ApiProperty({ description: 'Ciclo de facturacion' })
billingCycle: string;
@ApiProperty({ description: 'Fecha de renovacion', nullable: true })
renewalDate: Date | null;
@ApiProperty({ description: 'Tokens incluidos' })
includedTokens: number;
@ApiProperty({ description: 'Tokens utilizados' })
tokensUsed: number;
@ApiProperty({ description: 'Tokens disponibles' })
tokensRemaining: number;
@ApiProperty({ description: 'Limite de productos', nullable: true })
maxProducts: number | null;
@ApiProperty({ description: 'Limite de usuarios' })
maxUsers: number;
@ApiProperty({ description: 'Caracteristicas del plan' })
features: Record<string, boolean>;
}
export class SettingsResponseDto {
@ApiProperty({ description: 'Configuracion del negocio' })
business: BusinessSettingsDto;
@ApiProperty({ description: 'Configuracion de fiado' })
fiado: FiadoSettingsDto;
@ApiProperty({ description: 'Configuracion de WhatsApp' })
whatsapp: {
phoneNumber: string | null;
connected: boolean;
verified: boolean;
usesPlatformNumber: boolean;
autoRepliesEnabled: boolean;
orderNotificationsEnabled: boolean;
};
@ApiProperty({ description: 'Configuracion de notificaciones' })
notifications: NotificationSettingsDto;
@ApiProperty({ description: 'Informacion de suscripcion' })
subscription: {
planName: string;
status: string;
};
}
export class TestWhatsAppResponseDto {
@ApiProperty({ description: 'Resultado del test' })
success: boolean;
@ApiProperty({ description: 'Mensaje del resultado' })
message: string;
}

View File

@ -0,0 +1,59 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
@Entity({ schema: 'public', name: 'tenant_settings' })
@Index(['tenantId'], { unique: true })
export class TenantSettings {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'tenant_id', type: 'uuid', unique: true })
tenantId: string;
// ==================== FIADO SETTINGS ====================
@Column({ name: 'fiado_enabled', default: true })
fiadoEnabled: boolean;
@Column({ name: 'fiado_default_credit_limit', type: 'decimal', precision: 10, scale: 2, default: 500 })
fiadoDefaultCreditLimit: number;
@Column({ name: 'fiado_default_due_days', default: 15 })
fiadoDefaultDueDays: number;
// ==================== WHATSAPP SETTINGS ====================
@Column({ name: 'whatsapp_auto_replies_enabled', default: true })
whatsappAutoRepliesEnabled: boolean;
@Column({ name: 'whatsapp_order_notifications_enabled', default: true })
whatsappOrderNotificationsEnabled: boolean;
// ==================== NOTIFICATION SETTINGS ====================
@Column({ name: 'notification_low_stock_alert', default: true })
notificationLowStockAlert: boolean;
@Column({ name: 'notification_overdue_debts_alert', default: true })
notificationOverdueDebtsAlert: boolean;
@Column({ name: 'notification_new_orders_alert', default: true })
notificationNewOrdersAlert: boolean;
@Column({ name: 'notification_new_orders_sound', default: true })
notificationNewOrdersSound: boolean;
// ==================== TIMESTAMPS ====================
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@ -0,0 +1,65 @@
import {
Controller,
Get,
Patch,
Post,
Body,
UseGuards,
Request,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { SettingsService } from './settings.service';
import {
UpdateSettingsDto,
SettingsResponseDto,
WhatsAppStatusResponseDto,
SubscriptionInfoResponseDto,
TestWhatsAppResponseDto,
} from './dto/settings.dto';
@ApiTags('settings')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('v1/settings')
export class SettingsController {
constructor(private readonly settingsService: SettingsService) {}
@Get()
@ApiOperation({ summary: 'Obtener configuracion del tenant' })
@ApiResponse({ status: 200, description: 'Configuracion del tenant', type: SettingsResponseDto })
getSettings(@Request() req): Promise<SettingsResponseDto> {
return this.settingsService.getSettings(req.user.tenantId);
}
@Patch()
@ApiOperation({ summary: 'Actualizar configuracion del tenant' })
@ApiResponse({ status: 200, description: 'Configuracion actualizada', type: SettingsResponseDto })
updateSettings(
@Request() req,
@Body() dto: UpdateSettingsDto,
): Promise<SettingsResponseDto> {
return this.settingsService.updateSettings(req.user.tenantId, dto);
}
@Get('whatsapp')
@ApiOperation({ summary: 'Obtener estado de WhatsApp' })
@ApiResponse({ status: 200, description: 'Estado de WhatsApp', type: WhatsAppStatusResponseDto })
getWhatsAppStatus(@Request() req): Promise<WhatsAppStatusResponseDto> {
return this.settingsService.getWhatsAppStatus(req.user.tenantId);
}
@Post('whatsapp/test')
@ApiOperation({ summary: 'Probar conexion de WhatsApp' })
@ApiResponse({ status: 200, description: 'Resultado del test', type: TestWhatsAppResponseDto })
testWhatsAppConnection(@Request() req): Promise<TestWhatsAppResponseDto> {
return this.settingsService.testWhatsAppConnection(req.user.tenantId);
}
@Get('subscription')
@ApiOperation({ summary: 'Obtener informacion de suscripcion' })
@ApiResponse({ status: 200, description: 'Informacion de suscripcion', type: SubscriptionInfoResponseDto })
getSubscriptionInfo(@Request() req): Promise<SubscriptionInfoResponseDto> {
return this.settingsService.getSubscriptionInfo(req.user.tenantId);
}
}

View File

@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SettingsController } from './settings.controller';
import { SettingsService } from './settings.service';
import { TenantSettings } from './entities/tenant-settings.entity';
import { Tenant } from '../auth/entities/tenant.entity';
import { TenantWhatsAppNumber } from '../integrations/entities/tenant-whatsapp-number.entity';
import { Subscription } from '../subscriptions/entities/subscription.entity';
import { TokenBalance } from '../subscriptions/entities/token-balance.entity';
import { Plan } from '../subscriptions/entities/plan.entity';
@Module({
imports: [
TypeOrmModule.forFeature([
TenantSettings,
Tenant,
TenantWhatsAppNumber,
Subscription,
TokenBalance,
Plan,
]),
],
controllers: [SettingsController],
providers: [SettingsService],
exports: [SettingsService],
})
export class SettingsModule {}

View File

@ -0,0 +1,273 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Tenant } from '../auth/entities/tenant.entity';
import { TenantSettings } from './entities/tenant-settings.entity';
import { TenantWhatsAppNumber } from '../integrations/entities/tenant-whatsapp-number.entity';
import { Subscription } from '../subscriptions/entities/subscription.entity';
import { TokenBalance } from '../subscriptions/entities/token-balance.entity';
import { Plan } from '../subscriptions/entities/plan.entity';
import {
UpdateSettingsDto,
SettingsResponseDto,
WhatsAppStatusResponseDto,
SubscriptionInfoResponseDto,
TestWhatsAppResponseDto,
} from './dto/settings.dto';
@Injectable()
export class SettingsService {
constructor(
@InjectRepository(Tenant)
private readonly tenantRepo: Repository<Tenant>,
@InjectRepository(TenantSettings)
private readonly settingsRepo: Repository<TenantSettings>,
@InjectRepository(TenantWhatsAppNumber)
private readonly whatsappRepo: Repository<TenantWhatsAppNumber>,
@InjectRepository(Subscription)
private readonly subscriptionRepo: Repository<Subscription>,
@InjectRepository(TokenBalance)
private readonly tokenBalanceRepo: Repository<TokenBalance>,
@InjectRepository(Plan)
private readonly planRepo: Repository<Plan>,
) {}
// ==================== GET SETTINGS ====================
async getSettings(tenantId: string): Promise<SettingsResponseDto> {
const tenant = await this.tenantRepo.findOne({ where: { id: tenantId } });
if (!tenant) {
throw new NotFoundException('Tenant no encontrado');
}
const settings = await this.getOrCreateSettings(tenantId);
const whatsapp = await this.whatsappRepo.findOne({
where: { tenantId, isActive: true },
});
const subscription = await this.subscriptionRepo.findOne({
where: { tenantId },
relations: ['plan'],
});
return {
business: {
name: tenant.name,
businessType: tenant.businessType,
phone: tenant.phone,
email: tenant.email,
address: tenant.address,
city: tenant.city,
state: tenant.state,
zipCode: tenant.zipCode,
timezone: tenant.timezone,
currency: tenant.currency,
taxRate: Number(tenant.taxRate),
taxIncluded: tenant.taxIncluded,
},
fiado: {
enabled: settings.fiadoEnabled,
defaultCreditLimit: Number(settings.fiadoDefaultCreditLimit),
defaultDueDays: settings.fiadoDefaultDueDays,
},
whatsapp: {
phoneNumber: whatsapp?.phoneNumber || tenant.whatsappNumber || null,
connected: !!whatsapp?.isActive || tenant.whatsappVerified,
verified: tenant.whatsappVerified,
usesPlatformNumber: tenant.usesPlatformNumber,
autoRepliesEnabled: settings.whatsappAutoRepliesEnabled,
orderNotificationsEnabled: settings.whatsappOrderNotificationsEnabled,
},
notifications: {
lowStockAlert: settings.notificationLowStockAlert,
overdueDebtsAlert: settings.notificationOverdueDebtsAlert,
newOrdersAlert: settings.notificationNewOrdersAlert,
newOrdersSound: settings.notificationNewOrdersSound,
},
subscription: {
planName: subscription?.plan?.name || 'Sin plan',
status: subscription?.status || 'inactive',
},
};
}
// ==================== UPDATE SETTINGS ====================
async updateSettings(tenantId: string, dto: UpdateSettingsDto): Promise<SettingsResponseDto> {
const tenant = await this.tenantRepo.findOne({ where: { id: tenantId } });
if (!tenant) {
throw new NotFoundException('Tenant no encontrado');
}
const settings = await this.getOrCreateSettings(tenantId);
// Update business settings on tenant
if (dto.business) {
const { name, businessType, phone, email, address, city, state, zipCode, timezone, currency, taxRate, taxIncluded } = dto.business;
if (name !== undefined) tenant.name = name;
if (businessType !== undefined) tenant.businessType = businessType;
if (phone !== undefined) tenant.phone = phone;
if (email !== undefined) tenant.email = email;
if (address !== undefined) tenant.address = address;
if (city !== undefined) tenant.city = city;
if (state !== undefined) tenant.state = state;
if (zipCode !== undefined) tenant.zipCode = zipCode;
if (timezone !== undefined) tenant.timezone = timezone;
if (currency !== undefined) tenant.currency = currency;
if (taxRate !== undefined) tenant.taxRate = taxRate;
if (taxIncluded !== undefined) tenant.taxIncluded = taxIncluded;
await this.tenantRepo.save(tenant);
}
// Update fiado settings
if (dto.fiado) {
const { enabled, defaultCreditLimit, defaultDueDays } = dto.fiado;
if (enabled !== undefined) settings.fiadoEnabled = enabled;
if (defaultCreditLimit !== undefined) settings.fiadoDefaultCreditLimit = defaultCreditLimit;
if (defaultDueDays !== undefined) settings.fiadoDefaultDueDays = defaultDueDays;
}
// Update whatsapp settings
if (dto.whatsapp) {
const { usePlatformNumber, autoRepliesEnabled, orderNotificationsEnabled } = dto.whatsapp;
if (usePlatformNumber !== undefined) {
tenant.usesPlatformNumber = usePlatformNumber;
await this.tenantRepo.save(tenant);
}
if (autoRepliesEnabled !== undefined) settings.whatsappAutoRepliesEnabled = autoRepliesEnabled;
if (orderNotificationsEnabled !== undefined) settings.whatsappOrderNotificationsEnabled = orderNotificationsEnabled;
}
// Update notification settings
if (dto.notifications) {
const { lowStockAlert, overdueDebtsAlert, newOrdersAlert, newOrdersSound } = dto.notifications;
if (lowStockAlert !== undefined) settings.notificationLowStockAlert = lowStockAlert;
if (overdueDebtsAlert !== undefined) settings.notificationOverdueDebtsAlert = overdueDebtsAlert;
if (newOrdersAlert !== undefined) settings.notificationNewOrdersAlert = newOrdersAlert;
if (newOrdersSound !== undefined) settings.notificationNewOrdersSound = newOrdersSound;
}
await this.settingsRepo.save(settings);
return this.getSettings(tenantId);
}
// ==================== WHATSAPP ====================
async getWhatsAppStatus(tenantId: string): Promise<WhatsAppStatusResponseDto> {
const tenant = await this.tenantRepo.findOne({ where: { id: tenantId } });
if (!tenant) {
throw new NotFoundException('Tenant no encontrado');
}
const whatsapp = await this.whatsappRepo.findOne({
where: { tenantId, isActive: true },
});
return {
connected: !!whatsapp?.isActive || tenant.whatsappVerified,
phoneNumber: whatsapp?.phoneNumber || tenant.whatsappNumber || null,
displayName: whatsapp?.displayName || null,
verified: tenant.whatsappVerified,
usesPlatformNumber: tenant.usesPlatformNumber,
};
}
async testWhatsAppConnection(tenantId: string): Promise<TestWhatsAppResponseDto> {
const tenant = await this.tenantRepo.findOne({ where: { id: tenantId } });
if (!tenant) {
throw new NotFoundException('Tenant no encontrado');
}
const whatsapp = await this.whatsappRepo.findOne({
where: { tenantId, isActive: true },
});
// In a real implementation, this would send a test message
// For now, we just check if there's an active configuration
if (whatsapp?.isActive || tenant.whatsappVerified) {
return {
success: true,
message: 'Conexion de WhatsApp verificada correctamente',
};
}
return {
success: false,
message: 'No hay numero de WhatsApp configurado o no esta activo',
};
}
// ==================== SUBSCRIPTION ====================
async getSubscriptionInfo(tenantId: string): Promise<SubscriptionInfoResponseDto> {
const subscription = await this.subscriptionRepo.findOne({
where: { tenantId },
relations: ['plan'],
});
const tokenBalance = await this.tokenBalanceRepo.findOne({
where: { tenantId },
});
if (!subscription || !subscription.plan) {
// Return default/free plan info
return {
planName: 'Sin plan',
planCode: 'none',
priceMonthly: 0,
currency: 'MXN',
status: 'inactive',
billingCycle: 'monthly',
renewalDate: null,
includedTokens: 0,
tokensUsed: tokenBalance?.usedTokens || 0,
tokensRemaining: tokenBalance?.availableTokens || 0,
maxProducts: null,
maxUsers: 1,
features: {},
};
}
const plan = subscription.plan;
return {
planName: plan.name,
planCode: plan.code,
priceMonthly: Number(plan.priceMonthly),
currency: plan.currency,
status: subscription.status,
billingCycle: subscription.billingCycle,
renewalDate: subscription.currentPeriodEnd,
includedTokens: plan.includedTokens,
tokensUsed: tokenBalance?.usedTokens || 0,
tokensRemaining: tokenBalance?.availableTokens || 0,
maxProducts: plan.maxProducts,
maxUsers: plan.maxUsers,
features: plan.features || {},
};
}
// ==================== HELPERS ====================
private async getOrCreateSettings(tenantId: string): Promise<TenantSettings> {
let settings = await this.settingsRepo.findOne({ where: { tenantId } });
if (!settings) {
settings = this.settingsRepo.create({
tenantId,
fiadoEnabled: true,
fiadoDefaultCreditLimit: 500,
fiadoDefaultDueDays: 15,
whatsappAutoRepliesEnabled: true,
whatsappOrderNotificationsEnabled: true,
notificationLowStockAlert: true,
notificationOverdueDebtsAlert: true,
notificationNewOrdersAlert: true,
notificationNewOrdersSound: true,
});
settings = await this.settingsRepo.save(settings);
}
return settings;
}
}