[MULTI-REPO] Convertir a estructura multi-repo con submodules -v2
Some checks are pending
Build / Build Backend (push) Waiting to run
Build / Build Docker Image (push) Blocked by required conditions
Build / Build Mobile (TypeScript Check) (push) Waiting to run
Lint / Lint Backend (push) Waiting to run
Lint / Lint Mobile (push) Waiting to run
Test / Backend E2E Tests (push) Waiting to run
Test / Mobile Unit Tests (push) Waiting to run

- backend → miinventario-backend-v2.git
- database → miinventario-database-v2.git
- mobile → miinventario-mobile-v2.git

Estandarización arquitectura multi-repo

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2026-01-16 08:30:07 -06:00
parent dedb0b623c
commit 30a7a6becd
251 changed files with 14 additions and 31419 deletions

11
.gitmodules vendored Normal file
View File

@ -0,0 +1,11 @@
[submodule "backend"]
path = backend
url = git@gitea-server:rckrdmrd/miinventario-backend-v2.git
[submodule "database"]
path = database
url = git@gitea-server:rckrdmrd/miinventario-database-v2.git
[submodule "mobile"]
path = mobile
url = git@gitea-server:rckrdmrd/miinventario-mobile-v2.git

View File

@ -1,50 +0,0 @@
# Dependencies
node_modules
npm-debug.log
yarn-debug.log
yarn-error.log
# Build output (will be recreated in Docker)
dist
# Test files
*.spec.ts
*.test.ts
__tests__
coverage
.nyc_output
junit.xml
# Development files
.env
.env.local
.env.development
.env.test
# IDE
.idea
.vscode
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Git
.git
.gitignore
# Docker
Dockerfile
docker-compose*.yml
.dockerignore
# Documentation
*.md
docs/
# Misc
*.log
.eslintcache
.prettierignore

View File

@ -1,50 +0,0 @@
# 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

View File

@ -1,16 +0,0 @@
# 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

View File

@ -1,30 +0,0 @@
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'] }],
},
};

View File

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

View File

@ -1,56 +0,0 @@
# Build stage
FROM node:18-alpine AS builder
WORKDIR /app
# Install build dependencies
RUN apk add --no-cache python3 make g++
# Copy package files
COPY package*.json ./
# Install all dependencies (including dev)
RUN npm ci
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Prune dev dependencies
RUN npm prune --production
# Production stage
FROM node:18-alpine AS production
# Install ffmpeg for video processing
RUN apk add --no-cache ffmpeg
WORKDIR /app
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nestjs -u 1001
# Copy built application from builder
COPY --from=builder --chown=nestjs:nodejs /app/dist ./dist
COPY --from=builder --chown=nestjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nestjs:nodejs /app/package.json ./package.json
# Set environment variables
ENV NODE_ENV=production
ENV PORT=3142
# Switch to non-root user
USER nestjs
# Expose port
EXPOSE 3142
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3142/api/v1/health || exit 1
# Start the application
CMD ["node", "dist/main.js"]

View File

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

View File

@ -1,107 +0,0 @@
{
"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",
"exceljs": "^4.4.0",
"fast-csv": "^5.0.5",
"firebase-admin": "^11.11.0",
"fluent-ffmpeg": "^2.1.3",
"ioredis": "^5.3.0",
"openai": "^6.16.0",
"passport": "^0.6.0",
"passport-jwt": "^4.0.1",
"pg": "^8.11.0",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1",
"stripe": "^14.0.0",
"typeorm": "^0.3.17",
"uuid": "^9.0.0"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@types/bcrypt": "^5.0.0",
"@types/express": "^4.17.17",
"@types/jest": "^29.5.2",
"@types/node": "^20.3.1",
"@types/passport-jwt": "^3.0.10",
"@types/supertest": "^6.0.3",
"@types/uuid": "^9.0.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"eslint": "^8.42.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"jest": "^29.5.0",
"prettier": "^3.0.0",
"source-map-support": "^0.5.21",
"supertest": "^6.3.3",
"ts-jest": "^29.1.0",
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

View File

@ -1,71 +0,0 @@
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';
import { ExportsModule } from './modules/exports/exports.module';
import { ReportsModule } from './modules/reports/reports.module';
import { IntegrationsModule } from './modules/integrations/integrations.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,
ExportsModule,
ReportsModule,
IntegrationsModule,
],
})
export class AppModule {}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,75 +0,0 @@
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();

View File

@ -1,75 +0,0 @@
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')
.addTag('exports', 'Exportacion de datos CSV/Excel')
.addTag('reports', 'Reportes avanzados')
.addTag('admin', 'Panel de administracion')
.addTag('feedback', 'Feedback y correcciones')
.addTag('validations', 'Validaciones crowdsourced')
.addTag('notifications', 'Sistema de notificaciones')
.addTag('health', 'Health checks')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);
}
const port = process.env.BACKEND_PORT || 3142;
await app.listen(port);
console.log(`
MIINVENTARIO API
Server running on: http://localhost:${port} ║
API Docs: http://localhost:${port}/api/docs ║
Environment: ${process.env.NODE_ENV || 'development'}
`);
}
bootstrap();

View File

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

View File

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

View File

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

View File

@ -1,91 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateExportsTables1768200000000 implements MigrationInterface {
name = 'CreateExportsTables1768200000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// Create enum for export format
await queryRunner.query(`
CREATE TYPE "export_format_enum" AS ENUM ('CSV', 'EXCEL')
`);
// Create enum for export type
await queryRunner.query(`
CREATE TYPE "export_type_enum" AS ENUM (
'INVENTORY',
'REPORT_VALUATION',
'REPORT_MOVEMENTS',
'REPORT_CATEGORIES',
'REPORT_LOW_STOCK'
)
`);
// Create enum for export status
await queryRunner.query(`
CREATE TYPE "export_status_enum" AS ENUM (
'PENDING',
'PROCESSING',
'COMPLETED',
'FAILED'
)
`);
// Create export_jobs table
await queryRunner.query(`
CREATE TABLE "export_jobs" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"userId" uuid NOT NULL,
"storeId" uuid NOT NULL,
"format" "export_format_enum" NOT NULL DEFAULT 'CSV',
"type" "export_type_enum" NOT NULL DEFAULT 'INVENTORY',
"status" "export_status_enum" NOT NULL DEFAULT 'PENDING',
"filters" jsonb,
"s3Key" varchar(500),
"downloadUrl" varchar(1000),
"expiresAt" timestamp,
"totalRows" integer,
"errorMessage" text,
"createdAt" timestamp NOT NULL DEFAULT now(),
"updatedAt" timestamp NOT NULL DEFAULT now(),
CONSTRAINT "PK_export_jobs" PRIMARY KEY ("id"),
CONSTRAINT "FK_export_jobs_user" FOREIGN KEY ("userId")
REFERENCES "users"("id") ON DELETE CASCADE,
CONSTRAINT "FK_export_jobs_store" FOREIGN KEY ("storeId")
REFERENCES "stores"("id") ON DELETE CASCADE
)
`);
// Create indexes
await queryRunner.query(`
CREATE INDEX "IDX_export_jobs_userId" ON "export_jobs" ("userId")
`);
await queryRunner.query(`
CREATE INDEX "IDX_export_jobs_storeId" ON "export_jobs" ("storeId")
`);
await queryRunner.query(`
CREATE INDEX "IDX_export_jobs_status" ON "export_jobs" ("status")
`);
await queryRunner.query(`
CREATE INDEX "IDX_export_jobs_createdAt" ON "export_jobs" ("createdAt")
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Drop indexes
await queryRunner.query(`DROP INDEX "IDX_export_jobs_createdAt"`);
await queryRunner.query(`DROP INDEX "IDX_export_jobs_status"`);
await queryRunner.query(`DROP INDEX "IDX_export_jobs_storeId"`);
await queryRunner.query(`DROP INDEX "IDX_export_jobs_userId"`);
// Drop table
await queryRunner.query(`DROP TABLE "export_jobs"`);
// Drop enums
await queryRunner.query(`DROP TYPE "export_status_enum"`);
await queryRunner.query(`DROP TYPE "export_type_enum"`);
await queryRunner.query(`DROP TYPE "export_format_enum"`);
}
}

View File

@ -1,156 +0,0 @@
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
export class CreateInventoryMovements1768200001000
implements MigrationInterface
{
name = 'CreateInventoryMovements1768200001000';
public async up(queryRunner: QueryRunner): Promise<void> {
// Create movement_type enum
await queryRunner.query(`
CREATE TYPE "movement_type_enum" AS ENUM (
'DETECTION',
'MANUAL_ADJUST',
'SALE',
'PURCHASE',
'CORRECTION',
'INITIAL',
'POS_SYNC'
)
`);
// Create trigger_type enum
await queryRunner.query(`
CREATE TYPE "trigger_type_enum" AS ENUM (
'USER',
'VIDEO',
'POS',
'SYSTEM'
)
`);
// Create inventory_movements table
await queryRunner.createTable(
new Table({
name: 'inventory_movements',
columns: [
{
name: 'id',
type: 'uuid',
isPrimary: true,
generationStrategy: 'uuid',
default: 'uuid_generate_v4()',
},
{
name: 'inventoryItemId',
type: 'uuid',
},
{
name: 'storeId',
type: 'uuid',
},
{
name: 'type',
type: 'movement_type_enum',
},
{
name: 'quantityBefore',
type: 'int',
},
{
name: 'quantityAfter',
type: 'int',
},
{
name: 'quantityChange',
type: 'int',
},
{
name: 'reason',
type: 'varchar',
length: '255',
isNullable: true,
},
{
name: 'triggeredById',
type: 'uuid',
isNullable: true,
},
{
name: 'triggerType',
type: 'trigger_type_enum',
default: `'SYSTEM'`,
},
{
name: 'referenceId',
type: 'uuid',
isNullable: true,
},
{
name: 'referenceType',
type: 'varchar',
length: '50',
isNullable: true,
},
{
name: 'createdAt',
type: 'timestamp',
default: 'CURRENT_TIMESTAMP',
},
],
foreignKeys: [
{
columnNames: ['inventoryItemId'],
referencedTableName: 'inventory_items',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
},
{
columnNames: ['storeId'],
referencedTableName: 'stores',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
},
{
columnNames: ['triggeredById'],
referencedTableName: 'users',
referencedColumnNames: ['id'],
onDelete: 'SET NULL',
},
],
}),
true,
);
// Create indexes
await queryRunner.createIndex(
'inventory_movements',
new TableIndex({
name: 'IDX_inventory_movements_store_created',
columnNames: ['storeId', 'createdAt'],
}),
);
await queryRunner.createIndex(
'inventory_movements',
new TableIndex({
name: 'IDX_inventory_movements_item_created',
columnNames: ['inventoryItemId', 'createdAt'],
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropIndex(
'inventory_movements',
'IDX_inventory_movements_item_created',
);
await queryRunner.dropIndex(
'inventory_movements',
'IDX_inventory_movements_store_created',
);
await queryRunner.dropTable('inventory_movements');
await queryRunner.query('DROP TYPE "trigger_type_enum"');
await queryRunner.query('DROP TYPE "movement_type_enum"');
}
}

View File

@ -1,261 +0,0 @@
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
export class CreatePosIntegrations1768200002000 implements MigrationInterface {
name = 'CreatePosIntegrations1768200002000';
public async up(queryRunner: QueryRunner): Promise<void> {
// Create pos_provider enum
await queryRunner.query(`
CREATE TYPE "pos_provider_enum" AS ENUM (
'SQUARE',
'SHOPIFY',
'CLOVER',
'LIGHTSPEED',
'TOAST',
'CUSTOM'
)
`);
// Create sync_direction enum
await queryRunner.query(`
CREATE TYPE "sync_direction_enum" AS ENUM (
'POS_TO_INVENTORY',
'INVENTORY_TO_POS',
'BIDIRECTIONAL'
)
`);
// Create sync_log_type enum
await queryRunner.query(`
CREATE TYPE "sync_log_type_enum" AS ENUM (
'WEBHOOK_RECEIVED',
'MANUAL_SYNC',
'SCHEDULED_SYNC'
)
`);
// Create sync_log_status enum
await queryRunner.query(`
CREATE TYPE "sync_log_status_enum" AS ENUM (
'SUCCESS',
'PARTIAL',
'FAILED'
)
`);
// Create pos_integrations table
await queryRunner.createTable(
new Table({
name: 'pos_integrations',
columns: [
{
name: 'id',
type: 'uuid',
isPrimary: true,
generationStrategy: 'uuid',
default: 'uuid_generate_v4()',
},
{
name: 'storeId',
type: 'uuid',
},
{
name: 'provider',
type: 'pos_provider_enum',
},
{
name: 'displayName',
type: 'varchar',
length: '255',
isNullable: true,
},
{
name: 'credentials',
type: 'jsonb',
isNullable: true,
},
{
name: 'webhookSecret',
type: 'varchar',
length: '255',
isNullable: true,
},
{
name: 'webhookUrl',
type: 'varchar',
length: '500',
isNullable: true,
},
{
name: 'isActive',
type: 'boolean',
default: false,
},
{
name: 'syncEnabled',
type: 'boolean',
default: true,
},
{
name: 'syncDirection',
type: 'sync_direction_enum',
default: `'POS_TO_INVENTORY'`,
},
{
name: 'syncConfig',
type: 'jsonb',
isNullable: true,
},
{
name: 'lastSyncAt',
type: 'timestamp',
isNullable: true,
},
{
name: 'lastSyncStatus',
type: 'varchar',
length: '255',
isNullable: true,
},
{
name: 'syncErrorCount',
type: 'int',
default: 0,
},
{
name: 'createdAt',
type: 'timestamp',
default: 'CURRENT_TIMESTAMP',
},
{
name: 'updatedAt',
type: 'timestamp',
default: 'CURRENT_TIMESTAMP',
},
],
foreignKeys: [
{
columnNames: ['storeId'],
referencedTableName: 'stores',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
},
],
}),
true,
);
// Create unique index
await queryRunner.createIndex(
'pos_integrations',
new TableIndex({
name: 'IDX_pos_integrations_store_provider',
columnNames: ['storeId', 'provider'],
isUnique: true,
}),
);
// Create pos_sync_logs table
await queryRunner.createTable(
new Table({
name: 'pos_sync_logs',
columns: [
{
name: 'id',
type: 'uuid',
isPrimary: true,
generationStrategy: 'uuid',
default: 'uuid_generate_v4()',
},
{
name: 'integrationId',
type: 'uuid',
},
{
name: 'type',
type: 'sync_log_type_enum',
},
{
name: 'status',
type: 'sync_log_status_enum',
},
{
name: 'itemsProcessed',
type: 'int',
default: 0,
},
{
name: 'itemsCreated',
type: 'int',
default: 0,
},
{
name: 'itemsUpdated',
type: 'int',
default: 0,
},
{
name: 'itemsSkipped',
type: 'int',
default: 0,
},
{
name: 'itemsFailed',
type: 'int',
default: 0,
},
{
name: 'details',
type: 'jsonb',
isNullable: true,
},
{
name: 'errorMessage',
type: 'text',
isNullable: true,
},
{
name: 'createdAt',
type: 'timestamp',
default: 'CURRENT_TIMESTAMP',
},
],
foreignKeys: [
{
columnNames: ['integrationId'],
referencedTableName: 'pos_integrations',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
},
],
}),
true,
);
// Create index for sync logs
await queryRunner.createIndex(
'pos_sync_logs',
new TableIndex({
name: 'IDX_pos_sync_logs_integration_created',
columnNames: ['integrationId', 'createdAt'],
}),
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropIndex(
'pos_sync_logs',
'IDX_pos_sync_logs_integration_created',
);
await queryRunner.dropTable('pos_sync_logs');
await queryRunner.dropIndex(
'pos_integrations',
'IDX_pos_integrations_store_provider',
);
await queryRunner.dropTable('pos_integrations');
await queryRunner.query('DROP TYPE "sync_log_status_enum"');
await queryRunner.query('DROP TYPE "sync_log_type_enum"');
await queryRunner.query('DROP TYPE "sync_direction_enum"');
await queryRunner.query('DROP TYPE "pos_provider_enum"');
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,77 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsEnum,
IsOptional,
IsString,
IsBoolean,
IsDateString,
} from 'class-validator';
import { ExportFormat, ExportType } from '../entities/export-job.entity';
export class ExportInventoryDto {
@ApiProperty({
description: 'Formato de exportación',
enum: ExportFormat,
example: ExportFormat.CSV,
})
@IsEnum(ExportFormat)
format: ExportFormat;
@ApiPropertyOptional({
description: 'Filtrar por categoría',
example: 'Bebidas',
})
@IsOptional()
@IsString()
category?: string;
@ApiPropertyOptional({
description: 'Solo items con stock bajo',
example: false,
})
@IsOptional()
@IsBoolean()
lowStockOnly?: boolean;
}
export class ExportReportDto {
@ApiProperty({
description: 'Tipo de reporte',
enum: ExportType,
example: ExportType.REPORT_VALUATION,
})
@IsEnum(ExportType)
type: ExportType;
@ApiProperty({
description: 'Formato de exportación',
enum: ExportFormat,
example: ExportFormat.EXCEL,
})
@IsEnum(ExportFormat)
format: ExportFormat;
@ApiPropertyOptional({
description: 'Fecha de inicio del periodo',
example: '2024-01-01',
})
@IsOptional()
@IsDateString()
startDate?: string;
@ApiPropertyOptional({
description: 'Fecha de fin del periodo',
example: '2024-01-31',
})
@IsOptional()
@IsDateString()
endDate?: string;
}
export class ExportJobResponseDto {
@ApiProperty({ description: 'ID del trabajo de exportación' })
jobId: string;
@ApiProperty({ description: 'Mensaje informativo' })
message: string;
}

View File

@ -1,47 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
ExportFormat,
ExportType,
ExportStatus,
ExportFilters,
} from '../entities/export-job.entity';
export class ExportStatusDto {
@ApiProperty({ description: 'ID del trabajo' })
id: string;
@ApiProperty({ description: 'Estado actual', enum: ExportStatus })
status: ExportStatus;
@ApiProperty({ description: 'Formato', enum: ExportFormat })
format: ExportFormat;
@ApiProperty({ description: 'Tipo de exportación', enum: ExportType })
type: ExportType;
@ApiPropertyOptional({ description: 'Filtros aplicados' })
filters?: ExportFilters;
@ApiPropertyOptional({ description: 'Total de filas exportadas' })
totalRows?: number;
@ApiPropertyOptional({ description: 'Mensaje de error si falló' })
errorMessage?: string;
@ApiProperty({ description: 'Fecha de creación' })
createdAt: Date;
@ApiPropertyOptional({ description: 'Fecha de expiración de descarga' })
expiresAt?: Date;
}
export class ExportDownloadDto {
@ApiProperty({ description: 'URL de descarga presignada' })
url: string;
@ApiProperty({ description: 'Fecha de expiración de la URL' })
expiresAt: Date;
@ApiProperty({ description: 'Nombre del archivo' })
filename: string;
}

View File

@ -1,103 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
import { Store } from '../../stores/entities/store.entity';
export enum ExportFormat {
CSV = 'CSV',
EXCEL = 'EXCEL',
}
export enum ExportType {
INVENTORY = 'INVENTORY',
REPORT_VALUATION = 'REPORT_VALUATION',
REPORT_MOVEMENTS = 'REPORT_MOVEMENTS',
REPORT_CATEGORIES = 'REPORT_CATEGORIES',
REPORT_LOW_STOCK = 'REPORT_LOW_STOCK',
}
export enum ExportStatus {
PENDING = 'PENDING',
PROCESSING = 'PROCESSING',
COMPLETED = 'COMPLETED',
FAILED = 'FAILED',
}
export interface ExportFilters {
category?: string;
lowStockOnly?: boolean;
startDate?: Date;
endDate?: Date;
}
@Entity('export_jobs')
export class ExportJob {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
userId: string;
@ManyToOne(() => User)
@JoinColumn({ name: 'userId' })
user: User;
@Column({ type: 'uuid' })
storeId: string;
@ManyToOne(() => Store)
@JoinColumn({ name: 'storeId' })
store: Store;
@Column({
type: 'enum',
enum: ExportFormat,
default: ExportFormat.CSV,
})
format: ExportFormat;
@Column({
type: 'enum',
enum: ExportType,
default: ExportType.INVENTORY,
})
type: ExportType;
@Column({
type: 'enum',
enum: ExportStatus,
default: ExportStatus.PENDING,
})
status: ExportStatus;
@Column({ type: 'jsonb', nullable: true })
filters: ExportFilters;
@Column({ type: 'varchar', length: 500, nullable: true })
s3Key: string;
@Column({ type: 'varchar', length: 1000, nullable: true })
downloadUrl: string;
@Column({ type: 'timestamp', nullable: true })
expiresAt: Date;
@Column({ type: 'int', nullable: true })
totalRows: number;
@Column({ type: 'text', nullable: true })
errorMessage: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@ -1,127 +0,0 @@
import {
Controller,
Post,
Get,
Param,
Body,
UseGuards,
Request,
ParseUUIDPipe,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiParam,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { ExportsService } from './exports.service';
import {
ExportInventoryDto,
ExportReportDto,
ExportJobResponseDto,
} from './dto/export-request.dto';
import { ExportStatusDto, ExportDownloadDto } from './dto/export-status.dto';
import { AuthenticatedRequest } from '../../common/interfaces/authenticated-request.interface';
@ApiTags('exports')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('stores/:storeId/exports')
export class ExportsController {
constructor(private readonly exportsService: ExportsService) {}
@Post('inventory')
@ApiOperation({ summary: 'Solicitar exportación de inventario' })
@ApiParam({ name: 'storeId', description: 'ID de la tienda' })
@ApiResponse({
status: 201,
description: 'Trabajo de exportación creado',
type: ExportJobResponseDto,
})
async requestInventoryExport(
@Param('storeId', ParseUUIDPipe) storeId: string,
@Body() dto: ExportInventoryDto,
@Request() req: AuthenticatedRequest,
): Promise<ExportJobResponseDto> {
const result = await this.exportsService.requestInventoryExport(
req.user.id,
storeId,
dto.format,
{
category: dto.category,
lowStockOnly: dto.lowStockOnly,
},
);
return {
jobId: result.jobId,
message:
'Exportación iniciada. Consulta el estado con GET /exports/:jobId',
};
}
@Post('report')
@ApiOperation({ summary: 'Solicitar exportación de reporte' })
@ApiParam({ name: 'storeId', description: 'ID de la tienda' })
@ApiResponse({
status: 201,
description: 'Trabajo de exportación creado',
type: ExportJobResponseDto,
})
async requestReportExport(
@Param('storeId', ParseUUIDPipe) storeId: string,
@Body() dto: ExportReportDto,
@Request() req: AuthenticatedRequest,
): Promise<ExportJobResponseDto> {
const result = await this.exportsService.requestReportExport(
req.user.id,
storeId,
dto.type,
dto.format,
{
startDate: dto.startDate ? new Date(dto.startDate) : undefined,
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
},
);
return {
jobId: result.jobId,
message:
'Exportación de reporte iniciada. Consulta el estado con GET /exports/:jobId',
};
}
@Get(':jobId')
@ApiOperation({ summary: 'Obtener estado de exportación' })
@ApiParam({ name: 'storeId', description: 'ID de la tienda' })
@ApiParam({ name: 'jobId', description: 'ID del trabajo de exportación' })
@ApiResponse({
status: 200,
description: 'Estado del trabajo',
type: ExportStatusDto,
})
async getExportStatus(
@Param('jobId', ParseUUIDPipe) jobId: string,
@Request() req: AuthenticatedRequest,
): Promise<ExportStatusDto> {
return this.exportsService.getExportStatus(jobId, req.user.id);
}
@Get(':jobId/download')
@ApiOperation({ summary: 'Obtener URL de descarga' })
@ApiParam({ name: 'storeId', description: 'ID de la tienda' })
@ApiParam({ name: 'jobId', description: 'ID del trabajo de exportación' })
@ApiResponse({
status: 200,
description: 'URL de descarga presignada',
type: ExportDownloadDto,
})
async getDownloadUrl(
@Param('jobId', ParseUUIDPipe) jobId: string,
@Request() req: AuthenticatedRequest,
): Promise<ExportDownloadDto> {
return this.exportsService.getDownloadUrl(jobId, req.user.id);
}
}

View File

@ -1,23 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BullModule } from '@nestjs/bull';
import { ConfigModule } from '@nestjs/config';
import { ExportsController } from './exports.controller';
import { ExportsService } from './exports.service';
import { ExportsProcessor } from './exports.processor';
import { ExportJob } from './entities/export-job.entity';
import { InventoryItem } from '../inventory/entities/inventory-item.entity';
@Module({
imports: [
TypeOrmModule.forFeature([ExportJob, InventoryItem]),
BullModule.registerQueue({
name: 'exports',
}),
ConfigModule,
],
controllers: [ExportsController],
providers: [ExportsService, ExportsProcessor],
exports: [ExportsService],
})
export class ExportsModule {}

View File

@ -1,30 +0,0 @@
import { Process, Processor } from '@nestjs/bull';
import { Logger } from '@nestjs/common';
import { Job } from 'bull';
import { ExportsService, ExportJobData } from './exports.service';
@Processor('exports')
export class ExportsProcessor {
private readonly logger = new Logger(ExportsProcessor.name);
constructor(private readonly exportsService: ExportsService) {}
@Process('generate-export')
async handleExport(job: Job<ExportJobData>): Promise<void> {
this.logger.log(
`Processing export job ${job.data.jobId} for store ${job.data.storeId}`,
);
try {
await this.exportsService.processExport(job.data);
this.logger.log(`Export job ${job.data.jobId} completed successfully`);
} catch (error) {
this.logger.error(
`Export job ${job.data.jobId} failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
error instanceof Error ? error.stack : undefined,
);
throw error;
}
}
}

View File

@ -1,413 +0,0 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { InjectQueue } from '@nestjs/bull';
import { Queue } from 'bull';
import { ConfigService } from '@nestjs/config';
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import * as fastCsv from 'fast-csv';
import * as ExcelJS from 'exceljs';
import { Readable } from 'stream';
import {
ExportJob,
ExportFormat,
ExportType,
ExportStatus,
ExportFilters,
} from './entities/export-job.entity';
import { InventoryItem } from '../inventory/entities/inventory-item.entity';
import { ExportStatusDto, ExportDownloadDto } from './dto/export-status.dto';
export interface ExportJobData {
jobId: string;
userId: string;
storeId: string;
format: ExportFormat;
type: ExportType;
filters?: ExportFilters;
}
@Injectable()
export class ExportsService {
private s3Client: S3Client;
private bucket: string;
private urlExpiry: number;
constructor(
@InjectRepository(ExportJob)
private exportJobRepository: Repository<ExportJob>,
@InjectRepository(InventoryItem)
private inventoryRepository: Repository<InventoryItem>,
@InjectQueue('exports')
private exportQueue: Queue<ExportJobData>,
private configService: ConfigService,
) {
this.bucket = this.configService.get('S3_BUCKET', 'miinventario');
this.urlExpiry = this.configService.get('EXPORT_URL_EXPIRY', 3600);
const endpoint = this.configService.get('S3_ENDPOINT');
if (endpoint) {
this.s3Client = new S3Client({
endpoint,
region: this.configService.get('S3_REGION', 'us-east-1'),
credentials: {
accessKeyId: this.configService.get('S3_ACCESS_KEY', 'minioadmin'),
secretAccessKey: this.configService.get('S3_SECRET_KEY', 'minioadmin'),
},
forcePathStyle: true,
});
}
}
async requestInventoryExport(
userId: string,
storeId: string,
format: ExportFormat,
filters?: ExportFilters,
): Promise<{ jobId: string }> {
const job = this.exportJobRepository.create({
userId,
storeId,
format,
type: ExportType.INVENTORY,
status: ExportStatus.PENDING,
filters,
});
const savedJob = await this.exportJobRepository.save(job);
await this.exportQueue.add('generate-export', {
jobId: savedJob.id,
userId,
storeId,
format,
type: ExportType.INVENTORY,
filters,
});
return { jobId: savedJob.id };
}
async requestReportExport(
userId: string,
storeId: string,
type: ExportType,
format: ExportFormat,
filters?: ExportFilters,
): Promise<{ jobId: string }> {
const job = this.exportJobRepository.create({
userId,
storeId,
format,
type,
status: ExportStatus.PENDING,
filters,
});
const savedJob = await this.exportJobRepository.save(job);
await this.exportQueue.add('generate-export', {
jobId: savedJob.id,
userId,
storeId,
format,
type,
filters,
});
return { jobId: savedJob.id };
}
async getExportStatus(
jobId: string,
userId: string,
): Promise<ExportStatusDto> {
const job = await this.exportJobRepository.findOne({
where: { id: jobId, userId },
});
if (!job) {
throw new NotFoundException('Export job not found');
}
return {
id: job.id,
status: job.status,
format: job.format,
type: job.type,
filters: job.filters,
totalRows: job.totalRows,
errorMessage: job.errorMessage,
createdAt: job.createdAt,
expiresAt: job.expiresAt,
};
}
async getDownloadUrl(
jobId: string,
userId: string,
): Promise<ExportDownloadDto> {
const job = await this.exportJobRepository.findOne({
where: { id: jobId, userId },
});
if (!job) {
throw new NotFoundException('Export job not found');
}
if (job.status !== ExportStatus.COMPLETED) {
throw new BadRequestException(
`Export is not ready. Current status: ${job.status}`,
);
}
if (!job.s3Key) {
throw new BadRequestException('Export file not found');
}
const command = new GetObjectCommand({
Bucket: this.bucket,
Key: job.s3Key,
});
const url = await getSignedUrl(this.s3Client, command, {
expiresIn: this.urlExpiry,
});
const expiresAt = new Date(Date.now() + this.urlExpiry * 1000);
const extension = job.format === ExportFormat.CSV ? 'csv' : 'xlsx';
const filename = `inventory_export_${job.id}.${extension}`;
return { url, expiresAt, filename };
}
async processExport(data: ExportJobData): Promise<void> {
const { jobId, storeId, format, type, filters } = data;
await this.exportJobRepository.update(jobId, {
status: ExportStatus.PROCESSING,
});
try {
let buffer: Buffer;
let totalRows = 0;
if (type === ExportType.INVENTORY) {
const result = await this.generateInventoryExport(
storeId,
format,
filters,
);
buffer = result.buffer;
totalRows = result.totalRows;
} else {
throw new Error(`Unsupported export type: ${type}`);
}
const extension = format === ExportFormat.CSV ? 'csv' : 'xlsx';
const s3Key = `exports/${storeId}/${jobId}.${extension}`;
await this.uploadToS3(s3Key, buffer, format);
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
await this.exportJobRepository.update(jobId, {
status: ExportStatus.COMPLETED,
s3Key,
totalRows,
expiresAt,
});
} catch (error) {
await this.exportJobRepository.update(jobId, {
status: ExportStatus.FAILED,
errorMessage: error instanceof Error ? error.message : 'Unknown error',
});
throw error;
}
}
private async generateInventoryExport(
storeId: string,
format: ExportFormat,
filters?: ExportFilters,
): Promise<{ buffer: Buffer; totalRows: number }> {
const queryBuilder = this.inventoryRepository
.createQueryBuilder('item')
.where('item.storeId = :storeId', { storeId })
.orderBy('item.name', 'ASC');
if (filters?.category) {
queryBuilder.andWhere('item.category = :category', {
category: filters.category,
});
}
if (filters?.lowStockOnly) {
queryBuilder.andWhere('item.quantity <= item.minStock');
}
const items = await queryBuilder.getMany();
if (format === ExportFormat.CSV) {
return this.generateCsv(items);
} else {
return this.generateExcel(items, storeId);
}
}
private async generateCsv(
items: InventoryItem[],
): Promise<{ buffer: Buffer; totalRows: number }> {
return new Promise((resolve, reject) => {
const rows: string[][] = [];
const csvStream = fastCsv.format({ headers: true });
csvStream.on('data', (chunk) => rows.push(chunk));
csvStream.on('end', () => {
const buffer = Buffer.from(rows.join(''));
resolve({ buffer, totalRows: items.length });
});
csvStream.on('error', reject);
for (const item of items) {
csvStream.write({
ID: item.id,
Nombre: item.name,
Categoria: item.category || '',
Subcategoria: item.subcategory || '',
'Codigo de Barras': item.barcode || '',
Cantidad: item.quantity,
'Stock Minimo': item.minStock || 0,
Precio: item.price || 0,
Costo: item.cost || 0,
'Valor Total': (item.quantity * (item.price || 0)).toFixed(2),
'Confianza Deteccion': item.detectionConfidence
? (item.detectionConfidence * 100).toFixed(1) + '%'
: '',
'Stock Bajo': item.quantity <= (item.minStock || 0) ? 'Sí' : 'No',
'Fecha Creacion': item.createdAt?.toISOString() || '',
'Ultima Actualizacion': item.updatedAt?.toISOString() || '',
});
}
csvStream.end();
});
}
private async generateExcel(
items: InventoryItem[],
storeId: string,
): Promise<{ buffer: Buffer; totalRows: number }> {
const workbook = new ExcelJS.Workbook();
workbook.creator = 'MiInventario';
workbook.created = new Date();
const summarySheet = workbook.addWorksheet('Resumen');
const totalItems = items.length;
const totalValue = items.reduce(
(sum, item) => sum + item.quantity * (item.price || 0),
0,
);
const totalCost = items.reduce(
(sum, item) => sum + item.quantity * (item.cost || 0),
0,
);
const lowStockCount = items.filter(
(item) => item.quantity <= (item.minStock || 0),
).length;
const categories = [...new Set(items.map((item) => item.category).filter(Boolean))];
summarySheet.columns = [
{ header: 'Métrica', key: 'metric', width: 30 },
{ header: 'Valor', key: 'value', width: 20 },
];
summarySheet.addRows([
{ metric: 'Total de Productos', value: totalItems },
{ metric: 'Valor Total (Precio)', value: `$${totalValue.toFixed(2)}` },
{ metric: 'Costo Total', value: `$${totalCost.toFixed(2)}` },
{ metric: 'Margen Potencial', value: `$${(totalValue - totalCost).toFixed(2)}` },
{ metric: 'Productos con Stock Bajo', value: lowStockCount },
{ metric: 'Categorías', value: categories.length },
{ metric: 'Fecha de Exportación', value: new Date().toLocaleString('es-MX') },
]);
summarySheet.getRow(1).font = { bold: true };
const inventorySheet = workbook.addWorksheet('Inventario');
inventorySheet.columns = [
{ header: 'Nombre', key: 'name', width: 30 },
{ header: 'Categoría', key: 'category', width: 15 },
{ header: 'Código', key: 'barcode', width: 15 },
{ header: 'Cantidad', key: 'quantity', width: 10 },
{ header: 'Stock Mín.', key: 'minStock', width: 10 },
{ header: 'Precio', key: 'price', width: 12 },
{ header: 'Costo', key: 'cost', width: 12 },
{ header: 'Valor Total', key: 'totalValue', width: 12 },
{ header: 'Confianza', key: 'confidence', width: 10 },
{ header: 'Stock Bajo', key: 'lowStock', width: 10 },
];
for (const item of items) {
const row = inventorySheet.addRow({
name: item.name,
category: item.category || '',
barcode: item.barcode || '',
quantity: item.quantity,
minStock: item.minStock || 0,
price: item.price || 0,
cost: item.cost || 0,
totalValue: item.quantity * (item.price || 0),
confidence: item.detectionConfidence
? `${(item.detectionConfidence * 100).toFixed(0)}%`
: '',
lowStock: item.quantity <= (item.minStock || 0) ? 'Sí' : 'No',
});
if (item.quantity <= (item.minStock || 0)) {
row.getCell('lowStock').fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFFFCCCC' },
};
}
}
inventorySheet.getRow(1).font = { bold: true };
inventorySheet.getRow(1).fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFE0E0E0' },
};
const buffer = await workbook.xlsx.writeBuffer();
return { buffer: Buffer.from(buffer), totalRows: items.length };
}
private async uploadToS3(
key: string,
buffer: Buffer,
format: ExportFormat,
): Promise<void> {
const contentType =
format === ExportFormat.CSV
? 'text/csv'
: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
const command = new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: buffer,
ContentType: contentType,
});
await this.s3Client.send(command);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,548 +0,0 @@
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import OpenAI from 'openai';
import Anthropic from '@anthropic-ai/sdk';
import * as crypto from 'crypto';
import Redis from 'ioredis';
import { DetectedItem } from '../inventory/inventory.service';
export interface IAProvider {
name: string;
detectInventory(frames: string[], context?: string): Promise<DetectedItem[]>;
}
export interface ProviderMetrics {
provider: string;
calls: number;
errors: number;
avgLatencyMs: number;
lastLatencyMs: number;
cacheHits: number;
cacheMisses: number;
}
interface ProviderConfig {
timeoutMs: number;
enabled: boolean;
}
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 implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(IAProviderService.name);
private activeProvider: string;
private openai: OpenAI | null = null;
private anthropic: Anthropic | null = null;
private redis: Redis | null = null;
// Provider configurations
private providerConfigs: Map<string, ProviderConfig> = new Map([
['openai', { timeoutMs: 60000, enabled: true }],
['claude', { timeoutMs: 90000, enabled: true }],
]);
// Metrics tracking
private metrics: Map<string, ProviderMetrics> = new Map([
['openai', { provider: 'openai', calls: 0, errors: 0, avgLatencyMs: 0, lastLatencyMs: 0, cacheHits: 0, cacheMisses: 0 }],
['claude', { provider: 'claude', calls: 0, errors: 0, avgLatencyMs: 0, lastLatencyMs: 0, cacheHits: 0, cacheMisses: 0 }],
]);
// Cache TTL in seconds (24 hours)
private readonly CACHE_TTL = 24 * 60 * 60;
private readonly CACHE_PREFIX = 'ia:detection:';
constructor(private readonly configService: ConfigService) {
this.activeProvider = this.configService.get('IA_PROVIDER', 'openai');
}
async onModuleInit() {
this.initializeClients();
this.initializeRedis();
}
async onModuleDestroy() {
if (this.redis) {
await this.redis.quit();
}
}
private initializeRedis() {
try {
const redisHost = this.configService.get('REDIS_HOST', 'localhost');
const redisPort = this.configService.get('REDIS_PORT', 6380);
const redisPassword = this.configService.get('REDIS_PASSWORD');
this.redis = new Redis({
host: redisHost,
port: redisPort,
password: redisPassword,
maxRetriesPerRequest: 3,
lazyConnect: true,
});
this.redis.on('connect', () => {
this.logger.log('Redis cache connected for IA provider');
});
this.redis.on('error', (err) => {
this.logger.warn(`Redis cache error: ${err.message}`);
});
this.redis.connect().catch(() => {
this.logger.warn('Redis not available for caching');
this.redis = null;
});
} catch {
this.logger.warn('Failed to initialize Redis cache');
this.redis = null;
}
}
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}`,
);
// Generate cache key from frames hash
const cacheKey = this.generateCacheKey(frames, storeId);
// Try to get from cache
const cached = await this.getFromCache(cacheKey);
if (cached) {
this.logger.log('Cache hit for detection request');
this.updateMetrics(this.activeProvider, 0, false, true);
return cached;
}
this.logger.log('Cache miss, calling IA provider');
const startTime = Date.now();
let result: DetectedItem[] = [];
let usedProvider = this.activeProvider;
try {
result = await this.detectWithTimeout(
this.activeProvider,
frames,
storeId,
);
} catch (error) {
this.logger.warn(`Primary provider (${this.activeProvider}) failed: ${error.message}`);
// Try fallback provider
const fallbackProvider = this.activeProvider === 'openai' ? 'claude' : 'openai';
const fallbackConfig = this.providerConfigs.get(fallbackProvider);
if (fallbackConfig?.enabled && this.getProviderClient(fallbackProvider)) {
this.logger.log(`Trying fallback provider: ${fallbackProvider}`);
try {
result = await this.detectWithTimeout(fallbackProvider, frames, storeId);
usedProvider = fallbackProvider;
} catch (fallbackError) {
this.logger.error(`Fallback provider also failed: ${fallbackError.message}`);
this.updateMetrics(fallbackProvider, Date.now() - startTime, true, false);
// Last resort: development mock
if (this.configService.get('NODE_ENV') === 'development') {
this.logger.warn('Falling back to mock detection');
return this.getMockDetection();
}
throw error;
}
} else {
if (this.configService.get('NODE_ENV') === 'development') {
return this.getMockDetection();
}
throw error;
}
}
const latency = Date.now() - startTime;
this.updateMetrics(usedProvider, latency, false, false);
// Cache the result
await this.saveToCache(cacheKey, result);
this.logger.log(`Detection completed in ${latency}ms using ${usedProvider}`);
return result;
}
private async detectWithTimeout(
provider: string,
frames: string[],
storeId: string,
): Promise<DetectedItem[]> {
const config = this.providerConfigs.get(provider);
const timeoutMs = config?.timeoutMs || 60000;
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error(`Provider ${provider} timeout after ${timeoutMs}ms`)), timeoutMs);
});
const detectPromise = provider === 'openai'
? this.detectWithOpenAI(frames, storeId)
: this.detectWithClaude(frames, storeId);
return Promise.race([detectPromise, timeoutPromise]);
}
private getProviderClient(provider: string): boolean {
if (provider === 'openai') return this.openai !== null;
if (provider === 'claude') return this.anthropic !== null;
return false;
}
private generateCacheKey(frames: string[], storeId: string): string {
// Create hash from first 5 frames to identify unique video content
const framesHash = crypto
.createHash('sha256')
.update(frames.slice(0, 5).join(''))
.digest('hex')
.substring(0, 16);
return `${this.CACHE_PREFIX}${storeId}:${framesHash}`;
}
private async getFromCache(key: string): Promise<DetectedItem[] | null> {
if (!this.redis) return null;
try {
const cached = await this.redis.get(key);
if (cached) {
return JSON.parse(cached);
}
} catch (err) {
this.logger.warn(`Cache read error: ${err.message}`);
}
return null;
}
private async saveToCache(key: string, data: DetectedItem[]): Promise<void> {
if (!this.redis) return;
try {
await this.redis.setex(key, this.CACHE_TTL, JSON.stringify(data));
} catch (err) {
this.logger.warn(`Cache write error: ${err.message}`);
}
}
private updateMetrics(
provider: string,
latencyMs: number,
isError: boolean,
isCacheHit: boolean,
): void {
const metrics = this.metrics.get(provider);
if (!metrics) return;
metrics.calls++;
if (isError) {
metrics.errors++;
} else if (isCacheHit) {
metrics.cacheHits++;
} else {
metrics.cacheMisses++;
metrics.lastLatencyMs = latencyMs;
// Running average
metrics.avgLatencyMs = Math.round(
(metrics.avgLatencyMs * (metrics.calls - 1) + latencyMs) / metrics.calls,
);
}
}
getMetrics(): ProviderMetrics[] {
return Array.from(this.metrics.values());
}
getProviderConfig(provider: string): ProviderConfig | undefined {
return this.providerConfigs.get(provider);
}
setProviderTimeout(provider: string, timeoutMs: number): void {
const config = this.providerConfigs.get(provider);
if (config) {
config.timeoutMs = timeoutMs;
this.logger.log(`Updated ${provider} timeout to ${timeoutMs}ms`);
}
}
private async detectWithOpenAI(
frames: string[],
storeId: string,
): Promise<DetectedItem[]> {
if (!this.openai) {
this.logger.warn('OpenAI not configured, using mock data');
return this.getMockDetection();
}
this.logger.log(`Calling OpenAI Vision API with ${frames.length} images`);
// Prepare image content for the API
const imageContent: OpenAI.Chat.Completions.ChatCompletionContentPart[] =
frames.slice(0, 10).map((frame) => ({
type: 'image_url' as const,
image_url: {
url: frame.startsWith('data:') ? frame : `data:image/jpeg;base64,${frame}`,
detail: 'high' as const,
},
}));
try {
const response = await this.openai.chat.completions.create({
model: 'gpt-4o',
messages: [
{
role: 'user',
content: [
{ type: 'text', text: INVENTORY_DETECTION_PROMPT },
...imageContent,
],
},
],
max_tokens: 4096,
temperature: 0.1,
});
const content = response.choices[0]?.message?.content;
if (!content) {
this.logger.warn('Empty response from OpenAI');
return [];
}
return this.parseDetectionResponse(content);
} catch (error) {
this.logger.error(`OpenAI API error: ${error.message}`);
throw error;
}
}
private async detectWithClaude(
frames: string[],
storeId: string,
): Promise<DetectedItem[]> {
if (!this.anthropic) {
this.logger.warn('Anthropic not configured, using mock data');
return this.getMockDetection();
}
this.logger.log(`Calling Claude Vision API with ${frames.length} images`);
// Prepare image content for Claude
const imageContent: Anthropic.Messages.ImageBlockParam[] = frames
.slice(0, 10)
.map((frame) => ({
type: 'image' as const,
source: {
type: 'base64' as const,
media_type: 'image/jpeg' as const,
data: frame.startsWith('data:')
? frame.replace(/^data:image\/\w+;base64,/, '')
: frame,
},
}));
try {
const response = await this.anthropic.messages.create({
model: 'claude-sonnet-4-20250514',
max_tokens: 4096,
messages: [
{
role: 'user',
content: [
...imageContent,
{ type: 'text', text: INVENTORY_DETECTION_PROMPT },
],
},
],
});
const content = response.content[0];
if (content.type !== 'text') {
this.logger.warn('Unexpected response type from Claude');
return [];
}
return this.parseDetectionResponse(content.text);
} catch (error) {
this.logger.error(`Claude API error: ${error.message}`);
throw error;
}
}
private parseDetectionResponse(content: string): DetectedItem[] {
try {
// Try to extract JSON from the response
let jsonStr = content.trim();
// Handle markdown code blocks
const jsonMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/);
if (jsonMatch) {
jsonStr = jsonMatch[1].trim();
}
// Find the JSON array
const arrayMatch = jsonStr.match(/\[[\s\S]*\]/);
if (arrayMatch) {
jsonStr = arrayMatch[0];
}
const items = JSON.parse(jsonStr);
if (!Array.isArray(items)) {
this.logger.warn('Response is not an array');
return [];
}
// Validate and normalize items
return items
.filter(
(item: any) =>
item.name &&
typeof item.quantity === 'number' &&
item.quantity >= 0,
)
.map((item: any) => ({
name: String(item.name).trim(),
quantity: Math.max(0, Math.floor(item.quantity)),
confidence: Math.min(1, Math.max(0, Number(item.confidence) || 0.5)),
category: item.category ? String(item.category).trim() : undefined,
barcode: item.barcode ? String(item.barcode).trim() : undefined,
}));
} catch (error) {
this.logger.error(`Failed to parse detection response: ${error.message}`);
this.logger.debug(`Raw response: ${content.substring(0, 500)}`);
return [];
}
}
private getMockDetection(): DetectedItem[] {
// Simulated detection results for Mexican tiendita products
return [
{
name: 'Coca Cola 600ml',
quantity: 24,
confidence: 0.95,
category: 'Bebidas',
barcode: '7501055303045',
},
{
name: 'Sabritas Original 45g',
quantity: 15,
confidence: 0.92,
category: 'Botanas',
},
{
name: 'Maruchan Pollo',
quantity: 30,
confidence: 0.88,
category: 'Sopas',
},
{
name: 'Bimbo Pan Blanco',
quantity: 8,
confidence: 0.91,
category: 'Panaderia',
},
{
name: 'Leche Lala 1L',
quantity: 12,
confidence: 0.89,
category: 'Lacteos',
},
{
name: 'Huevos San Juan (12)',
quantity: 6,
confidence: 0.87,
category: 'Huevos',
},
{
name: 'Aceite 1-2-3 1L',
quantity: 5,
confidence: 0.93,
category: 'Aceites',
},
{
name: 'Azucar Zulka 1kg',
quantity: 10,
confidence: 0.90,
category: 'Abarrotes',
},
{
name: 'Frijoles La Costena 400g',
quantity: 18,
confidence: 0.86,
category: 'Enlatados',
},
{
name: 'Jabon Roma 250g',
quantity: 20,
confidence: 0.94,
category: 'Limpieza',
},
];
}
getActiveProvider(): string {
return this.activeProvider;
}
setActiveProvider(provider: string): void {
if (!['openai', 'claude'].includes(provider)) {
throw new Error(`Unknown provider: ${provider}`);
}
this.activeProvider = provider;
this.logger.log(`Switched to provider: ${provider}`);
}
isConfigured(): boolean {
return this.openai !== null || this.anthropic !== null;
}
getAvailableProviders(): string[] {
const providers: string[] = [];
if (this.openai) providers.push('openai');
if (this.anthropic) providers.push('claude');
return providers;
}
}

View File

@ -1,95 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { Store } from '../../stores/entities/store.entity';
export enum PosProvider {
SQUARE = 'SQUARE',
SHOPIFY = 'SHOPIFY',
CLOVER = 'CLOVER',
LIGHTSPEED = 'LIGHTSPEED',
TOAST = 'TOAST',
CUSTOM = 'CUSTOM',
}
export enum SyncDirection {
POS_TO_INVENTORY = 'POS_TO_INVENTORY',
INVENTORY_TO_POS = 'INVENTORY_TO_POS',
BIDIRECTIONAL = 'BIDIRECTIONAL',
}
@Entity('pos_integrations')
@Index(['storeId', 'provider'], { unique: true })
export class PosIntegration {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
storeId: string;
@ManyToOne(() => Store, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'storeId' })
store: Store;
@Column({
type: 'enum',
enum: PosProvider,
})
provider: PosProvider;
@Column({ type: 'varchar', length: 255, nullable: true })
displayName: string;
@Column({ type: 'jsonb', nullable: true })
credentials: Record<string, unknown>;
@Column({ type: 'varchar', length: 255, nullable: true })
webhookSecret: string;
@Column({ type: 'varchar', length: 500, nullable: true })
webhookUrl: string;
@Column({ type: 'boolean', default: false })
isActive: boolean;
@Column({ type: 'boolean', default: true })
syncEnabled: boolean;
@Column({
type: 'enum',
enum: SyncDirection,
default: SyncDirection.POS_TO_INVENTORY,
})
syncDirection: SyncDirection;
@Column({ type: 'jsonb', nullable: true })
syncConfig: {
syncOnSale?: boolean;
syncOnRestock?: boolean;
syncCategories?: boolean;
autoCreateItems?: boolean;
conflictResolution?: 'pos_wins' | 'inventory_wins' | 'newest_wins';
};
@Column({ type: 'timestamp', nullable: true })
lastSyncAt: Date;
@Column({ type: 'varchar', length: 255, nullable: true })
lastSyncStatus: string;
@Column({ type: 'int', default: 0 })
syncErrorCount: number;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View File

@ -1,77 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
import { PosIntegration } from './pos-integration.entity';
export enum SyncLogType {
WEBHOOK_RECEIVED = 'WEBHOOK_RECEIVED',
MANUAL_SYNC = 'MANUAL_SYNC',
SCHEDULED_SYNC = 'SCHEDULED_SYNC',
}
export enum SyncLogStatus {
SUCCESS = 'SUCCESS',
PARTIAL = 'PARTIAL',
FAILED = 'FAILED',
}
@Entity('pos_sync_logs')
@Index(['integrationId', 'createdAt'])
export class PosSyncLog {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid' })
integrationId: string;
@ManyToOne(() => PosIntegration, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'integrationId' })
integration: PosIntegration;
@Column({
type: 'enum',
enum: SyncLogType,
})
type: SyncLogType;
@Column({
type: 'enum',
enum: SyncLogStatus,
})
status: SyncLogStatus;
@Column({ type: 'int', default: 0 })
itemsProcessed: number;
@Column({ type: 'int', default: 0 })
itemsCreated: number;
@Column({ type: 'int', default: 0 })
itemsUpdated: number;
@Column({ type: 'int', default: 0 })
itemsSkipped: number;
@Column({ type: 'int', default: 0 })
itemsFailed: number;
@Column({ type: 'jsonb', nullable: true })
details: {
webhookEventType?: string;
webhookEventId?: string;
errors?: { itemId: string; error: string }[];
duration?: number;
};
@Column({ type: 'text', nullable: true })
errorMessage: string;
@CreateDateColumn()
createdAt: Date;
}

View File

@ -1,22 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PosIntegration } from './entities/pos-integration.entity';
import { PosSyncLog } from './entities/pos-sync-log.entity';
import { InventoryItem } from '../inventory/entities/inventory-item.entity';
import { PosController } from './pos/pos.controller';
import { PosWebhookService } from './pos/services/pos-webhook.service';
import { InventorySyncService } from './pos/services/inventory-sync.service';
import { StoresModule } from '../stores/stores.module';
import { ReportsModule } from '../reports/reports.module';
@Module({
imports: [
TypeOrmModule.forFeature([PosIntegration, PosSyncLog, InventoryItem]),
StoresModule,
ReportsModule,
],
controllers: [PosController],
providers: [PosWebhookService, InventorySyncService],
exports: [PosWebhookService, InventorySyncService],
})
export class IntegrationsModule {}

View File

@ -1,85 +0,0 @@
import * as crypto from 'crypto';
import { PosProvider } from '../../entities/pos-integration.entity';
import {
IPosAdapter,
PosAdapterConfig,
PosProduct,
PosSale,
PosInventoryUpdate,
} from '../interfaces/pos-adapter.interface';
export abstract class BasePosAdapter implements IPosAdapter {
abstract readonly provider: PosProvider;
protected config: PosAdapterConfig;
async initialize(config: PosAdapterConfig): Promise<void> {
this.config = config;
}
abstract validateCredentials(): Promise<boolean>;
abstract getProducts(): Promise<PosProduct[]>;
abstract getProduct(externalId: string): Promise<PosProduct | null>;
abstract updateInventory(updates: PosInventoryUpdate[]): Promise<void>;
abstract getSales(since: Date): Promise<PosSale[]>;
generateWebhookSecret(): string {
return crypto.randomBytes(32).toString('hex');
}
verifyWebhookSignature(
payload: string,
signature: string,
secret: string,
): boolean {
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature),
);
}
protected getCredential<T>(key: string, defaultValue?: T): T {
const value = this.config?.credentials?.[key];
if (value === undefined) {
if (defaultValue !== undefined) {
return defaultValue;
}
throw new Error(`Missing credential: ${key}`);
}
return value as T;
}
}
/**
* Placeholder adapter for custom POS integrations
* This adapter is meant to be extended for specific POS implementations
*/
export class CustomPosAdapter extends BasePosAdapter {
readonly provider = PosProvider.CUSTOM;
async validateCredentials(): Promise<boolean> {
// Custom adapters always return true - validation is handled externally
return true;
}
async getProducts(): Promise<PosProduct[]> {
// Custom adapters don't fetch products - they receive webhooks
return [];
}
async getProduct(): Promise<PosProduct | null> {
return null;
}
async updateInventory(): Promise<void> {
// Custom adapters don't push updates - they receive webhooks
}
async getSales(): Promise<PosSale[]> {
return [];
}
}

View File

@ -1,87 +0,0 @@
import { PosProvider } from '../../entities/pos-integration.entity';
export interface PosProduct {
externalId: string;
name: string;
sku?: string;
barcode?: string;
category?: string;
quantity: number;
price?: number;
cost?: number;
imageUrl?: string;
}
export interface PosSaleItem {
externalProductId: string;
quantity: number;
unitPrice: number;
totalPrice: number;
}
export interface PosSale {
externalId: string;
items: PosSaleItem[];
totalAmount: number;
timestamp: Date;
}
export interface PosInventoryUpdate {
externalProductId: string;
newQuantity: number;
reason?: string;
}
export interface PosAdapterConfig {
provider: PosProvider;
credentials: Record<string, unknown>;
storeId: string;
}
export interface IPosAdapter {
readonly provider: PosProvider;
/**
* Initialize the adapter with credentials
*/
initialize(config: PosAdapterConfig): Promise<void>;
/**
* Validate the credentials are correct
*/
validateCredentials(): Promise<boolean>;
/**
* Fetch all products from POS
*/
getProducts(): Promise<PosProduct[]>;
/**
* Fetch a single product by external ID
*/
getProduct(externalId: string): Promise<PosProduct | null>;
/**
* Update inventory quantity in POS
*/
updateInventory(updates: PosInventoryUpdate[]): Promise<void>;
/**
* Fetch recent sales
*/
getSales(since: Date): Promise<PosSale[]>;
/**
* Generate webhook secret for this integration
*/
generateWebhookSecret(): string;
/**
* Verify webhook signature
*/
verifyWebhookSignature(
payload: string,
signature: string,
secret: string,
): boolean;
}

View File

@ -1,90 +0,0 @@
import { PosProvider } from '../../entities/pos-integration.entity';
export enum PosWebhookEventType {
SALE_CREATED = 'SALE_CREATED',
SALE_UPDATED = 'SALE_UPDATED',
SALE_REFUNDED = 'SALE_REFUNDED',
INVENTORY_UPDATED = 'INVENTORY_UPDATED',
PRODUCT_CREATED = 'PRODUCT_CREATED',
PRODUCT_UPDATED = 'PRODUCT_UPDATED',
PRODUCT_DELETED = 'PRODUCT_DELETED',
}
export interface PosWebhookPayload {
provider: PosProvider;
eventType: PosWebhookEventType;
eventId: string;
timestamp: Date;
data: unknown;
}
export interface SaleWebhookData {
saleId: string;
items: {
productId: string;
productName?: string;
quantity: number;
unitPrice: number;
}[];
totalAmount: number;
transactionTime: Date;
}
export interface InventoryWebhookData {
productId: string;
productName?: string;
previousQuantity?: number;
newQuantity: number;
reason?: string;
}
export interface ProductWebhookData {
productId: string;
name: string;
sku?: string;
barcode?: string;
category?: string;
price?: number;
cost?: number;
quantity?: number;
}
export interface IPosWebhookHandler {
/**
* Handle incoming webhook from POS
*/
handleWebhook(
storeId: string,
provider: PosProvider,
rawPayload: string,
signature: string,
): Promise<{ success: boolean; message: string }>;
/**
* Process a sale event
*/
processSaleEvent(
storeId: string,
integrationId: string,
data: SaleWebhookData,
): Promise<void>;
/**
* Process an inventory update event
*/
processInventoryEvent(
storeId: string,
integrationId: string,
data: InventoryWebhookData,
): Promise<void>;
/**
* Process a product event
*/
processProductEvent(
storeId: string,
integrationId: string,
eventType: PosWebhookEventType,
data: ProductWebhookData,
): Promise<void>;
}

View File

@ -1,310 +0,0 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
Headers,
UseGuards,
Request,
ParseUUIDPipe,
RawBodyRequest,
Req,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiHeader,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { StoresService } from '../../stores/stores.service';
import {
PosIntegration,
PosProvider,
} from '../entities/pos-integration.entity';
import { PosSyncLog } from '../entities/pos-sync-log.entity';
import { PosWebhookService } from './services/pos-webhook.service';
import { AuthenticatedRequest } from '../../../common/interfaces/authenticated-request.interface';
// DTOs
class CreatePosIntegrationDto {
provider: PosProvider;
displayName?: string;
credentials?: Record<string, unknown>;
syncConfig?: {
syncOnSale?: boolean;
syncOnRestock?: boolean;
syncCategories?: boolean;
autoCreateItems?: boolean;
};
}
class UpdatePosIntegrationDto {
displayName?: string;
credentials?: Record<string, unknown>;
isActive?: boolean;
syncEnabled?: boolean;
syncConfig?: {
syncOnSale?: boolean;
syncOnRestock?: boolean;
syncCategories?: boolean;
autoCreateItems?: boolean;
};
}
@ApiTags('integrations')
@Controller()
export class PosController {
constructor(
@InjectRepository(PosIntegration)
private integrationRepository: Repository<PosIntegration>,
@InjectRepository(PosSyncLog)
private syncLogRepository: Repository<PosSyncLog>,
private storesService: StoresService,
private webhookService: PosWebhookService,
) {}
// ============ Protected Endpoints (Require Auth) ============
@Get('stores/:storeId/integrations/pos')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Listar integraciones POS de una tienda' })
@ApiResponse({ status: 200, description: 'Lista de integraciones' })
async listIntegrations(
@Request() req: AuthenticatedRequest,
@Param('storeId', ParseUUIDPipe) storeId: string,
) {
await this.storesService.verifyOwnership(storeId, req.user.id);
const integrations = await this.integrationRepository.find({
where: { storeId },
order: { createdAt: 'DESC' },
});
// Remove sensitive data
return integrations.map((i) => ({
...i,
credentials: undefined,
webhookSecret: undefined,
}));
}
@Post('stores/:storeId/integrations/pos')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Crear nueva integración POS' })
@ApiResponse({ status: 201, description: 'Integración creada' })
async createIntegration(
@Request() req: AuthenticatedRequest,
@Param('storeId', ParseUUIDPipe) storeId: string,
@Body() dto: CreatePosIntegrationDto,
) {
await this.storesService.verifyOwnership(storeId, req.user.id);
// Generate webhook secret
const webhookSecret = require('crypto').randomBytes(32).toString('hex');
const integration = this.integrationRepository.create({
storeId,
provider: dto.provider,
displayName: dto.displayName || dto.provider,
credentials: dto.credentials,
webhookSecret,
syncConfig: dto.syncConfig || {
syncOnSale: true,
autoCreateItems: true,
},
});
const saved = await this.integrationRepository.save(integration);
// Generate webhook URL
const webhookUrl = `/api/v1/webhooks/pos/${dto.provider.toLowerCase()}/${storeId}`;
await this.integrationRepository.update(saved.id, { webhookUrl });
return {
...saved,
webhookUrl,
webhookSecret, // Only returned once on creation
credentials: undefined, // Don't return credentials
};
}
@Get('stores/:storeId/integrations/pos/:integrationId')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Obtener detalles de integración POS' })
@ApiResponse({ status: 200, description: 'Detalles de integración' })
async getIntegration(
@Request() req: AuthenticatedRequest,
@Param('storeId', ParseUUIDPipe) storeId: string,
@Param('integrationId', ParseUUIDPipe) integrationId: string,
) {
await this.storesService.verifyOwnership(storeId, req.user.id);
const integration = await this.integrationRepository.findOne({
where: { id: integrationId, storeId },
});
if (!integration) {
return null;
}
return {
...integration,
credentials: undefined,
webhookSecret: undefined,
};
}
@Patch('stores/:storeId/integrations/pos/:integrationId')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Actualizar integración POS' })
@ApiResponse({ status: 200, description: 'Integración actualizada' })
async updateIntegration(
@Request() req: AuthenticatedRequest,
@Param('storeId', ParseUUIDPipe) storeId: string,
@Param('integrationId', ParseUUIDPipe) integrationId: string,
@Body() dto: UpdatePosIntegrationDto,
) {
await this.storesService.verifyOwnership(storeId, req.user.id);
const updateData: {
displayName?: string;
isActive?: boolean;
syncEnabled?: boolean;
syncConfig?: Record<string, unknown>;
} = {};
if (dto.displayName !== undefined) updateData.displayName = dto.displayName;
if (dto.isActive !== undefined) updateData.isActive = dto.isActive;
if (dto.syncEnabled !== undefined) updateData.syncEnabled = dto.syncEnabled;
if (dto.syncConfig !== undefined) updateData.syncConfig = dto.syncConfig;
await this.integrationRepository.update(
{ id: integrationId, storeId },
updateData,
);
return this.getIntegration(req, storeId, integrationId);
}
@Delete('stores/:storeId/integrations/pos/:integrationId')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Eliminar integración POS' })
@ApiResponse({ status: 200, description: 'Integración eliminada' })
async deleteIntegration(
@Request() req: AuthenticatedRequest,
@Param('storeId', ParseUUIDPipe) storeId: string,
@Param('integrationId', ParseUUIDPipe) integrationId: string,
) {
await this.storesService.verifyOwnership(storeId, req.user.id);
await this.integrationRepository.delete({ id: integrationId, storeId });
return { message: 'Integration deleted successfully' };
}
@Get('stores/:storeId/integrations/pos/:integrationId/logs')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Obtener logs de sincronización' })
@ApiResponse({ status: 200, description: 'Logs de sincronización' })
async getSyncLogs(
@Request() req: AuthenticatedRequest,
@Param('storeId', ParseUUIDPipe) storeId: string,
@Param('integrationId', ParseUUIDPipe) integrationId: string,
@Query('page') page = 1,
@Query('limit') limit = 20,
) {
await this.storesService.verifyOwnership(storeId, req.user.id);
const [logs, total] = await this.syncLogRepository.findAndCount({
where: { integrationId },
order: { createdAt: 'DESC' },
skip: (page - 1) * limit,
take: limit,
});
return {
logs,
total,
page,
limit,
hasMore: page * limit < total,
};
}
@Post('stores/:storeId/integrations/pos/:integrationId/activate')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Activar integración POS' })
@ApiResponse({ status: 200, description: 'Integración activada' })
async activateIntegration(
@Request() req: AuthenticatedRequest,
@Param('storeId', ParseUUIDPipe) storeId: string,
@Param('integrationId', ParseUUIDPipe) integrationId: string,
) {
await this.storesService.verifyOwnership(storeId, req.user.id);
await this.integrationRepository.update(
{ id: integrationId, storeId },
{ isActive: true },
);
return { message: 'Integration activated' };
}
@Post('stores/:storeId/integrations/pos/:integrationId/deactivate')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Desactivar integración POS' })
@ApiResponse({ status: 200, description: 'Integración desactivada' })
async deactivateIntegration(
@Request() req: AuthenticatedRequest,
@Param('storeId', ParseUUIDPipe) storeId: string,
@Param('integrationId', ParseUUIDPipe) integrationId: string,
) {
await this.storesService.verifyOwnership(storeId, req.user.id);
await this.integrationRepository.update(
{ id: integrationId, storeId },
{ isActive: false },
);
return { message: 'Integration deactivated' };
}
// ============ Webhook Endpoint (No Auth) ============
@Post('webhooks/pos/:provider/:storeId')
@ApiOperation({ summary: 'Recibir webhook de POS' })
@ApiHeader({ name: 'x-webhook-signature', description: 'Firma HMAC del payload' })
@ApiResponse({ status: 200, description: 'Webhook procesado' })
async handleWebhook(
@Param('provider') provider: string,
@Param('storeId', ParseUUIDPipe) storeId: string,
@Headers('x-webhook-signature') signature: string,
@Body() rawBody: string,
) {
const posProvider = provider.toUpperCase() as PosProvider;
return this.webhookService.handleWebhook(
storeId,
posProvider,
typeof rawBody === 'string' ? rawBody : JSON.stringify(rawBody),
signature || '',
);
}
}

View File

@ -1,348 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { InventoryItem } from '../../../inventory/entities/inventory-item.entity';
import { InventoryReportsService } from '../../../reports/services/inventory-reports.service';
import {
MovementType,
TriggerType,
} from '../../../reports/entities/inventory-movement.entity';
import {
PosIntegration,
SyncDirection,
} from '../../entities/pos-integration.entity';
import {
PosSyncLog,
SyncLogType,
SyncLogStatus,
} from '../../entities/pos-sync-log.entity';
import { PosProduct } from '../interfaces/pos-adapter.interface';
export interface SyncResult {
itemsProcessed: number;
itemsCreated: number;
itemsUpdated: number;
itemsSkipped: number;
itemsFailed: number;
errors: { itemId: string; error: string }[];
}
@Injectable()
export class InventorySyncService {
private readonly logger = new Logger(InventorySyncService.name);
constructor(
@InjectRepository(InventoryItem)
private inventoryRepository: Repository<InventoryItem>,
@InjectRepository(PosSyncLog)
private syncLogRepository: Repository<PosSyncLog>,
private reportsService: InventoryReportsService,
) {}
/**
* Sync products from POS to inventory
*/
async syncFromPos(
integration: PosIntegration,
products: PosProduct[],
logType: SyncLogType = SyncLogType.WEBHOOK_RECEIVED,
): Promise<SyncResult> {
const startTime = Date.now();
const result: SyncResult = {
itemsProcessed: 0,
itemsCreated: 0,
itemsUpdated: 0,
itemsSkipped: 0,
itemsFailed: 0,
errors: [],
};
const syncConfig = integration.syncConfig || {};
for (const product of products) {
result.itemsProcessed++;
try {
// Find existing item by barcode or external reference
let existingItem = await this.findExistingItem(
integration.storeId,
product,
);
if (existingItem) {
// Update existing item
const quantityBefore = existingItem.quantity;
const updated = await this.updateItem(
existingItem,
product,
syncConfig,
);
if (updated) {
result.itemsUpdated++;
// Record movement if quantity changed
if (quantityBefore !== existingItem.quantity) {
await this.reportsService.recordMovement(
existingItem.id,
integration.storeId,
MovementType.POS_SYNC,
quantityBefore,
existingItem.quantity,
undefined,
TriggerType.POS,
`POS sync from ${integration.provider}`,
integration.id,
'pos_integration',
);
}
} else {
result.itemsSkipped++;
}
} else if (syncConfig.autoCreateItems !== false) {
// Create new item
const newItem = await this.createItem(integration.storeId, product);
result.itemsCreated++;
// Record initial movement
await this.reportsService.recordMovement(
newItem.id,
integration.storeId,
MovementType.INITIAL,
0,
newItem.quantity,
undefined,
TriggerType.POS,
`Created from POS ${integration.provider}`,
integration.id,
'pos_integration',
);
} else {
result.itemsSkipped++;
}
} catch (error) {
result.itemsFailed++;
result.errors.push({
itemId: product.externalId,
error: error.message,
});
this.logger.error(
`Failed to sync product ${product.externalId}: ${error.message}`,
);
}
}
// Log the sync
await this.logSync(integration.id, logType, result, Date.now() - startTime);
return result;
}
/**
* Update inventory quantity from a sale
*/
async processSale(
integration: PosIntegration,
saleItems: { productId: string; quantity: number }[],
saleId: string,
): Promise<SyncResult> {
const startTime = Date.now();
const result: SyncResult = {
itemsProcessed: 0,
itemsCreated: 0,
itemsUpdated: 0,
itemsSkipped: 0,
itemsFailed: 0,
errors: [],
};
for (const saleItem of saleItems) {
result.itemsProcessed++;
try {
const item = await this.findItemByExternalId(
integration.storeId,
saleItem.productId,
);
if (item) {
const quantityBefore = item.quantity;
const newQuantity = Math.max(0, item.quantity - saleItem.quantity);
await this.inventoryRepository.update(item.id, {
quantity: newQuantity,
});
result.itemsUpdated++;
await this.reportsService.recordMovement(
item.id,
integration.storeId,
MovementType.SALE,
quantityBefore,
newQuantity,
undefined,
TriggerType.POS,
`Sale from ${integration.provider}`,
saleId,
'pos_sale',
);
} else {
result.itemsSkipped++;
this.logger.warn(
`Product ${saleItem.productId} not found in inventory for sale sync`,
);
}
} catch (error) {
result.itemsFailed++;
result.errors.push({
itemId: saleItem.productId,
error: error.message,
});
}
}
await this.logSync(
integration.id,
SyncLogType.WEBHOOK_RECEIVED,
result,
Date.now() - startTime,
{ saleId },
);
return result;
}
private async findExistingItem(
storeId: string,
product: PosProduct,
): Promise<InventoryItem | null> {
// Try to find by barcode first
if (product.barcode) {
const item = await this.inventoryRepository.findOne({
where: { storeId, barcode: product.barcode },
});
if (item) return item;
}
// Try to find by external ID stored in metadata
return this.findItemByExternalId(storeId, product.externalId);
}
private async findItemByExternalId(
storeId: string,
externalId: string,
): Promise<InventoryItem | null> {
const items = await this.inventoryRepository
.createQueryBuilder('item')
.where('item.storeId = :storeId', { storeId })
.andWhere("item.metadata->>'posExternalId' = :externalId", { externalId })
.getOne();
return items;
}
private async updateItem(
item: InventoryItem,
product: PosProduct,
syncConfig: PosIntegration['syncConfig'],
): Promise<boolean> {
const updates: {
quantity?: number;
price?: number;
cost?: number;
category?: string;
} = {};
let hasChanges = false;
// Update quantity
if (product.quantity !== item.quantity) {
updates.quantity = product.quantity;
hasChanges = true;
}
// Update price if POS has it and we should sync
if (product.price !== undefined && product.price !== item.price) {
updates.price = product.price;
hasChanges = true;
}
// Update cost if POS has it
if (product.cost !== undefined && product.cost !== item.cost) {
updates.cost = product.cost;
hasChanges = true;
}
// Update category if enabled
if (
syncConfig?.syncCategories &&
product.category &&
product.category !== item.category
) {
updates.category = product.category;
hasChanges = true;
}
if (hasChanges) {
await this.inventoryRepository.update(item.id, updates);
Object.assign(item, updates);
}
return hasChanges;
}
private async createItem(
storeId: string,
product: PosProduct,
): Promise<InventoryItem> {
const item = this.inventoryRepository.create({
storeId,
name: product.name,
category: product.category,
barcode: product.barcode,
quantity: product.quantity,
price: product.price,
cost: product.cost,
imageUrl: product.imageUrl,
metadata: {
posExternalId: product.externalId,
posSku: product.sku,
},
});
return this.inventoryRepository.save(item);
}
private async logSync(
integrationId: string,
type: SyncLogType,
result: SyncResult,
duration: number,
additionalDetails?: Record<string, unknown>,
): Promise<void> {
const status =
result.itemsFailed === 0
? SyncLogStatus.SUCCESS
: result.itemsFailed < result.itemsProcessed
? SyncLogStatus.PARTIAL
: SyncLogStatus.FAILED;
const log = this.syncLogRepository.create({
integrationId,
type,
status,
itemsProcessed: result.itemsProcessed,
itemsCreated: result.itemsCreated,
itemsUpdated: result.itemsUpdated,
itemsSkipped: result.itemsSkipped,
itemsFailed: result.itemsFailed,
details: {
...additionalDetails,
errors: result.errors.length > 0 ? result.errors : undefined,
duration,
},
});
await this.syncLogRepository.save(log);
}
}

View File

@ -1,263 +0,0 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as crypto from 'crypto';
import {
PosIntegration,
PosProvider,
} from '../../entities/pos-integration.entity';
import { InventorySyncService } from './inventory-sync.service';
import {
IPosWebhookHandler,
PosWebhookEventType,
SaleWebhookData,
InventoryWebhookData,
ProductWebhookData,
} from '../interfaces/pos-webhook.interface';
@Injectable()
export class PosWebhookService implements IPosWebhookHandler {
private readonly logger = new Logger(PosWebhookService.name);
constructor(
@InjectRepository(PosIntegration)
private integrationRepository: Repository<PosIntegration>,
private inventorySyncService: InventorySyncService,
) {}
private verifyWebhookSignature(
payload: string,
signature: string,
secret: string,
): boolean {
try {
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature),
);
} catch {
return false;
}
}
async handleWebhook(
storeId: string,
provider: PosProvider,
rawPayload: string,
signature: string,
): Promise<{ success: boolean; message: string }> {
this.logger.log(
`Received webhook from ${provider} for store ${storeId}`,
);
// Find integration
const integration = await this.integrationRepository.findOne({
where: { storeId, provider, isActive: true },
});
if (!integration) {
throw new NotFoundException(
`No active integration found for provider ${provider}`,
);
}
// Verify signature
if (integration.webhookSecret && signature) {
const isValid = this.verifyWebhookSignature(
rawPayload,
signature,
integration.webhookSecret,
);
if (!isValid) {
this.logger.warn(
`Invalid webhook signature for integration ${integration.id}`,
);
return { success: false, message: 'Invalid signature' };
}
}
try {
const payload = JSON.parse(rawPayload);
await this.processWebhookPayload(integration, payload);
return { success: true, message: 'Webhook processed successfully' };
} catch (error) {
this.logger.error(`Failed to process webhook: ${error.message}`);
return { success: false, message: error.message };
}
}
private async processWebhookPayload(
integration: PosIntegration,
payload: {
eventType: PosWebhookEventType;
eventId?: string;
data: unknown;
},
): Promise<void> {
const { eventType, data } = payload;
switch (eventType) {
case PosWebhookEventType.SALE_CREATED:
case PosWebhookEventType.SALE_UPDATED:
await this.processSaleEvent(
integration.storeId,
integration.id,
data as SaleWebhookData,
);
break;
case PosWebhookEventType.SALE_REFUNDED:
// Handle refunds - increase inventory
await this.processSaleRefund(
integration.storeId,
integration.id,
data as SaleWebhookData,
);
break;
case PosWebhookEventType.INVENTORY_UPDATED:
await this.processInventoryEvent(
integration.storeId,
integration.id,
data as InventoryWebhookData,
);
break;
case PosWebhookEventType.PRODUCT_CREATED:
case PosWebhookEventType.PRODUCT_UPDATED:
case PosWebhookEventType.PRODUCT_DELETED:
await this.processProductEvent(
integration.storeId,
integration.id,
eventType,
data as ProductWebhookData,
);
break;
default:
this.logger.warn(`Unknown event type: ${eventType}`);
}
}
async processSaleEvent(
storeId: string,
integrationId: string,
data: SaleWebhookData,
): Promise<void> {
const integration = await this.integrationRepository.findOneOrFail({
where: { id: integrationId },
});
if (!integration.syncConfig?.syncOnSale) {
this.logger.log('Sale sync disabled for this integration, skipping');
return;
}
const saleItems = data.items.map((item) => ({
productId: item.productId,
quantity: item.quantity,
}));
await this.inventorySyncService.processSale(
integration,
saleItems,
data.saleId,
);
this.logger.log(
`Processed sale ${data.saleId} with ${saleItems.length} items`,
);
}
private async processSaleRefund(
storeId: string,
integrationId: string,
data: SaleWebhookData,
): Promise<void> {
// For refunds, we add the quantity back
const integration = await this.integrationRepository.findOneOrFail({
where: { id: integrationId },
});
// Convert refund to inventory updates (positive quantities)
const products = data.items.map((item) => ({
externalId: item.productId,
name: item.productName || `Product ${item.productId}`,
quantity: item.quantity, // This will be added back
}));
await this.inventorySyncService.syncFromPos(integration, products);
this.logger.log(
`Processed refund for sale ${data.saleId} with ${products.length} items`,
);
}
async processInventoryEvent(
storeId: string,
integrationId: string,
data: InventoryWebhookData,
): Promise<void> {
const integration = await this.integrationRepository.findOneOrFail({
where: { id: integrationId },
});
const products = [
{
externalId: data.productId,
name: data.productName || `Product ${data.productId}`,
quantity: data.newQuantity,
},
];
await this.inventorySyncService.syncFromPos(integration, products);
this.logger.log(
`Processed inventory update for product ${data.productId}: ${data.newQuantity}`,
);
}
async processProductEvent(
storeId: string,
integrationId: string,
eventType: PosWebhookEventType,
data: ProductWebhookData,
): Promise<void> {
const integration = await this.integrationRepository.findOneOrFail({
where: { id: integrationId },
});
if (eventType === PosWebhookEventType.PRODUCT_DELETED) {
// We don't delete items from our inventory when deleted from POS
// Just log it
this.logger.log(`Product ${data.productId} deleted in POS, skipping`);
return;
}
const products = [
{
externalId: data.productId,
name: data.name,
sku: data.sku,
barcode: data.barcode,
category: data.category,
quantity: data.quantity || 0,
price: data.price,
cost: data.cost,
},
];
await this.inventorySyncService.syncFromPos(integration, products);
this.logger.log(
`Processed product ${eventType} for ${data.productId}: ${data.name}`,
);
}
}

View File

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

View File

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

View File

@ -1,135 +0,0 @@
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, req.user.id);
}
@Delete(':itemId')
@ApiOperation({ summary: 'Eliminar un producto' })
@ApiResponse({ status: 200, description: 'Producto eliminado' })
async delete(
@Request() req: AuthenticatedRequest,
@Param('storeId', ParseUUIDPipe) storeId: string,
@Param('itemId', ParseUUIDPipe) itemId: string,
) {
await this.storesService.verifyOwnership(storeId, req.user.id);
return this.inventoryService.delete(storeId, itemId);
}
}

View File

@ -1,19 +0,0 @@
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';
import { ReportsModule } from '../reports/reports.module';
@Module({
imports: [
TypeOrmModule.forFeature([InventoryItem]),
forwardRef(() => StoresModule),
forwardRef(() => ReportsModule),
],
controllers: [InventoryController],
providers: [InventoryService],
exports: [InventoryService],
})
export class InventoryModule {}

View File

@ -1,230 +0,0 @@
import { Injectable, NotFoundException, Inject, forwardRef } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { InventoryItem } from './entities/inventory-item.entity';
import { InventoryReportsService } from '../reports/services/inventory-reports.service';
import {
MovementType,
TriggerType,
} from '../reports/entities/inventory-movement.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>,
@Inject(forwardRef(() => InventoryReportsService))
private readonly reportsService: InventoryReportsService,
) {}
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>,
userId?: string,
): Promise<InventoryItem> {
const item = await this.findById(storeId, itemId);
const quantityBefore = item.quantity;
Object.assign(item, data, { isManuallyEdited: true });
const savedItem = await this.inventoryRepository.save(item);
// Record movement if quantity changed
if (data.quantity !== undefined && data.quantity !== quantityBefore) {
await this.reportsService.recordMovement(
itemId,
storeId,
MovementType.MANUAL_ADJUST,
quantityBefore,
data.quantity,
userId,
userId ? TriggerType.USER : TriggerType.SYSTEM,
'Manual inventory adjustment',
);
}
return savedItem;
}
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) {
const quantityBefore = existing.quantity;
// 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);
// Record movement if quantity changed
if (detected.quantity !== quantityBefore) {
await this.reportsService.recordMovement(
existing.id,
storeId,
MovementType.DETECTION,
quantityBefore,
detected.quantity,
undefined,
TriggerType.VIDEO,
`Video detection (confidence: ${(detected.confidence * 100).toFixed(1)}%)`,
videoId,
'video',
);
}
}
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(),
});
const savedItem = await this.inventoryRepository.save(newItem);
// Record initial movement for new item
await this.reportsService.recordMovement(
savedItem.id,
storeId,
MovementType.INITIAL,
0,
detected.quantity,
undefined,
TriggerType.VIDEO,
`Initial detection from video (confidence: ${(detected.confidence * 100).toFixed(1)}%)`,
videoId,
'video',
);
results.push(savedItem);
}
}
return results;
}
async getCategories(storeId: string): Promise<string[]> {
const result = await this.inventoryRepository
.createQueryBuilder('item')
.select('DISTINCT item.category', 'category')
.where('item.storeId = :storeId', { storeId })
.andWhere('item.category IS NOT NULL')
.getRawMany();
return result.map((r) => r.category);
}
async getLowStockItems(storeId: string): Promise<InventoryItem[]> {
return this.inventoryRepository
.createQueryBuilder('item')
.where('item.storeId = :storeId', { storeId })
.andWhere('item.minStock IS NOT NULL')
.andWhere('item.quantity <= item.minStock')
.orderBy('item.quantity', 'ASC')
.getMany();
}
async getStatistics(storeId: string) {
const totalItems = await this.inventoryRepository.count({
where: { storeId },
});
const lowStockCount = await this.inventoryRepository
.createQueryBuilder('item')
.where('item.storeId = :storeId', { storeId })
.andWhere('item.minStock IS NOT NULL')
.andWhere('item.quantity <= item.minStock')
.getCount();
const categoriesResult = await this.inventoryRepository
.createQueryBuilder('item')
.select('COUNT(DISTINCT item.category)', 'count')
.where('item.storeId = :storeId', { storeId })
.andWhere('item.category IS NOT NULL')
.getRawOne();
const totalValue = await this.inventoryRepository
.createQueryBuilder('item')
.select('SUM(item.quantity * item.price)', 'total')
.where('item.storeId = :storeId', { storeId })
.andWhere('item.price IS NOT NULL')
.getRawOne();
return {
totalItems,
lowStockCount,
categoriesCount: parseInt(categoriesResult?.count || '0', 10),
totalValue: parseFloat(totalValue?.total || '0'),
};
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,416 +0,0 @@
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;
}
}

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