[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:
commit
1a53b5c4d3
102
.env.example
Normal file
102
.env.example
Normal 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
4
.eslintrc.js
Normal file
@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
ignorePatterns: ['apps/**', 'node_modules'],
|
||||
};
|
||||
111
.gitignore
vendored
Normal file
111
.gitignore
vendored
Normal 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
10
.prettierrc
Normal 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
111
README.md
Normal 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
50
apps/backend/.env.example
Normal 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
16
apps/backend/.env.test
Normal 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
30
apps/backend/.eslintrc.js
Normal 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
10
apps/backend/.prettierrc
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
8
apps/backend/nest-cli.json
Normal file
8
apps/backend/nest-cli.json
Normal 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
105
apps/backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
65
apps/backend/src/app.module.ts
Normal file
65
apps/backend/src/app.module.ts
Normal 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 {}
|
||||
5
apps/backend/src/common/decorators/roles.decorator.ts
Normal file
5
apps/backend/src/common/decorators/roles.decorator.ts
Normal 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);
|
||||
44
apps/backend/src/common/guards/roles.guard.ts
Normal file
44
apps/backend/src/common/guards/roles.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
18
apps/backend/src/config/database.config.ts
Normal file
18
apps/backend/src/config/database.config.ts
Normal 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,
|
||||
});
|
||||
21
apps/backend/src/config/redis.config.ts
Normal file
21
apps/backend/src/config/redis.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
20
apps/backend/src/config/typeorm.config.ts
Normal file
20
apps/backend/src/config/typeorm.config.ts
Normal 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;
|
||||
75
apps/backend/src/database/seed.ts
Normal file
75
apps/backend/src/database/seed.ts
Normal 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
68
apps/backend/src/main.ts
Normal 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();
|
||||
@ -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"`);
|
||||
}
|
||||
}
|
||||
136
apps/backend/src/migrations/1736600000000-CreateAdminTables.ts
Normal file
136
apps/backend/src/migrations/1736600000000-CreateAdminTables.ts
Normal 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
|
||||
}
|
||||
}
|
||||
122
apps/backend/src/migrations/1768099560565-Init.ts
Normal file
122
apps/backend/src/migrations/1768099560565-Init.ts
Normal 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"`);
|
||||
}
|
||||
|
||||
}
|
||||
248
apps/backend/src/modules/admin/admin.controller.ts
Normal file
248
apps/backend/src/modules/admin/admin.controller.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
53
apps/backend/src/modules/admin/admin.module.ts
Normal file
53
apps/backend/src/modules/admin/admin.module.ts
Normal 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 {}
|
||||
66
apps/backend/src/modules/admin/dto/dashboard.dto.ts
Normal file
66
apps/backend/src/modules/admin/dto/dashboard.dto.ts
Normal 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;
|
||||
}
|
||||
39
apps/backend/src/modules/admin/dto/moderation.dto.ts
Normal file
39
apps/backend/src/modules/admin/dto/moderation.dto.ts
Normal 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;
|
||||
}
|
||||
80
apps/backend/src/modules/admin/dto/package.dto.ts
Normal file
80
apps/backend/src/modules/admin/dto/package.dto.ts
Normal 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;
|
||||
}
|
||||
137
apps/backend/src/modules/admin/dto/promotion.dto.ts
Normal file
137
apps/backend/src/modules/admin/dto/promotion.dto.ts
Normal 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;
|
||||
}
|
||||
42
apps/backend/src/modules/admin/dto/provider.dto.ts
Normal file
42
apps/backend/src/modules/admin/dto/provider.dto.ts
Normal 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;
|
||||
}
|
||||
51
apps/backend/src/modules/admin/entities/audit-log.entity.ts
Normal file
51
apps/backend/src/modules/admin/entities/audit-log.entity.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
90
apps/backend/src/modules/admin/entities/promotion.entity.ts
Normal file
90
apps/backend/src/modules/admin/entities/promotion.entity.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
57
apps/backend/src/modules/admin/services/audit-log.service.ts
Normal file
57
apps/backend/src/modules/admin/services/audit-log.service.ts
Normal 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'],
|
||||
});
|
||||
}
|
||||
}
|
||||
52
apps/backend/src/modules/auth/auth.controller.ts
Normal file
52
apps/backend/src/modules/auth/auth.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
34
apps/backend/src/modules/auth/auth.module.ts
Normal file
34
apps/backend/src/modules/auth/auth.module.ts
Normal 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 {}
|
||||
259
apps/backend/src/modules/auth/auth.service.ts
Normal file
259
apps/backend/src/modules/auth/auth.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
22
apps/backend/src/modules/auth/dto/login.dto.ts
Normal file
22
apps/backend/src/modules/auth/dto/login.dto.ts
Normal 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;
|
||||
}
|
||||
11
apps/backend/src/modules/auth/dto/refresh-token.dto.ts
Normal file
11
apps/backend/src/modules/auth/dto/refresh-token.dto.ts
Normal 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;
|
||||
}
|
||||
23
apps/backend/src/modules/auth/dto/register.dto.ts
Normal file
23
apps/backend/src/modules/auth/dto/register.dto.ts
Normal 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;
|
||||
}
|
||||
49
apps/backend/src/modules/auth/dto/verify-otp.dto.ts
Normal file
49
apps/backend/src/modules/auth/dto/verify-otp.dto.ts
Normal 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;
|
||||
}
|
||||
42
apps/backend/src/modules/auth/entities/otp.entity.ts
Normal file
42
apps/backend/src/modules/auth/entities/otp.entity.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
9
apps/backend/src/modules/auth/guards/jwt-auth.guard.ts
Normal file
9
apps/backend/src/modules/auth/guards/jwt-auth.guard.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
27
apps/backend/src/modules/auth/strategies/jwt.strategy.ts
Normal file
27
apps/backend/src/modules/auth/strategies/jwt.strategy.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
72
apps/backend/src/modules/credits/credits.controller.ts
Normal file
72
apps/backend/src/modules/credits/credits.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
17
apps/backend/src/modules/credits/credits.module.ts
Normal file
17
apps/backend/src/modules/credits/credits.module.ts
Normal 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 {}
|
||||
211
apps/backend/src/modules/credits/credits.service.ts
Normal file
211
apps/backend/src/modules/credits/credits.service.ts
Normal 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',
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
23
apps/backend/src/modules/feedback/dto/correct-sku.dto.ts
Normal file
23
apps/backend/src/modules/feedback/dto/correct-sku.dto.ts
Normal 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;
|
||||
}
|
||||
46
apps/backend/src/modules/feedback/dto/submit-product.dto.ts
Normal file
46
apps/backend/src/modules/feedback/dto/submit-product.dto.ts
Normal 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>;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
170
apps/backend/src/modules/feedback/feedback.controller.ts
Normal file
170
apps/backend/src/modules/feedback/feedback.controller.ts
Normal 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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
25
apps/backend/src/modules/feedback/feedback.module.ts
Normal file
25
apps/backend/src/modules/feedback/feedback.module.ts
Normal 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 {}
|
||||
239
apps/backend/src/modules/feedback/feedback.service.ts
Normal file
239
apps/backend/src/modules/feedback/feedback.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
27
apps/backend/src/modules/health/health.controller.ts
Normal file
27
apps/backend/src/modules/health/health.controller.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
7
apps/backend/src/modules/health/health.module.ts
Normal file
7
apps/backend/src/modules/health/health.module.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { IAProviderService } from './ia-provider.service';
|
||||
|
||||
@Module({
|
||||
providers: [IAProviderService],
|
||||
exports: [IAProviderService],
|
||||
})
|
||||
export class IAProviderModule {}
|
||||
331
apps/backend/src/modules/ia-provider/ia-provider.service.ts
Normal file
331
apps/backend/src/modules/ia-provider/ia-provider.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
135
apps/backend/src/modules/inventory/inventory.controller.ts
Normal file
135
apps/backend/src/modules/inventory/inventory.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
17
apps/backend/src/modules/inventory/inventory.module.ts
Normal file
17
apps/backend/src/modules/inventory/inventory.module.ts
Normal 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 {}
|
||||
172
apps/backend/src/modules/inventory/inventory.service.ts
Normal file
172
apps/backend/src/modules/inventory/inventory.service.ts
Normal 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'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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 };
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
228
apps/backend/src/modules/notifications/notifications.service.ts
Normal file
228
apps/backend/src/modules/notifications/notifications.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
29
apps/backend/src/modules/payments/dto/create-payment.dto.ts
Normal file
29
apps/backend/src/modules/payments/dto/create-payment.dto.ts
Normal 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;
|
||||
}
|
||||
92
apps/backend/src/modules/payments/entities/payment.entity.ts
Normal file
92
apps/backend/src/modules/payments/entities/payment.entity.ts
Normal 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;
|
||||
}
|
||||
103
apps/backend/src/modules/payments/payments.controller.ts
Normal file
103
apps/backend/src/modules/payments/payments.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
21
apps/backend/src/modules/payments/payments.module.ts
Normal file
21
apps/backend/src/modules/payments/payments.module.ts
Normal 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 {}
|
||||
416
apps/backend/src/modules/payments/payments.service.ts
Normal file
416
apps/backend/src/modules/payments/payments.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
96
apps/backend/src/modules/referrals/referrals.controller.ts
Normal file
96
apps/backend/src/modules/referrals/referrals.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
21
apps/backend/src/modules/referrals/referrals.module.ts
Normal file
21
apps/backend/src/modules/referrals/referrals.module.ts
Normal 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 {}
|
||||
286
apps/backend/src/modules/referrals/referrals.service.ts
Normal file
286
apps/backend/src/modules/referrals/referrals.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
86
apps/backend/src/modules/stores/dto/create-store.dto.ts
Normal file
86
apps/backend/src/modules/stores/dto/create-store.dto.ts
Normal 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;
|
||||
}
|
||||
4
apps/backend/src/modules/stores/dto/update-store.dto.ts
Normal file
4
apps/backend/src/modules/stores/dto/update-store.dto.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateStoreDto } from './create-store.dto';
|
||||
|
||||
export class UpdateStoreDto extends PartialType(CreateStoreDto) {}
|
||||
@ -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;
|
||||
}
|
||||
50
apps/backend/src/modules/stores/entities/store.entity.ts
Normal file
50
apps/backend/src/modules/stores/entities/store.entity.ts
Normal 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;
|
||||
}
|
||||
82
apps/backend/src/modules/stores/stores.controller.ts
Normal file
82
apps/backend/src/modules/stores/stores.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
14
apps/backend/src/modules/stores/stores.module.ts
Normal file
14
apps/backend/src/modules/stores/stores.module.ts
Normal 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 {}
|
||||
82
apps/backend/src/modules/stores/stores.service.ts
Normal file
82
apps/backend/src/modules/stores/stores.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
31
apps/backend/src/modules/users/dto/create-user.dto.ts
Normal file
31
apps/backend/src/modules/users/dto/create-user.dto.ts
Normal 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;
|
||||
}
|
||||
29
apps/backend/src/modules/users/dto/update-user.dto.ts
Normal file
29
apps/backend/src/modules/users/dto/update-user.dto.ts
Normal 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;
|
||||
}
|
||||
60
apps/backend/src/modules/users/entities/user.entity.ts
Normal file
60
apps/backend/src/modules/users/entities/user.entity.ts
Normal 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;
|
||||
}
|
||||
40
apps/backend/src/modules/users/users.controller.ts
Normal file
40
apps/backend/src/modules/users/users.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
13
apps/backend/src/modules/users/users.module.ts
Normal file
13
apps/backend/src/modules/users/users.module.ts
Normal 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 {}
|
||||
54
apps/backend/src/modules/users/users.service.ts
Normal file
54
apps/backend/src/modules/users/users.service.ts
Normal 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
Loading…
Reference in New Issue
Block a user