diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..157d621 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,50 @@ +# 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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9229691 --- /dev/null +++ b/.env.example @@ -0,0 +1,50 @@ +# Server +NODE_ENV=development +BACKEND_PORT=3142 + +# Database +DATABASE_HOST=localhost +DATABASE_PORT=5433 +DATABASE_USER=miinventario +DATABASE_PASSWORD=miinventario_dev +DATABASE_NAME=miinventario + +# Redis +REDIS_HOST=localhost +REDIS_PORT=6380 + +# JWT +JWT_SECRET=your-jwt-secret-change-in-production +JWT_REFRESH_SECRET=your-refresh-secret-change-in-production +JWT_EXPIRES_IN=15m +JWT_REFRESH_EXPIRES_IN=7d + +# Storage (MinIO/S3) +S3_ENDPOINT=http://localhost:9002 +S3_ACCESS_KEY=miinventario +S3_SECRET_KEY=miinventario_dev +S3_BUCKET_VIDEOS=miinventario-videos +S3_REGION=us-east-1 + +# Stripe +STRIPE_SECRET_KEY=sk_test_your_stripe_key +STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret + +# Conekta (7-Eleven) +CONEKTA_API_KEY=key_your_conekta_key + +# Firebase (FCM) +FIREBASE_PROJECT_ID=miinventario +FIREBASE_CLIENT_EMAIL=firebase-adminsdk@miinventario.iam.gserviceaccount.com +FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nYOUR_KEY_HERE\n-----END PRIVATE KEY-----" + +# OpenAI +OPENAI_API_KEY=sk-your-openai-key + +# Claude (Anthropic) +ANTHROPIC_API_KEY=sk-ant-your-anthropic-key + +# SMS (Twilio) +TWILIO_ACCOUNT_SID=your_account_sid +TWILIO_AUTH_TOKEN=your_auth_token +TWILIO_PHONE_NUMBER=+1234567890 diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..3cffb0b --- /dev/null +++ b/.env.test @@ -0,0 +1,16 @@ +# Test Database +DATABASE_HOST=localhost +DATABASE_PORT=5433 +DATABASE_NAME=miinventario_test +DATABASE_USER=postgres +DATABASE_PASSWORD=postgres + +# JWT +JWT_SECRET=test-secret +JWT_EXPIRES_IN=15m +JWT_REFRESH_SECRET=test-refresh-secret +JWT_REFRESH_EXPIRES_IN=7d + +# App +NODE_ENV=development +PORT=3143 diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..93b9e06 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,30 @@ +module.exports = { + parser: '@typescript-eslint/parser', + parserOptions: { + project: 'tsconfig.json', + tsconfigRootDir: __dirname, + sourceType: 'module', + }, + plugins: ['@typescript-eslint/eslint-plugin'], + extends: [ + 'plugin:@typescript-eslint/recommended', + 'plugin:prettier/recommended', + ], + root: true, + env: { + node: true, + jest: true, + }, + ignorePatterns: ['.eslintrc.js', 'dist', 'node_modules'], + rules: { + '@typescript-eslint/interface-name-prefix': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_' }, + ], + 'no-console': ['warn', { allow: ['warn', 'error'] }], + }, +}; diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1900a45 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +coverage/ +.env diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..73267ea --- /dev/null +++ b/.prettierrc @@ -0,0 +1,10 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "printWidth": 80, + "tabWidth": 2, + "semi": true, + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6b637ad --- /dev/null +++ b/Dockerfile @@ -0,0 +1,56 @@ +# 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"] diff --git a/nest-cli.json b/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ab66c62 --- /dev/null +++ b/package.json @@ -0,0 +1,107 @@ +{ + "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" + } +} diff --git a/src/app.module.ts b/src/app.module.ts new file mode 100644 index 0000000..8d2db98 --- /dev/null +++ b/src/app.module.ts @@ -0,0 +1,71 @@ +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 {} diff --git a/src/common/decorators/roles.decorator.ts b/src/common/decorators/roles.decorator.ts new file mode 100644 index 0000000..e0d8bd5 --- /dev/null +++ b/src/common/decorators/roles.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; +import { UserRole } from '../../modules/users/entities/user.entity'; + +export const ROLES_KEY = 'roles'; +export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles); diff --git a/src/common/guards/roles.guard.ts b/src/common/guards/roles.guard.ts new file mode 100644 index 0000000..1a4e97f --- /dev/null +++ b/src/common/guards/roles.guard.ts @@ -0,0 +1,44 @@ +import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { UserRole } from '../../modules/users/entities/user.entity'; +import { ROLES_KEY } from '../decorators/roles.decorator'; + +// Role hierarchy - higher number = more permissions +const ROLE_HIERARCHY: Record = { + [UserRole.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(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; + } +} diff --git a/src/common/interfaces/authenticated-request.interface.ts b/src/common/interfaces/authenticated-request.interface.ts new file mode 100644 index 0000000..c4c2f99 --- /dev/null +++ b/src/common/interfaces/authenticated-request.interface.ts @@ -0,0 +1,12 @@ +import { Request } from 'express'; + +export interface AuthenticatedUser { + id: string; + phone: string; + email?: string; + role: string; +} + +export interface AuthenticatedRequest extends Request { + user: AuthenticatedUser; +} diff --git a/src/config/database.config.ts b/src/config/database.config.ts new file mode 100644 index 0000000..fc5ea5a --- /dev/null +++ b/src/config/database.config.ts @@ -0,0 +1,18 @@ +import { ConfigService } from '@nestjs/config'; +import { TypeOrmModuleOptions } from '@nestjs/typeorm'; + +export const databaseConfig = ( + configService: ConfigService, +): TypeOrmModuleOptions => ({ + type: 'postgres', + host: configService.get('DB_HOST', 'localhost'), + port: configService.get('DB_PORT', 5433), + username: configService.get('DB_USER', 'miinventario'), + password: configService.get('DB_PASSWORD', 'miinventario_pass'), + database: configService.get('DB_NAME', 'miinventario_db'), + entities: [__dirname + '/../**/*.entity{.ts,.js}'], + migrations: [__dirname + '/../migrations/*{.ts,.js}'], + synchronize: configService.get('NODE_ENV') === 'development', + logging: configService.get('NODE_ENV') === 'development', + ssl: configService.get('DB_SSL') === 'true' ? { rejectUnauthorized: false } : false, +}); diff --git a/src/config/redis.config.ts b/src/config/redis.config.ts new file mode 100644 index 0000000..07892bb --- /dev/null +++ b/src/config/redis.config.ts @@ -0,0 +1,21 @@ +import { ConfigService } from '@nestjs/config'; +import { BullModuleOptions } from '@nestjs/bull'; + +export const redisConfig = ( + configService: ConfigService, +): BullModuleOptions => ({ + redis: { + host: configService.get('REDIS_HOST', 'localhost'), + port: configService.get('REDIS_PORT', 6380), + password: configService.get('REDIS_PASSWORD'), + }, + defaultJobOptions: { + attempts: 3, + backoff: { + type: 'exponential', + delay: 5000, + }, + removeOnComplete: 100, + removeOnFail: 50, + }, +}); diff --git a/src/config/typeorm.config.ts b/src/config/typeorm.config.ts new file mode 100644 index 0000000..497e379 --- /dev/null +++ b/src/config/typeorm.config.ts @@ -0,0 +1,20 @@ +import { DataSource, DataSourceOptions } from 'typeorm'; +import { config } from 'dotenv'; + +config(); + +export const dataSourceOptions: DataSourceOptions = { + type: 'postgres', + host: process.env.DATABASE_HOST || 'localhost', + port: parseInt(process.env.DATABASE_PORT || '5433', 10), + username: process.env.DATABASE_USER || 'postgres', + password: process.env.DATABASE_PASSWORD || 'postgres', + database: process.env.DATABASE_NAME || 'miinventario_dev', + entities: ['dist/**/*.entity.js'], + migrations: ['dist/migrations/*.js'], + synchronize: false, + logging: process.env.NODE_ENV === 'development', +}; + +const dataSource = new DataSource(dataSourceOptions); +export default dataSource; diff --git a/src/database/seed.ts b/src/database/seed.ts new file mode 100644 index 0000000..2cd4227 --- /dev/null +++ b/src/database/seed.ts @@ -0,0 +1,75 @@ +import { DataSource } from 'typeorm'; +import { dataSourceOptions } from '../config/typeorm.config'; + +async function seed() { + const dataSource = new DataSource(dataSourceOptions); + + try { + await dataSource.initialize(); + console.log('Database connected for seeding'); + + // Seed Credit Packages + console.log('Seeding credit packages...'); + await dataSource.query(` + INSERT INTO credit_packages (id, name, description, credits, "priceMXN", "isPopular", "isActive", "sortOrder") + VALUES + (uuid_generate_v4(), 'Starter', 'Perfecto para probar', 10, 49.00, false, true, 1), + (uuid_generate_v4(), 'Basico', 'Para uso regular', 30, 129.00, false, true, 2), + (uuid_generate_v4(), 'Popular', 'El mas vendido', 75, 299.00, true, true, 3), + (uuid_generate_v4(), 'Pro', 'Para negocios activos', 150, 549.00, false, true, 4), + (uuid_generate_v4(), 'Enterprise', 'Uso intensivo', 300, 999.00, false, true, 5) + ON CONFLICT DO NOTHING; + `); + console.log('Credit packages seeded'); + + // Create test user (development only) + if (process.env.NODE_ENV === 'development') { + console.log('Creating test user...'); + + // Check if test user exists + const existingUser = await dataSource.query(` + SELECT id FROM users WHERE phone = '+521234567890' + `); + + if (existingUser.length === 0) { + const userResult = await dataSource.query(` + INSERT INTO users (id, phone, name, "businessName", role, "isActive") + VALUES (uuid_generate_v4(), '+521234567890', 'Usuario Demo', 'Tiendita Demo', 'USER', true) + RETURNING id; + `); + const userId = userResult[0].id; + + // Create test store + await dataSource.query(` + INSERT INTO stores (id, "ownerId", name, giro, address, "isActive") + VALUES (uuid_generate_v4(), $1, 'Mi Tiendita Demo', 'Abarrotes', 'Av. Principal 123, Col. Centro', true); + `, [userId]); + + // Create credit balance with bonus credits + await dataSource.query(` + INSERT INTO credit_balances (id, "userId", balance, "totalPurchased", "totalConsumed", "totalFromReferrals") + VALUES (uuid_generate_v4(), $1, 50, 0, 0, 50); + `, [userId]); + + // Create welcome notification + await dataSource.query(` + INSERT INTO notifications (id, "userId", type, title, body, "isRead") + VALUES (uuid_generate_v4(), $1, 'SYSTEM', 'Bienvenido a MiInventario', 'Tu cuenta ha sido creada. Tienes 50 creditos de bienvenida para comenzar.', false); + `, [userId]); + + console.log('Test user created with ID:', userId); + } else { + console.log('Test user already exists'); + } + } + + console.log('Seeding completed successfully!'); + } catch (error) { + console.error('Error seeding database:', error); + process.exit(1); + } finally { + await dataSource.destroy(); + } +} + +seed(); diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..3f7e034 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,75 @@ +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(); diff --git a/src/migrations/1736502000000-CreateFeedbackTables.ts b/src/migrations/1736502000000-CreateFeedbackTables.ts new file mode 100644 index 0000000..02dc5e6 --- /dev/null +++ b/src/migrations/1736502000000-CreateFeedbackTables.ts @@ -0,0 +1,201 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreateFeedbackTables1736502000000 implements MigrationInterface { + name = 'CreateFeedbackTables1736502000000' + + public async up(queryRunner: QueryRunner): Promise { + // 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 { + // 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"`); + } +} diff --git a/src/migrations/1736600000000-CreateAdminTables.ts b/src/migrations/1736600000000-CreateAdminTables.ts new file mode 100644 index 0000000..4750adb --- /dev/null +++ b/src/migrations/1736600000000-CreateAdminTables.ts @@ -0,0 +1,136 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreateAdminTables1736600000000 implements MigrationInterface { + name = 'CreateAdminTables1736600000000' + + public async up(queryRunner: QueryRunner): Promise { + // 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 { + // 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 + } +} diff --git a/src/migrations/1768099560565-Init.ts b/src/migrations/1768099560565-Init.ts new file mode 100644 index 0000000..1ef2c99 --- /dev/null +++ b/src/migrations/1768099560565-Init.ts @@ -0,0 +1,122 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class Init1768099560565 implements MigrationInterface { + name = 'Init1768099560565' + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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"`); + } + +} diff --git a/src/migrations/1768200000000-CreateExportsTables.ts b/src/migrations/1768200000000-CreateExportsTables.ts new file mode 100644 index 0000000..25779ed --- /dev/null +++ b/src/migrations/1768200000000-CreateExportsTables.ts @@ -0,0 +1,91 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateExportsTables1768200000000 implements MigrationInterface { + name = 'CreateExportsTables1768200000000'; + + public async up(queryRunner: QueryRunner): Promise { + // 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 { + // 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"`); + } +} diff --git a/src/migrations/1768200001000-CreateInventoryMovements.ts b/src/migrations/1768200001000-CreateInventoryMovements.ts new file mode 100644 index 0000000..74aa3fb --- /dev/null +++ b/src/migrations/1768200001000-CreateInventoryMovements.ts @@ -0,0 +1,156 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +export class CreateInventoryMovements1768200001000 + implements MigrationInterface +{ + name = 'CreateInventoryMovements1768200001000'; + + public async up(queryRunner: QueryRunner): Promise { + // 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 { + 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"'); + } +} diff --git a/src/migrations/1768200002000-CreatePosIntegrations.ts b/src/migrations/1768200002000-CreatePosIntegrations.ts new file mode 100644 index 0000000..f268412 --- /dev/null +++ b/src/migrations/1768200002000-CreatePosIntegrations.ts @@ -0,0 +1,261 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +export class CreatePosIntegrations1768200002000 implements MigrationInterface { + name = 'CreatePosIntegrations1768200002000'; + + public async up(queryRunner: QueryRunner): Promise { + // 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 { + 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"'); + } +} diff --git a/src/modules/admin/admin.controller.ts b/src/modules/admin/admin.controller.ts new file mode 100644 index 0000000..529be2b --- /dev/null +++ b/src/modules/admin/admin.controller.ts @@ -0,0 +1,248 @@ +import { + Controller, + Get, + Post, + Patch, + Body, + Param, + Query, + UseGuards, + Req, + ParseUUIDPipe, +} from '@nestjs/common'; +import { Request } from 'express'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../../common/guards/roles.guard'; +import { Roles } from '../../common/decorators/roles.decorator'; +import { UserRole } from '../users/entities/user.entity'; +import { AdminDashboardService } from './services/admin-dashboard.service'; +import { AdminProvidersService } from './services/admin-providers.service'; +import { AdminPackagesService } from './services/admin-packages.service'; +import { AdminPromotionsService } from './services/admin-promotions.service'; +import { AdminModerationService } from './services/admin-moderation.service'; +import { DashboardQueryDto, DashboardPeriod } from './dto/dashboard.dto'; +import { UpdateProviderDto } from './dto/provider.dto'; +import { CreatePackageDto, UpdatePackageDto } from './dto/package.dto'; +import { CreatePromotionDto, UpdatePromotionDto, ValidatePromoCodeDto } from './dto/promotion.dto'; +import { ApproveProductDto, RejectProductDto, ApproveReferralDto, RejectReferralDto } from './dto/moderation.dto'; + +interface AuthRequest extends Request { + user: { id: string; role: string }; +} + +@Controller('admin') +@UseGuards(JwtAuthGuard, RolesGuard) +export class AdminController { + constructor( + private readonly dashboardService: AdminDashboardService, + private readonly providersService: AdminProvidersService, + private readonly packagesService: AdminPackagesService, + private readonly promotionsService: AdminPromotionsService, + private readonly moderationService: AdminModerationService, + ) {} + + // Dashboard Endpoints + + @Get('dashboard') + @Roles(UserRole.VIEWER) + async getDashboard(@Query() query: DashboardQueryDto) { + const startDate = query.startDate ? new Date(query.startDate) : undefined; + const endDate = query.endDate ? new Date(query.endDate) : undefined; + + const metrics = await this.dashboardService.getDashboardMetrics(startDate, endDate); + return { metrics }; + } + + @Get('dashboard/revenue-series') + @Roles(UserRole.ADMIN) + async getRevenueSeries(@Query() query: DashboardQueryDto) { + const now = new Date(); + const startDate = query.startDate + ? new Date(query.startDate) + : new Date(now.getFullYear(), now.getMonth() - 1, 1); + const endDate = query.endDate ? new Date(query.endDate) : now; + const period = query.period || DashboardPeriod.DAY; + + const series = await this.dashboardService.getRevenueSeries(startDate, endDate, period); + return { series }; + } + + // IA Providers Endpoints + + @Get('providers') + @Roles(UserRole.ADMIN) + async getProviders() { + const providers = await this.providersService.findAll(); + return { providers }; + } + + @Patch('providers/:id') + @Roles(UserRole.SUPER_ADMIN) + async updateProvider( + @Req() req: AuthRequest, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateProviderDto, + ) { + const provider = await this.providersService.update(id, dto, req.user.id); + return { + message: 'Proveedor actualizado exitosamente', + provider, + }; + } + + // Credit Packages Endpoints + + @Get('packages') + @Roles(UserRole.ADMIN) + async getPackages(@Query('includeInactive') includeInactive?: string) { + const packages = await this.packagesService.findAll(includeInactive === 'true'); + return { packages }; + } + + @Post('packages') + @Roles(UserRole.ADMIN) + async createPackage(@Req() req: AuthRequest, @Body() dto: CreatePackageDto) { + const pkg = await this.packagesService.create(dto, req.user.id); + return { + message: 'Paquete creado exitosamente', + package: pkg, + }; + } + + @Patch('packages/:id') + @Roles(UserRole.ADMIN) + async updatePackage( + @Req() req: AuthRequest, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdatePackageDto, + ) { + const pkg = await this.packagesService.update(id, dto, req.user.id); + return { + message: 'Paquete actualizado exitosamente', + package: pkg, + }; + } + + // Promotions Endpoints + + @Get('promotions') + @Roles(UserRole.ADMIN) + async getPromotions(@Query('includeExpired') includeExpired?: string) { + const promotions = await this.promotionsService.findAll(includeExpired === 'true'); + return { promotions }; + } + + @Post('promotions') + @Roles(UserRole.ADMIN) + async createPromotion(@Req() req: AuthRequest, @Body() dto: CreatePromotionDto) { + const promotion = await this.promotionsService.create(dto, req.user.id); + return { + message: 'Promocion creada exitosamente', + promotion, + }; + } + + @Patch('promotions/:id') + @Roles(UserRole.ADMIN) + async updatePromotion( + @Req() req: AuthRequest, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdatePromotionDto, + ) { + const promotion = await this.promotionsService.update(id, dto, req.user.id); + return { + message: 'Promocion actualizada exitosamente', + promotion, + }; + } + + @Post('promotions/validate') + @Roles(UserRole.VIEWER) + async validatePromoCode(@Body() dto: ValidatePromoCodeDto) { + return this.promotionsService.validateCode(dto.code, dto.packageId, dto.purchaseAmount); + } + + // Product Moderation Endpoints + + @Get('products/pending') + @Roles(UserRole.MODERATOR) + async getPendingProducts( + @Query('page') page?: string, + @Query('limit') limit?: string, + ) { + return this.moderationService.getPendingProducts( + page ? parseInt(page, 10) : 1, + limit ? parseInt(limit, 10) : 20, + ); + } + + @Post('products/:id/approve') + @Roles(UserRole.MODERATOR) + async approveProduct( + @Req() req: AuthRequest, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: ApproveProductDto, + ) { + const product = await this.moderationService.approveProduct(id, dto, req.user.id); + return { + message: 'Producto aprobado exitosamente', + product, + }; + } + + @Post('products/:id/reject') + @Roles(UserRole.MODERATOR) + async rejectProduct( + @Req() req: AuthRequest, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: RejectProductDto, + ) { + const product = await this.moderationService.rejectProduct(id, dto, req.user.id); + return { + message: 'Producto rechazado', + product, + }; + } + + // Referral Fraud Moderation Endpoints + + @Get('referrals/fraud-holds') + @Roles(UserRole.MODERATOR) + async getFraudHoldReferrals( + @Query('page') page?: string, + @Query('limit') limit?: string, + ) { + return this.moderationService.getFraudHoldReferrals( + page ? parseInt(page, 10) : 1, + limit ? parseInt(limit, 10) : 20, + ); + } + + @Post('referrals/:id/approve') + @Roles(UserRole.MODERATOR) + async approveReferral( + @Req() req: AuthRequest, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: ApproveReferralDto, + ) { + const referral = await this.moderationService.approveReferral(id, dto, req.user.id); + return { + message: 'Referido aprobado exitosamente', + referral, + }; + } + + @Post('referrals/:id/reject') + @Roles(UserRole.MODERATOR) + async rejectReferral( + @Req() req: AuthRequest, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: RejectReferralDto, + ) { + const referral = await this.moderationService.rejectReferral(id, dto, req.user.id); + return { + message: 'Referido rechazado por fraude', + referral, + }; + } +} diff --git a/src/modules/admin/admin.module.ts b/src/modules/admin/admin.module.ts new file mode 100644 index 0000000..71e0489 --- /dev/null +++ b/src/modules/admin/admin.module.ts @@ -0,0 +1,53 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AdminController } from './admin.controller'; +import { AdminDashboardService } from './services/admin-dashboard.service'; +import { AdminProvidersService } from './services/admin-providers.service'; +import { AdminPackagesService } from './services/admin-packages.service'; +import { AdminPromotionsService } from './services/admin-promotions.service'; +import { AdminModerationService } from './services/admin-moderation.service'; +import { AuditLogService } from './services/audit-log.service'; +import { IaProvider } from './entities/ia-provider.entity'; +import { Promotion } from './entities/promotion.entity'; +import { AuditLog } from './entities/audit-log.entity'; +import { User } from '../users/entities/user.entity'; +import { Store } from '../stores/entities/store.entity'; +import { Video } from '../videos/entities/video.entity'; +import { Payment } from '../payments/entities/payment.entity'; +import { CreditPackage } from '../credits/entities/credit-package.entity'; +import { CreditTransaction } from '../credits/entities/credit-transaction.entity'; +import { ProductSubmission } from '../feedback/entities/product-submission.entity'; +import { Referral } from '../referrals/entities/referral.entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + IaProvider, + Promotion, + AuditLog, + User, + Store, + Video, + Payment, + CreditPackage, + CreditTransaction, + ProductSubmission, + Referral, + ]), + ], + controllers: [AdminController], + providers: [ + AdminDashboardService, + AdminProvidersService, + AdminPackagesService, + AdminPromotionsService, + AdminModerationService, + AuditLogService, + ], + exports: [ + AdminProvidersService, + AdminPromotionsService, + AuditLogService, + ], +}) +export class AdminModule {} diff --git a/src/modules/admin/dto/dashboard.dto.ts b/src/modules/admin/dto/dashboard.dto.ts new file mode 100644 index 0000000..c4f6ccf --- /dev/null +++ b/src/modules/admin/dto/dashboard.dto.ts @@ -0,0 +1,66 @@ +import { IsOptional, IsDateString, IsEnum } from 'class-validator'; + +export enum DashboardPeriod { + DAY = 'day', + WEEK = 'week', + MONTH = 'month', + YEAR = 'year', +} + +export class DashboardQueryDto { + @IsOptional() + @IsDateString() + startDate?: string; + + @IsOptional() + @IsDateString() + endDate?: string; + + @IsOptional() + @IsEnum(DashboardPeriod) + period?: DashboardPeriod; +} + +export interface DashboardMetrics { + users: { + total: number; + mau: number; + dau: number; + newThisPeriod: number; + }; + stores: { + total: number; + activeThisPeriod: number; + }; + videos: { + total: number; + processedThisPeriod: number; + averageProcessingTime: number; + }; + revenue: { + total: number; + thisPeriod: number; + byPackage: { packageId: string; name: string; amount: number }[]; + }; + cogs: { + total: number; + thisPeriod: number; + byProvider: { providerId: string; name: string; cost: number }[]; + }; + margin: { + gross: number; + percentage: number; + }; + credits: { + purchased: number; + used: number; + gifted: number; + }; +} + +export interface RevenueSeriesPoint { + date: string; + revenue: number; + cogs: number; + margin: number; +} diff --git a/src/modules/admin/dto/moderation.dto.ts b/src/modules/admin/dto/moderation.dto.ts new file mode 100644 index 0000000..06ee7dc --- /dev/null +++ b/src/modules/admin/dto/moderation.dto.ts @@ -0,0 +1,39 @@ +import { IsString, IsOptional, IsUUID, Length } from 'class-validator'; + +export class ApproveProductDto { + @IsOptional() + @IsString() + @Length(0, 255) + notes?: string; + + @IsOptional() + @IsString() + category?: string; +} + +export class RejectProductDto { + @IsString() + @Length(2, 500) + reason: string; +} + +export class ApproveReferralDto { + @IsOptional() + @IsString() + @Length(0, 255) + notes?: string; +} + +export class RejectReferralDto { + @IsString() + @Length(2, 500) + reason: string; +} + +export class PaginationQueryDto { + @IsOptional() + page?: number; + + @IsOptional() + limit?: number; +} diff --git a/src/modules/admin/dto/package.dto.ts b/src/modules/admin/dto/package.dto.ts new file mode 100644 index 0000000..78c3d15 --- /dev/null +++ b/src/modules/admin/dto/package.dto.ts @@ -0,0 +1,80 @@ +import { IsString, IsNumber, IsBoolean, IsOptional, Min, Max, Length } from 'class-validator'; + +export class CreatePackageDto { + @IsString() + @Length(2, 100) + name: string; + + @IsOptional() + @IsString() + @Length(0, 500) + description?: string; + + @IsNumber() + @Min(1) + credits: number; + + @IsNumber() + @Min(0) + priceMxn: number; + + @IsOptional() + @IsNumber() + @Min(0) + priceUsd?: number; + + @IsOptional() + @IsBoolean() + isPopular?: boolean; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + discountPercentage?: number; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class UpdatePackageDto { + @IsOptional() + @IsString() + @Length(2, 100) + name?: string; + + @IsOptional() + @IsString() + @Length(0, 500) + description?: string; + + @IsOptional() + @IsNumber() + @Min(1) + credits?: number; + + @IsOptional() + @IsNumber() + @Min(0) + priceMxn?: number; + + @IsOptional() + @IsNumber() + @Min(0) + priceUsd?: number; + + @IsOptional() + @IsBoolean() + isPopular?: boolean; + + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + discountPercentage?: number; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/src/modules/admin/dto/promotion.dto.ts b/src/modules/admin/dto/promotion.dto.ts new file mode 100644 index 0000000..2e6c254 --- /dev/null +++ b/src/modules/admin/dto/promotion.dto.ts @@ -0,0 +1,137 @@ +import { + IsString, + IsNumber, + IsBoolean, + IsOptional, + IsEnum, + IsDateString, + IsArray, + IsUUID, + Min, + Max, + Length, +} from 'class-validator'; +import { PromotionType } from '../entities/promotion.entity'; + +export class CreatePromotionDto { + @IsString() + @Length(2, 100) + name: string; + + @IsString() + @Length(2, 50) + code: string; + + @IsOptional() + @IsString() + description?: string; + + @IsEnum(PromotionType) + type: PromotionType; + + @IsNumber() + @Min(0) + value: number; + + @IsOptional() + @IsNumber() + @Min(0) + minPurchaseAmount?: number; + + @IsOptional() + @IsNumber() + @Min(0) + maxDiscount?: number; + + @IsOptional() + @IsNumber() + @Min(1) + usageLimit?: number; + + @IsOptional() + @IsNumber() + @Min(1) + perUserLimit?: number; + + @IsDateString() + startsAt: string; + + @IsDateString() + endsAt: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsArray() + @IsUUID('4', { each: true }) + applicablePackageIds?: string[]; +} + +export class UpdatePromotionDto { + @IsOptional() + @IsString() + @Length(2, 100) + name?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsNumber() + @Min(0) + value?: number; + + @IsOptional() + @IsNumber() + @Min(0) + minPurchaseAmount?: number; + + @IsOptional() + @IsNumber() + @Min(0) + maxDiscount?: number; + + @IsOptional() + @IsNumber() + @Min(1) + usageLimit?: number; + + @IsOptional() + @IsNumber() + @Min(1) + perUserLimit?: number; + + @IsOptional() + @IsDateString() + startsAt?: string; + + @IsOptional() + @IsDateString() + endsAt?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsArray() + @IsUUID('4', { each: true }) + applicablePackageIds?: string[]; +} + +export class ValidatePromoCodeDto { + @IsString() + code: string; + + @IsOptional() + @IsUUID() + packageId?: string; + + @IsOptional() + @IsNumber() + @Min(0) + purchaseAmount?: number; +} diff --git a/src/modules/admin/dto/provider.dto.ts b/src/modules/admin/dto/provider.dto.ts new file mode 100644 index 0000000..7d272f7 --- /dev/null +++ b/src/modules/admin/dto/provider.dto.ts @@ -0,0 +1,42 @@ +import { IsString, IsNumber, IsBoolean, IsOptional, Min } from 'class-validator'; + +export class UpdateProviderDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsNumber() + @Min(0) + costPerFrame?: number; + + @IsOptional() + @IsNumber() + @Min(0) + costPerToken?: number; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsBoolean() + isDefault?: boolean; + + @IsOptional() + config?: Record; +} + +export interface ProviderUsageDto { + providerId: string; + providerName: string; + totalRequests: number; + totalFrames: number; + totalTokens: number; + totalCost: number; + period: string; +} diff --git a/src/modules/admin/entities/audit-log.entity.ts b/src/modules/admin/entities/audit-log.entity.ts new file mode 100644 index 0000000..0b4c5c0 --- /dev/null +++ b/src/modules/admin/entities/audit-log.entity.ts @@ -0,0 +1,51 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; + +@Entity('audit_logs') +@Index(['userId']) +@Index(['action']) +@Index(['resource', 'resourceId']) +@Index(['createdAt']) +export class AuditLog { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + userId: string; + + @ManyToOne(() => User) + @JoinColumn({ name: 'userId' }) + user: User; + + @Column({ type: 'varchar', length: 100 }) + action: string; + + @Column({ type: 'varchar', length: 100 }) + resource: string; + + @Column({ type: 'uuid', nullable: true }) + resourceId: string; + + @Column({ type: 'jsonb', nullable: true }) + previousValue: Record; + + @Column({ type: 'jsonb', nullable: true }) + newValue: Record; + + @Column({ type: 'varchar', length: 45, nullable: true }) + ipAddress: string; + + @Column({ type: 'text', nullable: true }) + userAgent: string; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/src/modules/admin/entities/ia-provider.entity.ts b/src/modules/admin/entities/ia-provider.entity.ts new file mode 100644 index 0000000..ec384a9 --- /dev/null +++ b/src/modules/admin/entities/ia-provider.entity.ts @@ -0,0 +1,46 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +@Entity('ia_providers') +@Index(['code']) +@Index(['isActive']) +export class IaProvider { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'varchar', length: 50, unique: true }) + code: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ type: 'numeric', precision: 10, scale: 6, default: 0 }) + costPerFrame: number; + + @Column({ type: 'numeric', precision: 10, scale: 8, default: 0 }) + costPerToken: number; + + @Column({ type: 'boolean', default: true }) + isActive: boolean; + + @Column({ type: 'boolean', default: false }) + isDefault: boolean; + + @Column({ type: 'jsonb', nullable: true }) + config: Record; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/modules/admin/entities/promotion.entity.ts b/src/modules/admin/entities/promotion.entity.ts new file mode 100644 index 0000000..309b547 --- /dev/null +++ b/src/modules/admin/entities/promotion.entity.ts @@ -0,0 +1,90 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; + +export enum PromotionType { + PERCENTAGE = 'PERCENTAGE', + FIXED_CREDITS = 'FIXED_CREDITS', + MULTIPLIER = 'MULTIPLIER', +} + +@Entity('promotions') +@Index(['code']) +@Index(['isActive', 'startsAt', 'endsAt']) +export class Promotion { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'varchar', length: 100 }) + name: string; + + @Column({ type: 'varchar', length: 50, unique: true }) + code: string; + + @Column({ type: 'text', nullable: true }) + description: string; + + @Column({ type: 'enum', enum: PromotionType }) + type: PromotionType; + + @Column({ type: 'numeric', precision: 10, scale: 2 }) + value: number; + + @Column({ type: 'numeric', precision: 10, scale: 2, nullable: true }) + minPurchaseAmount: number; + + @Column({ type: 'numeric', precision: 10, scale: 2, nullable: true }) + maxDiscount: number; + + @Column({ type: 'int', nullable: true }) + usageLimit: number; + + @Column({ type: 'int', default: 0 }) + usageCount: number; + + @Column({ type: 'int', nullable: true, default: 1 }) + perUserLimit: number; + + @Column({ type: 'timestamp' }) + startsAt: Date; + + @Column({ type: 'timestamp' }) + endsAt: Date; + + @Column({ type: 'boolean', default: true }) + isActive: boolean; + + @Column({ type: 'uuid', array: true, nullable: true }) + applicablePackageIds: string[]; + + @Column({ type: 'uuid', nullable: true }) + createdBy: string; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'createdBy' }) + creator: User; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + isValid(): boolean { + const now = new Date(); + return ( + this.isActive && + this.startsAt <= now && + this.endsAt >= now && + (this.usageLimit === null || this.usageCount < this.usageLimit) + ); + } +} diff --git a/src/modules/admin/services/admin-dashboard.service.ts b/src/modules/admin/services/admin-dashboard.service.ts new file mode 100644 index 0000000..642ed67 --- /dev/null +++ b/src/modules/admin/services/admin-dashboard.service.ts @@ -0,0 +1,206 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, Between, MoreThanOrEqual } from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { Store } from '../../stores/entities/store.entity'; +import { Video } from '../../videos/entities/video.entity'; +import { Payment, PaymentStatus } from '../../payments/entities/payment.entity'; +import { CreditTransaction, TransactionType } from '../../credits/entities/credit-transaction.entity'; +import { DashboardMetrics, RevenueSeriesPoint, DashboardPeriod } from '../dto/dashboard.dto'; + +@Injectable() +export class AdminDashboardService { + constructor( + @InjectRepository(User) + private userRepository: Repository, + @InjectRepository(Store) + private storeRepository: Repository, + @InjectRepository(Video) + private videoRepository: Repository