[MIINVENTARIO] feat: Initial commit - Sistema de inventario con análisis de video IA

- Backend NestJS con módulos de autenticación, inventario, créditos
- Frontend React con dashboard y componentes UI
- Base de datos PostgreSQL con migraciones
- Tests E2E configurados
- Configuración de Docker y deployment

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2026-01-13 02:25:48 -06:00
commit 1a53b5c4d3
267 changed files with 67330 additions and 0 deletions

102
.env.example Normal file
View File

@ -0,0 +1,102 @@
# MiInventario - Variables de Entorno
# Copiar a .env y configurar valores
# ===========================================
# BASE DE DATOS
# ===========================================
DATABASE_URL=postgresql://postgres:postgres@localhost:5433/miinventario_dev
REDIS_URL=redis://localhost:6380
# ===========================================
# BACKEND
# ===========================================
BACKEND_PORT=3150
NODE_ENV=development
JWT_SECRET=your-jwt-secret-here-change-in-production
JWT_EXPIRES_IN=7d
REFRESH_TOKEN_EXPIRES_IN=30d
# ===========================================
# MOBILE
# ===========================================
MOBILE_PORT=8082
API_URL=http://localhost:3150
# ===========================================
# STRIPE (Pagos)
# ===========================================
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_PUBLISHABLE_KEY=pk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
STRIPE_OXXO_ENABLED=true
# ===========================================
# ALMACENAMIENTO S3
# ===========================================
S3_ENDPOINT=http://localhost:9002
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
S3_BUCKET=miinventario
S3_REGION=us-east-1
# ===========================================
# PROVEEDOR IA
# ===========================================
AI_PROVIDER=openai
AI_API_KEY=sk-xxx
AI_MODEL=gpt-4-vision-preview
AI_MAX_TOKENS=4096
# ===========================================
# FIREBASE (Notificaciones Push)
# ===========================================
FIREBASE_PROJECT_ID=
FIREBASE_PRIVATE_KEY=
FIREBASE_CLIENT_EMAIL=
# ===========================================
# CONFIGURACION DE CREDITOS
# ===========================================
CREDIT_MULTIPLIER=2.0
MIN_CREDITS_PER_SESSION=1
MAX_VIDEO_DURATION_SECONDS=60
MAX_FRAMES_PER_SESSION=100
# ===========================================
# VALIDACION Y FEEDBACK
# ===========================================
VALIDATION_SAMPLE_RATE=0.10
CONFIDENCE_THRESHOLD=0.7
UNKNOWN_THRESHOLD=0.3
# ===========================================
# REFERIDOS
# ===========================================
REFERRAL_REWARD_CREDITS=1
REFERRAL_MAX_LEVELS=1
REFERRAL_ATTRIBUTION_WINDOW_DAYS=30
# ===========================================
# PAGOS EN EFECTIVO
# ===========================================
OXXO_VOUCHER_EXPIRATION_HOURS=72
SEVENELEVEN_ENABLED=false
SEVENELEVEN_PROVIDER=
# ===========================================
# ALMACENAMIENTO Y RETENCION
# ===========================================
VIDEO_RETENTION_DAYS=7
FRAME_RETENTION_DAYS=30
# ===========================================
# RATE LIMITING
# ===========================================
RATE_LIMIT_TTL=60
RATE_LIMIT_MAX=100
# ===========================================
# LOGS Y MONITOREO
# ===========================================
LOG_LEVEL=debug
ENABLE_AUDIT_LOGS=true

4
.eslintrc.js Normal file
View File

@ -0,0 +1,4 @@
module.exports = {
root: true,
ignorePatterns: ['apps/**', 'node_modules'],
};

111
.gitignore vendored Normal file
View File

@ -0,0 +1,111 @@
# Dependencies
node_modules/
.pnp
.pnp.js
# Build outputs
dist/
build/
.next/
out/
# Environment files
.env
.env.local
.env.*.local
!.env.example
# IDE and editors
.idea/
.vscode/
*.swp
*.swo
*.sublime-workspace
*.sublime-project
# OS files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Testing
coverage/
.nyc_output/
*.lcov
# Expo / React Native
.expo/
.expo-shared/
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
web-build/
# Android
*.apk
*.aab
android/app/build/
android/.gradle/
android/captures/
android/local.properties
# iOS
*.ipa
ios/Pods/
ios/build/
ios/*.xcworkspace
# Temporary files
tmp/
temp/
*.tmp
*.temp
# Cache
.cache/
.parcel-cache/
.turbo/
# Database
*.sqlite
*.sqlite3
# Uploads and user content
uploads/
videos/
frames/
# Secrets (extra safety)
*.pem
secrets/
credentials/
# Lock files (optional - uncomment if using npm)
# package-lock.json
# yarn.lock
# Generated documentation
docs-generated/
# Test artifacts
test-results/
playwright-report/
# Misc
*.bak
*.backup

10
.prettierrc Normal file
View File

@ -0,0 +1,10 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 80,
"tabWidth": 2,
"semi": true,
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "lf"
}

111
README.md Normal file
View File

@ -0,0 +1,111 @@
# MiInventario
> SaaS Movil para inventario automatico por video para negocios pequenos en Mexico
## Descripcion
MiInventario permite a tienditas, miscelaneas y puestos generar inventario automatico a partir de un video de anaqueles (30-60s). El sistema detecta productos (SKU) y los cuenta usando inteligencia artificial.
## Caracteristicas Principales
- Inventario automatico por video (30-60 segundos)
- Deteccion de productos con IA
- Sistema de creditos/tokens
- Pagos: tarjeta, OXXO, 7-Eleven
- Sistema de referidos multinivel
- Retroalimentacion y mejora continua del modelo
## Stack Tecnologico
| Componente | Tecnologia |
|------------|------------|
| Mobile | React Native (Expo) |
| Backend | NestJS + TypeScript |
| Base de Datos | PostgreSQL + Redis |
| Cola de Trabajos | Bull |
| Almacenamiento | S3 Compatible |
| Pagos | Stripe + Agregadores |
## Estructura del Proyecto
```
miinventario/
├── apps/
│ ├── backend/ # API NestJS
│ └── mobile/ # App React Native (Expo)
├── database/
│ ├── schemas/ # DDL PostgreSQL
│ └── seeds/ # Datos iniciales
├── docs/ # Documentacion SIMCO
├── orchestration/ # Orquestacion del proyecto
└── deploy/ # Configuracion de despliegue
```
## Documentacion
- [Vision del Proyecto](./docs/00-vision-general/VISION-PROYECTO.md)
- [Requerimientos Funcionales](./docs/00-vision-general/REQUERIMIENTOS-FUNCIONALES.md)
- [Arquitectura Tecnica](./docs/00-vision-general/ARQUITECTURA-TECNICA.md)
- [Mapa de Documentacion](./docs/_MAP.md)
## Desarrollo
### Requisitos
- Node.js 18+
- PostgreSQL 15+
- Redis 7+
- Docker (opcional)
### Instalacion
```bash
# Clonar repositorio
git clone git@git.isem.site:isem/miinventario.git
# Instalar dependencias
npm install
# Levantar servicios con Docker
docker-compose up -d
# Iniciar desarrollo
npm run dev:backend
npm run dev:mobile
```
### Puertos de Desarrollo
| Servicio | Puerto |
|----------|--------|
| PostgreSQL | 5433 |
| Redis | 6380 |
| MinIO (S3) | 9002 |
| Backend API | 3150 |
| Mobile (Expo) | 8082 |
## Estado
| Campo | Valor |
|-------|-------|
| **Version** | 0.1.0 |
| **Estado** | En Planificacion |
| **Ultima Actualizacion** | 2026-01-10 |
| **SIMCO Version** | 4.0.0 |
## Modelo de Negocio
- **Modelo:** Pago por consumo (creditos/tokens)
- **Precio:** 2x costo IA
- **Paquetes:** $50, $100, $200, $500 MXN
- **Referidos:** 1 credito por activacion
## Roadmap
- **Fase 1:** MVP Core (Auth, Tiendas, Video, IA, Reportes)
- **Fase 2:** Retroalimentacion y Validacion
- **Fase 3:** Monetizacion (Wallet, Pagos)
- **Fase 4:** Crecimiento (Referidos, Admin)
---
Proyecto parte de workspace-v2 | SIMCO v4.0.0

50
apps/backend/.env.example Normal file
View File

@ -0,0 +1,50 @@
# Server
NODE_ENV=development
BACKEND_PORT=3142
# Database
DATABASE_HOST=localhost
DATABASE_PORT=5433
DATABASE_USER=miinventario
DATABASE_PASSWORD=miinventario_dev
DATABASE_NAME=miinventario
# Redis
REDIS_HOST=localhost
REDIS_PORT=6380
# JWT
JWT_SECRET=your-jwt-secret-change-in-production
JWT_REFRESH_SECRET=your-refresh-secret-change-in-production
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
# Storage (MinIO/S3)
S3_ENDPOINT=http://localhost:9002
S3_ACCESS_KEY=miinventario
S3_SECRET_KEY=miinventario_dev
S3_BUCKET_VIDEOS=miinventario-videos
S3_REGION=us-east-1
# Stripe
STRIPE_SECRET_KEY=sk_test_your_stripe_key
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
# Conekta (7-Eleven)
CONEKTA_API_KEY=key_your_conekta_key
# Firebase (FCM)
FIREBASE_PROJECT_ID=miinventario
FIREBASE_CLIENT_EMAIL=firebase-adminsdk@miinventario.iam.gserviceaccount.com
FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nYOUR_KEY_HERE\n-----END PRIVATE KEY-----"
# OpenAI
OPENAI_API_KEY=sk-your-openai-key
# Claude (Anthropic)
ANTHROPIC_API_KEY=sk-ant-your-anthropic-key
# SMS (Twilio)
TWILIO_ACCOUNT_SID=your_account_sid
TWILIO_AUTH_TOKEN=your_auth_token
TWILIO_PHONE_NUMBER=+1234567890

16
apps/backend/.env.test Normal file
View File

@ -0,0 +1,16 @@
# Test Database
DATABASE_HOST=localhost
DATABASE_PORT=5433
DATABASE_NAME=miinventario_test
DATABASE_USER=postgres
DATABASE_PASSWORD=postgres
# JWT
JWT_SECRET=test-secret
JWT_EXPIRES_IN=15m
JWT_REFRESH_SECRET=test-refresh-secret
JWT_REFRESH_EXPIRES_IN=7d
# App
NODE_ENV=development
PORT=3143

30
apps/backend/.eslintrc.js Normal file
View File

@ -0,0 +1,30 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: __dirname,
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
root: true,
env: {
node: true,
jest: true,
},
ignorePatterns: ['.eslintrc.js', 'dist', 'node_modules'],
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_' },
],
'no-console': ['warn', { allow: ['warn', 'error'] }],
},
};

10
apps/backend/.prettierrc Normal file
View File

@ -0,0 +1,10 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 80,
"tabWidth": 2,
"semi": true,
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "lf"
}

View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

105
apps/backend/package.json Normal file
View File

@ -0,0 +1,105 @@
{
"name": "@miinventario/backend",
"version": "0.1.0",
"description": "MiInventario Backend API",
"author": "MiInventario Team",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"typeorm": "typeorm-ts-node-commonjs",
"migration:generate": "npm run typeorm -- migration:generate -d src/config/typeorm.config.ts",
"migration:run": "npm run typeorm -- migration:run -d src/config/typeorm.config.ts",
"migration:revert": "npm run typeorm -- migration:revert -d src/config/typeorm.config.ts",
"migration:create": "npm run typeorm -- migration:create",
"schema:sync": "npm run typeorm -- schema:sync -d src/config/typeorm.config.ts",
"schema:drop": "npm run typeorm -- schema:drop -d src/config/typeorm.config.ts",
"seed": "ts-node src/database/seed.ts",
"db:setup": "npm run migration:run && npm run seed"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.71.2",
"@aws-sdk/client-s3": "^3.450.0",
"@aws-sdk/s3-request-presigner": "^3.450.0",
"@nestjs/bull": "^10.0.0",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.1.0",
"@nestjs/core": "^10.0.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.2",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/swagger": "^7.1.0",
"@nestjs/typeorm": "^10.0.0",
"@types/fluent-ffmpeg": "^2.1.28",
"bcrypt": "^5.1.1",
"bull": "^4.11.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"firebase-admin": "^11.11.0",
"fluent-ffmpeg": "^2.1.3",
"ioredis": "^5.3.0",
"openai": "^6.16.0",
"passport": "^0.6.0",
"passport-jwt": "^4.0.1",
"pg": "^8.11.0",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1",
"stripe": "^14.0.0",
"typeorm": "^0.3.17",
"uuid": "^9.0.0"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/bcrypt": "^5.0.0",
"@types/express": "^4.17.17",
"@types/jest": "^29.5.2",
"@types/node": "^20.3.1",
"@types/passport-jwt": "^3.0.10",
"@types/supertest": "^6.0.3",
"@types/uuid": "^9.0.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"eslint": "^8.42.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"jest": "^29.5.0",
"prettier": "^3.0.0",
"source-map-support": "^0.5.21",
"supertest": "^6.3.3",
"ts-jest": "^29.1.0",
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

View File

@ -0,0 +1,65 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BullModule } from '@nestjs/bull';
// Config
import { databaseConfig } from './config/database.config';
import { redisConfig } from './config/redis.config';
// Modules
import { AuthModule } from './modules/auth/auth.module';
import { UsersModule } from './modules/users/users.module';
import { StoresModule } from './modules/stores/stores.module';
import { VideosModule } from './modules/videos/videos.module';
import { InventoryModule } from './modules/inventory/inventory.module';
import { CreditsModule } from './modules/credits/credits.module';
import { PaymentsModule } from './modules/payments/payments.module';
import { ReferralsModule } from './modules/referrals/referrals.module';
import { NotificationsModule } from './modules/notifications/notifications.module';
import { IAProviderModule } from './modules/ia-provider/ia-provider.module';
import { HealthModule } from './modules/health/health.module';
import { FeedbackModule } from './modules/feedback/feedback.module';
import { ValidationsModule } from './modules/validations/validations.module';
import { AdminModule } from './modules/admin/admin.module';
@Module({
imports: [
// Configuration
ConfigModule.forRoot({
isGlobal: true,
envFilePath: ['.env.local', '.env'],
}),
// Database
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
useFactory: databaseConfig,
inject: [ConfigService],
}),
// Redis & Bull Queue
BullModule.forRootAsync({
imports: [ConfigModule],
useFactory: redisConfig,
inject: [ConfigService],
}),
// Feature Modules
HealthModule,
AuthModule,
UsersModule,
StoresModule,
VideosModule,
InventoryModule,
CreditsModule,
PaymentsModule,
ReferralsModule,
NotificationsModule,
IAProviderModule,
FeedbackModule,
ValidationsModule,
AdminModule,
],
})
export class AppModule {}

View File

@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { UserRole } from '../../modules/users/entities/user.entity';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);

View File

@ -0,0 +1,44 @@
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { UserRole } from '../../modules/users/entities/user.entity';
import { ROLES_KEY } from '../decorators/roles.decorator';
// Role hierarchy - higher number = more permissions
const ROLE_HIERARCHY: Record<UserRole, number> = {
[UserRole.USER]: 0,
[UserRole.VIEWER]: 1,
[UserRole.MODERATOR]: 2,
[UserRole.ADMIN]: 3,
[UserRole.SUPER_ADMIN]: 4,
};
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
const { user } = context.switchToHttp().getRequest();
if (!user || !user.role) {
throw new ForbiddenException('No tienes permisos para acceder a este recurso');
}
const userRoleLevel = ROLE_HIERARCHY[user.role as UserRole] ?? 0;
const minRequiredLevel = Math.min(...requiredRoles.map(role => ROLE_HIERARCHY[role]));
if (userRoleLevel < minRequiredLevel) {
throw new ForbiddenException('No tienes permisos suficientes para esta accion');
}
return true;
}
}

View File

@ -0,0 +1,12 @@
import { Request } from 'express';
export interface AuthenticatedUser {
id: string;
phone: string;
email?: string;
role: string;
}
export interface AuthenticatedRequest extends Request {
user: AuthenticatedUser;
}

View File

@ -0,0 +1,18 @@
import { ConfigService } from '@nestjs/config';
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
export const databaseConfig = (
configService: ConfigService,
): TypeOrmModuleOptions => ({
type: 'postgres',
host: configService.get('DB_HOST', 'localhost'),
port: configService.get('DB_PORT', 5433),
username: configService.get('DB_USER', 'miinventario'),
password: configService.get('DB_PASSWORD', 'miinventario_pass'),
database: configService.get('DB_NAME', 'miinventario_db'),
entities: [__dirname + '/../**/*.entity{.ts,.js}'],
migrations: [__dirname + '/../migrations/*{.ts,.js}'],
synchronize: configService.get('NODE_ENV') === 'development',
logging: configService.get('NODE_ENV') === 'development',
ssl: configService.get('DB_SSL') === 'true' ? { rejectUnauthorized: false } : false,
});

View File

@ -0,0 +1,21 @@
import { ConfigService } from '@nestjs/config';
import { BullModuleOptions } from '@nestjs/bull';
export const redisConfig = (
configService: ConfigService,
): BullModuleOptions => ({
redis: {
host: configService.get('REDIS_HOST', 'localhost'),
port: configService.get('REDIS_PORT', 6380),
password: configService.get('REDIS_PASSWORD'),
},
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 5000,
},
removeOnComplete: 100,
removeOnFail: 50,
},
});

View File

@ -0,0 +1,20 @@
import { DataSource, DataSourceOptions } from 'typeorm';
import { config } from 'dotenv';
config();
export const dataSourceOptions: DataSourceOptions = {
type: 'postgres',
host: process.env.DATABASE_HOST || 'localhost',
port: parseInt(process.env.DATABASE_PORT || '5433', 10),
username: process.env.DATABASE_USER || 'postgres',
password: process.env.DATABASE_PASSWORD || 'postgres',
database: process.env.DATABASE_NAME || 'miinventario_dev',
entities: ['dist/**/*.entity.js'],
migrations: ['dist/migrations/*.js'],
synchronize: false,
logging: process.env.NODE_ENV === 'development',
};
const dataSource = new DataSource(dataSourceOptions);
export default dataSource;

View File

@ -0,0 +1,75 @@
import { DataSource } from 'typeorm';
import { dataSourceOptions } from '../config/typeorm.config';
async function seed() {
const dataSource = new DataSource(dataSourceOptions);
try {
await dataSource.initialize();
console.log('Database connected for seeding');
// Seed Credit Packages
console.log('Seeding credit packages...');
await dataSource.query(`
INSERT INTO credit_packages (id, name, description, credits, "priceMXN", "isPopular", "isActive", "sortOrder")
VALUES
(uuid_generate_v4(), 'Starter', 'Perfecto para probar', 10, 49.00, false, true, 1),
(uuid_generate_v4(), 'Basico', 'Para uso regular', 30, 129.00, false, true, 2),
(uuid_generate_v4(), 'Popular', 'El mas vendido', 75, 299.00, true, true, 3),
(uuid_generate_v4(), 'Pro', 'Para negocios activos', 150, 549.00, false, true, 4),
(uuid_generate_v4(), 'Enterprise', 'Uso intensivo', 300, 999.00, false, true, 5)
ON CONFLICT DO NOTHING;
`);
console.log('Credit packages seeded');
// Create test user (development only)
if (process.env.NODE_ENV === 'development') {
console.log('Creating test user...');
// Check if test user exists
const existingUser = await dataSource.query(`
SELECT id FROM users WHERE phone = '+521234567890'
`);
if (existingUser.length === 0) {
const userResult = await dataSource.query(`
INSERT INTO users (id, phone, name, "businessName", role, "isActive")
VALUES (uuid_generate_v4(), '+521234567890', 'Usuario Demo', 'Tiendita Demo', 'USER', true)
RETURNING id;
`);
const userId = userResult[0].id;
// Create test store
await dataSource.query(`
INSERT INTO stores (id, "ownerId", name, giro, address, "isActive")
VALUES (uuid_generate_v4(), $1, 'Mi Tiendita Demo', 'Abarrotes', 'Av. Principal 123, Col. Centro', true);
`, [userId]);
// Create credit balance with bonus credits
await dataSource.query(`
INSERT INTO credit_balances (id, "userId", balance, "totalPurchased", "totalConsumed", "totalFromReferrals")
VALUES (uuid_generate_v4(), $1, 50, 0, 0, 50);
`, [userId]);
// Create welcome notification
await dataSource.query(`
INSERT INTO notifications (id, "userId", type, title, body, "isRead")
VALUES (uuid_generate_v4(), $1, 'SYSTEM', 'Bienvenido a MiInventario', 'Tu cuenta ha sido creada. Tienes 50 creditos de bienvenida para comenzar.', false);
`, [userId]);
console.log('Test user created with ID:', userId);
} else {
console.log('Test user already exists');
}
}
console.log('Seeding completed successfully!');
} catch (error) {
console.error('Error seeding database:', error);
process.exit(1);
} finally {
await dataSource.destroy();
}
}
seed();

68
apps/backend/src/main.ts Normal file
View File

@ -0,0 +1,68 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
// Enable raw body for Stripe webhook signature verification
rawBody: true,
});
// Global prefix
app.setGlobalPrefix('api/v1');
// Validation pipe
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: {
enableImplicitConversion: true,
},
}),
);
// CORS
app.enableCors({
origin: process.env.CORS_ORIGINS?.split(',') || ['http://localhost:8082'],
credentials: true,
});
// Swagger documentation
if (process.env.NODE_ENV !== 'production') {
const config = new DocumentBuilder()
.setTitle('MiInventario API')
.setDescription('API para gestion de inventario automatico por video')
.setVersion('1.0')
.addBearerAuth()
.addTag('auth', 'Autenticacion y registro')
.addTag('users', 'Gestion de usuarios')
.addTag('stores', 'Gestion de tiendas')
.addTag('inventory', 'Sesiones de inventario')
.addTag('videos', 'Upload y procesamiento de videos')
.addTag('credits', 'Wallet y creditos')
.addTag('payments', 'Pagos y paquetes')
.addTag('referrals', 'Sistema de referidos')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
}
const port = process.env.BACKEND_PORT || 3142;
await app.listen(port);
console.log(`
MIINVENTARIO API
Server running on: http://localhost:${port} ║
API Docs: http://localhost:${port}/api/docs ║
Environment: ${process.env.NODE_ENV || 'development'}
`);
}
bootstrap();

View File

@ -0,0 +1,201 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class CreateFeedbackTables1736502000000 implements MigrationInterface {
name = 'CreateFeedbackTables1736502000000'
public async up(queryRunner: QueryRunner): Promise<void> {
// ENUMs
await queryRunner.query(`CREATE TYPE "public"."corrections_type_enum" AS ENUM('QUANTITY', 'SKU', 'CONFIRMATION')`);
await queryRunner.query(`CREATE TYPE "public"."ground_truth_status_enum" AS ENUM('PENDING', 'APPROVED', 'REJECTED')`);
await queryRunner.query(`CREATE TYPE "public"."product_submissions_status_enum" AS ENUM('PENDING', 'APPROVED', 'REJECTED')`);
await queryRunner.query(`CREATE TYPE "public"."validation_requests_status_enum" AS ENUM('PENDING', 'COMPLETED', 'SKIPPED', 'EXPIRED')`);
// Corrections table - Historial de correcciones de usuario
await queryRunner.query(`CREATE TABLE "corrections" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"inventoryItemId" uuid NOT NULL,
"userId" uuid NOT NULL,
"storeId" uuid NOT NULL,
"type" "public"."corrections_type_enum" NOT NULL,
"previousValue" jsonb NOT NULL,
"newValue" jsonb NOT NULL,
"reason" character varying(255),
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_corrections" PRIMARY KEY ("id")
)`);
await queryRunner.query(`CREATE INDEX "IDX_corrections_item" ON "corrections" ("inventoryItemId")`);
await queryRunner.query(`CREATE INDEX "IDX_corrections_user" ON "corrections" ("userId")`);
await queryRunner.query(`CREATE INDEX "IDX_corrections_store" ON "corrections" ("storeId")`);
await queryRunner.query(`CREATE INDEX "IDX_corrections_type" ON "corrections" ("type", "createdAt")`);
// Ground Truth table - Datos validados para entrenamiento
await queryRunner.query(`CREATE TABLE "ground_truth" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"inventoryItemId" uuid NOT NULL,
"videoId" uuid NOT NULL,
"storeId" uuid NOT NULL,
"originalDetection" jsonb NOT NULL,
"correctedData" jsonb NOT NULL,
"frameTimestamp" integer,
"boundingBox" jsonb,
"status" "public"."ground_truth_status_enum" NOT NULL DEFAULT 'PENDING',
"validatedBy" uuid,
"validationScore" numeric(5,2),
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_ground_truth" PRIMARY KEY ("id")
)`);
await queryRunner.query(`CREATE INDEX "IDX_ground_truth_item" ON "ground_truth" ("inventoryItemId")`);
await queryRunner.query(`CREATE INDEX "IDX_ground_truth_video" ON "ground_truth" ("videoId")`);
await queryRunner.query(`CREATE INDEX "IDX_ground_truth_status" ON "ground_truth" ("status")`);
await queryRunner.query(`CREATE INDEX "IDX_ground_truth_store" ON "ground_truth" ("storeId")`);
// Product Submissions table - Nuevos productos etiquetados por usuarios
await queryRunner.query(`CREATE TABLE "product_submissions" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"userId" uuid NOT NULL,
"storeId" uuid NOT NULL,
"videoId" uuid,
"name" character varying(255) NOT NULL,
"category" character varying(100),
"barcode" character varying(50),
"imageUrl" character varying(500),
"frameTimestamp" integer,
"boundingBox" jsonb,
"status" "public"."product_submissions_status_enum" NOT NULL DEFAULT 'PENDING',
"reviewedBy" uuid,
"reviewNotes" text,
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_product_submissions" PRIMARY KEY ("id")
)`);
await queryRunner.query(`CREATE INDEX "IDX_product_submissions_user" ON "product_submissions" ("userId")`);
await queryRunner.query(`CREATE INDEX "IDX_product_submissions_store" ON "product_submissions" ("storeId")`);
await queryRunner.query(`CREATE INDEX "IDX_product_submissions_status" ON "product_submissions" ("status")`);
await queryRunner.query(`CREATE INDEX "IDX_product_submissions_barcode" ON "product_submissions" ("barcode")`);
// Validation Requests table - Solicitudes de micro-auditoria
await queryRunner.query(`CREATE TABLE "validation_requests" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"videoId" uuid NOT NULL,
"userId" uuid NOT NULL,
"storeId" uuid NOT NULL,
"totalItems" integer NOT NULL DEFAULT '0',
"itemsValidated" integer NOT NULL DEFAULT '0',
"triggerReason" character varying(100) NOT NULL,
"probabilityScore" numeric(5,2) NOT NULL,
"status" "public"."validation_requests_status_enum" NOT NULL DEFAULT 'PENDING',
"expiresAt" TIMESTAMP NOT NULL,
"completedAt" TIMESTAMP,
"creditsRewarded" integer NOT NULL DEFAULT '0',
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_validation_requests" PRIMARY KEY ("id")
)`);
await queryRunner.query(`CREATE INDEX "IDX_validation_requests_video" ON "validation_requests" ("videoId")`);
await queryRunner.query(`CREATE INDEX "IDX_validation_requests_user" ON "validation_requests" ("userId")`);
await queryRunner.query(`CREATE INDEX "IDX_validation_requests_store" ON "validation_requests" ("storeId")`);
await queryRunner.query(`CREATE INDEX "IDX_validation_requests_status" ON "validation_requests" ("status", "expiresAt")`);
// Validation Responses table - Respuestas de validacion por item
await queryRunner.query(`CREATE TABLE "validation_responses" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"requestId" uuid NOT NULL,
"inventoryItemId" uuid NOT NULL,
"userId" uuid NOT NULL,
"isCorrect" boolean,
"correctedQuantity" integer,
"correctedName" character varying(255),
"responseTimeMs" integer,
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_validation_responses" PRIMARY KEY ("id")
)`);
await queryRunner.query(`CREATE INDEX "IDX_validation_responses_request" ON "validation_responses" ("requestId")`);
await queryRunner.query(`CREATE INDEX "IDX_validation_responses_item" ON "validation_responses" ("inventoryItemId")`);
await queryRunner.query(`CREATE INDEX "IDX_validation_responses_user" ON "validation_responses" ("userId")`);
// Foreign Keys
await queryRunner.query(`ALTER TABLE "corrections" ADD CONSTRAINT "FK_corrections_item" FOREIGN KEY ("inventoryItemId") REFERENCES "inventory_items"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "corrections" ADD CONSTRAINT "FK_corrections_user" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "corrections" ADD CONSTRAINT "FK_corrections_store" FOREIGN KEY ("storeId") REFERENCES "stores"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "ground_truth" ADD CONSTRAINT "FK_ground_truth_item" FOREIGN KEY ("inventoryItemId") REFERENCES "inventory_items"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "ground_truth" ADD CONSTRAINT "FK_ground_truth_video" FOREIGN KEY ("videoId") REFERENCES "videos"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "ground_truth" ADD CONSTRAINT "FK_ground_truth_store" FOREIGN KEY ("storeId") REFERENCES "stores"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "ground_truth" ADD CONSTRAINT "FK_ground_truth_validator" FOREIGN KEY ("validatedBy") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "product_submissions" ADD CONSTRAINT "FK_product_submissions_user" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "product_submissions" ADD CONSTRAINT "FK_product_submissions_store" FOREIGN KEY ("storeId") REFERENCES "stores"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "product_submissions" ADD CONSTRAINT "FK_product_submissions_video" FOREIGN KEY ("videoId") REFERENCES "videos"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "product_submissions" ADD CONSTRAINT "FK_product_submissions_reviewer" FOREIGN KEY ("reviewedBy") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "validation_requests" ADD CONSTRAINT "FK_validation_requests_video" FOREIGN KEY ("videoId") REFERENCES "videos"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "validation_requests" ADD CONSTRAINT "FK_validation_requests_user" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "validation_requests" ADD CONSTRAINT "FK_validation_requests_store" FOREIGN KEY ("storeId") REFERENCES "stores"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "validation_responses" ADD CONSTRAINT "FK_validation_responses_request" FOREIGN KEY ("requestId") REFERENCES "validation_requests"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "validation_responses" ADD CONSTRAINT "FK_validation_responses_item" FOREIGN KEY ("inventoryItemId") REFERENCES "inventory_items"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "validation_responses" ADD CONSTRAINT "FK_validation_responses_user" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Drop Foreign Keys
await queryRunner.query(`ALTER TABLE "validation_responses" DROP CONSTRAINT "FK_validation_responses_user"`);
await queryRunner.query(`ALTER TABLE "validation_responses" DROP CONSTRAINT "FK_validation_responses_item"`);
await queryRunner.query(`ALTER TABLE "validation_responses" DROP CONSTRAINT "FK_validation_responses_request"`);
await queryRunner.query(`ALTER TABLE "validation_requests" DROP CONSTRAINT "FK_validation_requests_store"`);
await queryRunner.query(`ALTER TABLE "validation_requests" DROP CONSTRAINT "FK_validation_requests_user"`);
await queryRunner.query(`ALTER TABLE "validation_requests" DROP CONSTRAINT "FK_validation_requests_video"`);
await queryRunner.query(`ALTER TABLE "product_submissions" DROP CONSTRAINT "FK_product_submissions_reviewer"`);
await queryRunner.query(`ALTER TABLE "product_submissions" DROP CONSTRAINT "FK_product_submissions_video"`);
await queryRunner.query(`ALTER TABLE "product_submissions" DROP CONSTRAINT "FK_product_submissions_store"`);
await queryRunner.query(`ALTER TABLE "product_submissions" DROP CONSTRAINT "FK_product_submissions_user"`);
await queryRunner.query(`ALTER TABLE "ground_truth" DROP CONSTRAINT "FK_ground_truth_validator"`);
await queryRunner.query(`ALTER TABLE "ground_truth" DROP CONSTRAINT "FK_ground_truth_store"`);
await queryRunner.query(`ALTER TABLE "ground_truth" DROP CONSTRAINT "FK_ground_truth_video"`);
await queryRunner.query(`ALTER TABLE "ground_truth" DROP CONSTRAINT "FK_ground_truth_item"`);
await queryRunner.query(`ALTER TABLE "corrections" DROP CONSTRAINT "FK_corrections_store"`);
await queryRunner.query(`ALTER TABLE "corrections" DROP CONSTRAINT "FK_corrections_user"`);
await queryRunner.query(`ALTER TABLE "corrections" DROP CONSTRAINT "FK_corrections_item"`);
// Drop Tables
await queryRunner.query(`DROP INDEX "public"."IDX_validation_responses_user"`);
await queryRunner.query(`DROP INDEX "public"."IDX_validation_responses_item"`);
await queryRunner.query(`DROP INDEX "public"."IDX_validation_responses_request"`);
await queryRunner.query(`DROP TABLE "validation_responses"`);
await queryRunner.query(`DROP INDEX "public"."IDX_validation_requests_status"`);
await queryRunner.query(`DROP INDEX "public"."IDX_validation_requests_store"`);
await queryRunner.query(`DROP INDEX "public"."IDX_validation_requests_user"`);
await queryRunner.query(`DROP INDEX "public"."IDX_validation_requests_video"`);
await queryRunner.query(`DROP TABLE "validation_requests"`);
await queryRunner.query(`DROP INDEX "public"."IDX_product_submissions_barcode"`);
await queryRunner.query(`DROP INDEX "public"."IDX_product_submissions_status"`);
await queryRunner.query(`DROP INDEX "public"."IDX_product_submissions_store"`);
await queryRunner.query(`DROP INDEX "public"."IDX_product_submissions_user"`);
await queryRunner.query(`DROP TABLE "product_submissions"`);
await queryRunner.query(`DROP INDEX "public"."IDX_ground_truth_store"`);
await queryRunner.query(`DROP INDEX "public"."IDX_ground_truth_status"`);
await queryRunner.query(`DROP INDEX "public"."IDX_ground_truth_video"`);
await queryRunner.query(`DROP INDEX "public"."IDX_ground_truth_item"`);
await queryRunner.query(`DROP TABLE "ground_truth"`);
await queryRunner.query(`DROP INDEX "public"."IDX_corrections_type"`);
await queryRunner.query(`DROP INDEX "public"."IDX_corrections_store"`);
await queryRunner.query(`DROP INDEX "public"."IDX_corrections_user"`);
await queryRunner.query(`DROP INDEX "public"."IDX_corrections_item"`);
await queryRunner.query(`DROP TABLE "corrections"`);
// Drop ENUMs
await queryRunner.query(`DROP TYPE "public"."validation_requests_status_enum"`);
await queryRunner.query(`DROP TYPE "public"."product_submissions_status_enum"`);
await queryRunner.query(`DROP TYPE "public"."ground_truth_status_enum"`);
await queryRunner.query(`DROP TYPE "public"."corrections_type_enum"`);
}
}

View File

@ -0,0 +1,136 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class CreateAdminTables1736600000000 implements MigrationInterface {
name = 'CreateAdminTables1736600000000'
public async up(queryRunner: QueryRunner): Promise<void> {
// Extend users role enum with new roles
await queryRunner.query(`ALTER TYPE "public"."users_role_enum" ADD VALUE IF NOT EXISTS 'SUPER_ADMIN'`);
await queryRunner.query(`ALTER TYPE "public"."users_role_enum" ADD VALUE IF NOT EXISTS 'MODERATOR'`);
await queryRunner.query(`ALTER TYPE "public"."users_role_enum" ADD VALUE IF NOT EXISTS 'VIEWER'`);
// Create promotion type enum
await queryRunner.query(`CREATE TYPE "public"."promotions_type_enum" AS ENUM('PERCENTAGE', 'FIXED_CREDITS', 'MULTIPLIER')`);
// Create IA Providers table
await queryRunner.query(`CREATE TABLE "ia_providers" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"name" character varying(100) NOT NULL,
"code" character varying(50) NOT NULL,
"description" text,
"costPerFrame" numeric(10,6) NOT NULL DEFAULT '0',
"costPerToken" numeric(10,8) NOT NULL DEFAULT '0',
"isActive" boolean NOT NULL DEFAULT true,
"isDefault" boolean NOT NULL DEFAULT false,
"config" jsonb,
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "UQ_ia_providers_code" UNIQUE ("code"),
CONSTRAINT "PK_ia_providers" PRIMARY KEY ("id")
)`);
await queryRunner.query(`CREATE INDEX "IDX_ia_providers_code" ON "ia_providers" ("code")`);
await queryRunner.query(`CREATE INDEX "IDX_ia_providers_active" ON "ia_providers" ("isActive")`);
// Create Promotions table
await queryRunner.query(`CREATE TABLE "promotions" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"name" character varying(100) NOT NULL,
"code" character varying(50) NOT NULL,
"description" text,
"type" "public"."promotions_type_enum" NOT NULL,
"value" numeric(10,2) NOT NULL,
"minPurchaseAmount" numeric(10,2),
"maxDiscount" numeric(10,2),
"usageLimit" integer,
"usageCount" integer NOT NULL DEFAULT '0',
"perUserLimit" integer DEFAULT '1',
"startsAt" TIMESTAMP NOT NULL,
"endsAt" TIMESTAMP NOT NULL,
"isActive" boolean NOT NULL DEFAULT true,
"applicablePackageIds" uuid[],
"createdBy" uuid,
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "UQ_promotions_code" UNIQUE ("code"),
CONSTRAINT "PK_promotions" PRIMARY KEY ("id")
)`);
await queryRunner.query(`CREATE INDEX "IDX_promotions_code" ON "promotions" ("code")`);
await queryRunner.query(`CREATE INDEX "IDX_promotions_active_dates" ON "promotions" ("isActive", "startsAt", "endsAt")`);
// Create Audit Logs table
await queryRunner.query(`CREATE TABLE "audit_logs" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"userId" uuid NOT NULL,
"action" character varying(100) NOT NULL,
"resource" character varying(100) NOT NULL,
"resourceId" uuid,
"previousValue" jsonb,
"newValue" jsonb,
"ipAddress" character varying(45),
"userAgent" text,
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_audit_logs" PRIMARY KEY ("id")
)`);
await queryRunner.query(`CREATE INDEX "IDX_audit_logs_user" ON "audit_logs" ("userId")`);
await queryRunner.query(`CREATE INDEX "IDX_audit_logs_action" ON "audit_logs" ("action")`);
await queryRunner.query(`CREATE INDEX "IDX_audit_logs_resource" ON "audit_logs" ("resource", "resourceId")`);
await queryRunner.query(`CREATE INDEX "IDX_audit_logs_created" ON "audit_logs" ("createdAt")`);
// Add fraud detection columns to referrals
await queryRunner.query(`ALTER TABLE "referrals" ADD "fraudHold" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`ALTER TABLE "referrals" ADD "fraudReason" character varying(255)`);
await queryRunner.query(`ALTER TABLE "referrals" ADD "reviewedBy" uuid`);
await queryRunner.query(`ALTER TABLE "referrals" ADD "reviewedAt" TIMESTAMP`);
await queryRunner.query(`CREATE INDEX "IDX_referrals_fraud" ON "referrals" ("fraudHold")`);
// Foreign Keys
await queryRunner.query(`ALTER TABLE "promotions" ADD CONSTRAINT "FK_promotions_creator" FOREIGN KEY ("createdBy") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "audit_logs" ADD CONSTRAINT "FK_audit_logs_user" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "referrals" ADD CONSTRAINT "FK_referrals_reviewer" FOREIGN KEY ("reviewedBy") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`);
// Seed initial IA providers
await queryRunner.query(`
INSERT INTO "ia_providers" ("name", "code", "description", "costPerFrame", "costPerToken", "isActive", "isDefault")
VALUES
('OpenAI GPT-4 Vision', 'openai-gpt4v', 'OpenAI GPT-4 con capacidades de vision', 0.01, 0.00003, true, true),
('Claude 3 Sonnet', 'claude-sonnet', 'Anthropic Claude 3 Sonnet para analisis de imagenes', 0.003, 0.000015, true, false),
('Claude 3 Haiku', 'claude-haiku', 'Anthropic Claude 3 Haiku - rapido y economico', 0.00025, 0.00000125, true, false)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Drop Foreign Keys
await queryRunner.query(`ALTER TABLE "referrals" DROP CONSTRAINT IF EXISTS "FK_referrals_reviewer"`);
await queryRunner.query(`ALTER TABLE "audit_logs" DROP CONSTRAINT "FK_audit_logs_user"`);
await queryRunner.query(`ALTER TABLE "promotions" DROP CONSTRAINT "FK_promotions_creator"`);
// Remove fraud columns from referrals
await queryRunner.query(`DROP INDEX "public"."IDX_referrals_fraud"`);
await queryRunner.query(`ALTER TABLE "referrals" DROP COLUMN "reviewedAt"`);
await queryRunner.query(`ALTER TABLE "referrals" DROP COLUMN "reviewedBy"`);
await queryRunner.query(`ALTER TABLE "referrals" DROP COLUMN "fraudReason"`);
await queryRunner.query(`ALTER TABLE "referrals" DROP COLUMN "fraudHold"`);
// Drop Audit Logs table
await queryRunner.query(`DROP INDEX "public"."IDX_audit_logs_created"`);
await queryRunner.query(`DROP INDEX "public"."IDX_audit_logs_resource"`);
await queryRunner.query(`DROP INDEX "public"."IDX_audit_logs_action"`);
await queryRunner.query(`DROP INDEX "public"."IDX_audit_logs_user"`);
await queryRunner.query(`DROP TABLE "audit_logs"`);
// Drop Promotions table
await queryRunner.query(`DROP INDEX "public"."IDX_promotions_active_dates"`);
await queryRunner.query(`DROP INDEX "public"."IDX_promotions_code"`);
await queryRunner.query(`DROP TABLE "promotions"`);
// Drop IA Providers table
await queryRunner.query(`DROP INDEX "public"."IDX_ia_providers_active"`);
await queryRunner.query(`DROP INDEX "public"."IDX_ia_providers_code"`);
await queryRunner.query(`DROP TABLE "ia_providers"`);
// Drop ENUMs
await queryRunner.query(`DROP TYPE "public"."promotions_type_enum"`);
// Note: Cannot remove enum values in PostgreSQL, they remain but are unused
}
}

View File

@ -0,0 +1,122 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class Init1768099560565 implements MigrationInterface {
name = 'Init1768099560565'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TYPE "public"."users_role_enum" AS ENUM('USER', 'ADMIN')`);
await queryRunner.query(`CREATE TABLE "users" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "phone" character varying(20), "email" character varying(255), "passwordHash" character varying(255), "name" character varying(100), "businessName" character varying(100), "location" character varying(255), "giro" character varying(50), "role" "public"."users_role_enum" NOT NULL DEFAULT 'USER', "isActive" boolean NOT NULL DEFAULT true, "fcmToken" character varying(255), "stripeCustomerId" character varying(100), "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_a000cca60bcf04454e727699490" UNIQUE ("phone"), CONSTRAINT "UQ_97672ac88f789774dd47f7c8be3" UNIQUE ("email"), CONSTRAINT "PK_a3ffb1c0c8416b9fc6f907b7433" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE TABLE "stores" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "ownerId" uuid NOT NULL, "name" character varying(100) NOT NULL, "giro" character varying(50), "address" character varying(255), "phone" character varying(20), "logoUrl" character varying(500), "settings" jsonb NOT NULL DEFAULT '{}', "isActive" boolean NOT NULL DEFAULT true, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_7aa6e7d71fa7acdd7ca43d7c9cb" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE TYPE "public"."videos_status_enum" AS ENUM('PENDING', 'UPLOADING', 'UPLOADED', 'PROCESSING', 'COMPLETED', 'FAILED')`);
await queryRunner.query(`CREATE TABLE "videos" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "storeId" uuid NOT NULL, "uploadedById" uuid NOT NULL, "fileName" character varying(255) NOT NULL, "s3Key" character varying(500), "fileSize" bigint, "durationSeconds" integer, "status" "public"."videos_status_enum" NOT NULL DEFAULT 'PENDING', "processingProgress" integer NOT NULL DEFAULT '0', "itemsDetected" integer NOT NULL DEFAULT '0', "creditsUsed" integer NOT NULL DEFAULT '0', "errorMessage" text, "metadata" jsonb, "processedAt" TIMESTAMP, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_e4c86c0cf95aff16e9fb8220f6b" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE TYPE "public"."store_users_role_enum" AS ENUM('OWNER', 'OPERATOR')`);
await queryRunner.query(`CREATE TABLE "store_users" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "store_id" uuid NOT NULL, "user_id" uuid NOT NULL, "role" "public"."store_users_role_enum" NOT NULL, "isActive" boolean NOT NULL DEFAULT true, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_6af90d774177332a7a99a7c1c9d" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE TYPE "public"."referrals_status_enum" AS ENUM('PENDING', 'REGISTERED', 'QUALIFIED', 'REWARDED')`);
await queryRunner.query(`CREATE TABLE "referrals" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "referrerId" uuid NOT NULL, "referredId" uuid, "referralCode" character varying(20) NOT NULL, "status" "public"."referrals_status_enum" NOT NULL DEFAULT 'PENDING', "referrerBonusCredits" integer NOT NULL DEFAULT '0', "referredBonusCredits" integer NOT NULL DEFAULT '0', "registeredAt" TIMESTAMP, "qualifiedAt" TIMESTAMP, "rewardedAt" TIMESTAMP, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_13a1bad9eef9e5cfa4b61765261" UNIQUE ("referralCode"), CONSTRAINT "PK_ea9980e34f738b6252817326c08" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE INDEX "IDX_13a1bad9eef9e5cfa4b6176526" ON "referrals" ("referralCode") `);
await queryRunner.query(`CREATE INDEX "IDX_ad6772c3fcb57375f43114b5cb" ON "referrals" ("referredId") `);
await queryRunner.query(`CREATE INDEX "IDX_59de462f9ce130da142e3b5a9f" ON "referrals" ("referrerId") `);
await queryRunner.query(`CREATE TABLE "credit_packages" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying(100) NOT NULL, "description" character varying(255), "credits" integer NOT NULL, "priceMXN" numeric(10,2) NOT NULL, "isPopular" boolean NOT NULL DEFAULT false, "isActive" boolean NOT NULL DEFAULT true, "sortOrder" integer NOT NULL DEFAULT '0', "stripePriceId" character varying(100), "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_c10750b5b0638b06330b0c09bfd" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE TYPE "public"."payments_method_enum" AS ENUM('CARD', 'OXXO', '7ELEVEN')`);
await queryRunner.query(`CREATE TYPE "public"."payments_status_enum" AS ENUM('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', 'REFUNDED', 'EXPIRED')`);
await queryRunner.query(`CREATE TABLE "payments" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "userId" uuid NOT NULL, "packageId" uuid, "amountMXN" numeric(10,2) NOT NULL, "creditsGranted" integer NOT NULL, "method" "public"."payments_method_enum" NOT NULL, "status" "public"."payments_status_enum" NOT NULL DEFAULT 'PENDING', "externalId" character varying(255), "provider" character varying(50), "voucherUrl" character varying(500), "voucherCode" character varying(100), "expiresAt" TIMESTAMP, "completedAt" TIMESTAMP, "errorMessage" text, "metadata" jsonb, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_197ab7af18c93fbb0c9b28b4a59" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE INDEX "IDX_c9ca05ceb7cc5eae113c2eb57c" ON "payments" ("externalId") `);
await queryRunner.query(`CREATE INDEX "IDX_3cc6f3e2f26955eaa64fd7f9ea" ON "payments" ("status", "createdAt") `);
await queryRunner.query(`CREATE INDEX "IDX_1c9b5fb7b9f38cccddd7c5761b" ON "payments" ("userId", "createdAt") `);
await queryRunner.query(`CREATE TYPE "public"."notifications_type_enum" AS ENUM('VIDEO_PROCESSING_COMPLETE', 'VIDEO_PROCESSING_FAILED', 'LOW_CREDITS', 'PAYMENT_COMPLETE', 'PAYMENT_FAILED', 'REFERRAL_BONUS', 'PROMO', 'SYSTEM')`);
await queryRunner.query(`CREATE TABLE "notifications" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "userId" uuid NOT NULL, "type" "public"."notifications_type_enum" NOT NULL, "title" character varying(255) NOT NULL, "body" text, "isRead" boolean NOT NULL DEFAULT false, "isPushSent" boolean NOT NULL DEFAULT false, "data" jsonb, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_6a72c3c0f683f6462415e653c3a" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE INDEX "IDX_5340fc241f57310d243e5ab20b" ON "notifications" ("userId", "isRead") `);
await queryRunner.query(`CREATE INDEX "IDX_21e65af2f4f242d4c85a92aff4" ON "notifications" ("userId", "createdAt") `);
await queryRunner.query(`CREATE TABLE "inventory_items" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "storeId" uuid NOT NULL, "detectedByVideoId" uuid, "name" character varying(255) NOT NULL, "category" character varying(100), "subcategory" character varying(100), "barcode" character varying(50), "quantity" integer NOT NULL DEFAULT '0', "minStock" integer, "price" numeric(10,2), "cost" numeric(10,2), "imageUrl" character varying(500), "detectionConfidence" numeric(5,2), "isManuallyEdited" boolean NOT NULL DEFAULT false, "metadata" jsonb, "lastCountedAt" TIMESTAMP, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_cf2f451407242e132547ac19169" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE INDEX "IDX_e6661f3647402a7434bb2135df" ON "inventory_items" ("storeId", "barcode") `);
await queryRunner.query(`CREATE INDEX "IDX_81d46f4b3749d1db8628a33561" ON "inventory_items" ("storeId", "category") `);
await queryRunner.query(`CREATE INDEX "IDX_9ef0ca05424108394eaaa8f511" ON "inventory_items" ("storeId", "name") `);
await queryRunner.query(`CREATE TYPE "public"."credit_transactions_type_enum" AS ENUM('PURCHASE', 'CONSUMPTION', 'REFERRAL_BONUS', 'PROMO', 'REFUND')`);
await queryRunner.query(`CREATE TABLE "credit_transactions" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "userId" uuid NOT NULL, "type" "public"."credit_transactions_type_enum" NOT NULL, "amount" integer NOT NULL, "balanceAfter" integer NOT NULL, "description" character varying(255), "referenceId" uuid, "referenceType" character varying(50), "metadata" jsonb, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_a408319811d1ab32832ec86fc2c" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE INDEX "IDX_413894b75b111744ab18f39b1e" ON "credit_transactions" ("type", "createdAt") `);
await queryRunner.query(`CREATE INDEX "IDX_f31233f25cf2015095fca7f6b6" ON "credit_transactions" ("userId", "createdAt") `);
await queryRunner.query(`CREATE TABLE "credit_balances" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "userId" uuid NOT NULL, "balance" integer NOT NULL DEFAULT '0', "totalPurchased" integer NOT NULL DEFAULT '0', "totalConsumed" integer NOT NULL DEFAULT '0', "totalFromReferrals" integer NOT NULL DEFAULT '0', "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_7f8103cfe175c66f1e5e8acfe23" UNIQUE ("userId"), CONSTRAINT "REL_7f8103cfe175c66f1e5e8acfe2" UNIQUE ("userId"), CONSTRAINT "PK_b9f1be6c9f3f23c5716fa7d8545" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE TABLE "refresh_tokens" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "userId" uuid NOT NULL, "token" character varying(500) NOT NULL, "deviceInfo" character varying(100), "ipAddress" character varying(50), "isRevoked" boolean NOT NULL DEFAULT false, "expiresAt" TIMESTAMP NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_7d8bee0204106019488c4c50ffa" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE INDEX "IDX_56b91d98f71e3d1b649ed6e9f3" ON "refresh_tokens" ("expiresAt") `);
await queryRunner.query(`CREATE INDEX "IDX_4542dd2f38a61354a040ba9fd5" ON "refresh_tokens" ("token") `);
await queryRunner.query(`CREATE INDEX "IDX_610102b60fea1455310ccd299d" ON "refresh_tokens" ("userId") `);
await queryRunner.query(`CREATE TYPE "public"."otps_purpose_enum" AS ENUM('REGISTRATION', 'LOGIN', 'PASSWORD_RESET')`);
await queryRunner.query(`CREATE TABLE "otps" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "phone" character varying(20) NOT NULL, "code" character varying(6) NOT NULL, "purpose" "public"."otps_purpose_enum" NOT NULL, "attempts" integer NOT NULL DEFAULT '0', "isUsed" boolean NOT NULL DEFAULT false, "expiresAt" TIMESTAMP NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_91fef5ed60605b854a2115d2410" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE INDEX "IDX_2afebf0234962331e12c59c592" ON "otps" ("expiresAt") `);
await queryRunner.query(`CREATE INDEX "IDX_ee9e52dc460b0ae28fd9f276ce" ON "otps" ("phone", "purpose") `);
await queryRunner.query(`ALTER TABLE "stores" ADD CONSTRAINT "FK_a447ba082271c05997a61df26df" FOREIGN KEY ("ownerId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "videos" ADD CONSTRAINT "FK_4177a2c071cf429219b7eb31a43" FOREIGN KEY ("storeId") REFERENCES "stores"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "videos" ADD CONSTRAINT "FK_159f8e5c7959016a0863ec419a3" FOREIGN KEY ("uploadedById") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "store_users" ADD CONSTRAINT "FK_3077a42ec6ad94cfb93f919359d" FOREIGN KEY ("store_id") REFERENCES "stores"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "store_users" ADD CONSTRAINT "FK_d741d647a3ef6419a31592f8ad6" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "referrals" ADD CONSTRAINT "FK_59de462f9ce130da142e3b5a9f4" FOREIGN KEY ("referrerId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "referrals" ADD CONSTRAINT "FK_ad6772c3fcb57375f43114b5cb5" FOREIGN KEY ("referredId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "payments" ADD CONSTRAINT "FK_d35cb3c13a18e1ea1705b2817b1" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "payments" ADD CONSTRAINT "FK_f14a98c8d21e57b7ddf02940bad" FOREIGN KEY ("packageId") REFERENCES "credit_packages"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "notifications" ADD CONSTRAINT "FK_692a909ee0fa9383e7859f9b406" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "inventory_items" ADD CONSTRAINT "FK_3677990d712c77ee30c1c7baa6c" FOREIGN KEY ("storeId") REFERENCES "stores"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "inventory_items" ADD CONSTRAINT "FK_212ff322ac6cd4bc78bec2bd3dd" FOREIGN KEY ("detectedByVideoId") REFERENCES "videos"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "credit_transactions" ADD CONSTRAINT "FK_2121be176f72337ccf7cc4ef04e" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "credit_balances" ADD CONSTRAINT "FK_7f8103cfe175c66f1e5e8acfe23" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "refresh_tokens" ADD CONSTRAINT "FK_610102b60fea1455310ccd299de" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "refresh_tokens" DROP CONSTRAINT "FK_610102b60fea1455310ccd299de"`);
await queryRunner.query(`ALTER TABLE "credit_balances" DROP CONSTRAINT "FK_7f8103cfe175c66f1e5e8acfe23"`);
await queryRunner.query(`ALTER TABLE "credit_transactions" DROP CONSTRAINT "FK_2121be176f72337ccf7cc4ef04e"`);
await queryRunner.query(`ALTER TABLE "inventory_items" DROP CONSTRAINT "FK_212ff322ac6cd4bc78bec2bd3dd"`);
await queryRunner.query(`ALTER TABLE "inventory_items" DROP CONSTRAINT "FK_3677990d712c77ee30c1c7baa6c"`);
await queryRunner.query(`ALTER TABLE "notifications" DROP CONSTRAINT "FK_692a909ee0fa9383e7859f9b406"`);
await queryRunner.query(`ALTER TABLE "payments" DROP CONSTRAINT "FK_f14a98c8d21e57b7ddf02940bad"`);
await queryRunner.query(`ALTER TABLE "payments" DROP CONSTRAINT "FK_d35cb3c13a18e1ea1705b2817b1"`);
await queryRunner.query(`ALTER TABLE "referrals" DROP CONSTRAINT "FK_ad6772c3fcb57375f43114b5cb5"`);
await queryRunner.query(`ALTER TABLE "referrals" DROP CONSTRAINT "FK_59de462f9ce130da142e3b5a9f4"`);
await queryRunner.query(`ALTER TABLE "store_users" DROP CONSTRAINT "FK_d741d647a3ef6419a31592f8ad6"`);
await queryRunner.query(`ALTER TABLE "store_users" DROP CONSTRAINT "FK_3077a42ec6ad94cfb93f919359d"`);
await queryRunner.query(`ALTER TABLE "videos" DROP CONSTRAINT "FK_159f8e5c7959016a0863ec419a3"`);
await queryRunner.query(`ALTER TABLE "videos" DROP CONSTRAINT "FK_4177a2c071cf429219b7eb31a43"`);
await queryRunner.query(`ALTER TABLE "stores" DROP CONSTRAINT "FK_a447ba082271c05997a61df26df"`);
await queryRunner.query(`DROP INDEX "public"."IDX_ee9e52dc460b0ae28fd9f276ce"`);
await queryRunner.query(`DROP INDEX "public"."IDX_2afebf0234962331e12c59c592"`);
await queryRunner.query(`DROP TABLE "otps"`);
await queryRunner.query(`DROP TYPE "public"."otps_purpose_enum"`);
await queryRunner.query(`DROP INDEX "public"."IDX_610102b60fea1455310ccd299d"`);
await queryRunner.query(`DROP INDEX "public"."IDX_4542dd2f38a61354a040ba9fd5"`);
await queryRunner.query(`DROP INDEX "public"."IDX_56b91d98f71e3d1b649ed6e9f3"`);
await queryRunner.query(`DROP TABLE "refresh_tokens"`);
await queryRunner.query(`DROP TABLE "credit_balances"`);
await queryRunner.query(`DROP INDEX "public"."IDX_f31233f25cf2015095fca7f6b6"`);
await queryRunner.query(`DROP INDEX "public"."IDX_413894b75b111744ab18f39b1e"`);
await queryRunner.query(`DROP TABLE "credit_transactions"`);
await queryRunner.query(`DROP TYPE "public"."credit_transactions_type_enum"`);
await queryRunner.query(`DROP INDEX "public"."IDX_9ef0ca05424108394eaaa8f511"`);
await queryRunner.query(`DROP INDEX "public"."IDX_81d46f4b3749d1db8628a33561"`);
await queryRunner.query(`DROP INDEX "public"."IDX_e6661f3647402a7434bb2135df"`);
await queryRunner.query(`DROP TABLE "inventory_items"`);
await queryRunner.query(`DROP INDEX "public"."IDX_21e65af2f4f242d4c85a92aff4"`);
await queryRunner.query(`DROP INDEX "public"."IDX_5340fc241f57310d243e5ab20b"`);
await queryRunner.query(`DROP TABLE "notifications"`);
await queryRunner.query(`DROP TYPE "public"."notifications_type_enum"`);
await queryRunner.query(`DROP INDEX "public"."IDX_1c9b5fb7b9f38cccddd7c5761b"`);
await queryRunner.query(`DROP INDEX "public"."IDX_3cc6f3e2f26955eaa64fd7f9ea"`);
await queryRunner.query(`DROP INDEX "public"."IDX_c9ca05ceb7cc5eae113c2eb57c"`);
await queryRunner.query(`DROP TABLE "payments"`);
await queryRunner.query(`DROP TYPE "public"."payments_status_enum"`);
await queryRunner.query(`DROP TYPE "public"."payments_method_enum"`);
await queryRunner.query(`DROP TABLE "credit_packages"`);
await queryRunner.query(`DROP INDEX "public"."IDX_59de462f9ce130da142e3b5a9f"`);
await queryRunner.query(`DROP INDEX "public"."IDX_ad6772c3fcb57375f43114b5cb"`);
await queryRunner.query(`DROP INDEX "public"."IDX_13a1bad9eef9e5cfa4b6176526"`);
await queryRunner.query(`DROP TABLE "referrals"`);
await queryRunner.query(`DROP TYPE "public"."referrals_status_enum"`);
await queryRunner.query(`DROP TABLE "store_users"`);
await queryRunner.query(`DROP TYPE "public"."store_users_role_enum"`);
await queryRunner.query(`DROP TABLE "videos"`);
await queryRunner.query(`DROP TYPE "public"."videos_status_enum"`);
await queryRunner.query(`DROP TABLE "stores"`);
await queryRunner.query(`DROP TABLE "users"`);
await queryRunner.query(`DROP TYPE "public"."users_role_enum"`);
}
}

View File

@ -0,0 +1,248 @@
import {
Controller,
Get,
Post,
Patch,
Body,
Param,
Query,
UseGuards,
Req,
ParseUUIDPipe,
} from '@nestjs/common';
import { Request } from 'express';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../../common/guards/roles.guard';
import { Roles } from '../../common/decorators/roles.decorator';
import { UserRole } from '../users/entities/user.entity';
import { AdminDashboardService } from './services/admin-dashboard.service';
import { AdminProvidersService } from './services/admin-providers.service';
import { AdminPackagesService } from './services/admin-packages.service';
import { AdminPromotionsService } from './services/admin-promotions.service';
import { AdminModerationService } from './services/admin-moderation.service';
import { DashboardQueryDto, DashboardPeriod } from './dto/dashboard.dto';
import { UpdateProviderDto } from './dto/provider.dto';
import { CreatePackageDto, UpdatePackageDto } from './dto/package.dto';
import { CreatePromotionDto, UpdatePromotionDto, ValidatePromoCodeDto } from './dto/promotion.dto';
import { ApproveProductDto, RejectProductDto, ApproveReferralDto, RejectReferralDto } from './dto/moderation.dto';
interface AuthRequest extends Request {
user: { id: string; role: string };
}
@Controller('admin')
@UseGuards(JwtAuthGuard, RolesGuard)
export class AdminController {
constructor(
private readonly dashboardService: AdminDashboardService,
private readonly providersService: AdminProvidersService,
private readonly packagesService: AdminPackagesService,
private readonly promotionsService: AdminPromotionsService,
private readonly moderationService: AdminModerationService,
) {}
// Dashboard Endpoints
@Get('dashboard')
@Roles(UserRole.VIEWER)
async getDashboard(@Query() query: DashboardQueryDto) {
const startDate = query.startDate ? new Date(query.startDate) : undefined;
const endDate = query.endDate ? new Date(query.endDate) : undefined;
const metrics = await this.dashboardService.getDashboardMetrics(startDate, endDate);
return { metrics };
}
@Get('dashboard/revenue-series')
@Roles(UserRole.ADMIN)
async getRevenueSeries(@Query() query: DashboardQueryDto) {
const now = new Date();
const startDate = query.startDate
? new Date(query.startDate)
: new Date(now.getFullYear(), now.getMonth() - 1, 1);
const endDate = query.endDate ? new Date(query.endDate) : now;
const period = query.period || DashboardPeriod.DAY;
const series = await this.dashboardService.getRevenueSeries(startDate, endDate, period);
return { series };
}
// IA Providers Endpoints
@Get('providers')
@Roles(UserRole.ADMIN)
async getProviders() {
const providers = await this.providersService.findAll();
return { providers };
}
@Patch('providers/:id')
@Roles(UserRole.SUPER_ADMIN)
async updateProvider(
@Req() req: AuthRequest,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateProviderDto,
) {
const provider = await this.providersService.update(id, dto, req.user.id);
return {
message: 'Proveedor actualizado exitosamente',
provider,
};
}
// Credit Packages Endpoints
@Get('packages')
@Roles(UserRole.ADMIN)
async getPackages(@Query('includeInactive') includeInactive?: string) {
const packages = await this.packagesService.findAll(includeInactive === 'true');
return { packages };
}
@Post('packages')
@Roles(UserRole.ADMIN)
async createPackage(@Req() req: AuthRequest, @Body() dto: CreatePackageDto) {
const pkg = await this.packagesService.create(dto, req.user.id);
return {
message: 'Paquete creado exitosamente',
package: pkg,
};
}
@Patch('packages/:id')
@Roles(UserRole.ADMIN)
async updatePackage(
@Req() req: AuthRequest,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdatePackageDto,
) {
const pkg = await this.packagesService.update(id, dto, req.user.id);
return {
message: 'Paquete actualizado exitosamente',
package: pkg,
};
}
// Promotions Endpoints
@Get('promotions')
@Roles(UserRole.ADMIN)
async getPromotions(@Query('includeExpired') includeExpired?: string) {
const promotions = await this.promotionsService.findAll(includeExpired === 'true');
return { promotions };
}
@Post('promotions')
@Roles(UserRole.ADMIN)
async createPromotion(@Req() req: AuthRequest, @Body() dto: CreatePromotionDto) {
const promotion = await this.promotionsService.create(dto, req.user.id);
return {
message: 'Promocion creada exitosamente',
promotion,
};
}
@Patch('promotions/:id')
@Roles(UserRole.ADMIN)
async updatePromotion(
@Req() req: AuthRequest,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdatePromotionDto,
) {
const promotion = await this.promotionsService.update(id, dto, req.user.id);
return {
message: 'Promocion actualizada exitosamente',
promotion,
};
}
@Post('promotions/validate')
@Roles(UserRole.VIEWER)
async validatePromoCode(@Body() dto: ValidatePromoCodeDto) {
return this.promotionsService.validateCode(dto.code, dto.packageId, dto.purchaseAmount);
}
// Product Moderation Endpoints
@Get('products/pending')
@Roles(UserRole.MODERATOR)
async getPendingProducts(
@Query('page') page?: string,
@Query('limit') limit?: string,
) {
return this.moderationService.getPendingProducts(
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 20,
);
}
@Post('products/:id/approve')
@Roles(UserRole.MODERATOR)
async approveProduct(
@Req() req: AuthRequest,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ApproveProductDto,
) {
const product = await this.moderationService.approveProduct(id, dto, req.user.id);
return {
message: 'Producto aprobado exitosamente',
product,
};
}
@Post('products/:id/reject')
@Roles(UserRole.MODERATOR)
async rejectProduct(
@Req() req: AuthRequest,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RejectProductDto,
) {
const product = await this.moderationService.rejectProduct(id, dto, req.user.id);
return {
message: 'Producto rechazado',
product,
};
}
// Referral Fraud Moderation Endpoints
@Get('referrals/fraud-holds')
@Roles(UserRole.MODERATOR)
async getFraudHoldReferrals(
@Query('page') page?: string,
@Query('limit') limit?: string,
) {
return this.moderationService.getFraudHoldReferrals(
page ? parseInt(page, 10) : 1,
limit ? parseInt(limit, 10) : 20,
);
}
@Post('referrals/:id/approve')
@Roles(UserRole.MODERATOR)
async approveReferral(
@Req() req: AuthRequest,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ApproveReferralDto,
) {
const referral = await this.moderationService.approveReferral(id, dto, req.user.id);
return {
message: 'Referido aprobado exitosamente',
referral,
};
}
@Post('referrals/:id/reject')
@Roles(UserRole.MODERATOR)
async rejectReferral(
@Req() req: AuthRequest,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RejectReferralDto,
) {
const referral = await this.moderationService.rejectReferral(id, dto, req.user.id);
return {
message: 'Referido rechazado por fraude',
referral,
};
}
}

View File

@ -0,0 +1,53 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AdminController } from './admin.controller';
import { AdminDashboardService } from './services/admin-dashboard.service';
import { AdminProvidersService } from './services/admin-providers.service';
import { AdminPackagesService } from './services/admin-packages.service';
import { AdminPromotionsService } from './services/admin-promotions.service';
import { AdminModerationService } from './services/admin-moderation.service';
import { AuditLogService } from './services/audit-log.service';
import { IaProvider } from './entities/ia-provider.entity';
import { Promotion } from './entities/promotion.entity';
import { AuditLog } from './entities/audit-log.entity';
import { User } from '../users/entities/user.entity';
import { Store } from '../stores/entities/store.entity';
import { Video } from '../videos/entities/video.entity';
import { Payment } from '../payments/entities/payment.entity';
import { CreditPackage } from '../credits/entities/credit-package.entity';
import { CreditTransaction } from '../credits/entities/credit-transaction.entity';
import { ProductSubmission } from '../feedback/entities/product-submission.entity';
import { Referral } from '../referrals/entities/referral.entity';
@Module({
imports: [
TypeOrmModule.forFeature([
IaProvider,
Promotion,
AuditLog,
User,
Store,
Video,
Payment,
CreditPackage,
CreditTransaction,
ProductSubmission,
Referral,
]),
],
controllers: [AdminController],
providers: [
AdminDashboardService,
AdminProvidersService,
AdminPackagesService,
AdminPromotionsService,
AdminModerationService,
AuditLogService,
],
exports: [
AdminProvidersService,
AdminPromotionsService,
AuditLogService,
],
})
export class AdminModule {}

View File

@ -0,0 +1,66 @@
import { IsOptional, IsDateString, IsEnum } from 'class-validator';
export enum DashboardPeriod {
DAY = 'day',
WEEK = 'week',
MONTH = 'month',
YEAR = 'year',
}
export class DashboardQueryDto {
@IsOptional()
@IsDateString()
startDate?: string;
@IsOptional()
@IsDateString()
endDate?: string;
@IsOptional()
@IsEnum(DashboardPeriod)
period?: DashboardPeriod;
}
export interface DashboardMetrics {
users: {
total: number;
mau: number;
dau: number;
newThisPeriod: number;
};
stores: {
total: number;
activeThisPeriod: number;
};
videos: {
total: number;
processedThisPeriod: number;
averageProcessingTime: number;
};
revenue: {
total: number;
thisPeriod: number;
byPackage: { packageId: string; name: string; amount: number }[];
};
cogs: {
total: number;
thisPeriod: number;
byProvider: { providerId: string; name: string; cost: number }[];
};
margin: {
gross: number;
percentage: number;
};
credits: {
purchased: number;
used: number;
gifted: number;
};
}
export interface RevenueSeriesPoint {
date: string;
revenue: number;
cogs: number;
margin: number;
}

View File

@ -0,0 +1,39 @@
import { IsString, IsOptional, IsUUID, Length } from 'class-validator';
export class ApproveProductDto {
@IsOptional()
@IsString()
@Length(0, 255)
notes?: string;
@IsOptional()
@IsString()
category?: string;
}
export class RejectProductDto {
@IsString()
@Length(2, 500)
reason: string;
}
export class ApproveReferralDto {
@IsOptional()
@IsString()
@Length(0, 255)
notes?: string;
}
export class RejectReferralDto {
@IsString()
@Length(2, 500)
reason: string;
}
export class PaginationQueryDto {
@IsOptional()
page?: number;
@IsOptional()
limit?: number;
}

View File

@ -0,0 +1,80 @@
import { IsString, IsNumber, IsBoolean, IsOptional, Min, Max, Length } from 'class-validator';
export class CreatePackageDto {
@IsString()
@Length(2, 100)
name: string;
@IsOptional()
@IsString()
@Length(0, 500)
description?: string;
@IsNumber()
@Min(1)
credits: number;
@IsNumber()
@Min(0)
priceMxn: number;
@IsOptional()
@IsNumber()
@Min(0)
priceUsd?: number;
@IsOptional()
@IsBoolean()
isPopular?: boolean;
@IsOptional()
@IsNumber()
@Min(0)
@Max(100)
discountPercentage?: number;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class UpdatePackageDto {
@IsOptional()
@IsString()
@Length(2, 100)
name?: string;
@IsOptional()
@IsString()
@Length(0, 500)
description?: string;
@IsOptional()
@IsNumber()
@Min(1)
credits?: number;
@IsOptional()
@IsNumber()
@Min(0)
priceMxn?: number;
@IsOptional()
@IsNumber()
@Min(0)
priceUsd?: number;
@IsOptional()
@IsBoolean()
isPopular?: boolean;
@IsOptional()
@IsNumber()
@Min(0)
@Max(100)
discountPercentage?: number;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}

View File

@ -0,0 +1,137 @@
import {
IsString,
IsNumber,
IsBoolean,
IsOptional,
IsEnum,
IsDateString,
IsArray,
IsUUID,
Min,
Max,
Length,
} from 'class-validator';
import { PromotionType } from '../entities/promotion.entity';
export class CreatePromotionDto {
@IsString()
@Length(2, 100)
name: string;
@IsString()
@Length(2, 50)
code: string;
@IsOptional()
@IsString()
description?: string;
@IsEnum(PromotionType)
type: PromotionType;
@IsNumber()
@Min(0)
value: number;
@IsOptional()
@IsNumber()
@Min(0)
minPurchaseAmount?: number;
@IsOptional()
@IsNumber()
@Min(0)
maxDiscount?: number;
@IsOptional()
@IsNumber()
@Min(1)
usageLimit?: number;
@IsOptional()
@IsNumber()
@Min(1)
perUserLimit?: number;
@IsDateString()
startsAt: string;
@IsDateString()
endsAt: string;
@IsOptional()
@IsBoolean()
isActive?: boolean;
@IsOptional()
@IsArray()
@IsUUID('4', { each: true })
applicablePackageIds?: string[];
}
export class UpdatePromotionDto {
@IsOptional()
@IsString()
@Length(2, 100)
name?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsNumber()
@Min(0)
value?: number;
@IsOptional()
@IsNumber()
@Min(0)
minPurchaseAmount?: number;
@IsOptional()
@IsNumber()
@Min(0)
maxDiscount?: number;
@IsOptional()
@IsNumber()
@Min(1)
usageLimit?: number;
@IsOptional()
@IsNumber()
@Min(1)
perUserLimit?: number;
@IsOptional()
@IsDateString()
startsAt?: string;
@IsOptional()
@IsDateString()
endsAt?: string;
@IsOptional()
@IsBoolean()
isActive?: boolean;
@IsOptional()
@IsArray()
@IsUUID('4', { each: true })
applicablePackageIds?: string[];
}
export class ValidatePromoCodeDto {
@IsString()
code: string;
@IsOptional()
@IsUUID()
packageId?: string;
@IsOptional()
@IsNumber()
@Min(0)
purchaseAmount?: number;
}

View File

@ -0,0 +1,42 @@
import { IsString, IsNumber, IsBoolean, IsOptional, Min } from 'class-validator';
export class UpdateProviderDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsNumber()
@Min(0)
costPerFrame?: number;
@IsOptional()
@IsNumber()
@Min(0)
costPerToken?: number;
@IsOptional()
@IsBoolean()
isActive?: boolean;
@IsOptional()
@IsBoolean()
isDefault?: boolean;
@IsOptional()
config?: Record<string, any>;
}
export interface ProviderUsageDto {
providerId: string;
providerName: string;
totalRequests: number;
totalFrames: number;
totalTokens: number;
totalCost: number;
period: string;
}

View File

@ -0,0 +1,51 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
@Entity('audit_logs')
@Index(['userId'])
@Index(['action'])
@Index(['resource', 'resourceId'])
@Index(['createdAt'])
export class AuditLog {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
userId: string;
@ManyToOne(() => User)
@JoinColumn({ name: 'userId' })
user: User;
@Column({ type: 'varchar', length: 100 })
action: string;
@Column({ type: 'varchar', length: 100 })
resource: string;
@Column({ type: 'uuid', nullable: true })
resourceId: string;
@Column({ type: 'jsonb', nullable: true })
previousValue: Record<string, any>;
@Column({ type: 'jsonb', nullable: true })
newValue: Record<string, any>;
@Column({ type: 'varchar', length: 45, nullable: true })
ipAddress: string;
@Column({ type: 'text', nullable: true })
userAgent: string;
@CreateDateColumn()
createdAt: Date;
}

View File

@ -0,0 +1,46 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
@Entity('ia_providers')
@Index(['code'])
@Index(['isActive'])
export class IaProvider {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 100 })
name: string;
@Column({ type: 'varchar', length: 50, unique: true })
code: string;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ type: 'numeric', precision: 10, scale: 6, default: 0 })
costPerFrame: number;
@Column({ type: 'numeric', precision: 10, scale: 8, default: 0 })
costPerToken: number;
@Column({ type: 'boolean', default: true })
isActive: boolean;
@Column({ type: 'boolean', default: false })
isDefault: boolean;
@Column({ type: 'jsonb', nullable: true })
config: Record<string, any>;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@ -0,0 +1,90 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
export enum PromotionType {
PERCENTAGE = 'PERCENTAGE',
FIXED_CREDITS = 'FIXED_CREDITS',
MULTIPLIER = 'MULTIPLIER',
}
@Entity('promotions')
@Index(['code'])
@Index(['isActive', 'startsAt', 'endsAt'])
export class Promotion {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 100 })
name: string;
@Column({ type: 'varchar', length: 50, unique: true })
code: string;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ type: 'enum', enum: PromotionType })
type: PromotionType;
@Column({ type: 'numeric', precision: 10, scale: 2 })
value: number;
@Column({ type: 'numeric', precision: 10, scale: 2, nullable: true })
minPurchaseAmount: number;
@Column({ type: 'numeric', precision: 10, scale: 2, nullable: true })
maxDiscount: number;
@Column({ type: 'int', nullable: true })
usageLimit: number;
@Column({ type: 'int', default: 0 })
usageCount: number;
@Column({ type: 'int', nullable: true, default: 1 })
perUserLimit: number;
@Column({ type: 'timestamp' })
startsAt: Date;
@Column({ type: 'timestamp' })
endsAt: Date;
@Column({ type: 'boolean', default: true })
isActive: boolean;
@Column({ type: 'uuid', array: true, nullable: true })
applicablePackageIds: string[];
@Column({ type: 'uuid', nullable: true })
createdBy: string;
@ManyToOne(() => User, { nullable: true })
@JoinColumn({ name: 'createdBy' })
creator: User;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
isValid(): boolean {
const now = new Date();
return (
this.isActive &&
this.startsAt <= now &&
this.endsAt >= now &&
(this.usageLimit === null || this.usageCount < this.usageLimit)
);
}
}

View File

@ -0,0 +1,206 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Between, MoreThanOrEqual } from 'typeorm';
import { User } from '../../users/entities/user.entity';
import { Store } from '../../stores/entities/store.entity';
import { Video } from '../../videos/entities/video.entity';
import { Payment, PaymentStatus } from '../../payments/entities/payment.entity';
import { CreditTransaction, TransactionType } from '../../credits/entities/credit-transaction.entity';
import { DashboardMetrics, RevenueSeriesPoint, DashboardPeriod } from '../dto/dashboard.dto';
@Injectable()
export class AdminDashboardService {
constructor(
@InjectRepository(User)
private userRepository: Repository<User>,
@InjectRepository(Store)
private storeRepository: Repository<Store>,
@InjectRepository(Video)
private videoRepository: Repository<Video>,
@InjectRepository(Payment)
private paymentRepository: Repository<Payment>,
@InjectRepository(CreditTransaction)
private creditTransactionRepository: Repository<CreditTransaction>,
) {}
async getDashboardMetrics(
startDate?: Date,
endDate?: Date,
): Promise<DashboardMetrics> {
const now = new Date();
const start = startDate || new Date(now.getFullYear(), now.getMonth(), 1);
const end = endDate || now;
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const [
totalUsers,
newUsers,
totalStores,
activeStores,
totalVideos,
processedVideos,
payments,
creditTransactions,
mauCount,
dauCount,
] = await Promise.all([
this.userRepository.count(),
this.userRepository.count({
where: { createdAt: Between(start, end) },
}),
this.storeRepository.count(),
this.storeRepository
.createQueryBuilder('store')
.innerJoin('videos', 'video', 'video.storeId = store.id')
.where('video.createdAt >= :start', { start })
.getCount(),
this.videoRepository.count(),
this.videoRepository.count({
where: { createdAt: Between(start, end) },
}),
this.paymentRepository.find({
where: {
status: PaymentStatus.COMPLETED,
createdAt: Between(start, end),
},
relations: ['package'],
}),
this.creditTransactionRepository.find({
where: { createdAt: Between(start, end) },
}),
this.getActiveUsersCount(thirtyDaysAgo),
this.getActiveUsersCount(oneDayAgo),
]);
const revenue = payments.reduce((sum, p) => sum + Number(p.amountMXN), 0);
const revenueByPackage = this.groupRevenueByPackage(payments);
const purchased = creditTransactions
.filter(t => t.type === TransactionType.PURCHASE)
.reduce((sum, t) => sum + t.amount, 0);
const used = creditTransactions
.filter(t => t.type === TransactionType.CONSUMPTION)
.reduce((sum, t) => sum + Math.abs(t.amount), 0);
const gifted = creditTransactions
.filter(t => [TransactionType.REFERRAL_BONUS, TransactionType.PROMO].includes(t.type))
.reduce((sum, t) => sum + t.amount, 0);
// Estimate COGS (placeholder - would integrate with IA provider usage)
const estimatedCogs = revenue * 0.15; // 15% estimate
return {
users: {
total: totalUsers,
mau: mauCount,
dau: dauCount,
newThisPeriod: newUsers,
},
stores: {
total: totalStores,
activeThisPeriod: activeStores,
},
videos: {
total: totalVideos,
processedThisPeriod: processedVideos,
averageProcessingTime: 0, // Would calculate from video metadata
},
revenue: {
total: revenue,
thisPeriod: revenue,
byPackage: revenueByPackage,
},
cogs: {
total: estimatedCogs,
thisPeriod: estimatedCogs,
byProvider: [], // Would integrate with IA provider usage tracking
},
margin: {
gross: revenue - estimatedCogs,
percentage: revenue > 0 ? ((revenue - estimatedCogs) / revenue) * 100 : 0,
},
credits: {
purchased,
used,
gifted,
},
};
}
async getRevenueSeries(
startDate: Date,
endDate: Date,
period: DashboardPeriod = DashboardPeriod.DAY,
): Promise<RevenueSeriesPoint[]> {
const payments = await this.paymentRepository.find({
where: {
status: PaymentStatus.COMPLETED,
createdAt: Between(startDate, endDate),
},
order: { createdAt: 'ASC' },
});
const groupedData = new Map<string, { revenue: number; cogs: number }>();
for (const payment of payments) {
const dateKey = this.getDateKey(payment.createdAt, period);
const existing = groupedData.get(dateKey) || { revenue: 0, cogs: 0 };
existing.revenue += Number(payment.amountMXN);
existing.cogs += Number(payment.amountMXN) * 0.15; // Estimated COGS
groupedData.set(dateKey, existing);
}
return Array.from(groupedData.entries()).map(([date, data]) => ({
date,
revenue: data.revenue,
cogs: data.cogs,
margin: data.revenue - data.cogs,
}));
}
private async getActiveUsersCount(since: Date): Promise<number> {
const result = await this.videoRepository
.createQueryBuilder('video')
.select('COUNT(DISTINCT video.uploadedById)', 'count')
.where('video.createdAt >= :since', { since })
.getRawOne();
return parseInt(result?.count || '0', 10);
}
private groupRevenueByPackage(payments: Payment[]): { packageId: string; name: string; amount: number }[] {
const grouped = new Map<string, { name: string; amount: number }>();
for (const payment of payments) {
if (payment.packageId && payment.package) {
const existing = grouped.get(payment.packageId) || { name: payment.package.name, amount: 0 };
existing.amount += Number(payment.amountMXN);
grouped.set(payment.packageId, existing);
}
}
return Array.from(grouped.entries()).map(([packageId, data]) => ({
packageId,
name: data.name,
amount: data.amount,
}));
}
private getDateKey(date: Date, period: DashboardPeriod): string {
switch (period) {
case DashboardPeriod.DAY:
return date.toISOString().split('T')[0];
case DashboardPeriod.WEEK:
const weekStart = new Date(date);
weekStart.setDate(date.getDate() - date.getDay());
return weekStart.toISOString().split('T')[0];
case DashboardPeriod.MONTH:
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
case DashboardPeriod.YEAR:
return String(date.getFullYear());
default:
return date.toISOString().split('T')[0];
}
}
}

View File

@ -0,0 +1,264 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ProductSubmission } from '../../feedback/entities/product-submission.entity';
import { Referral, ReferralStatus } from '../../referrals/entities/referral.entity';
import { AuditLogService } from './audit-log.service';
import { ApproveProductDto, RejectProductDto, ApproveReferralDto, RejectReferralDto } from '../dto/moderation.dto';
@Injectable()
export class AdminModerationService {
constructor(
@InjectRepository(ProductSubmission)
private productSubmissionRepository: Repository<ProductSubmission>,
@InjectRepository(Referral)
private referralRepository: Repository<Referral>,
private auditLogService: AuditLogService,
) {}
// Product Moderation
async getPendingProducts(page = 1, limit = 20): Promise<{
products: ProductSubmission[];
total: number;
page: number;
totalPages: number;
}> {
const [products, total] = await this.productSubmissionRepository.findAndCount({
where: { status: 'PENDING' as any },
order: { createdAt: 'ASC' },
skip: (page - 1) * limit,
take: limit,
relations: ['user', 'store'],
});
return {
products,
total,
page,
totalPages: Math.ceil(total / limit),
};
}
async approveProduct(
id: string,
dto: ApproveProductDto,
userId: string,
): Promise<ProductSubmission> {
const product = await this.productSubmissionRepository.findOne({
where: { id },
});
if (!product) {
throw new NotFoundException('Producto no encontrado');
}
if (product.status !== 'PENDING') {
throw new BadRequestException('El producto ya fue procesado');
}
const previousValue = { status: product.status };
product.status = 'APPROVED' as any;
product.reviewedBy = userId;
product.reviewNotes = dto.notes;
if (dto.category) {
product.category = dto.category;
}
const updated = await this.productSubmissionRepository.save(product);
await this.auditLogService.log({
userId,
action: 'APPROVE_PRODUCT',
resource: 'product_submissions',
resourceId: id,
previousValue,
newValue: { status: 'APPROVED', notes: dto.notes },
});
return updated;
}
async rejectProduct(
id: string,
dto: RejectProductDto,
userId: string,
): Promise<ProductSubmission> {
const product = await this.productSubmissionRepository.findOne({
where: { id },
});
if (!product) {
throw new NotFoundException('Producto no encontrado');
}
if (product.status !== 'PENDING') {
throw new BadRequestException('El producto ya fue procesado');
}
const previousValue = { status: product.status };
product.status = 'REJECTED' as any;
product.reviewedBy = userId;
product.reviewNotes = dto.reason;
const updated = await this.productSubmissionRepository.save(product);
await this.auditLogService.log({
userId,
action: 'REJECT_PRODUCT',
resource: 'product_submissions',
resourceId: id,
previousValue,
newValue: { status: 'REJECTED', reason: dto.reason },
});
return updated;
}
// Referral Moderation (Fraud Detection)
async getFraudHoldReferrals(page = 1, limit = 20): Promise<{
referrals: Referral[];
total: number;
page: number;
totalPages: number;
}> {
const [referrals, total] = await this.referralRepository.findAndCount({
where: { fraudHold: true },
order: { createdAt: 'ASC' },
skip: (page - 1) * limit,
take: limit,
relations: ['referrer', 'referred'],
});
return {
referrals,
total,
page,
totalPages: Math.ceil(total / limit),
};
}
async approveReferral(
id: string,
dto: ApproveReferralDto,
userId: string,
): Promise<Referral> {
const referral = await this.referralRepository.findOne({
where: { id },
relations: ['referrer', 'referred'],
});
if (!referral) {
throw new NotFoundException('Referido no encontrado');
}
if (!referral.fraudHold) {
throw new BadRequestException('El referido no esta en revision de fraude');
}
const previousValue = {
fraudHold: referral.fraudHold,
fraudReason: referral.fraudReason,
};
referral.fraudHold = false;
referral.fraudReason = undefined as any;
referral.reviewedBy = userId;
referral.reviewedAt = new Date();
const updated = await this.referralRepository.save(referral);
await this.auditLogService.log({
userId,
action: 'APPROVE_REFERRAL',
resource: 'referrals',
resourceId: id,
previousValue,
newValue: { fraudHold: false, notes: dto.notes },
});
return updated;
}
async rejectReferral(
id: string,
dto: RejectReferralDto,
userId: string,
): Promise<Referral> {
const referral = await this.referralRepository.findOne({
where: { id },
relations: ['referrer', 'referred'],
});
if (!referral) {
throw new NotFoundException('Referido no encontrado');
}
if (!referral.fraudHold) {
throw new BadRequestException('El referido no esta en revision de fraude');
}
const previousValue = {
fraudHold: referral.fraudHold,
fraudReason: referral.fraudReason,
status: referral.status,
};
referral.fraudHold = true;
referral.fraudReason = dto.reason;
referral.status = ReferralStatus.PENDING; // Reset status
referral.reviewedBy = userId;
referral.reviewedAt = new Date();
const updated = await this.referralRepository.save(referral);
await this.auditLogService.log({
userId,
action: 'REJECT_REFERRAL',
resource: 'referrals',
resourceId: id,
previousValue,
newValue: { fraudHold: true, fraudReason: dto.reason },
});
return updated;
}
async flagReferralForFraud(
id: string,
reason: string,
userId: string,
): Promise<Referral> {
const referral = await this.referralRepository.findOne({
where: { id },
});
if (!referral) {
throw new NotFoundException('Referido no encontrado');
}
const previousValue = {
fraudHold: referral.fraudHold,
fraudReason: referral.fraudReason,
};
referral.fraudHold = true;
referral.fraudReason = reason;
const updated = await this.referralRepository.save(referral);
await this.auditLogService.log({
userId,
action: 'FLAG_REFERRAL_FRAUD',
resource: 'referrals',
resourceId: id,
previousValue,
newValue: { fraudHold: true, fraudReason: reason },
});
return updated;
}
}

View File

@ -0,0 +1,101 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CreditPackage } from '../../credits/entities/credit-package.entity';
import { CreatePackageDto, UpdatePackageDto } from '../dto/package.dto';
import { AuditLogService } from './audit-log.service';
@Injectable()
export class AdminPackagesService {
constructor(
@InjectRepository(CreditPackage)
private packageRepository: Repository<CreditPackage>,
private auditLogService: AuditLogService,
) {}
async findAll(includeInactive = false): Promise<CreditPackage[]> {
const where = includeInactive ? {} : { isActive: true };
return this.packageRepository.find({
where,
order: { sortOrder: 'ASC', credits: 'ASC' },
});
}
async findOne(id: string): Promise<CreditPackage> {
const pkg = await this.packageRepository.findOne({ where: { id } });
if (!pkg) {
throw new NotFoundException('Paquete de creditos no encontrado');
}
return pkg;
}
async create(dto: CreatePackageDto, userId: string): Promise<CreditPackage> {
const maxOrder = await this.packageRepository
.createQueryBuilder('pkg')
.select('MAX(pkg.sortOrder)', 'max')
.getRawOne();
const pkg = this.packageRepository.create({
...dto,
priceMXN: dto.priceMxn,
sortOrder: (maxOrder?.max || 0) + 1,
});
const saved = await this.packageRepository.save(pkg);
await this.auditLogService.log({
userId,
action: 'CREATE_PACKAGE',
resource: 'credit_packages',
resourceId: saved.id,
newValue: { ...saved },
});
return saved;
}
async update(
id: string,
dto: UpdatePackageDto,
userId: string,
): Promise<CreditPackage> {
const pkg = await this.findOne(id);
const previousValue = { ...pkg };
if (dto.priceMxn !== undefined) {
(dto as any).priceMXN = dto.priceMxn;
delete dto.priceMxn;
}
Object.assign(pkg, dto);
const updated = await this.packageRepository.save(pkg);
await this.auditLogService.log({
userId,
action: 'UPDATE_PACKAGE',
resource: 'credit_packages',
resourceId: id,
previousValue,
newValue: { ...updated },
});
return updated;
}
async deactivate(id: string, userId: string): Promise<void> {
const pkg = await this.findOne(id);
const previousValue = { ...pkg };
pkg.isActive = false;
await this.packageRepository.save(pkg);
await this.auditLogService.log({
userId,
action: 'DEACTIVATE_PACKAGE',
resource: 'credit_packages',
resourceId: id,
previousValue,
newValue: { isActive: false },
});
}
}

View File

@ -0,0 +1,151 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
import { Promotion, PromotionType } from '../entities/promotion.entity';
import { CreatePromotionDto, UpdatePromotionDto } from '../dto/promotion.dto';
import { AuditLogService } from './audit-log.service';
@Injectable()
export class AdminPromotionsService {
constructor(
@InjectRepository(Promotion)
private promotionRepository: Repository<Promotion>,
private auditLogService: AuditLogService,
) {}
async findAll(includeExpired = false): Promise<Promotion[]> {
const qb = this.promotionRepository.createQueryBuilder('promotion');
if (!includeExpired) {
const now = new Date();
qb.where('promotion.endsAt >= :now', { now });
}
return qb.orderBy('promotion.createdAt', 'DESC').getMany();
}
async findOne(id: string): Promise<Promotion> {
const promotion = await this.promotionRepository.findOne({ where: { id } });
if (!promotion) {
throw new NotFoundException('Promocion no encontrada');
}
return promotion;
}
async findByCode(code: string): Promise<Promotion | null> {
return this.promotionRepository.findOne({
where: { code: code.toUpperCase() },
});
}
async create(dto: CreatePromotionDto, userId: string): Promise<Promotion> {
const existingCode = await this.findByCode(dto.code);
if (existingCode) {
throw new BadRequestException('El codigo de promocion ya existe');
}
const promotion = this.promotionRepository.create({
...dto,
code: dto.code.toUpperCase(),
createdBy: userId,
startsAt: new Date(dto.startsAt),
endsAt: new Date(dto.endsAt),
});
const saved = await this.promotionRepository.save(promotion);
await this.auditLogService.log({
userId,
action: 'CREATE_PROMOTION',
resource: 'promotions',
resourceId: saved.id,
newValue: { ...saved },
});
return saved;
}
async update(
id: string,
dto: UpdatePromotionDto,
userId: string,
): Promise<Promotion> {
const promotion = await this.findOne(id);
const previousValue = { ...promotion };
if (dto.startsAt) {
(dto as any).startsAt = new Date(dto.startsAt);
}
if (dto.endsAt) {
(dto as any).endsAt = new Date(dto.endsAt);
}
Object.assign(promotion, dto);
const updated = await this.promotionRepository.save(promotion);
await this.auditLogService.log({
userId,
action: 'UPDATE_PROMOTION',
resource: 'promotions',
resourceId: id,
previousValue,
newValue: { ...updated },
});
return updated;
}
async validateCode(
code: string,
packageId?: string,
purchaseAmount?: number,
): Promise<{ valid: boolean; promotion?: Promotion; discount?: number; message?: string }> {
const promotion = await this.findByCode(code);
if (!promotion) {
return { valid: false, message: 'Codigo de promocion no encontrado' };
}
if (!promotion.isValid()) {
return { valid: false, message: 'Promocion expirada o no disponible' };
}
if (promotion.minPurchaseAmount && purchaseAmount && purchaseAmount < Number(promotion.minPurchaseAmount)) {
return {
valid: false,
message: `Monto minimo de compra: $${promotion.minPurchaseAmount} MXN`,
};
}
if (promotion.applicablePackageIds?.length && packageId) {
if (!promotion.applicablePackageIds.includes(packageId)) {
return { valid: false, message: 'Promocion no valida para este paquete' };
}
}
let discount = 0;
if (purchaseAmount) {
switch (promotion.type) {
case PromotionType.PERCENTAGE:
discount = purchaseAmount * (Number(promotion.value) / 100);
break;
case PromotionType.FIXED_CREDITS:
discount = Number(promotion.value);
break;
case PromotionType.MULTIPLIER:
discount = purchaseAmount * (Number(promotion.value) - 1);
break;
}
if (promotion.maxDiscount && discount > Number(promotion.maxDiscount)) {
discount = Number(promotion.maxDiscount);
}
}
return { valid: true, promotion, discount };
}
async incrementUsage(id: string): Promise<void> {
await this.promotionRepository.increment({ id }, 'usageCount', 1);
}
}

View File

@ -0,0 +1,82 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { IaProvider } from '../entities/ia-provider.entity';
import { UpdateProviderDto } from '../dto/provider.dto';
import { AuditLogService } from './audit-log.service';
@Injectable()
export class AdminProvidersService {
constructor(
@InjectRepository(IaProvider)
private providerRepository: Repository<IaProvider>,
private auditLogService: AuditLogService,
) {}
async findAll(): Promise<IaProvider[]> {
return this.providerRepository.find({
order: { name: 'ASC' },
});
}
async findOne(id: string): Promise<IaProvider> {
const provider = await this.providerRepository.findOne({ where: { id } });
if (!provider) {
throw new NotFoundException('Proveedor IA no encontrado');
}
return provider;
}
async findDefault(): Promise<IaProvider | null> {
return this.providerRepository.findOne({
where: { isDefault: true, isActive: true },
});
}
async update(
id: string,
dto: UpdateProviderDto,
userId: string,
): Promise<IaProvider> {
const provider = await this.findOne(id);
const previousValue = { ...provider };
// If setting as default, unset other defaults
if (dto.isDefault === true) {
await this.providerRepository.update(
{ isDefault: true },
{ isDefault: false },
);
}
// Cannot unset the only default
if (dto.isDefault === false && provider.isDefault) {
const otherDefaults = await this.providerRepository.count({
where: { isDefault: true },
});
if (otherDefaults <= 1) {
throw new BadRequestException('Debe haber al menos un proveedor por defecto');
}
}
Object.assign(provider, dto);
const updated = await this.providerRepository.save(provider);
await this.auditLogService.log({
userId,
action: 'UPDATE_PROVIDER',
resource: 'ia_providers',
resourceId: id,
previousValue,
newValue: { ...updated },
});
return updated;
}
async getProviderByCode(code: string): Promise<IaProvider | null> {
return this.providerRepository.findOne({
where: { code, isActive: true },
});
}
}

View File

@ -0,0 +1,57 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AuditLog } from '../entities/audit-log.entity';
export interface AuditLogInput {
userId: string;
action: string;
resource: string;
resourceId?: string;
previousValue?: Record<string, any>;
newValue?: Record<string, any>;
ipAddress?: string;
userAgent?: string;
}
@Injectable()
export class AuditLogService {
constructor(
@InjectRepository(AuditLog)
private auditLogRepository: Repository<AuditLog>,
) {}
async log(input: AuditLogInput): Promise<AuditLog> {
const auditLog = this.auditLogRepository.create(input);
return this.auditLogRepository.save(auditLog);
}
async findByResource(
resource: string,
resourceId: string,
limit = 50,
): Promise<AuditLog[]> {
return this.auditLogRepository.find({
where: { resource, resourceId },
order: { createdAt: 'DESC' },
take: limit,
relations: ['user'],
});
}
async findByUser(userId: string, limit = 50): Promise<AuditLog[]> {
return this.auditLogRepository.find({
where: { userId },
order: { createdAt: 'DESC' },
take: limit,
});
}
async findRecent(limit = 100): Promise<AuditLog[]> {
return this.auditLogRepository.find({
order: { createdAt: 'DESC' },
take: limit,
relations: ['user'],
});
}
}

View File

@ -0,0 +1,52 @@
import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { VerifyOtpDto } from './dto/verify-otp.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
@ApiTags('auth')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('register')
@ApiOperation({ summary: 'Iniciar registro con OTP' })
@ApiResponse({ status: 201, description: 'OTP enviado exitosamente' })
async register(@Body() registerDto: RegisterDto) {
return this.authService.initiateRegistration(registerDto);
}
@Post('verify-otp')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Verificar OTP y crear cuenta' })
@ApiResponse({ status: 200, description: 'Cuenta creada exitosamente' })
async verifyOtp(@Body() verifyOtpDto: VerifyOtpDto) {
return this.authService.verifyOtpAndCreateAccount(verifyOtpDto);
}
@Post('login')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Iniciar sesion' })
@ApiResponse({ status: 200, description: 'Login exitoso' })
async login(@Body() loginDto: LoginDto) {
return this.authService.login(loginDto);
}
@Post('refresh')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Renovar tokens' })
@ApiResponse({ status: 200, description: 'Tokens renovados' })
async refresh(@Body() refreshTokenDto: RefreshTokenDto) {
return this.authService.refreshTokens(refreshTokenDto);
}
@Post('logout')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Cerrar sesion' })
@ApiResponse({ status: 200, description: 'Sesion cerrada' })
async logout(@Body() body: { refreshToken: string }) {
return this.authService.logout(body.refreshToken);
}
}

View File

@ -0,0 +1,34 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { UsersModule } from '../users/users.module';
import { Otp } from './entities/otp.entity';
import { RefreshToken } from './entities/refresh-token.entity';
@Module({
imports: [
UsersModule,
TypeOrmModule.forFeature([Otp, RefreshToken]),
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
secret: configService.get('JWT_SECRET', 'your-secret-key'),
signOptions: {
expiresIn: configService.get('JWT_EXPIRES_IN', '15m'),
},
}),
inject: [ConfigService],
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [AuthService, JwtModule],
})
export class AuthModule {}

View File

@ -0,0 +1,259 @@
import {
Injectable,
UnauthorizedException,
BadRequestException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, MoreThan } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { UsersService } from '../users/users.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { VerifyOtpDto } from './dto/verify-otp.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
import { Otp, OtpPurpose } from './entities/otp.entity';
import { RefreshToken } from './entities/refresh-token.entity';
@Injectable()
export class AuthService {
constructor(
private readonly usersService: UsersService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
@InjectRepository(Otp)
private readonly otpRepository: Repository<Otp>,
@InjectRepository(RefreshToken)
private readonly refreshTokenRepository: Repository<RefreshToken>,
) {}
async initiateRegistration(registerDto: RegisterDto) {
const { phone, name } = registerDto;
// Check if user already exists
const existingUser = await this.usersService.findByPhone(phone);
if (existingUser) {
throw new BadRequestException('Usuario ya registrado con este telefono');
}
// Invalidate any existing OTPs for this phone
await this.otpRepository.update(
{ phone, purpose: OtpPurpose.REGISTRATION, isUsed: false },
{ isUsed: true },
);
// Generate and save OTP
const otp = this.generateOtp();
const expiresAt = new Date(Date.now() + 5 * 60 * 1000); // 5 minutes
await this.otpRepository.save({
phone,
code: otp,
purpose: OtpPurpose.REGISTRATION,
expiresAt,
});
// TODO: Send OTP via SMS (Twilio)
// In development, log the OTP
if (this.configService.get('NODE_ENV') === 'development') {
console.warn(`[DEV] OTP for ${phone}: ${otp}`);
}
return {
message: 'Codigo de verificacion enviado',
expiresIn: 300,
};
}
async verifyOtpAndCreateAccount(verifyOtpDto: VerifyOtpDto) {
const { phone, otp, password, name } = verifyOtpDto;
// Find valid OTP
const otpRecord = await this.otpRepository.findOne({
where: {
phone,
code: otp,
purpose: OtpPurpose.REGISTRATION,
isUsed: false,
expiresAt: MoreThan(new Date()),
},
});
// In development, accept 123456 as test OTP
const isDev = this.configService.get('NODE_ENV') === 'development';
if (!otpRecord && !(isDev && otp === '123456')) {
throw new BadRequestException('Codigo invalido o expirado');
}
// Check attempts
if (otpRecord && otpRecord.attempts >= 3) {
throw new BadRequestException(
'Demasiados intentos. Solicita un nuevo codigo',
);
}
// Update attempts if wrong
if (otpRecord) {
await this.otpRepository.update(otpRecord.id, {
isUsed: true,
});
}
// Hash password
const passwordHash = await bcrypt.hash(password, 10);
// Create user
const user = await this.usersService.create({
phone,
name,
passwordHash,
});
// Generate tokens
const tokens = await this.generateTokens(user.id);
// Save refresh token
await this.saveRefreshToken(user.id, tokens.refreshToken);
return {
user: {
id: user.id,
name: user.name,
phone: user.phone,
},
...tokens,
};
}
async login(loginDto: LoginDto) {
const { phone, password } = loginDto;
const user = await this.usersService.findByPhone(phone);
if (!user) {
throw new UnauthorizedException('Credenciales invalidas');
}
if (!user.passwordHash) {
throw new UnauthorizedException(
'Usuario sin contrasena configurada. Use OTP para acceder.',
);
}
const isValid = await bcrypt.compare(password, user.passwordHash);
if (!isValid) {
throw new UnauthorizedException('Credenciales invalidas');
}
if (!user.isActive) {
throw new UnauthorizedException('Cuenta desactivada');
}
// Generate tokens
const tokens = await this.generateTokens(user.id);
// Save refresh token
await this.saveRefreshToken(user.id, tokens.refreshToken);
return {
user: {
id: user.id,
name: user.name,
phone: user.phone,
},
...tokens,
};
}
async refreshTokens(refreshTokenDto: RefreshTokenDto) {
const { refreshToken } = refreshTokenDto;
try {
const payload = this.jwtService.verify(refreshToken, {
secret: this.configService.get('JWT_REFRESH_SECRET', 'refresh-secret'),
});
// Verify refresh token in database
const tokenRecord = await this.refreshTokenRepository.findOne({
where: {
userId: payload.sub,
token: refreshToken,
isRevoked: false,
expiresAt: MoreThan(new Date()),
},
});
if (!tokenRecord) {
throw new UnauthorizedException('Refresh token invalido o expirado');
}
// Revoke old token
await this.refreshTokenRepository.update(tokenRecord.id, {
isRevoked: true,
});
// Generate new tokens
const tokens = await this.generateTokens(payload.sub);
// Save new refresh token
await this.saveRefreshToken(payload.sub, tokens.refreshToken);
return tokens;
} catch (error) {
if (error instanceof UnauthorizedException) {
throw error;
}
throw new UnauthorizedException('Refresh token invalido');
}
}
async logout(refreshToken: string) {
// Revoke refresh token
await this.refreshTokenRepository.update(
{ token: refreshToken },
{ isRevoked: true },
);
return { message: 'Sesion cerrada exitosamente' };
}
async revokeAllUserTokens(userId: string) {
await this.refreshTokenRepository.update(
{ userId, isRevoked: false },
{ isRevoked: true },
);
}
private async generateTokens(userId: string) {
const payload = { sub: userId };
const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(payload),
this.jwtService.signAsync(payload, {
secret: this.configService.get('JWT_REFRESH_SECRET', 'refresh-secret'),
expiresIn: this.configService.get('JWT_REFRESH_EXPIRES_IN', '7d'),
}),
]);
return {
accessToken,
refreshToken,
expiresIn: 900, // 15 minutes in seconds
};
}
private async saveRefreshToken(userId: string, token: string) {
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
await this.refreshTokenRepository.save({
userId,
token,
expiresAt,
});
}
private generateOtp(): string {
return Math.floor(100000 + Math.random() * 900000).toString();
}
}

View File

@ -0,0 +1,22 @@
import { IsString, IsNotEmpty, Matches, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class LoginDto {
@ApiProperty({
description: 'Numero de telefono (10 digitos)',
example: '5512345678',
})
@IsString()
@IsNotEmpty()
@Matches(/^[0-9]{10}$/, { message: 'Telefono debe tener 10 digitos' })
phone: string;
@ApiProperty({
description: 'Contrasena',
example: 'miContrasena123',
})
@IsString()
@IsNotEmpty()
@MinLength(6)
password: string;
}

View File

@ -0,0 +1,11 @@
import { IsString, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class RefreshTokenDto {
@ApiProperty({
description: 'Refresh token',
})
@IsString()
@IsNotEmpty()
refreshToken: string;
}

View File

@ -0,0 +1,23 @@
import { IsString, IsNotEmpty, Matches, MinLength, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class RegisterDto {
@ApiProperty({
description: 'Numero de telefono (10 digitos)',
example: '5512345678',
})
@IsString()
@IsNotEmpty()
@Matches(/^[0-9]{10}$/, { message: 'Telefono debe tener 10 digitos' })
phone: string;
@ApiProperty({
description: 'Nombre del usuario',
example: 'Juan Perez',
})
@IsString()
@IsNotEmpty()
@MinLength(2)
@MaxLength(100)
name: string;
}

View File

@ -0,0 +1,49 @@
import {
IsString,
IsNotEmpty,
Length,
Matches,
MinLength,
MaxLength,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class VerifyOtpDto {
@ApiProperty({
description: 'Numero de telefono (10 digitos)',
example: '5512345678',
})
@IsString()
@IsNotEmpty()
@Matches(/^[0-9]{10}$/, { message: 'Telefono debe tener 10 digitos' })
phone: string;
@ApiProperty({
description: 'Codigo OTP de 6 digitos',
example: '123456',
})
@IsString()
@Length(6, 6, { message: 'El codigo debe tener 6 digitos' })
@Matches(/^[0-9]+$/, { message: 'El codigo solo debe contener numeros' })
otp: string;
@ApiProperty({
description: 'Contrasena para la cuenta',
example: 'miContrasena123',
})
@IsString()
@IsNotEmpty()
@MinLength(6, { message: 'La contrasena debe tener al menos 6 caracteres' })
@MaxLength(50)
password: string;
@ApiProperty({
description: 'Nombre del usuario',
example: 'Juan Perez',
})
@IsString()
@IsNotEmpty()
@MinLength(2)
@MaxLength(100)
name: string;
}

View File

@ -0,0 +1,42 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
} from 'typeorm';
export enum OtpPurpose {
REGISTRATION = 'REGISTRATION',
LOGIN = 'LOGIN',
PASSWORD_RESET = 'PASSWORD_RESET',
}
@Entity('otps')
@Index(['phone', 'purpose'])
@Index(['expiresAt'])
export class Otp {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 20 })
phone: string;
@Column({ type: 'varchar', length: 6 })
code: string;
@Column({ type: 'enum', enum: OtpPurpose })
purpose: OtpPurpose;
@Column({ type: 'int', default: 0 })
attempts: number;
@Column({ type: 'boolean', default: false })
isUsed: boolean;
@Column({ type: 'timestamp' })
expiresAt: Date;
@CreateDateColumn()
createdAt: Date;
}

View File

@ -0,0 +1,44 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
@Entity('refresh_tokens')
@Index(['userId'])
@Index(['token'])
@Index(['expiresAt'])
export class RefreshToken {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
userId: string;
@ManyToOne(() => User)
@JoinColumn({ name: 'userId' })
user: User;
@Column({ type: 'varchar', length: 500 })
token: string;
@Column({ type: 'varchar', length: 100, nullable: true })
deviceInfo: string;
@Column({ type: 'varchar', length: 50, nullable: true })
ipAddress: string;
@Column({ type: 'boolean', default: false })
isRevoked: boolean;
@Column({ type: 'timestamp' })
expiresAt: Date;
@CreateDateColumn()
createdAt: Date;
}

View File

@ -0,0 +1,9 @@
import { Injectable, ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
canActivate(context: ExecutionContext) {
return super.canActivate(context);
}
}

View File

@ -0,0 +1,27 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { UsersService } from '../../users/users.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private readonly configService: ConfigService,
private readonly usersService: UsersService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get('JWT_SECRET', 'your-secret-key'),
});
}
async validate(payload: { sub: string }) {
const user = await this.usersService.findById(payload.sub);
if (!user || !user.isActive) {
throw new UnauthorizedException('Usuario no autorizado');
}
return user;
}
}

View File

@ -0,0 +1,72 @@
import {
Controller,
Get,
Query,
UseGuards,
Request,
ParseIntPipe,
DefaultValuePipe,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CreditsService } from './credits.service';
import { AuthenticatedRequest } from '../../common/interfaces/authenticated-request.interface';
@ApiTags('credits')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('credits')
export class CreditsController {
constructor(private readonly creditsService: CreditsService) {}
@Get('balance')
@ApiOperation({ summary: 'Obtener balance de creditos' })
@ApiResponse({ status: 200, description: 'Balance actual' })
async getBalance(@Request() req: AuthenticatedRequest) {
const balance = await this.creditsService.getBalance(req.user.id);
return {
balance: balance.balance,
totalPurchased: balance.totalPurchased,
totalConsumed: balance.totalConsumed,
totalFromReferrals: balance.totalFromReferrals,
};
}
@Get('transactions')
@ApiOperation({ summary: 'Obtener historial de transacciones' })
@ApiResponse({ status: 200, description: 'Lista de transacciones' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
async getTransactions(
@Request() req: AuthenticatedRequest,
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number,
) {
const { transactions, total } = await this.creditsService.getTransactions(
req.user.id,
page,
Math.min(limit, 100),
);
return {
transactions,
total,
page,
limit,
hasMore: page * limit < total,
};
}
@Get('packages')
@ApiOperation({ summary: 'Obtener paquetes de creditos disponibles' })
@ApiResponse({ status: 200, description: 'Lista de paquetes' })
async getPackages() {
return this.creditsService.getPackages();
}
}

View File

@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CreditsController } from './credits.controller';
import { CreditsService } from './credits.service';
import { CreditBalance } from './entities/credit-balance.entity';
import { CreditTransaction } from './entities/credit-transaction.entity';
import { CreditPackage } from './entities/credit-package.entity';
@Module({
imports: [
TypeOrmModule.forFeature([CreditBalance, CreditTransaction, CreditPackage]),
],
controllers: [CreditsController],
providers: [CreditsService],
exports: [CreditsService],
})
export class CreditsModule {}

View File

@ -0,0 +1,211 @@
import {
Injectable,
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { CreditBalance } from './entities/credit-balance.entity';
import {
CreditTransaction,
TransactionType,
} from './entities/credit-transaction.entity';
import { CreditPackage } from './entities/credit-package.entity';
@Injectable()
export class CreditsService {
constructor(
@InjectRepository(CreditBalance)
private readonly balanceRepository: Repository<CreditBalance>,
@InjectRepository(CreditTransaction)
private readonly transactionRepository: Repository<CreditTransaction>,
@InjectRepository(CreditPackage)
private readonly packageRepository: Repository<CreditPackage>,
private readonly dataSource: DataSource,
) {}
async getBalance(userId: string): Promise<CreditBalance> {
let balance = await this.balanceRepository.findOne({ where: { userId } });
if (!balance) {
// Create initial balance for new users
balance = this.balanceRepository.create({
userId,
balance: 0,
totalPurchased: 0,
totalConsumed: 0,
totalFromReferrals: 0,
});
await this.balanceRepository.save(balance);
}
return balance;
}
async hasMinimumCredits(userId: string, minimum: number): Promise<boolean> {
const balance = await this.getBalance(userId);
return balance.balance >= minimum;
}
async addCredits(
userId: string,
amount: number,
type: TransactionType,
description: string,
referenceId?: string,
referenceType?: string,
): Promise<CreditTransaction> {
return this.dataSource.transaction(async (manager) => {
const balanceRepo = manager.getRepository(CreditBalance);
const transactionRepo = manager.getRepository(CreditTransaction);
// Get or create balance
let balance = await balanceRepo.findOne({ where: { userId } });
if (!balance) {
balance = balanceRepo.create({
userId,
balance: 0,
totalPurchased: 0,
totalConsumed: 0,
totalFromReferrals: 0,
});
}
// Update balance
balance.balance += amount;
if (type === TransactionType.PURCHASE) {
balance.totalPurchased += amount;
} else if (type === TransactionType.REFERRAL_BONUS) {
balance.totalFromReferrals += amount;
}
await balanceRepo.save(balance);
// Create transaction record
const transaction = transactionRepo.create({
userId,
type,
amount,
balanceAfter: balance.balance,
description,
referenceId,
referenceType,
});
return transactionRepo.save(transaction);
});
}
async consume(
userId: string,
amount: number,
description: string,
referenceId?: string,
referenceType?: string,
): Promise<CreditTransaction> {
return this.dataSource.transaction(async (manager) => {
const balanceRepo = manager.getRepository(CreditBalance);
const transactionRepo = manager.getRepository(CreditTransaction);
const balance = await balanceRepo.findOne({ where: { userId } });
if (!balance || balance.balance < amount) {
throw new BadRequestException('Creditos insuficientes');
}
// Deduct credits
balance.balance -= amount;
balance.totalConsumed += amount;
await balanceRepo.save(balance);
// Create transaction record
const transaction = transactionRepo.create({
userId,
type: TransactionType.CONSUMPTION,
amount: -amount,
balanceAfter: balance.balance,
description,
referenceId,
referenceType,
});
return transactionRepo.save(transaction);
});
}
async getTransactions(
userId: string,
page = 1,
limit = 20,
): Promise<{ transactions: CreditTransaction[]; total: number }> {
const [transactions, total] = await this.transactionRepository.findAndCount(
{
where: { userId },
order: { createdAt: 'DESC' },
skip: (page - 1) * limit,
take: limit,
},
);
return { transactions, total };
}
async getPackages(): Promise<CreditPackage[]> {
return this.packageRepository.find({
where: { isActive: true },
order: { sortOrder: 'ASC' },
});
}
async getPackageById(packageId: string): Promise<CreditPackage> {
const pkg = await this.packageRepository.findOne({
where: { id: packageId, isActive: true },
});
if (!pkg) {
throw new NotFoundException('Paquete no encontrado');
}
return pkg;
}
async grantWelcomeCredits(userId: string): Promise<void> {
const welcomeCredits = 50; // Free credits for new users
await this.addCredits(
userId,
welcomeCredits,
TransactionType.PROMO,
'Creditos de bienvenida',
);
}
async grantReferralBonus(
referrerId: string,
referredId: string,
referralId: string,
): Promise<void> {
const referrerBonus = 100;
const referredBonus = 50;
// Give bonus to referrer
await this.addCredits(
referrerId,
referrerBonus,
TransactionType.REFERRAL_BONUS,
'Bonus por referido',
referralId,
'referral',
);
// Give bonus to referred user
await this.addCredits(
referredId,
referredBonus,
TransactionType.REFERRAL_BONUS,
'Bonus por usar codigo de referido',
referralId,
'referral',
);
}
}

View File

@ -0,0 +1,41 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToOne,
JoinColumn,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
@Entity('credit_balances')
export class CreditBalance {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', unique: true })
userId: string;
@OneToOne(() => User)
@JoinColumn({ name: 'userId' })
user: User;
@Column({ type: 'int', default: 0 })
balance: number;
@Column({ type: 'int', default: 0 })
totalPurchased: number;
@Column({ type: 'int', default: 0 })
totalConsumed: number;
@Column({ type: 'int', default: 0 })
totalFromReferrals: number;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@ -0,0 +1,43 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('credit_packages')
export class CreditPackage {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 100 })
name: string;
@Column({ type: 'varchar', length: 255, nullable: true })
description: string;
@Column({ type: 'int' })
credits: number;
@Column({ type: 'decimal', precision: 10, scale: 2 })
priceMXN: number;
@Column({ type: 'boolean', default: false })
isPopular: boolean;
@Column({ type: 'boolean', default: true })
isActive: boolean;
@Column({ type: 'int', default: 0 })
sortOrder: number;
@Column({ type: 'varchar', length: 100, nullable: true })
stripePriceId: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@ -0,0 +1,57 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
export enum TransactionType {
PURCHASE = 'PURCHASE',
CONSUMPTION = 'CONSUMPTION',
REFERRAL_BONUS = 'REFERRAL_BONUS',
PROMO = 'PROMO',
REFUND = 'REFUND',
}
@Entity('credit_transactions')
@Index(['userId', 'createdAt'])
@Index(['type', 'createdAt'])
export class CreditTransaction {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
userId: string;
@ManyToOne(() => User)
@JoinColumn({ name: 'userId' })
user: User;
@Column({ type: 'enum', enum: TransactionType })
type: TransactionType;
@Column({ type: 'int' })
amount: number;
@Column({ type: 'int' })
balanceAfter: number;
@Column({ type: 'varchar', length: 255, nullable: true })
description: string;
@Column({ type: 'uuid', nullable: true })
referenceId: string;
@Column({ type: 'varchar', length: 50, nullable: true })
referenceType: string;
@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, unknown>;
@CreateDateColumn()
createdAt: Date;
}

View File

@ -0,0 +1,12 @@
import { IsInt, IsOptional, IsString, Min, MaxLength } from 'class-validator';
export class CorrectQuantityDto {
@IsInt()
@Min(0)
quantity: number;
@IsOptional()
@IsString()
@MaxLength(255)
reason?: string;
}

View File

@ -0,0 +1,23 @@
import { IsString, IsOptional, MaxLength, IsNotEmpty } from 'class-validator';
export class CorrectSkuDto {
@IsString()
@IsNotEmpty()
@MaxLength(255)
name: string;
@IsOptional()
@IsString()
@MaxLength(100)
category?: string;
@IsOptional()
@IsString()
@MaxLength(50)
barcode?: string;
@IsOptional()
@IsString()
@MaxLength(255)
reason?: string;
}

View File

@ -0,0 +1,46 @@
import {
IsString,
IsOptional,
IsUUID,
IsInt,
IsObject,
MaxLength,
IsNotEmpty,
} from 'class-validator';
export class SubmitProductDto {
@IsUUID()
storeId: string;
@IsOptional()
@IsUUID()
videoId?: string;
@IsString()
@IsNotEmpty()
@MaxLength(255)
name: string;
@IsOptional()
@IsString()
@MaxLength(100)
category?: string;
@IsOptional()
@IsString()
@MaxLength(50)
barcode?: string;
@IsOptional()
@IsString()
@MaxLength(500)
imageUrl?: string;
@IsOptional()
@IsInt()
frameTimestamp?: number;
@IsOptional()
@IsObject()
boundingBox?: Record<string, any>;
}

View File

@ -0,0 +1,67 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
import { Store } from '../../stores/entities/store.entity';
import { InventoryItem } from '../../inventory/entities/inventory-item.entity';
export enum CorrectionType {
QUANTITY = 'QUANTITY',
SKU = 'SKU',
CONFIRMATION = 'CONFIRMATION',
}
@Entity('corrections')
@Index(['inventoryItemId'])
@Index(['userId'])
@Index(['storeId'])
@Index(['type', 'createdAt'])
export class Correction {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column('uuid')
inventoryItemId: string;
@Column('uuid')
userId: string;
@Column('uuid')
storeId: string;
@Column({
type: 'enum',
enum: CorrectionType,
})
type: CorrectionType;
@Column('jsonb')
previousValue: Record<string, any>;
@Column('jsonb')
newValue: Record<string, any>;
@Column({ type: 'varchar', length: 255, nullable: true })
reason?: string;
@CreateDateColumn()
createdAt: Date;
@ManyToOne(() => InventoryItem, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'inventoryItemId' })
inventoryItem: InventoryItem;
@ManyToOne(() => User)
@JoinColumn({ name: 'userId' })
user: User;
@ManyToOne(() => Store)
@JoinColumn({ name: 'storeId' })
store: Store;
}

View File

@ -0,0 +1,86 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
import { Store } from '../../stores/entities/store.entity';
import { Video } from '../../videos/entities/video.entity';
import { InventoryItem } from '../../inventory/entities/inventory-item.entity';
export enum GroundTruthStatus {
PENDING = 'PENDING',
APPROVED = 'APPROVED',
REJECTED = 'REJECTED',
}
@Entity('ground_truth')
@Index(['inventoryItemId'])
@Index(['videoId'])
@Index(['status'])
@Index(['storeId'])
export class GroundTruth {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column('uuid')
inventoryItemId: string;
@Column('uuid')
videoId: string;
@Column('uuid')
storeId: string;
@Column('jsonb')
originalDetection: Record<string, any>;
@Column('jsonb')
correctedData: Record<string, any>;
@Column({ type: 'int', nullable: true })
frameTimestamp?: number;
@Column({ type: 'jsonb', nullable: true })
boundingBox?: Record<string, any>;
@Column({
type: 'enum',
enum: GroundTruthStatus,
default: GroundTruthStatus.PENDING,
})
status: GroundTruthStatus;
@Column({ type: 'uuid', nullable: true })
validatedBy?: string;
@Column({ type: 'decimal', precision: 5, scale: 2, nullable: true })
validationScore?: number;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@ManyToOne(() => InventoryItem, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'inventoryItemId' })
inventoryItem: InventoryItem;
@ManyToOne(() => Video)
@JoinColumn({ name: 'videoId' })
video: Video;
@ManyToOne(() => Store)
@JoinColumn({ name: 'storeId' })
store: Store;
@ManyToOne(() => User, { nullable: true })
@JoinColumn({ name: 'validatedBy' })
validator?: User;
}

View File

@ -0,0 +1,91 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
import { Store } from '../../stores/entities/store.entity';
import { Video } from '../../videos/entities/video.entity';
export enum ProductSubmissionStatus {
PENDING = 'PENDING',
APPROVED = 'APPROVED',
REJECTED = 'REJECTED',
}
@Entity('product_submissions')
@Index(['userId'])
@Index(['storeId'])
@Index(['status'])
@Index(['barcode'])
export class ProductSubmission {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column('uuid')
userId: string;
@Column('uuid')
storeId: string;
@Column({ type: 'uuid', nullable: true })
videoId?: string;
@Column({ type: 'varchar', length: 255 })
name: string;
@Column({ type: 'varchar', length: 100, nullable: true })
category?: string;
@Column({ type: 'varchar', length: 50, nullable: true })
barcode?: string;
@Column({ type: 'varchar', length: 500, nullable: true })
imageUrl?: string;
@Column({ type: 'int', nullable: true })
frameTimestamp?: number;
@Column({ type: 'jsonb', nullable: true })
boundingBox?: Record<string, any>;
@Column({
type: 'enum',
enum: ProductSubmissionStatus,
default: ProductSubmissionStatus.PENDING,
})
status: ProductSubmissionStatus;
@Column({ type: 'uuid', nullable: true })
reviewedBy?: string;
@Column({ type: 'text', nullable: true })
reviewNotes?: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@ManyToOne(() => User)
@JoinColumn({ name: 'userId' })
user: User;
@ManyToOne(() => Store)
@JoinColumn({ name: 'storeId' })
store: Store;
@ManyToOne(() => Video, { nullable: true })
@JoinColumn({ name: 'videoId' })
video?: Video;
@ManyToOne(() => User, { nullable: true })
@JoinColumn({ name: 'reviewedBy' })
reviewer?: User;
}

View File

@ -0,0 +1,170 @@
import {
Controller,
Get,
Post,
Patch,
Body,
Param,
Query,
UseGuards,
Req,
ParseUUIDPipe,
} from '@nestjs/common';
import { Request } from 'express';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { FeedbackService } from './feedback.service';
import { CorrectQuantityDto } from './dto/correct-quantity.dto';
import { CorrectSkuDto } from './dto/correct-sku.dto';
import { SubmitProductDto } from './dto/submit-product.dto';
interface AuthRequest extends Request {
user: { id: string };
}
@Controller()
@UseGuards(JwtAuthGuard)
export class FeedbackController {
constructor(private readonly feedbackService: FeedbackService) {}
@Patch('stores/:storeId/inventory/:itemId/correct-quantity')
async correctQuantity(
@Req() req: AuthRequest,
@Param('storeId', ParseUUIDPipe) storeId: string,
@Param('itemId', ParseUUIDPipe) itemId: string,
@Body() dto: CorrectQuantityDto,
) {
const { correction, item } = await this.feedbackService.correctQuantity(
req.user.id,
storeId,
itemId,
dto,
);
return {
message: 'Cantidad corregida exitosamente',
correction: {
id: correction.id,
type: correction.type,
previousValue: correction.previousValue,
newValue: correction.newValue,
createdAt: correction.createdAt,
},
item: {
id: item.id,
name: item.name,
quantity: item.quantity,
},
};
}
@Patch('stores/:storeId/inventory/:itemId/correct-sku')
async correctSku(
@Req() req: AuthRequest,
@Param('storeId', ParseUUIDPipe) storeId: string,
@Param('itemId', ParseUUIDPipe) itemId: string,
@Body() dto: CorrectSkuDto,
) {
const { correction, item } = await this.feedbackService.correctSku(
req.user.id,
storeId,
itemId,
dto,
);
return {
message: 'Nombre/SKU corregido exitosamente',
correction: {
id: correction.id,
type: correction.type,
previousValue: correction.previousValue,
newValue: correction.newValue,
createdAt: correction.createdAt,
},
item: {
id: item.id,
name: item.name,
category: item.category,
barcode: item.barcode,
},
};
}
@Post('stores/:storeId/inventory/:itemId/confirm')
async confirmItem(
@Req() req: AuthRequest,
@Param('storeId', ParseUUIDPipe) storeId: string,
@Param('itemId', ParseUUIDPipe) itemId: string,
) {
const { correction, item } = await this.feedbackService.confirmItem(
req.user.id,
storeId,
itemId,
);
return {
message: 'Item confirmado exitosamente',
correction: {
id: correction.id,
type: correction.type,
createdAt: correction.createdAt,
},
item: {
id: item.id,
name: item.name,
quantity: item.quantity,
},
};
}
@Get('stores/:storeId/inventory/:itemId/history')
async getCorrectionHistory(
@Req() req: AuthRequest,
@Param('storeId', ParseUUIDPipe) storeId: string,
@Param('itemId', ParseUUIDPipe) itemId: string,
) {
const corrections = await this.feedbackService.getCorrectionHistory(
req.user.id,
storeId,
itemId,
);
return {
corrections: corrections.map((c) => ({
id: c.id,
type: c.type,
previousValue: c.previousValue,
newValue: c.newValue,
reason: c.reason,
createdAt: c.createdAt,
user: c.user ? { id: c.user.id, name: c.user.name } : null,
})),
};
}
@Post('products/submit')
async submitProduct(@Req() req: AuthRequest, @Body() dto: SubmitProductDto) {
const submission = await this.feedbackService.submitProduct(req.user.id, dto);
return {
message: 'Producto enviado para revision',
submission: {
id: submission.id,
name: submission.name,
status: submission.status,
createdAt: submission.createdAt,
},
};
}
@Get('products/search')
async searchProducts(@Query('q') query: string, @Query('limit') limit?: string) {
const products = await this.feedbackService.searchProducts(
query || '',
limit ? parseInt(limit, 10) : 10,
);
return {
products: products.map((p) => ({
id: p.id,
name: p.name,
category: p.category,
barcode: p.barcode,
imageUrl: p.imageUrl,
})),
};
}
}

View File

@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FeedbackController } from './feedback.controller';
import { FeedbackService } from './feedback.service';
import { Correction } from './entities/correction.entity';
import { GroundTruth } from './entities/ground-truth.entity';
import { ProductSubmission } from './entities/product-submission.entity';
import { InventoryItem } from '../inventory/entities/inventory-item.entity';
import { StoreUser } from '../stores/entities/store-user.entity';
@Module({
imports: [
TypeOrmModule.forFeature([
Correction,
GroundTruth,
ProductSubmission,
InventoryItem,
StoreUser,
]),
],
controllers: [FeedbackController],
providers: [FeedbackService],
exports: [FeedbackService],
})
export class FeedbackModule {}

View File

@ -0,0 +1,239 @@
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Correction, CorrectionType } from './entities/correction.entity';
import { GroundTruth, GroundTruthStatus } from './entities/ground-truth.entity';
import { ProductSubmission } from './entities/product-submission.entity';
import { InventoryItem } from '../inventory/entities/inventory-item.entity';
import { StoreUser } from '../stores/entities/store-user.entity';
import { CorrectQuantityDto } from './dto/correct-quantity.dto';
import { CorrectSkuDto } from './dto/correct-sku.dto';
import { SubmitProductDto } from './dto/submit-product.dto';
@Injectable()
export class FeedbackService {
constructor(
@InjectRepository(Correction)
private correctionRepository: Repository<Correction>,
@InjectRepository(GroundTruth)
private groundTruthRepository: Repository<GroundTruth>,
@InjectRepository(ProductSubmission)
private productSubmissionRepository: Repository<ProductSubmission>,
@InjectRepository(InventoryItem)
private inventoryItemRepository: Repository<InventoryItem>,
@InjectRepository(StoreUser)
private storeUserRepository: Repository<StoreUser>,
) {}
async verifyStoreAccess(userId: string, storeId: string): Promise<void> {
const storeUser = await this.storeUserRepository.findOne({
where: { userId, storeId, isActive: true },
});
if (!storeUser) {
throw new ForbiddenException('No tienes acceso a esta tienda');
}
}
async getInventoryItem(itemId: string, storeId: string): Promise<InventoryItem> {
const item = await this.inventoryItemRepository.findOne({
where: { id: itemId, storeId },
});
if (!item) {
throw new NotFoundException('Item de inventario no encontrado');
}
return item;
}
async correctQuantity(
userId: string,
storeId: string,
itemId: string,
dto: CorrectQuantityDto,
): Promise<{ correction: Correction; item: InventoryItem }> {
await this.verifyStoreAccess(userId, storeId);
const item = await this.getInventoryItem(itemId, storeId);
const previousValue = { quantity: item.quantity };
const newValue = { quantity: dto.quantity };
// Create correction record
const correction = this.correctionRepository.create({
inventoryItemId: itemId,
userId,
storeId,
type: CorrectionType.QUANTITY,
previousValue,
newValue,
reason: dto.reason,
});
await this.correctionRepository.save(correction);
// Update item
item.quantity = dto.quantity;
item.isManuallyEdited = true;
await this.inventoryItemRepository.save(item);
// Create ground truth entry if item was detected from video
if (item.detectedByVideoId) {
await this.createGroundTruth(item, previousValue, newValue);
}
return { correction, item };
}
async correctSku(
userId: string,
storeId: string,
itemId: string,
dto: CorrectSkuDto,
): Promise<{ correction: Correction; item: InventoryItem }> {
await this.verifyStoreAccess(userId, storeId);
const item = await this.getInventoryItem(itemId, storeId);
const previousValue = {
name: item.name,
category: item.category,
barcode: item.barcode,
};
const newValue = {
name: dto.name,
category: dto.category ?? item.category,
barcode: dto.barcode ?? item.barcode,
};
// Create correction record
const correction = this.correctionRepository.create({
inventoryItemId: itemId,
userId,
storeId,
type: CorrectionType.SKU,
previousValue,
newValue,
reason: dto.reason,
});
await this.correctionRepository.save(correction);
// Update item
item.name = dto.name;
if (dto.category) item.category = dto.category;
if (dto.barcode) item.barcode = dto.barcode;
item.isManuallyEdited = true;
await this.inventoryItemRepository.save(item);
// Create ground truth entry if item was detected from video
if (item.detectedByVideoId) {
await this.createGroundTruth(item, previousValue, newValue);
}
return { correction, item };
}
async confirmItem(
userId: string,
storeId: string,
itemId: string,
): Promise<{ correction: Correction; item: InventoryItem }> {
await this.verifyStoreAccess(userId, storeId);
const item = await this.getInventoryItem(itemId, storeId);
const currentValue = {
name: item.name,
quantity: item.quantity,
category: item.category,
};
// Create confirmation record
const correction = this.correctionRepository.create({
inventoryItemId: itemId,
userId,
storeId,
type: CorrectionType.CONFIRMATION,
previousValue: currentValue,
newValue: currentValue,
});
await this.correctionRepository.save(correction);
// Mark as manually verified
item.isManuallyEdited = true;
await this.inventoryItemRepository.save(item);
// Create approved ground truth if from video
if (item.detectedByVideoId) {
const groundTruth = this.groundTruthRepository.create({
inventoryItemId: itemId,
videoId: item.detectedByVideoId,
storeId,
originalDetection: currentValue,
correctedData: currentValue,
status: GroundTruthStatus.APPROVED,
validatedBy: userId,
validationScore: 1.0,
});
await this.groundTruthRepository.save(groundTruth);
}
return { correction, item };
}
async getCorrectionHistory(
userId: string,
storeId: string,
itemId: string,
): Promise<Correction[]> {
await this.verifyStoreAccess(userId, storeId);
await this.getInventoryItem(itemId, storeId);
return this.correctionRepository.find({
where: { inventoryItemId: itemId },
order: { createdAt: 'DESC' },
relations: ['user'],
});
}
async submitProduct(userId: string, dto: SubmitProductDto): Promise<ProductSubmission> {
await this.verifyStoreAccess(userId, dto.storeId);
const submission = this.productSubmissionRepository.create({
userId,
storeId: dto.storeId,
videoId: dto.videoId,
name: dto.name,
category: dto.category,
barcode: dto.barcode,
imageUrl: dto.imageUrl,
frameTimestamp: dto.frameTimestamp,
boundingBox: dto.boundingBox,
});
return this.productSubmissionRepository.save(submission);
}
async searchProducts(query: string, limit = 10): Promise<ProductSubmission[]> {
return this.productSubmissionRepository
.createQueryBuilder('ps')
.where('ps.status = :status', { status: 'APPROVED' })
.andWhere('(ps.name ILIKE :query OR ps.barcode = :exactQuery)', {
query: `%${query}%`,
exactQuery: query,
})
.orderBy('ps.name', 'ASC')
.limit(limit)
.getMany();
}
private async createGroundTruth(
item: InventoryItem,
originalDetection: Record<string, any>,
correctedData: Record<string, any>,
): Promise<GroundTruth> {
const groundTruth = this.groundTruthRepository.create({
inventoryItemId: item.id,
videoId: item.detectedByVideoId!,
storeId: item.storeId,
originalDetection,
correctedData,
status: GroundTruthStatus.PENDING,
});
return this.groundTruthRepository.save(groundTruth);
}
}

View File

@ -0,0 +1,27 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
@ApiTags('health')
@Controller('health')
export class HealthController {
@Get()
@ApiOperation({ summary: 'Health check' })
check() {
return {
status: 'ok',
timestamp: new Date().toISOString(),
service: 'miinventario-api',
version: '0.1.0',
};
}
@Get('ready')
@ApiOperation({ summary: 'Readiness check' })
ready() {
// TODO: Add database and redis connectivity checks
return {
status: 'ready',
timestamp: new Date().toISOString(),
};
}
}

View File

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { IAProviderService } from './ia-provider.service';
@Module({
providers: [IAProviderService],
exports: [IAProviderService],
})
export class IAProviderModule {}

View File

@ -0,0 +1,331 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import OpenAI from 'openai';
import Anthropic from '@anthropic-ai/sdk';
import { DetectedItem } from '../inventory/inventory.service';
export interface IAProvider {
name: string;
detectInventory(frames: string[], context?: string): Promise<DetectedItem[]>;
}
const INVENTORY_DETECTION_PROMPT = `Eres un sistema de vision por computadora especializado en detectar productos de inventario en tiendas mexicanas (abarrotes, miscelaneas, tienditas).
Analiza las imagenes proporcionadas y detecta todos los productos visibles en los estantes.
Para cada producto detectado, proporciona:
1. name: Nombre del producto incluyendo marca y presentacion (ej: "Coca Cola 600ml", "Sabritas Original 45g")
2. quantity: Cantidad estimada visible
3. confidence: Tu nivel de confianza (0.0 a 1.0)
4. category: Categoria del producto (Bebidas, Botanas, Lacteos, Panaderia, Abarrotes, Limpieza, etc.)
5. barcode: Codigo de barras si es visible (opcional)
Responde UNICAMENTE con un JSON array valido, sin texto adicional. Ejemplo:
[
{"name": "Coca Cola 600ml", "quantity": 24, "confidence": 0.95, "category": "Bebidas", "barcode": "7501055303045"},
{"name": "Sabritas Original 45g", "quantity": 15, "confidence": 0.92, "category": "Botanas"}
]
Si no puedes detectar productos, responde con un array vacio: []`;
@Injectable()
export class IAProviderService {
private readonly logger = new Logger(IAProviderService.name);
private activeProvider: string;
private openai: OpenAI | null = null;
private anthropic: Anthropic | null = null;
constructor(private readonly configService: ConfigService) {
this.activeProvider = this.configService.get('IA_PROVIDER', 'openai');
this.initializeClients();
}
private initializeClients() {
// Initialize OpenAI
const openaiKey = this.configService.get('OPENAI_API_KEY');
if (openaiKey && !openaiKey.includes('your-openai-key')) {
this.openai = new OpenAI({ apiKey: openaiKey });
this.logger.log('OpenAI client initialized');
}
// Initialize Anthropic
const anthropicKey = this.configService.get('ANTHROPIC_API_KEY');
if (anthropicKey && !anthropicKey.includes('your-anthropic-key')) {
this.anthropic = new Anthropic({ apiKey: anthropicKey });
this.logger.log('Anthropic client initialized');
}
if (!this.openai && !this.anthropic) {
this.logger.warn('No IA providers configured - will use mock detection');
}
}
async detectInventory(
frames: string[],
storeId: string,
): Promise<DetectedItem[]> {
this.logger.log(
`Detecting inventory from ${frames.length} frames using ${this.activeProvider}`,
);
try {
switch (this.activeProvider) {
case 'openai':
return this.detectWithOpenAI(frames, storeId);
case 'claude':
return this.detectWithClaude(frames, storeId);
default:
return this.detectWithOpenAI(frames, storeId);
}
} catch (error) {
this.logger.error(`Error detecting inventory: ${error.message}`);
// Fallback to mock data in development
if (this.configService.get('NODE_ENV') === 'development') {
this.logger.warn('Falling back to mock detection');
return this.getMockDetection();
}
throw error;
}
}
private async detectWithOpenAI(
frames: string[],
storeId: string,
): Promise<DetectedItem[]> {
if (!this.openai) {
this.logger.warn('OpenAI not configured, using mock data');
return this.getMockDetection();
}
this.logger.log(`Calling OpenAI Vision API with ${frames.length} images`);
// Prepare image content for the API
const imageContent: OpenAI.Chat.Completions.ChatCompletionContentPart[] =
frames.slice(0, 10).map((frame) => ({
type: 'image_url' as const,
image_url: {
url: frame.startsWith('data:') ? frame : `data:image/jpeg;base64,${frame}`,
detail: 'high' as const,
},
}));
try {
const response = await this.openai.chat.completions.create({
model: 'gpt-4o',
messages: [
{
role: 'user',
content: [
{ type: 'text', text: INVENTORY_DETECTION_PROMPT },
...imageContent,
],
},
],
max_tokens: 4096,
temperature: 0.1,
});
const content = response.choices[0]?.message?.content;
if (!content) {
this.logger.warn('Empty response from OpenAI');
return [];
}
return this.parseDetectionResponse(content);
} catch (error) {
this.logger.error(`OpenAI API error: ${error.message}`);
throw error;
}
}
private async detectWithClaude(
frames: string[],
storeId: string,
): Promise<DetectedItem[]> {
if (!this.anthropic) {
this.logger.warn('Anthropic not configured, using mock data');
return this.getMockDetection();
}
this.logger.log(`Calling Claude Vision API with ${frames.length} images`);
// Prepare image content for Claude
const imageContent: Anthropic.Messages.ImageBlockParam[] = frames
.slice(0, 10)
.map((frame) => ({
type: 'image' as const,
source: {
type: 'base64' as const,
media_type: 'image/jpeg' as const,
data: frame.startsWith('data:')
? frame.replace(/^data:image\/\w+;base64,/, '')
: frame,
},
}));
try {
const response = await this.anthropic.messages.create({
model: 'claude-sonnet-4-20250514',
max_tokens: 4096,
messages: [
{
role: 'user',
content: [
...imageContent,
{ type: 'text', text: INVENTORY_DETECTION_PROMPT },
],
},
],
});
const content = response.content[0];
if (content.type !== 'text') {
this.logger.warn('Unexpected response type from Claude');
return [];
}
return this.parseDetectionResponse(content.text);
} catch (error) {
this.logger.error(`Claude API error: ${error.message}`);
throw error;
}
}
private parseDetectionResponse(content: string): DetectedItem[] {
try {
// Try to extract JSON from the response
let jsonStr = content.trim();
// Handle markdown code blocks
const jsonMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/);
if (jsonMatch) {
jsonStr = jsonMatch[1].trim();
}
// Find the JSON array
const arrayMatch = jsonStr.match(/\[[\s\S]*\]/);
if (arrayMatch) {
jsonStr = arrayMatch[0];
}
const items = JSON.parse(jsonStr);
if (!Array.isArray(items)) {
this.logger.warn('Response is not an array');
return [];
}
// Validate and normalize items
return items
.filter(
(item: any) =>
item.name &&
typeof item.quantity === 'number' &&
item.quantity >= 0,
)
.map((item: any) => ({
name: String(item.name).trim(),
quantity: Math.max(0, Math.floor(item.quantity)),
confidence: Math.min(1, Math.max(0, Number(item.confidence) || 0.5)),
category: item.category ? String(item.category).trim() : undefined,
barcode: item.barcode ? String(item.barcode).trim() : undefined,
}));
} catch (error) {
this.logger.error(`Failed to parse detection response: ${error.message}`);
this.logger.debug(`Raw response: ${content.substring(0, 500)}`);
return [];
}
}
private getMockDetection(): DetectedItem[] {
// Simulated detection results for Mexican tiendita products
return [
{
name: 'Coca Cola 600ml',
quantity: 24,
confidence: 0.95,
category: 'Bebidas',
barcode: '7501055303045',
},
{
name: 'Sabritas Original 45g',
quantity: 15,
confidence: 0.92,
category: 'Botanas',
},
{
name: 'Maruchan Pollo',
quantity: 30,
confidence: 0.88,
category: 'Sopas',
},
{
name: 'Bimbo Pan Blanco',
quantity: 8,
confidence: 0.91,
category: 'Panaderia',
},
{
name: 'Leche Lala 1L',
quantity: 12,
confidence: 0.89,
category: 'Lacteos',
},
{
name: 'Huevos San Juan (12)',
quantity: 6,
confidence: 0.87,
category: 'Huevos',
},
{
name: 'Aceite 1-2-3 1L',
quantity: 5,
confidence: 0.93,
category: 'Aceites',
},
{
name: 'Azucar Zulka 1kg',
quantity: 10,
confidence: 0.90,
category: 'Abarrotes',
},
{
name: 'Frijoles La Costena 400g',
quantity: 18,
confidence: 0.86,
category: 'Enlatados',
},
{
name: 'Jabon Roma 250g',
quantity: 20,
confidence: 0.94,
category: 'Limpieza',
},
];
}
getActiveProvider(): string {
return this.activeProvider;
}
setActiveProvider(provider: string): void {
if (!['openai', 'claude'].includes(provider)) {
throw new Error(`Unknown provider: ${provider}`);
}
this.activeProvider = provider;
this.logger.log(`Switched to provider: ${provider}`);
}
isConfigured(): boolean {
return this.openai !== null || this.anthropic !== null;
}
getAvailableProviders(): string[] {
const providers: string[] = [];
if (this.openai) providers.push('openai');
if (this.anthropic) providers.push('claude');
return providers;
}
}

View File

@ -0,0 +1,82 @@
import {
IsString,
IsNumber,
IsOptional,
Min,
MaxLength,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateInventoryItemDto {
@ApiPropertyOptional({
description: 'Nombre del producto',
example: 'Coca Cola 600ml',
})
@IsString()
@IsOptional()
@MaxLength(255)
name?: string;
@ApiPropertyOptional({
description: 'Cantidad en stock',
example: 24,
})
@IsNumber()
@IsOptional()
@Min(0)
quantity?: number;
@ApiPropertyOptional({
description: 'Categoria',
example: 'Bebidas',
})
@IsString()
@IsOptional()
@MaxLength(100)
category?: string;
@ApiPropertyOptional({
description: 'Subcategoria',
example: 'Refrescos',
})
@IsString()
@IsOptional()
@MaxLength(100)
subcategory?: string;
@ApiPropertyOptional({
description: 'Codigo de barras',
example: '7501055303045',
})
@IsString()
@IsOptional()
@MaxLength(50)
barcode?: string;
@ApiPropertyOptional({
description: 'Stock minimo para alerta',
example: 5,
})
@IsNumber()
@IsOptional()
@Min(0)
minStock?: number;
@ApiPropertyOptional({
description: 'Precio de venta',
example: 18.5,
})
@IsNumber()
@IsOptional()
@Min(0)
price?: number;
@ApiPropertyOptional({
description: 'Costo',
example: 12.0,
})
@IsNumber()
@IsOptional()
@Min(0)
cost?: number;
}

View File

@ -0,0 +1,80 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { Store } from '../../stores/entities/store.entity';
import { Video } from '../../videos/entities/video.entity';
@Entity('inventory_items')
@Index(['storeId', 'name'])
@Index(['storeId', 'category'])
@Index(['storeId', 'barcode'])
export class InventoryItem {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
storeId: string;
@ManyToOne(() => Store)
@JoinColumn({ name: 'storeId' })
store: Store;
@Column({ type: 'uuid', nullable: true })
detectedByVideoId: string;
@ManyToOne(() => Video, { nullable: true })
@JoinColumn({ name: 'detectedByVideoId' })
detectedByVideo: Video;
@Column({ type: 'varchar', length: 255 })
name: string;
@Column({ type: 'varchar', length: 100, nullable: true })
category: string;
@Column({ type: 'varchar', length: 100, nullable: true })
subcategory: string;
@Column({ type: 'varchar', length: 50, nullable: true })
barcode: string;
@Column({ type: 'int', default: 0 })
quantity: number;
@Column({ type: 'int', nullable: true })
minStock: number;
@Column({ type: 'decimal', precision: 10, scale: 2, nullable: true })
price: number;
@Column({ type: 'decimal', precision: 10, scale: 2, nullable: true })
cost: number;
@Column({ type: 'varchar', length: 500, nullable: true })
imageUrl: string;
@Column({ type: 'decimal', precision: 5, scale: 2, nullable: true })
detectionConfidence: number;
@Column({ type: 'boolean', default: false })
isManuallyEdited: boolean;
@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, unknown>;
@Column({ type: 'timestamp', nullable: true })
lastCountedAt: Date;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@ -0,0 +1,135 @@
import {
Controller,
Get,
Patch,
Delete,
Body,
Param,
Query,
UseGuards,
Request,
ParseUUIDPipe,
ParseIntPipe,
DefaultValuePipe,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { InventoryService } from './inventory.service';
import { StoresService } from '../stores/stores.service';
import { UpdateInventoryItemDto } from './dto/update-inventory-item.dto';
import { AuthenticatedRequest } from '../../common/interfaces/authenticated-request.interface';
@ApiTags('inventory')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('stores/:storeId/inventory')
export class InventoryController {
constructor(
private readonly inventoryService: InventoryService,
private readonly storesService: StoresService,
) {}
@Get()
@ApiOperation({ summary: 'Listar inventario de una tienda' })
@ApiResponse({ status: 200, description: 'Lista de productos' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
async findAll(
@Request() req: AuthenticatedRequest,
@Param('storeId', ParseUUIDPipe) storeId: string,
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit: number,
) {
await this.storesService.verifyOwnership(storeId, req.user.id);
const { items, total } = await this.inventoryService.findAllByStore(
storeId,
page,
Math.min(limit, 100),
);
return {
items,
total,
page,
limit,
hasMore: page * limit < total,
};
}
@Get('statistics')
@ApiOperation({ summary: 'Obtener estadisticas del inventario' })
@ApiResponse({ status: 200, description: 'Estadisticas' })
async getStatistics(
@Request() req: AuthenticatedRequest,
@Param('storeId', ParseUUIDPipe) storeId: string,
) {
await this.storesService.verifyOwnership(storeId, req.user.id);
return this.inventoryService.getStatistics(storeId);
}
@Get('low-stock')
@ApiOperation({ summary: 'Obtener productos con bajo stock' })
@ApiResponse({ status: 200, description: 'Lista de productos con bajo stock' })
async getLowStock(
@Request() req: AuthenticatedRequest,
@Param('storeId', ParseUUIDPipe) storeId: string,
) {
await this.storesService.verifyOwnership(storeId, req.user.id);
return this.inventoryService.getLowStockItems(storeId);
}
@Get('categories')
@ApiOperation({ summary: 'Obtener categorias del inventario' })
@ApiResponse({ status: 200, description: 'Lista de categorias' })
async getCategories(
@Request() req: AuthenticatedRequest,
@Param('storeId', ParseUUIDPipe) storeId: string,
) {
await this.storesService.verifyOwnership(storeId, req.user.id);
return this.inventoryService.getCategories(storeId);
}
@Get(':itemId')
@ApiOperation({ summary: 'Obtener un producto' })
@ApiResponse({ status: 200, description: 'Producto encontrado' })
async findOne(
@Request() req: AuthenticatedRequest,
@Param('storeId', ParseUUIDPipe) storeId: string,
@Param('itemId', ParseUUIDPipe) itemId: string,
) {
await this.storesService.verifyOwnership(storeId, req.user.id);
return this.inventoryService.findById(storeId, itemId);
}
@Patch(':itemId')
@ApiOperation({ summary: 'Actualizar un producto' })
@ApiResponse({ status: 200, description: 'Producto actualizado' })
async update(
@Request() req: AuthenticatedRequest,
@Param('storeId', ParseUUIDPipe) storeId: string,
@Param('itemId', ParseUUIDPipe) itemId: string,
@Body() dto: UpdateInventoryItemDto,
) {
await this.storesService.verifyOwnership(storeId, req.user.id);
return this.inventoryService.update(storeId, itemId, dto);
}
@Delete(':itemId')
@ApiOperation({ summary: 'Eliminar un producto' })
@ApiResponse({ status: 200, description: 'Producto eliminado' })
async delete(
@Request() req: AuthenticatedRequest,
@Param('storeId', ParseUUIDPipe) storeId: string,
@Param('itemId', ParseUUIDPipe) itemId: string,
) {
await this.storesService.verifyOwnership(storeId, req.user.id);
return this.inventoryService.delete(storeId, itemId);
}
}

View File

@ -0,0 +1,17 @@
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { InventoryController } from './inventory.controller';
import { InventoryService } from './inventory.service';
import { InventoryItem } from './entities/inventory-item.entity';
import { StoresModule } from '../stores/stores.module';
@Module({
imports: [
TypeOrmModule.forFeature([InventoryItem]),
forwardRef(() => StoresModule),
],
controllers: [InventoryController],
providers: [InventoryService],
exports: [InventoryService],
})
export class InventoryModule {}

View File

@ -0,0 +1,172 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { InventoryItem } from './entities/inventory-item.entity';
export interface DetectedItem {
name: string;
quantity: number;
confidence: number;
category?: string;
barcode?: string;
}
@Injectable()
export class InventoryService {
constructor(
@InjectRepository(InventoryItem)
private readonly inventoryRepository: Repository<InventoryItem>,
) {}
async findAllByStore(
storeId: string,
page = 1,
limit = 50,
): Promise<{ items: InventoryItem[]; total: number }> {
const [items, total] = await this.inventoryRepository.findAndCount({
where: { storeId },
order: { name: 'ASC' },
skip: (page - 1) * limit,
take: limit,
});
return { items, total };
}
async findById(storeId: string, itemId: string): Promise<InventoryItem> {
const item = await this.inventoryRepository.findOne({
where: { id: itemId, storeId },
});
if (!item) {
throw new NotFoundException('Producto no encontrado');
}
return item;
}
async update(
storeId: string,
itemId: string,
data: Partial<InventoryItem>,
): Promise<InventoryItem> {
const item = await this.findById(storeId, itemId);
Object.assign(item, data, { isManuallyEdited: true });
return this.inventoryRepository.save(item);
}
async delete(storeId: string, itemId: string): Promise<void> {
const item = await this.findById(storeId, itemId);
await this.inventoryRepository.remove(item);
}
async bulkUpsertFromDetection(
storeId: string,
videoId: string,
detectedItems: DetectedItem[],
): Promise<InventoryItem[]> {
const results: InventoryItem[] = [];
for (const detected of detectedItems) {
// Try to find existing item by name or barcode
let existing = await this.inventoryRepository.findOne({
where: [
{ storeId, name: detected.name },
...(detected.barcode
? [{ storeId, barcode: detected.barcode }]
: []),
],
});
if (existing) {
// Update existing item if not manually edited or if confidence is high
if (!existing.isManuallyEdited || detected.confidence > 0.95) {
existing.quantity = detected.quantity;
existing.detectionConfidence = detected.confidence;
existing.detectedByVideoId = videoId;
existing.lastCountedAt = new Date();
if (detected.category && !existing.category) {
existing.category = detected.category;
}
await this.inventoryRepository.save(existing);
}
results.push(existing);
} else {
// Create new item
const newItem = this.inventoryRepository.create({
storeId,
detectedByVideoId: videoId,
name: detected.name,
quantity: detected.quantity,
category: detected.category,
barcode: detected.barcode,
detectionConfidence: detected.confidence,
lastCountedAt: new Date(),
});
await this.inventoryRepository.save(newItem);
results.push(newItem);
}
}
return results;
}
async getCategories(storeId: string): Promise<string[]> {
const result = await this.inventoryRepository
.createQueryBuilder('item')
.select('DISTINCT item.category', 'category')
.where('item.storeId = :storeId', { storeId })
.andWhere('item.category IS NOT NULL')
.getRawMany();
return result.map((r) => r.category);
}
async getLowStockItems(storeId: string): Promise<InventoryItem[]> {
return this.inventoryRepository
.createQueryBuilder('item')
.where('item.storeId = :storeId', { storeId })
.andWhere('item.minStock IS NOT NULL')
.andWhere('item.quantity <= item.minStock')
.orderBy('item.quantity', 'ASC')
.getMany();
}
async getStatistics(storeId: string) {
const totalItems = await this.inventoryRepository.count({
where: { storeId },
});
const lowStockCount = await this.inventoryRepository
.createQueryBuilder('item')
.where('item.storeId = :storeId', { storeId })
.andWhere('item.minStock IS NOT NULL')
.andWhere('item.quantity <= item.minStock')
.getCount();
const categoriesResult = await this.inventoryRepository
.createQueryBuilder('item')
.select('COUNT(DISTINCT item.category)', 'count')
.where('item.storeId = :storeId', { storeId })
.andWhere('item.category IS NOT NULL')
.getRawOne();
const totalValue = await this.inventoryRepository
.createQueryBuilder('item')
.select('SUM(item.quantity * item.price)', 'total')
.where('item.storeId = :storeId', { storeId })
.andWhere('item.price IS NOT NULL')
.getRawOne();
return {
totalItems,
lowStockCount,
categoriesCount: parseInt(categoriesResult?.count || '0', 10),
totalValue: parseFloat(totalValue?.total || '0'),
};
}
}

View File

@ -0,0 +1,57 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
export enum NotificationType {
VIDEO_PROCESSING_COMPLETE = 'VIDEO_PROCESSING_COMPLETE',
VIDEO_PROCESSING_FAILED = 'VIDEO_PROCESSING_FAILED',
LOW_CREDITS = 'LOW_CREDITS',
PAYMENT_COMPLETE = 'PAYMENT_COMPLETE',
PAYMENT_FAILED = 'PAYMENT_FAILED',
REFERRAL_BONUS = 'REFERRAL_BONUS',
PROMO = 'PROMO',
SYSTEM = 'SYSTEM',
}
@Entity('notifications')
@Index(['userId', 'createdAt'])
@Index(['userId', 'isRead'])
export class Notification {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
userId: string;
@ManyToOne(() => User)
@JoinColumn({ name: 'userId' })
user: User;
@Column({ type: 'enum', enum: NotificationType })
type: NotificationType;
@Column({ type: 'varchar', length: 255 })
title: string;
@Column({ type: 'text', nullable: true })
body: string;
@Column({ type: 'boolean', default: false })
isRead: boolean;
@Column({ type: 'boolean', default: false })
isPushSent: boolean;
@Column({ type: 'jsonb', nullable: true })
data: Record<string, unknown>;
@CreateDateColumn()
createdAt: Date;
}

View File

@ -0,0 +1,100 @@
import {
Controller,
Get,
Post,
Patch,
Param,
Query,
Body,
UseGuards,
Request,
ParseUUIDPipe,
ParseIntPipe,
DefaultValuePipe,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { NotificationsService } from './notifications.service';
import { UsersService } from '../users/users.service';
import { AuthenticatedRequest } from '../../common/interfaces/authenticated-request.interface';
@ApiTags('notifications')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('notifications')
export class NotificationsController {
constructor(
private readonly notificationsService: NotificationsService,
private readonly usersService: UsersService,
) {}
@Get()
@ApiOperation({ summary: 'Obtener notificaciones del usuario' })
@ApiResponse({ status: 200, description: 'Lista de notificaciones' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
async getNotifications(
@Request() req: AuthenticatedRequest,
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number,
) {
const { notifications, total } =
await this.notificationsService.getUserNotifications(
req.user.id,
page,
Math.min(limit, 100),
);
return {
notifications,
total,
page,
limit,
hasMore: page * limit < total,
};
}
@Get('unread-count')
@ApiOperation({ summary: 'Obtener cantidad de notificaciones sin leer' })
@ApiResponse({ status: 200, description: 'Cantidad de no leidas' })
async getUnreadCount(@Request() req: AuthenticatedRequest) {
const count = await this.notificationsService.getUnreadCount(req.user.id);
return { count };
}
@Patch(':notificationId/read')
@ApiOperation({ summary: 'Marcar notificacion como leida' })
@ApiResponse({ status: 200, description: 'Notificacion marcada' })
async markAsRead(
@Request() req: AuthenticatedRequest,
@Param('notificationId', ParseUUIDPipe) notificationId: string,
) {
await this.notificationsService.markAsRead(req.user.id, notificationId);
return { success: true };
}
@Post('mark-all-read')
@ApiOperation({ summary: 'Marcar todas como leidas' })
@ApiResponse({ status: 200, description: 'Notificaciones marcadas' })
async markAllAsRead(@Request() req: AuthenticatedRequest) {
await this.notificationsService.markAllAsRead(req.user.id);
return { success: true };
}
@Post('register-token')
@ApiOperation({ summary: 'Registrar token FCM' })
@ApiResponse({ status: 200, description: 'Token registrado' })
async registerToken(
@Request() req: AuthenticatedRequest,
@Body('token') token: string,
) {
await this.usersService.updateFcmToken(req.user.id, token);
return { success: true };
}
}

View File

@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { NotificationsController } from './notifications.controller';
import { NotificationsService } from './notifications.service';
import { Notification } from './entities/notification.entity';
import { UsersModule } from '../users/users.module';
@Module({
imports: [
TypeOrmModule.forFeature([Notification]),
UsersModule,
],
controllers: [NotificationsController],
providers: [NotificationsService],
exports: [NotificationsService],
})
export class NotificationsModule {}

View File

@ -0,0 +1,228 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import * as admin from 'firebase-admin';
import {
Notification,
NotificationType,
} from './entities/notification.entity';
import { UsersService } from '../users/users.service';
@Injectable()
export class NotificationsService {
private readonly logger = new Logger(NotificationsService.name);
private firebaseApp: admin.app.App | null = null;
constructor(
@InjectRepository(Notification)
private readonly notificationsRepository: Repository<Notification>,
private readonly usersService: UsersService,
private readonly configService: ConfigService,
) {
this.initializeFirebase();
}
private initializeFirebase() {
const projectId = this.configService.get('FIREBASE_PROJECT_ID');
const clientEmail = this.configService.get('FIREBASE_CLIENT_EMAIL');
const privateKey = this.configService.get('FIREBASE_PRIVATE_KEY');
if (projectId && clientEmail && privateKey && !privateKey.includes('YOUR_KEY')) {
try {
this.firebaseApp = admin.initializeApp({
credential: admin.credential.cert({
projectId,
clientEmail,
privateKey: privateKey.replace(/\\n/g, '\n'),
}),
});
this.logger.log('Firebase initialized successfully');
} catch (error) {
this.logger.error('Failed to initialize Firebase:', error.message);
}
} else {
this.logger.warn('Firebase not configured - push notifications disabled');
}
}
async sendPush(
userId: string,
type: NotificationType,
title: string,
body: string,
data?: Record<string, unknown>,
): Promise<Notification> {
// Create notification record
const notification = this.notificationsRepository.create({
userId,
type,
title,
body,
data,
});
await this.notificationsRepository.save(notification);
// Try to send push notification
const user = await this.usersService.findById(userId);
if (user?.fcmToken && this.firebaseApp) {
try {
await admin.messaging().send({
token: user.fcmToken,
notification: { title, body },
data: data ? this.stringifyData(data) : undefined,
android: {
priority: 'high',
notification: {
channelId: 'miinventario_default',
},
},
apns: {
payload: {
aps: {
sound: 'default',
badge: 1,
},
},
},
});
notification.isPushSent = true;
await this.notificationsRepository.save(notification);
this.logger.log(`Push sent to user ${userId}: ${title}`);
} catch (error) {
this.logger.error(`Failed to send push to ${userId}:`, error.message);
// If token is invalid, clear it
if (error.code === 'messaging/invalid-registration-token' ||
error.code === 'messaging/registration-token-not-registered') {
await this.usersService.updateFcmToken(userId, null);
}
}
} else if (!this.firebaseApp) {
this.logger.warn(`Push simulation for ${userId}: ${title}`);
}
return notification;
}
async notifyVideoProcessingComplete(
userId: string,
videoId: string,
itemsDetected: number,
) {
return this.sendPush(
userId,
NotificationType.VIDEO_PROCESSING_COMPLETE,
'Video procesado',
`Se detectaron ${itemsDetected} productos en tu anaquel`,
{ videoId, itemsDetected },
);
}
async notifyVideoProcessingFailed(
userId: string,
videoId: string,
errorMessage: string,
) {
return this.sendPush(
userId,
NotificationType.VIDEO_PROCESSING_FAILED,
'Error al procesar video',
'Hubo un problema al procesar tu video. Intenta de nuevo.',
{ videoId, error: errorMessage },
);
}
async notifyLowCredits(userId: string, currentBalance: number) {
return this.sendPush(
userId,
NotificationType.LOW_CREDITS,
'Creditos bajos',
`Te quedan ${currentBalance} creditos. Recarga para seguir escaneando.`,
{ balance: currentBalance },
);
}
async notifyPaymentComplete(
userId: string,
paymentId: string,
creditsGranted: number,
) {
return this.sendPush(
userId,
NotificationType.PAYMENT_COMPLETE,
'Pago completado',
`Se agregaron ${creditsGranted} creditos a tu cuenta`,
{ paymentId, creditsGranted },
);
}
async notifyPaymentFailed(userId: string, paymentId: string) {
return this.sendPush(
userId,
NotificationType.PAYMENT_FAILED,
'Pago fallido',
'Tu pago no pudo ser procesado. Intenta con otro metodo.',
{ paymentId },
);
}
async notifyReferralBonus(
userId: string,
bonusCredits: number,
referredName: string,
) {
return this.sendPush(
userId,
NotificationType.REFERRAL_BONUS,
'Bonus de referido',
`${referredName} uso tu codigo. Ganaste ${bonusCredits} creditos.`,
{ bonusCredits, referredName },
);
}
async getUserNotifications(
userId: string,
page = 1,
limit = 20,
): Promise<{ notifications: Notification[]; total: number }> {
const [notifications, total] = await this.notificationsRepository.findAndCount({
where: { userId },
order: { createdAt: 'DESC' },
skip: (page - 1) * limit,
take: limit,
});
return { notifications, total };
}
async markAsRead(userId: string, notificationId: string): Promise<void> {
await this.notificationsRepository.update(
{ id: notificationId, userId },
{ isRead: true },
);
}
async markAllAsRead(userId: string): Promise<void> {
await this.notificationsRepository.update(
{ userId, isRead: false },
{ isRead: true },
);
}
async getUnreadCount(userId: string): Promise<number> {
return this.notificationsRepository.count({
where: { userId, isRead: false },
});
}
private stringifyData(data: Record<string, unknown>): Record<string, string> {
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(data)) {
result[key] = typeof value === 'string' ? value : JSON.stringify(value);
}
return result;
}
}

View File

@ -0,0 +1,29 @@
import { IsString, IsNotEmpty, IsEnum, IsOptional } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { PaymentMethod } from '../entities/payment.entity';
export class CreatePaymentDto {
@ApiProperty({
description: 'ID del paquete de creditos',
example: 'uuid-del-paquete',
})
@IsString()
@IsNotEmpty()
packageId: string;
@ApiProperty({
description: 'Metodo de pago',
enum: PaymentMethod,
example: PaymentMethod.CARD,
})
@IsEnum(PaymentMethod)
method: PaymentMethod;
@ApiPropertyOptional({
description: 'ID del metodo de pago guardado (para tarjetas)',
example: 'pm_1234567890',
})
@IsString()
@IsOptional()
paymentMethodId?: string;
}

View File

@ -0,0 +1,92 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
import { CreditPackage } from '../../credits/entities/credit-package.entity';
export enum PaymentStatus {
PENDING = 'PENDING',
PROCESSING = 'PROCESSING',
COMPLETED = 'COMPLETED',
FAILED = 'FAILED',
REFUNDED = 'REFUNDED',
EXPIRED = 'EXPIRED',
}
export enum PaymentMethod {
CARD = 'CARD',
OXXO = 'OXXO',
SEVEN_ELEVEN = '7ELEVEN',
}
@Entity('payments')
@Index(['userId', 'createdAt'])
@Index(['status', 'createdAt'])
@Index(['externalId'])
export class Payment {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
userId: string;
@ManyToOne(() => User)
@JoinColumn({ name: 'userId' })
user: User;
@Column({ type: 'uuid', nullable: true })
packageId: string;
@ManyToOne(() => CreditPackage, { nullable: true })
@JoinColumn({ name: 'packageId' })
package: CreditPackage;
@Column({ type: 'decimal', precision: 10, scale: 2 })
amountMXN: number;
@Column({ type: 'int' })
creditsGranted: number;
@Column({ type: 'enum', enum: PaymentMethod })
method: PaymentMethod;
@Column({ type: 'enum', enum: PaymentStatus, default: PaymentStatus.PENDING })
status: PaymentStatus;
@Column({ type: 'varchar', length: 255, nullable: true })
externalId: string;
@Column({ type: 'varchar', length: 50, nullable: true })
provider: string;
@Column({ type: 'varchar', length: 500, nullable: true })
voucherUrl?: string;
@Column({ type: 'varchar', length: 100, nullable: true })
voucherCode?: string;
@Column({ type: 'timestamp', nullable: true })
expiresAt: Date;
@Column({ type: 'timestamp', nullable: true })
completedAt: Date;
@Column({ type: 'text', nullable: true })
errorMessage: string;
@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, unknown>;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@ -0,0 +1,103 @@
import {
Controller,
Get,
Post,
Body,
Param,
Query,
UseGuards,
Request,
Headers,
RawBodyRequest,
Req,
ParseUUIDPipe,
ParseIntPipe,
DefaultValuePipe,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { Request as ExpressRequest } from 'express';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { PaymentsService } from './payments.service';
import { CreatePaymentDto } from './dto/create-payment.dto';
import { AuthenticatedRequest } from '../../common/interfaces/authenticated-request.interface';
@ApiTags('payments')
@Controller('payments')
export class PaymentsController {
constructor(private readonly paymentsService: PaymentsService) {}
@Post()
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Crear un nuevo pago' })
@ApiResponse({ status: 201, description: 'Pago creado' })
@ApiResponse({ status: 400, description: 'Error en el pago' })
create(
@Request() req: AuthenticatedRequest,
@Body() dto: CreatePaymentDto,
) {
return this.paymentsService.createPayment(req.user.id, dto);
}
@Get()
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Obtener historial de pagos' })
@ApiResponse({ status: 200, description: 'Lista de pagos' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
async getHistory(
@Request() req: AuthenticatedRequest,
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number,
) {
const { payments, total } = await this.paymentsService.getPaymentHistory(
req.user.id,
page,
Math.min(limit, 100),
);
return {
payments,
total,
page,
limit,
hasMore: page * limit < total,
};
}
@Get(':paymentId')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Obtener detalle de un pago' })
@ApiResponse({ status: 200, description: 'Detalle del pago' })
@ApiResponse({ status: 404, description: 'Pago no encontrado' })
getPayment(
@Request() req: AuthenticatedRequest,
@Param('paymentId', ParseUUIDPipe) paymentId: string,
) {
return this.paymentsService.getPaymentById(paymentId, req.user.id);
}
@Post('webhook/stripe')
@ApiOperation({ summary: 'Webhook de Stripe' })
@ApiResponse({ status: 200, description: 'Webhook procesado' })
@ApiResponse({ status: 400, description: 'Firma invalida' })
async handleStripeWebhook(
@Req() req: RawBodyRequest<ExpressRequest>,
@Headers('stripe-signature') signature: string,
) {
const rawBody = req.rawBody;
if (!rawBody) {
return { received: true, error: 'No raw body available' };
}
return this.paymentsService.handleStripeWebhook(rawBody, signature);
}
}

View File

@ -0,0 +1,21 @@
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PaymentsController } from './payments.controller';
import { PaymentsService } from './payments.service';
import { Payment } from './entities/payment.entity';
import { CreditsModule } from '../credits/credits.module';
import { UsersModule } from '../users/users.module';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [
TypeOrmModule.forFeature([Payment]),
CreditsModule,
UsersModule,
forwardRef(() => NotificationsModule),
],
controllers: [PaymentsController],
providers: [PaymentsService],
exports: [PaymentsService],
})
export class PaymentsModule {}

View File

@ -0,0 +1,416 @@
import {
Injectable,
BadRequestException,
NotFoundException,
Logger,
Inject,
forwardRef,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import Stripe from 'stripe';
import {
Payment,
PaymentStatus,
PaymentMethod,
} from './entities/payment.entity';
import { CreatePaymentDto } from './dto/create-payment.dto';
import { CreditsService } from '../credits/credits.service';
import { TransactionType } from '../credits/entities/credit-transaction.entity';
import { UsersService } from '../users/users.service';
import { NotificationsService } from '../notifications/notifications.service';
@Injectable()
export class PaymentsService {
private readonly logger = new Logger(PaymentsService.name);
private stripe: Stripe;
constructor(
@InjectRepository(Payment)
private readonly paymentsRepository: Repository<Payment>,
private readonly creditsService: CreditsService,
private readonly usersService: UsersService,
private readonly configService: ConfigService,
@Inject(forwardRef(() => NotificationsService))
private readonly notificationsService: NotificationsService,
) {
const stripeKey = this.configService.get('STRIPE_SECRET_KEY');
if (stripeKey && stripeKey !== 'sk_test_your_stripe_key') {
this.stripe = new Stripe(stripeKey, { apiVersion: '2023-10-16' });
}
}
async createPayment(userId: string, dto: CreatePaymentDto) {
// Get package
const pkg = await this.creditsService.getPackageById(dto.packageId);
// Get or create Stripe customer
const user = await this.usersService.findById(userId);
if (!user) {
throw new BadRequestException('Usuario no encontrado');
}
let customerId = user.stripeCustomerId;
if (!customerId && this.stripe) {
const customer = await this.stripe.customers.create({
phone: user.phone || undefined,
name: user.name || undefined,
metadata: { userId },
});
customerId = customer.id;
await this.usersService.update(userId, { stripeCustomerId: customerId });
}
// Create payment record
const payment = this.paymentsRepository.create({
userId,
packageId: pkg.id,
amountMXN: Number(pkg.priceMXN),
creditsGranted: pkg.credits,
method: dto.method,
status: PaymentStatus.PENDING,
provider: 'stripe',
});
await this.paymentsRepository.save(payment);
try {
switch (dto.method) {
case PaymentMethod.CARD:
return this.processCardPayment(payment, customerId, dto.paymentMethodId);
case PaymentMethod.OXXO:
return this.processOxxoPayment(payment, customerId);
case PaymentMethod.SEVEN_ELEVEN:
return this.process7ElevenPayment(payment);
default:
throw new BadRequestException('Metodo de pago no soportado');
}
} catch (error) {
payment.status = PaymentStatus.FAILED;
payment.errorMessage = error.message;
await this.paymentsRepository.save(payment);
throw error;
}
}
private async processCardPayment(
payment: Payment,
customerId: string,
paymentMethodId?: string,
) {
if (!this.stripe) {
// Development mode - simulate success
return this.simulatePaymentSuccess(payment);
}
if (!paymentMethodId) {
// Create PaymentIntent for new card
const paymentIntent = await this.stripe.paymentIntents.create({
amount: Math.round(payment.amountMXN * 100),
currency: 'mxn',
customer: customerId,
metadata: { paymentId: payment.id },
});
payment.externalId = paymentIntent.id;
await this.paymentsRepository.save(payment);
return {
paymentId: payment.id,
clientSecret: paymentIntent.client_secret,
status: 'requires_payment_method',
};
}
// Charge with existing payment method
const paymentIntent = await this.stripe.paymentIntents.create({
amount: Math.round(payment.amountMXN * 100),
currency: 'mxn',
customer: customerId,
payment_method: paymentMethodId,
confirm: true,
metadata: { paymentId: payment.id },
});
payment.externalId = paymentIntent.id;
if (paymentIntent.status === 'succeeded') {
await this.completePayment(payment);
return {
paymentId: payment.id,
status: 'completed',
creditsGranted: payment.creditsGranted,
};
}
payment.status = PaymentStatus.PROCESSING;
await this.paymentsRepository.save(payment);
return {
paymentId: payment.id,
status: paymentIntent.status,
};
}
private async processOxxoPayment(payment: Payment, customerId: string) {
if (!this.stripe) {
return this.simulateOxxoVoucher(payment);
}
// Create OXXO payment intent
const paymentIntent = await this.stripe.paymentIntents.create({
amount: Math.round(payment.amountMXN * 100),
currency: 'mxn',
customer: customerId,
payment_method_types: ['oxxo'],
metadata: { paymentId: payment.id },
});
// Confirm with OXXO
const confirmedIntent = await this.stripe.paymentIntents.confirm(
paymentIntent.id,
{
payment_method_data: {
type: 'oxxo',
billing_details: {
name: 'Cliente MiInventario',
email: 'cliente@miinventario.com',
},
},
},
);
payment.externalId = paymentIntent.id;
payment.status = PaymentStatus.PENDING;
payment.expiresAt = new Date(Date.now() + 72 * 60 * 60 * 1000); // 72 hours
// Get OXXO voucher details
const nextAction = confirmedIntent.next_action;
if (nextAction?.oxxo_display_details) {
payment.voucherCode = nextAction.oxxo_display_details.number || undefined;
payment.voucherUrl = nextAction.oxxo_display_details.hosted_voucher_url || undefined;
}
await this.paymentsRepository.save(payment);
return {
paymentId: payment.id,
status: 'pending',
method: 'oxxo',
voucherCode: payment.voucherCode,
voucherUrl: payment.voucherUrl,
expiresAt: payment.expiresAt,
amountMXN: payment.amountMXN,
};
}
private async process7ElevenPayment(payment: Payment) {
// TODO: Implement Conekta 7-Eleven integration
// For now, return simulated voucher
return this.simulate7ElevenVoucher(payment);
}
private async simulatePaymentSuccess(payment: Payment) {
this.logger.warn('Development mode: Simulating payment success');
payment.externalId = `sim_${Date.now()}`;
await this.completePayment(payment);
return {
paymentId: payment.id,
status: 'completed',
creditsGranted: payment.creditsGranted,
simulated: true,
};
}
private async simulateOxxoVoucher(payment: Payment) {
this.logger.warn('Development mode: Simulating OXXO voucher');
payment.externalId = `sim_oxxo_${Date.now()}`;
payment.voucherCode = Math.random().toString().slice(2, 16);
payment.voucherUrl = `https://example.com/voucher/${payment.id}`;
payment.expiresAt = new Date(Date.now() + 72 * 60 * 60 * 1000);
await this.paymentsRepository.save(payment);
return {
paymentId: payment.id,
status: 'pending',
method: 'oxxo',
voucherCode: payment.voucherCode,
voucherUrl: payment.voucherUrl,
expiresAt: payment.expiresAt,
amountMXN: payment.amountMXN,
simulated: true,
};
}
private async simulate7ElevenVoucher(payment: Payment) {
this.logger.warn('Development mode: Simulating 7-Eleven voucher');
payment.externalId = `sim_7eleven_${Date.now()}`;
payment.voucherCode = Math.random().toString().slice(2, 14);
payment.expiresAt = new Date(Date.now() + 48 * 60 * 60 * 1000);
await this.paymentsRepository.save(payment);
return {
paymentId: payment.id,
status: 'pending',
method: '7eleven',
voucherCode: payment.voucherCode,
expiresAt: payment.expiresAt,
amountMXN: payment.amountMXN,
simulated: true,
};
}
async handleStripeWebhook(payload: Buffer, signature: string) {
const webhookSecret = this.configService.get('STRIPE_WEBHOOK_SECRET');
if (!this.stripe || !webhookSecret) {
this.logger.warn('Stripe webhook not configured');
return { received: true, processed: false };
}
let event: Stripe.Event;
try {
event = this.stripe.webhooks.constructEvent(
payload,
signature,
webhookSecret,
);
} catch (err) {
this.logger.error(`Webhook signature verification failed: ${err.message}`);
throw new BadRequestException('Webhook signature verification failed');
}
this.logger.log(`Processing Stripe webhook: ${event.type}`);
switch (event.type) {
case 'payment_intent.succeeded':
await this.handlePaymentSuccess(event.data.object as Stripe.PaymentIntent);
break;
case 'payment_intent.payment_failed':
await this.handlePaymentFailed(event.data.object as Stripe.PaymentIntent);
break;
case 'payment_intent.processing':
await this.handlePaymentProcessing(event.data.object as Stripe.PaymentIntent);
break;
case 'charge.succeeded':
// Additional confirmation for OXXO/cash payments
this.logger.log(`Charge succeeded: ${(event.data.object as Stripe.Charge).id}`);
break;
case 'charge.pending':
// OXXO payment is pending (customer needs to pay at store)
this.logger.log(`Charge pending: ${(event.data.object as Stripe.Charge).id}`);
break;
default:
this.logger.log(`Unhandled event type: ${event.type}`);
}
return { received: true, processed: true, eventType: event.type };
}
private async handlePaymentProcessing(paymentIntent: Stripe.PaymentIntent) {
const paymentId = paymentIntent.metadata.paymentId;
if (!paymentId) return;
await this.paymentsRepository.update(paymentId, {
status: PaymentStatus.PROCESSING,
});
this.logger.log(`Payment ${paymentId} is now processing`);
}
private async handlePaymentSuccess(paymentIntent: Stripe.PaymentIntent) {
const paymentId = paymentIntent.metadata.paymentId;
if (!paymentId) return;
const payment = await this.paymentsRepository.findOne({
where: { id: paymentId },
});
if (payment && payment.status !== PaymentStatus.COMPLETED) {
await this.completePayment(payment);
}
}
private async handlePaymentFailed(paymentIntent: Stripe.PaymentIntent) {
const paymentId = paymentIntent.metadata.paymentId;
if (!paymentId) return;
const payment = await this.paymentsRepository.findOne({
where: { id: paymentId },
});
if (payment) {
await this.paymentsRepository.update(paymentId, {
status: PaymentStatus.FAILED,
errorMessage: paymentIntent.last_payment_error?.message,
});
// Send notification
try {
await this.notificationsService.notifyPaymentFailed(payment.userId, paymentId);
} catch (error) {
this.logger.error(`Failed to send payment failed notification: ${error.message}`);
}
}
}
private async completePayment(payment: Payment) {
payment.status = PaymentStatus.COMPLETED;
payment.completedAt = new Date();
await this.paymentsRepository.save(payment);
// Grant credits
await this.creditsService.addCredits(
payment.userId,
payment.creditsGranted,
TransactionType.PURCHASE,
`Compra de ${payment.creditsGranted} creditos`,
payment.id,
'payment',
);
// Send notification
try {
await this.notificationsService.notifyPaymentComplete(
payment.userId,
payment.id,
payment.creditsGranted,
);
} catch (error) {
this.logger.error(`Failed to send payment notification: ${error.message}`);
}
this.logger.log(
`Payment ${payment.id} completed. Granted ${payment.creditsGranted} credits to user ${payment.userId}`,
);
}
async getPaymentHistory(userId: string, page = 1, limit = 20) {
const [payments, total] = await this.paymentsRepository.findAndCount({
where: { userId },
order: { createdAt: 'DESC' },
skip: (page - 1) * limit,
take: limit,
});
return { payments, total };
}
async getPaymentById(paymentId: string, userId: string) {
const payment = await this.paymentsRepository.findOne({
where: { id: paymentId, userId },
});
if (!payment) {
throw new NotFoundException('Pago no encontrado');
}
return payment;
}
}

View File

@ -0,0 +1,85 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
export enum ReferralStatus {
PENDING = 'PENDING',
REGISTERED = 'REGISTERED',
QUALIFIED = 'QUALIFIED',
REWARDED = 'REWARDED',
}
@Entity('referrals')
@Index(['referrerId'])
@Index(['referredId'])
@Index(['referralCode'])
@Index(['fraudHold'])
export class Referral {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
referrerId: string;
@ManyToOne(() => User)
@JoinColumn({ name: 'referrerId' })
referrer: User;
@Column({ type: 'uuid', nullable: true })
referredId: string;
@ManyToOne(() => User, { nullable: true })
@JoinColumn({ name: 'referredId' })
referred: User;
@Column({ type: 'varchar', length: 20, unique: true })
referralCode: string;
@Column({ type: 'enum', enum: ReferralStatus, default: ReferralStatus.PENDING })
status: ReferralStatus;
@Column({ type: 'int', default: 0 })
referrerBonusCredits: number;
@Column({ type: 'int', default: 0 })
referredBonusCredits: number;
@Column({ type: 'timestamp', nullable: true })
registeredAt: Date;
@Column({ type: 'timestamp', nullable: true })
qualifiedAt: Date;
@Column({ type: 'timestamp', nullable: true })
rewardedAt: Date;
@Column({ type: 'boolean', default: false })
fraudHold: boolean;
@Column({ type: 'varchar', length: 255, nullable: true })
fraudReason: string;
@Column({ type: 'uuid', nullable: true })
reviewedBy: string;
@ManyToOne(() => User, { nullable: true })
@JoinColumn({ name: 'reviewedBy' })
reviewer: User;
@Column({ type: 'timestamp', nullable: true })
reviewedAt: Date;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@ -0,0 +1,96 @@
import {
Controller,
Get,
Post,
Body,
Query,
UseGuards,
Request,
ParseIntPipe,
DefaultValuePipe,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiQuery,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { ReferralsService } from './referrals.service';
import { AuthenticatedRequest } from '../../common/interfaces/authenticated-request.interface';
@ApiTags('referrals')
@Controller('referrals')
export class ReferralsController {
constructor(private readonly referralsService: ReferralsService) {}
@Get('my-code')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Obtener mi codigo de referido' })
@ApiResponse({ status: 200, description: 'Codigo de referido' })
async getMyReferralCode(@Request() req: AuthenticatedRequest) {
const code = await this.referralsService.getOrCreateReferralCode(
req.user.id,
);
return { referralCode: code };
}
@Get('stats')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Obtener estadisticas de referidos' })
@ApiResponse({ status: 200, description: 'Estadisticas de referidos' })
async getStats(@Request() req: AuthenticatedRequest) {
return this.referralsService.getReferralStats(req.user.id);
}
@Get()
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Obtener lista de mis referidos' })
@ApiResponse({ status: 200, description: 'Lista de referidos' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
async getReferrals(
@Request() req: AuthenticatedRequest,
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number,
) {
const { referrals, total } = await this.referralsService.getReferrals(
req.user.id,
page,
Math.min(limit, 100),
);
return {
referrals,
total,
page,
limit,
hasMore: page * limit < total,
};
}
@Get('validate')
@ApiOperation({ summary: 'Validar un codigo de referido' })
@ApiResponse({ status: 200, description: 'Resultado de validacion' })
@ApiQuery({ name: 'code', required: true, type: String })
async validateCode(@Query('code') code: string) {
return this.referralsService.validateCode(code);
}
@Post('apply')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Aplicar codigo de referido' })
@ApiResponse({ status: 200, description: 'Codigo aplicado' })
@ApiResponse({ status: 400, description: 'Codigo invalido' })
async applyCode(
@Request() req: AuthenticatedRequest,
@Body('code') code: string,
) {
return this.referralsService.applyReferralCode(req.user.id, code);
}
}

View File

@ -0,0 +1,21 @@
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ReferralsController } from './referrals.controller';
import { ReferralsService } from './referrals.service';
import { Referral } from './entities/referral.entity';
import { CreditsModule } from '../credits/credits.module';
import { UsersModule } from '../users/users.module';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [
TypeOrmModule.forFeature([Referral]),
CreditsModule,
UsersModule,
forwardRef(() => NotificationsModule),
],
controllers: [ReferralsController],
providers: [ReferralsService],
exports: [ReferralsService],
})
export class ReferralsModule {}

View File

@ -0,0 +1,286 @@
import {
Injectable,
NotFoundException,
BadRequestException,
Logger,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, IsNull } from 'typeorm';
import { Referral, ReferralStatus } from './entities/referral.entity';
import { CreditsService } from '../credits/credits.service';
import { UsersService } from '../users/users.service';
import { NotificationsService } from '../notifications/notifications.service';
@Injectable()
export class ReferralsService {
private readonly logger = new Logger(ReferralsService.name);
constructor(
@InjectRepository(Referral)
private readonly referralRepository: Repository<Referral>,
private readonly creditsService: CreditsService,
private readonly usersService: UsersService,
private readonly notificationsService: NotificationsService,
) {}
/**
* Get or create a referral code for a user
*/
async getOrCreateReferralCode(userId: string): Promise<string> {
// Check if user already has a referral record (as referrer)
let referral = await this.referralRepository.findOne({
where: { referrerId: userId, referredId: IsNull() },
});
if (!referral) {
// Generate unique code
const code = await this.generateUniqueCode(userId);
referral = this.referralRepository.create({
referrerId: userId,
referralCode: code,
status: ReferralStatus.PENDING,
});
await this.referralRepository.save(referral);
}
return referral.referralCode;
}
/**
* Apply a referral code during registration
*/
async applyReferralCode(
referredUserId: string,
referralCode: string,
): Promise<{ success: boolean; message: string }> {
// Find the referral record
const referral = await this.referralRepository.findOne({
where: { referralCode },
relations: ['referrer'],
});
if (!referral) {
throw new BadRequestException('Codigo de referido invalido');
}
if (referral.referrerId === referredUserId) {
throw new BadRequestException('No puedes usar tu propio codigo');
}
if (referral.referredId) {
// Code already used, create new referral for this referrer
const newReferral = this.referralRepository.create({
referrerId: referral.referrerId,
referredId: referredUserId,
referralCode: await this.generateUniqueCode(referral.referrerId),
status: ReferralStatus.REGISTERED,
registeredAt: new Date(),
});
await this.referralRepository.save(newReferral);
this.logger.log(`Referral applied: ${referral.referrerId} -> ${referredUserId}`);
return {
success: true,
message: 'Codigo de referido aplicado correctamente',
};
}
// Update existing referral
referral.referredId = referredUserId;
referral.status = ReferralStatus.REGISTERED;
referral.registeredAt = new Date();
await this.referralRepository.save(referral);
this.logger.log(`Referral applied: ${referral.referrerId} -> ${referredUserId}`);
return {
success: true,
message: 'Codigo de referido aplicado correctamente',
};
}
/**
* Mark a referral as qualified (user completed first video scan)
*/
async markAsQualified(referredUserId: string): Promise<void> {
const referral = await this.referralRepository.findOne({
where: { referredId: referredUserId, status: ReferralStatus.REGISTERED },
relations: ['referrer', 'referred'],
});
if (!referral) {
return; // No pending referral for this user
}
referral.status = ReferralStatus.QUALIFIED;
referral.qualifiedAt = new Date();
await this.referralRepository.save(referral);
// Now grant the bonuses
await this.grantReferralBonuses(referral);
}
/**
* Grant referral bonuses to both parties
*/
private async grantReferralBonuses(referral: Referral): Promise<void> {
const referrerBonus = 100;
const referredBonus = 50;
try {
// Grant bonus credits
await this.creditsService.grantReferralBonus(
referral.referrerId,
referral.referredId,
referral.id,
);
// Update referral record
referral.referrerBonusCredits = referrerBonus;
referral.referredBonusCredits = referredBonus;
referral.status = ReferralStatus.REWARDED;
referral.rewardedAt = new Date();
await this.referralRepository.save(referral);
// Get referred user name for notification
const referredUser = await this.usersService.findById(referral.referredId);
const referredName = referredUser?.name || 'Un usuario';
// Notify referrer
await this.notificationsService.notifyReferralBonus(
referral.referrerId,
referrerBonus,
referredName,
);
this.logger.log(
`Referral bonuses granted: ${referral.referrerId} (${referrerBonus}) & ${referral.referredId} (${referredBonus})`,
);
} catch (error) {
this.logger.error(`Failed to grant referral bonuses: ${error.message}`);
throw error;
}
}
/**
* Get referral statistics for a user
*/
async getReferralStats(userId: string): Promise<{
referralCode: string;
totalReferrals: number;
completedReferrals: number;
pendingReferrals: number;
totalCreditsEarned: number;
}> {
const referralCode = await this.getOrCreateReferralCode(userId);
// Count referrals
const referrals = await this.referralRepository.find({
where: { referrerId: userId },
});
const completedReferrals = referrals.filter(
(r) => r.status === ReferralStatus.REWARDED,
);
const pendingReferrals = referrals.filter(
(r) =>
r.status === ReferralStatus.REGISTERED ||
r.status === ReferralStatus.QUALIFIED,
);
const totalCreditsEarned = completedReferrals.reduce(
(sum, r) => sum + r.referrerBonusCredits,
0,
);
return {
referralCode,
totalReferrals: referrals.filter((r) => r.referredId).length,
completedReferrals: completedReferrals.length,
pendingReferrals: pendingReferrals.length,
totalCreditsEarned,
};
}
/**
* Get list of referrals for a user
*/
async getReferrals(
userId: string,
page = 1,
limit = 20,
): Promise<{ referrals: Referral[]; total: number }> {
const [referrals, total] = await this.referralRepository.findAndCount({
where: { referrerId: userId },
relations: ['referred'],
order: { createdAt: 'DESC' },
skip: (page - 1) * limit,
take: limit,
});
// Filter out the referral placeholder without a referred user
const validReferrals = referrals.filter((r) => r.referredId !== null);
return {
referrals: validReferrals.map((r) => ({
...r,
referred: r.referred
? {
id: r.referred.id,
name: r.referred.name,
createdAt: r.referred.createdAt,
}
: null,
})) as Referral[],
total: total - (referrals.length - validReferrals.length),
};
}
/**
* Validate a referral code
*/
async validateCode(code: string): Promise<{ valid: boolean; referrerName?: string }> {
const referral = await this.referralRepository.findOne({
where: { referralCode: code },
relations: ['referrer'],
});
if (!referral) {
return { valid: false };
}
return {
valid: true,
referrerName: referral.referrer?.name,
};
}
/**
* Generate a unique referral code
*/
private async generateUniqueCode(userId: string): Promise<string> {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Excluding confusing chars
let code = '';
let isUnique = false;
while (!isUnique) {
// Generate 8 character code
code = '';
for (let i = 0; i < 8; i++) {
code += chars.charAt(Math.floor(Math.random() * chars.length));
}
// Check if code already exists
const existing = await this.referralRepository.findOne({
where: { referralCode: code },
});
isUnique = !existing;
}
return code;
}
}

View File

@ -0,0 +1,86 @@
import {
IsString,
IsNotEmpty,
IsOptional,
MaxLength,
IsNumber,
Min,
Max,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateStoreDto {
@ApiProperty({
description: 'Nombre de la tienda',
example: 'Abarrotes Don Pedro',
})
@IsString()
@IsNotEmpty()
@MaxLength(100)
name: string;
@ApiPropertyOptional({
description: 'Direccion de la tienda',
example: 'Calle Principal 123, Col. Centro',
})
@IsString()
@IsOptional()
@MaxLength(255)
address?: string;
@ApiPropertyOptional({
description: 'Ciudad',
example: 'Ciudad de Mexico',
})
@IsString()
@IsOptional()
@MaxLength(50)
city?: string;
@ApiPropertyOptional({
description: 'Estado',
example: 'CDMX',
})
@IsString()
@IsOptional()
@MaxLength(50)
state?: string;
@ApiPropertyOptional({
description: 'Codigo postal',
example: '06600',
})
@IsString()
@IsOptional()
@MaxLength(10)
postalCode?: string;
@ApiPropertyOptional({
description: 'Giro del negocio',
example: 'Abarrotes',
})
@IsString()
@IsOptional()
@MaxLength(50)
giro?: string;
@ApiPropertyOptional({
description: 'Latitud',
example: 19.4326,
})
@IsNumber()
@IsOptional()
@Min(-90)
@Max(90)
latitude?: number;
@ApiPropertyOptional({
description: 'Longitud',
example: -99.1332,
})
@IsNumber()
@IsOptional()
@Min(-180)
@Max(180)
longitude?: number;
}

View File

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateStoreDto } from './create-store.dto';
export class UpdateStoreDto extends PartialType(CreateStoreDto) {}

View File

@ -0,0 +1,44 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
CreateDateColumn,
} from 'typeorm';
import { Store } from './store.entity';
import { User } from '../../users/entities/user.entity';
export enum StoreUserRole {
OWNER = 'OWNER',
OPERATOR = 'OPERATOR',
}
@Entity('store_users')
export class StoreUser {
@PrimaryGeneratedColumn('uuid')
id: string;
@ManyToOne(() => Store)
@JoinColumn({ name: 'store_id' })
store: Store;
@Column({ name: 'store_id' })
storeId: string;
@ManyToOne(() => User)
@JoinColumn({ name: 'user_id' })
user: User;
@Column({ name: 'user_id' })
userId: string;
@Column({ type: 'enum', enum: StoreUserRole })
role: StoreUserRole;
@Column({ type: 'boolean', default: true })
isActive: boolean;
@CreateDateColumn()
createdAt: Date;
}

View File

@ -0,0 +1,50 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
@Entity('stores')
export class Store {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
ownerId: string;
@ManyToOne(() => User)
@JoinColumn({ name: 'ownerId' })
owner: User;
@Column({ type: 'varchar', length: 100 })
name: string;
@Column({ type: 'varchar', length: 50, nullable: true })
giro: string;
@Column({ type: 'varchar', length: 255, nullable: true })
address: string;
@Column({ type: 'varchar', length: 20, nullable: true })
phone: string;
@Column({ type: 'varchar', length: 500, nullable: true })
logoUrl: string;
@Column({ type: 'jsonb', default: {} })
settings: Record<string, any>;
@Column({ type: 'boolean', default: true })
isActive: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@ -0,0 +1,82 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
UseGuards,
Request,
ParseUUIDPipe,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { StoresService } from './stores.service';
import { CreateStoreDto } from './dto/create-store.dto';
import { UpdateStoreDto } from './dto/update-store.dto';
import { AuthenticatedRequest } from '../../common/interfaces/authenticated-request.interface';
@ApiTags('stores')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('stores')
export class StoresController {
constructor(private readonly storesService: StoresService) {}
@Post()
@ApiOperation({ summary: 'Crear una nueva tienda' })
@ApiResponse({ status: 201, description: 'Tienda creada exitosamente' })
create(
@Request() req: AuthenticatedRequest,
@Body() createStoreDto: CreateStoreDto,
) {
return this.storesService.create(req.user.id, createStoreDto);
}
@Get()
@ApiOperation({ summary: 'Obtener todas las tiendas del usuario' })
@ApiResponse({ status: 200, description: 'Lista de tiendas' })
findAll(@Request() req: AuthenticatedRequest) {
return this.storesService.findAllByOwner(req.user.id);
}
@Get(':id')
@ApiOperation({ summary: 'Obtener una tienda por ID' })
@ApiResponse({ status: 200, description: 'Tienda encontrada' })
@ApiResponse({ status: 404, description: 'Tienda no encontrada' })
findOne(
@Request() req: AuthenticatedRequest,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.storesService.findByIdAndOwner(id, req.user.id);
}
@Patch(':id')
@ApiOperation({ summary: 'Actualizar una tienda' })
@ApiResponse({ status: 200, description: 'Tienda actualizada' })
@ApiResponse({ status: 404, description: 'Tienda no encontrada' })
update(
@Request() req: AuthenticatedRequest,
@Param('id', ParseUUIDPipe) id: string,
@Body() updateStoreDto: UpdateStoreDto,
) {
return this.storesService.update(id, req.user.id, updateStoreDto);
}
@Delete(':id')
@ApiOperation({ summary: 'Eliminar una tienda' })
@ApiResponse({ status: 200, description: 'Tienda eliminada' })
@ApiResponse({ status: 404, description: 'Tienda no encontrada' })
remove(
@Request() req: AuthenticatedRequest,
@Param('id', ParseUUIDPipe) id: string,
) {
return this.storesService.delete(id, req.user.id);
}
}

View File

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { StoresController } from './stores.controller';
import { StoresService } from './stores.service';
import { Store } from './entities/store.entity';
import { StoreUser } from './entities/store-user.entity';
@Module({
imports: [TypeOrmModule.forFeature([Store, StoreUser])],
controllers: [StoresController],
providers: [StoresService],
exports: [StoresService],
})
export class StoresModule {}

View File

@ -0,0 +1,82 @@
import {
Injectable,
NotFoundException,
ForbiddenException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Store } from './entities/store.entity';
import { CreateStoreDto } from './dto/create-store.dto';
import { UpdateStoreDto } from './dto/update-store.dto';
@Injectable()
export class StoresService {
constructor(
@InjectRepository(Store)
private readonly storesRepository: Repository<Store>,
) {}
async create(userId: string, createStoreDto: CreateStoreDto): Promise<Store> {
const store = this.storesRepository.create({
...createStoreDto,
ownerId: userId,
});
return this.storesRepository.save(store);
}
async findAllByOwner(userId: string): Promise<Store[]> {
return this.storesRepository.find({
where: { ownerId: userId, isActive: true },
order: { createdAt: 'DESC' },
});
}
async findById(id: string): Promise<Store | null> {
return this.storesRepository.findOne({ where: { id } });
}
async findByIdAndOwner(id: string, userId: string): Promise<Store> {
const store = await this.storesRepository.findOne({
where: { id, ownerId: userId },
});
if (!store) {
throw new NotFoundException('Tienda no encontrada');
}
return store;
}
async update(
id: string,
userId: string,
updateStoreDto: UpdateStoreDto,
): Promise<Store> {
const store = await this.findByIdAndOwner(id, userId);
Object.assign(store, updateStoreDto);
return this.storesRepository.save(store);
}
async delete(id: string, userId: string): Promise<void> {
const store = await this.findByIdAndOwner(id, userId);
// Soft delete - just mark as inactive
store.isActive = false;
await this.storesRepository.save(store);
}
async verifyOwnership(storeId: string, userId: string): Promise<Store> {
const store = await this.findById(storeId);
if (!store) {
throw new NotFoundException('Tienda no encontrada');
}
if (store.ownerId !== userId) {
throw new ForbiddenException('No tienes acceso a esta tienda');
}
return store;
}
}

View File

@ -0,0 +1,31 @@
import { IsString, IsOptional, IsEmail } from 'class-validator';
export class CreateUserDto {
@IsString()
@IsOptional()
phone?: string;
@IsEmail()
@IsOptional()
email?: string;
@IsString()
@IsOptional()
name?: string;
@IsString()
@IsOptional()
businessName?: string;
@IsString()
@IsOptional()
location?: string;
@IsString()
@IsOptional()
giro?: string;
@IsString()
@IsOptional()
passwordHash?: string;
}

View File

@ -0,0 +1,29 @@
import { IsString, IsOptional } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateUserDto {
@ApiPropertyOptional()
@IsString()
@IsOptional()
name?: string;
@ApiPropertyOptional()
@IsString()
@IsOptional()
businessName?: string;
@ApiPropertyOptional()
@IsString()
@IsOptional()
location?: string;
@ApiPropertyOptional()
@IsString()
@IsOptional()
giro?: string;
@ApiPropertyOptional()
@IsString()
@IsOptional()
stripeCustomerId?: string;
}

View File

@ -0,0 +1,60 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
export enum UserRole {
USER = 'USER',
VIEWER = 'VIEWER',
MODERATOR = 'MODERATOR',
ADMIN = 'ADMIN',
SUPER_ADMIN = 'SUPER_ADMIN',
}
@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 20, unique: true, nullable: true })
phone: string;
@Column({ type: 'varchar', length: 255, unique: true, nullable: true })
email: string;
@Column({ type: 'varchar', length: 255, nullable: true })
passwordHash: string;
@Column({ type: 'varchar', length: 100, nullable: true })
name: string;
@Column({ type: 'varchar', length: 100, nullable: true })
businessName: string;
@Column({ type: 'varchar', length: 255, nullable: true })
location: string;
@Column({ type: 'varchar', length: 50, nullable: true })
giro: string;
@Column({ type: 'enum', enum: UserRole, default: UserRole.USER })
role: UserRole;
@Column({ type: 'boolean', default: true })
isActive: boolean;
@Column({ type: 'varchar', length: 255, nullable: true })
fcmToken: string | null;
@Column({ type: 'varchar', length: 100, nullable: true })
stripeCustomerId: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@ -0,0 +1,40 @@
import { Controller, Get, Patch, Body, UseGuards, Request } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { UsersService } from './users.service';
import { UpdateUserDto } from './dto/update-user.dto';
@ApiTags('users')
@ApiBearerAuth()
@UseGuards(AuthGuard('jwt'))
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get('me')
@ApiOperation({ summary: 'Obtener perfil del usuario actual' })
async getProfile(@Request() req: any) {
return {
id: req.user.id,
name: req.user.name,
email: req.user.email,
phone: req.user.phone,
businessName: req.user.businessName,
location: req.user.location,
giro: req.user.giro,
};
}
@Patch('me')
@ApiOperation({ summary: 'Actualizar perfil del usuario actual' })
async updateProfile(@Request() req: any, @Body() updateUserDto: UpdateUserDto) {
return this.usersService.update(req.user.id, updateUserDto);
}
@Patch('me/fcm-token')
@ApiOperation({ summary: 'Actualizar FCM token para notificaciones' })
async updateFcmToken(@Request() req: any, @Body() body: { fcmToken: string }) {
await this.usersService.updateFcmToken(req.user.id, body.fcmToken);
return { success: true };
}
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { User } from './entities/user.entity';
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}

View File

@ -0,0 +1,54 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private readonly usersRepository: Repository<User>,
) {}
async create(createUserDto: CreateUserDto): Promise<User> {
const user = this.usersRepository.create(createUserDto);
return this.usersRepository.save(user);
}
async findById(id: string): Promise<User | null> {
return this.usersRepository.findOne({ where: { id } });
}
async findByIdentifier(identifier: string): Promise<User | null> {
return this.usersRepository.findOne({
where: [
{ phone: identifier },
{ email: identifier },
],
});
}
async findByPhone(phone: string): Promise<User | null> {
return this.usersRepository.findOne({ where: { phone } });
}
async findByEmail(email: string): Promise<User | null> {
return this.usersRepository.findOne({ where: { email } });
}
async update(id: string, updateUserDto: UpdateUserDto): Promise<User> {
const user = await this.findById(id);
if (!user) {
throw new NotFoundException('Usuario no encontrado');
}
Object.assign(user, updateUserDto);
return this.usersRepository.save(user);
}
async updateFcmToken(id: string, fcmToken: string | null): Promise<void> {
await this.usersRepository.update(id, { fcmToken });
}
}

Some files were not shown because too many files have changed in this diff Show More