Migración desde miinventario/backend - Estándar multi-repo v2
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
c2384a3273
commit
5a1c966ed2
50
.dockerignore
Normal file
50
.dockerignore
Normal file
@ -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
|
||||
50
.env.example
Normal file
50
.env.example
Normal file
@ -0,0 +1,50 @@
|
||||
# Server
|
||||
NODE_ENV=development
|
||||
BACKEND_PORT=3142
|
||||
|
||||
# Database
|
||||
DATABASE_HOST=localhost
|
||||
DATABASE_PORT=5433
|
||||
DATABASE_USER=miinventario
|
||||
DATABASE_PASSWORD=miinventario_dev
|
||||
DATABASE_NAME=miinventario
|
||||
|
||||
# Redis
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6380
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=your-jwt-secret-change-in-production
|
||||
JWT_REFRESH_SECRET=your-refresh-secret-change-in-production
|
||||
JWT_EXPIRES_IN=15m
|
||||
JWT_REFRESH_EXPIRES_IN=7d
|
||||
|
||||
# Storage (MinIO/S3)
|
||||
S3_ENDPOINT=http://localhost:9002
|
||||
S3_ACCESS_KEY=miinventario
|
||||
S3_SECRET_KEY=miinventario_dev
|
||||
S3_BUCKET_VIDEOS=miinventario-videos
|
||||
S3_REGION=us-east-1
|
||||
|
||||
# Stripe
|
||||
STRIPE_SECRET_KEY=sk_test_your_stripe_key
|
||||
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
|
||||
|
||||
# Conekta (7-Eleven)
|
||||
CONEKTA_API_KEY=key_your_conekta_key
|
||||
|
||||
# Firebase (FCM)
|
||||
FIREBASE_PROJECT_ID=miinventario
|
||||
FIREBASE_CLIENT_EMAIL=firebase-adminsdk@miinventario.iam.gserviceaccount.com
|
||||
FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nYOUR_KEY_HERE\n-----END PRIVATE KEY-----"
|
||||
|
||||
# OpenAI
|
||||
OPENAI_API_KEY=sk-your-openai-key
|
||||
|
||||
# Claude (Anthropic)
|
||||
ANTHROPIC_API_KEY=sk-ant-your-anthropic-key
|
||||
|
||||
# SMS (Twilio)
|
||||
TWILIO_ACCOUNT_SID=your_account_sid
|
||||
TWILIO_AUTH_TOKEN=your_auth_token
|
||||
TWILIO_PHONE_NUMBER=+1234567890
|
||||
16
.env.test
Normal file
16
.env.test
Normal file
@ -0,0 +1,16 @@
|
||||
# Test Database
|
||||
DATABASE_HOST=localhost
|
||||
DATABASE_PORT=5433
|
||||
DATABASE_NAME=miinventario_test
|
||||
DATABASE_USER=postgres
|
||||
DATABASE_PASSWORD=postgres
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=test-secret
|
||||
JWT_EXPIRES_IN=15m
|
||||
JWT_REFRESH_SECRET=test-refresh-secret
|
||||
JWT_REFRESH_EXPIRES_IN=7d
|
||||
|
||||
# App
|
||||
NODE_ENV=development
|
||||
PORT=3143
|
||||
30
.eslintrc.js
Normal file
30
.eslintrc.js
Normal file
@ -0,0 +1,30 @@
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: 'tsconfig.json',
|
||||
tsconfigRootDir: __dirname,
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: ['@typescript-eslint/eslint-plugin'],
|
||||
extends: [
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
],
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
jest: true,
|
||||
},
|
||||
ignorePatterns: ['.eslintrc.js', 'dist', 'node_modules'],
|
||||
rules: {
|
||||
'@typescript-eslint/interface-name-prefix': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{ argsIgnorePattern: '^_' },
|
||||
],
|
||||
'no-console': ['warn', { allow: ['warn', 'error'] }],
|
||||
},
|
||||
};
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
.env
|
||||
10
.prettierrc
Normal file
10
.prettierrc
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
56
Dockerfile
Normal file
56
Dockerfile
Normal file
@ -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"]
|
||||
8
nest-cli.json
Normal file
8
nest-cli.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
107
package.json
Normal file
107
package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
71
src/app.module.ts
Normal file
71
src/app.module.ts
Normal file
@ -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 {}
|
||||
5
src/common/decorators/roles.decorator.ts
Normal file
5
src/common/decorators/roles.decorator.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { UserRole } from '../../modules/users/entities/user.entity';
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
|
||||
44
src/common/guards/roles.guard.ts
Normal file
44
src/common/guards/roles.guard.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { UserRole } from '../../modules/users/entities/user.entity';
|
||||
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||
|
||||
// Role hierarchy - higher number = more permissions
|
||||
const ROLE_HIERARCHY: Record<UserRole, number> = {
|
||||
[UserRole.USER]: 0,
|
||||
[UserRole.VIEWER]: 1,
|
||||
[UserRole.MODERATOR]: 2,
|
||||
[UserRole.ADMIN]: 3,
|
||||
[UserRole.SUPER_ADMIN]: 4,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (!requiredRoles || requiredRoles.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { user } = context.switchToHttp().getRequest();
|
||||
|
||||
if (!user || !user.role) {
|
||||
throw new ForbiddenException('No tienes permisos para acceder a este recurso');
|
||||
}
|
||||
|
||||
const userRoleLevel = ROLE_HIERARCHY[user.role as UserRole] ?? 0;
|
||||
const minRequiredLevel = Math.min(...requiredRoles.map(role => ROLE_HIERARCHY[role]));
|
||||
|
||||
if (userRoleLevel < minRequiredLevel) {
|
||||
throw new ForbiddenException('No tienes permisos suficientes para esta accion');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
12
src/common/interfaces/authenticated-request.interface.ts
Normal file
12
src/common/interfaces/authenticated-request.interface.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Request } from 'express';
|
||||
|
||||
export interface AuthenticatedUser {
|
||||
id: string;
|
||||
phone: string;
|
||||
email?: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user: AuthenticatedUser;
|
||||
}
|
||||
18
src/config/database.config.ts
Normal file
18
src/config/database.config.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
|
||||
|
||||
export const databaseConfig = (
|
||||
configService: ConfigService,
|
||||
): TypeOrmModuleOptions => ({
|
||||
type: 'postgres',
|
||||
host: configService.get('DB_HOST', 'localhost'),
|
||||
port: configService.get('DB_PORT', 5433),
|
||||
username: configService.get('DB_USER', 'miinventario'),
|
||||
password: configService.get('DB_PASSWORD', 'miinventario_pass'),
|
||||
database: configService.get('DB_NAME', 'miinventario_db'),
|
||||
entities: [__dirname + '/../**/*.entity{.ts,.js}'],
|
||||
migrations: [__dirname + '/../migrations/*{.ts,.js}'],
|
||||
synchronize: configService.get('NODE_ENV') === 'development',
|
||||
logging: configService.get('NODE_ENV') === 'development',
|
||||
ssl: configService.get('DB_SSL') === 'true' ? { rejectUnauthorized: false } : false,
|
||||
});
|
||||
21
src/config/redis.config.ts
Normal file
21
src/config/redis.config.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { BullModuleOptions } from '@nestjs/bull';
|
||||
|
||||
export const redisConfig = (
|
||||
configService: ConfigService,
|
||||
): BullModuleOptions => ({
|
||||
redis: {
|
||||
host: configService.get('REDIS_HOST', 'localhost'),
|
||||
port: configService.get('REDIS_PORT', 6380),
|
||||
password: configService.get('REDIS_PASSWORD'),
|
||||
},
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 5000,
|
||||
},
|
||||
removeOnComplete: 100,
|
||||
removeOnFail: 50,
|
||||
},
|
||||
});
|
||||
20
src/config/typeorm.config.ts
Normal file
20
src/config/typeorm.config.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { DataSource, DataSourceOptions } from 'typeorm';
|
||||
import { config } from 'dotenv';
|
||||
|
||||
config();
|
||||
|
||||
export const dataSourceOptions: DataSourceOptions = {
|
||||
type: 'postgres',
|
||||
host: process.env.DATABASE_HOST || 'localhost',
|
||||
port: parseInt(process.env.DATABASE_PORT || '5433', 10),
|
||||
username: process.env.DATABASE_USER || 'postgres',
|
||||
password: process.env.DATABASE_PASSWORD || 'postgres',
|
||||
database: process.env.DATABASE_NAME || 'miinventario_dev',
|
||||
entities: ['dist/**/*.entity.js'],
|
||||
migrations: ['dist/migrations/*.js'],
|
||||
synchronize: false,
|
||||
logging: process.env.NODE_ENV === 'development',
|
||||
};
|
||||
|
||||
const dataSource = new DataSource(dataSourceOptions);
|
||||
export default dataSource;
|
||||
75
src/database/seed.ts
Normal file
75
src/database/seed.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { dataSourceOptions } from '../config/typeorm.config';
|
||||
|
||||
async function seed() {
|
||||
const dataSource = new DataSource(dataSourceOptions);
|
||||
|
||||
try {
|
||||
await dataSource.initialize();
|
||||
console.log('Database connected for seeding');
|
||||
|
||||
// Seed Credit Packages
|
||||
console.log('Seeding credit packages...');
|
||||
await dataSource.query(`
|
||||
INSERT INTO credit_packages (id, name, description, credits, "priceMXN", "isPopular", "isActive", "sortOrder")
|
||||
VALUES
|
||||
(uuid_generate_v4(), 'Starter', 'Perfecto para probar', 10, 49.00, false, true, 1),
|
||||
(uuid_generate_v4(), 'Basico', 'Para uso regular', 30, 129.00, false, true, 2),
|
||||
(uuid_generate_v4(), 'Popular', 'El mas vendido', 75, 299.00, true, true, 3),
|
||||
(uuid_generate_v4(), 'Pro', 'Para negocios activos', 150, 549.00, false, true, 4),
|
||||
(uuid_generate_v4(), 'Enterprise', 'Uso intensivo', 300, 999.00, false, true, 5)
|
||||
ON CONFLICT DO NOTHING;
|
||||
`);
|
||||
console.log('Credit packages seeded');
|
||||
|
||||
// Create test user (development only)
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('Creating test user...');
|
||||
|
||||
// Check if test user exists
|
||||
const existingUser = await dataSource.query(`
|
||||
SELECT id FROM users WHERE phone = '+521234567890'
|
||||
`);
|
||||
|
||||
if (existingUser.length === 0) {
|
||||
const userResult = await dataSource.query(`
|
||||
INSERT INTO users (id, phone, name, "businessName", role, "isActive")
|
||||
VALUES (uuid_generate_v4(), '+521234567890', 'Usuario Demo', 'Tiendita Demo', 'USER', true)
|
||||
RETURNING id;
|
||||
`);
|
||||
const userId = userResult[0].id;
|
||||
|
||||
// Create test store
|
||||
await dataSource.query(`
|
||||
INSERT INTO stores (id, "ownerId", name, giro, address, "isActive")
|
||||
VALUES (uuid_generate_v4(), $1, 'Mi Tiendita Demo', 'Abarrotes', 'Av. Principal 123, Col. Centro', true);
|
||||
`, [userId]);
|
||||
|
||||
// Create credit balance with bonus credits
|
||||
await dataSource.query(`
|
||||
INSERT INTO credit_balances (id, "userId", balance, "totalPurchased", "totalConsumed", "totalFromReferrals")
|
||||
VALUES (uuid_generate_v4(), $1, 50, 0, 0, 50);
|
||||
`, [userId]);
|
||||
|
||||
// Create welcome notification
|
||||
await dataSource.query(`
|
||||
INSERT INTO notifications (id, "userId", type, title, body, "isRead")
|
||||
VALUES (uuid_generate_v4(), $1, 'SYSTEM', 'Bienvenido a MiInventario', 'Tu cuenta ha sido creada. Tienes 50 creditos de bienvenida para comenzar.', false);
|
||||
`, [userId]);
|
||||
|
||||
console.log('Test user created with ID:', userId);
|
||||
} else {
|
||||
console.log('Test user already exists');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Seeding completed successfully!');
|
||||
} catch (error) {
|
||||
console.error('Error seeding database:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await dataSource.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
seed();
|
||||
75
src/main.ts
Normal file
75
src/main.ts
Normal file
@ -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();
|
||||
201
src/migrations/1736502000000-CreateFeedbackTables.ts
Normal file
201
src/migrations/1736502000000-CreateFeedbackTables.ts
Normal file
@ -0,0 +1,201 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class CreateFeedbackTables1736502000000 implements MigrationInterface {
|
||||
name = 'CreateFeedbackTables1736502000000'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// ENUMs
|
||||
await queryRunner.query(`CREATE TYPE "public"."corrections_type_enum" AS ENUM('QUANTITY', 'SKU', 'CONFIRMATION')`);
|
||||
await queryRunner.query(`CREATE TYPE "public"."ground_truth_status_enum" AS ENUM('PENDING', 'APPROVED', 'REJECTED')`);
|
||||
await queryRunner.query(`CREATE TYPE "public"."product_submissions_status_enum" AS ENUM('PENDING', 'APPROVED', 'REJECTED')`);
|
||||
await queryRunner.query(`CREATE TYPE "public"."validation_requests_status_enum" AS ENUM('PENDING', 'COMPLETED', 'SKIPPED', 'EXPIRED')`);
|
||||
|
||||
// Corrections table - Historial de correcciones de usuario
|
||||
await queryRunner.query(`CREATE TABLE "corrections" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"inventoryItemId" uuid NOT NULL,
|
||||
"userId" uuid NOT NULL,
|
||||
"storeId" uuid NOT NULL,
|
||||
"type" "public"."corrections_type_enum" NOT NULL,
|
||||
"previousValue" jsonb NOT NULL,
|
||||
"newValue" jsonb NOT NULL,
|
||||
"reason" character varying(255),
|
||||
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_corrections" PRIMARY KEY ("id")
|
||||
)`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_corrections_item" ON "corrections" ("inventoryItemId")`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_corrections_user" ON "corrections" ("userId")`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_corrections_store" ON "corrections" ("storeId")`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_corrections_type" ON "corrections" ("type", "createdAt")`);
|
||||
|
||||
// Ground Truth table - Datos validados para entrenamiento
|
||||
await queryRunner.query(`CREATE TABLE "ground_truth" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"inventoryItemId" uuid NOT NULL,
|
||||
"videoId" uuid NOT NULL,
|
||||
"storeId" uuid NOT NULL,
|
||||
"originalDetection" jsonb NOT NULL,
|
||||
"correctedData" jsonb NOT NULL,
|
||||
"frameTimestamp" integer,
|
||||
"boundingBox" jsonb,
|
||||
"status" "public"."ground_truth_status_enum" NOT NULL DEFAULT 'PENDING',
|
||||
"validatedBy" uuid,
|
||||
"validationScore" numeric(5,2),
|
||||
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_ground_truth" PRIMARY KEY ("id")
|
||||
)`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_ground_truth_item" ON "ground_truth" ("inventoryItemId")`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_ground_truth_video" ON "ground_truth" ("videoId")`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_ground_truth_status" ON "ground_truth" ("status")`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_ground_truth_store" ON "ground_truth" ("storeId")`);
|
||||
|
||||
// Product Submissions table - Nuevos productos etiquetados por usuarios
|
||||
await queryRunner.query(`CREATE TABLE "product_submissions" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"userId" uuid NOT NULL,
|
||||
"storeId" uuid NOT NULL,
|
||||
"videoId" uuid,
|
||||
"name" character varying(255) NOT NULL,
|
||||
"category" character varying(100),
|
||||
"barcode" character varying(50),
|
||||
"imageUrl" character varying(500),
|
||||
"frameTimestamp" integer,
|
||||
"boundingBox" jsonb,
|
||||
"status" "public"."product_submissions_status_enum" NOT NULL DEFAULT 'PENDING',
|
||||
"reviewedBy" uuid,
|
||||
"reviewNotes" text,
|
||||
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_product_submissions" PRIMARY KEY ("id")
|
||||
)`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_product_submissions_user" ON "product_submissions" ("userId")`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_product_submissions_store" ON "product_submissions" ("storeId")`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_product_submissions_status" ON "product_submissions" ("status")`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_product_submissions_barcode" ON "product_submissions" ("barcode")`);
|
||||
|
||||
// Validation Requests table - Solicitudes de micro-auditoria
|
||||
await queryRunner.query(`CREATE TABLE "validation_requests" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"videoId" uuid NOT NULL,
|
||||
"userId" uuid NOT NULL,
|
||||
"storeId" uuid NOT NULL,
|
||||
"totalItems" integer NOT NULL DEFAULT '0',
|
||||
"itemsValidated" integer NOT NULL DEFAULT '0',
|
||||
"triggerReason" character varying(100) NOT NULL,
|
||||
"probabilityScore" numeric(5,2) NOT NULL,
|
||||
"status" "public"."validation_requests_status_enum" NOT NULL DEFAULT 'PENDING',
|
||||
"expiresAt" TIMESTAMP NOT NULL,
|
||||
"completedAt" TIMESTAMP,
|
||||
"creditsRewarded" integer NOT NULL DEFAULT '0',
|
||||
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_validation_requests" PRIMARY KEY ("id")
|
||||
)`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_validation_requests_video" ON "validation_requests" ("videoId")`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_validation_requests_user" ON "validation_requests" ("userId")`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_validation_requests_store" ON "validation_requests" ("storeId")`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_validation_requests_status" ON "validation_requests" ("status", "expiresAt")`);
|
||||
|
||||
// Validation Responses table - Respuestas de validacion por item
|
||||
await queryRunner.query(`CREATE TABLE "validation_responses" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"requestId" uuid NOT NULL,
|
||||
"inventoryItemId" uuid NOT NULL,
|
||||
"userId" uuid NOT NULL,
|
||||
"isCorrect" boolean,
|
||||
"correctedQuantity" integer,
|
||||
"correctedName" character varying(255),
|
||||
"responseTimeMs" integer,
|
||||
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_validation_responses" PRIMARY KEY ("id")
|
||||
)`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_validation_responses_request" ON "validation_responses" ("requestId")`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_validation_responses_item" ON "validation_responses" ("inventoryItemId")`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_validation_responses_user" ON "validation_responses" ("userId")`);
|
||||
|
||||
// Foreign Keys
|
||||
await queryRunner.query(`ALTER TABLE "corrections" ADD CONSTRAINT "FK_corrections_item" FOREIGN KEY ("inventoryItemId") REFERENCES "inventory_items"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "corrections" ADD CONSTRAINT "FK_corrections_user" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "corrections" ADD CONSTRAINT "FK_corrections_store" FOREIGN KEY ("storeId") REFERENCES "stores"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
|
||||
|
||||
await queryRunner.query(`ALTER TABLE "ground_truth" ADD CONSTRAINT "FK_ground_truth_item" FOREIGN KEY ("inventoryItemId") REFERENCES "inventory_items"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "ground_truth" ADD CONSTRAINT "FK_ground_truth_video" FOREIGN KEY ("videoId") REFERENCES "videos"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "ground_truth" ADD CONSTRAINT "FK_ground_truth_store" FOREIGN KEY ("storeId") REFERENCES "stores"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "ground_truth" ADD CONSTRAINT "FK_ground_truth_validator" FOREIGN KEY ("validatedBy") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
|
||||
|
||||
await queryRunner.query(`ALTER TABLE "product_submissions" ADD CONSTRAINT "FK_product_submissions_user" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "product_submissions" ADD CONSTRAINT "FK_product_submissions_store" FOREIGN KEY ("storeId") REFERENCES "stores"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "product_submissions" ADD CONSTRAINT "FK_product_submissions_video" FOREIGN KEY ("videoId") REFERENCES "videos"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "product_submissions" ADD CONSTRAINT "FK_product_submissions_reviewer" FOREIGN KEY ("reviewedBy") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
|
||||
|
||||
await queryRunner.query(`ALTER TABLE "validation_requests" ADD CONSTRAINT "FK_validation_requests_video" FOREIGN KEY ("videoId") REFERENCES "videos"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "validation_requests" ADD CONSTRAINT "FK_validation_requests_user" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "validation_requests" ADD CONSTRAINT "FK_validation_requests_store" FOREIGN KEY ("storeId") REFERENCES "stores"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
|
||||
|
||||
await queryRunner.query(`ALTER TABLE "validation_responses" ADD CONSTRAINT "FK_validation_responses_request" FOREIGN KEY ("requestId") REFERENCES "validation_requests"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "validation_responses" ADD CONSTRAINT "FK_validation_responses_item" FOREIGN KEY ("inventoryItemId") REFERENCES "inventory_items"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "validation_responses" ADD CONSTRAINT "FK_validation_responses_user" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Drop Foreign Keys
|
||||
await queryRunner.query(`ALTER TABLE "validation_responses" DROP CONSTRAINT "FK_validation_responses_user"`);
|
||||
await queryRunner.query(`ALTER TABLE "validation_responses" DROP CONSTRAINT "FK_validation_responses_item"`);
|
||||
await queryRunner.query(`ALTER TABLE "validation_responses" DROP CONSTRAINT "FK_validation_responses_request"`);
|
||||
|
||||
await queryRunner.query(`ALTER TABLE "validation_requests" DROP CONSTRAINT "FK_validation_requests_store"`);
|
||||
await queryRunner.query(`ALTER TABLE "validation_requests" DROP CONSTRAINT "FK_validation_requests_user"`);
|
||||
await queryRunner.query(`ALTER TABLE "validation_requests" DROP CONSTRAINT "FK_validation_requests_video"`);
|
||||
|
||||
await queryRunner.query(`ALTER TABLE "product_submissions" DROP CONSTRAINT "FK_product_submissions_reviewer"`);
|
||||
await queryRunner.query(`ALTER TABLE "product_submissions" DROP CONSTRAINT "FK_product_submissions_video"`);
|
||||
await queryRunner.query(`ALTER TABLE "product_submissions" DROP CONSTRAINT "FK_product_submissions_store"`);
|
||||
await queryRunner.query(`ALTER TABLE "product_submissions" DROP CONSTRAINT "FK_product_submissions_user"`);
|
||||
|
||||
await queryRunner.query(`ALTER TABLE "ground_truth" DROP CONSTRAINT "FK_ground_truth_validator"`);
|
||||
await queryRunner.query(`ALTER TABLE "ground_truth" DROP CONSTRAINT "FK_ground_truth_store"`);
|
||||
await queryRunner.query(`ALTER TABLE "ground_truth" DROP CONSTRAINT "FK_ground_truth_video"`);
|
||||
await queryRunner.query(`ALTER TABLE "ground_truth" DROP CONSTRAINT "FK_ground_truth_item"`);
|
||||
|
||||
await queryRunner.query(`ALTER TABLE "corrections" DROP CONSTRAINT "FK_corrections_store"`);
|
||||
await queryRunner.query(`ALTER TABLE "corrections" DROP CONSTRAINT "FK_corrections_user"`);
|
||||
await queryRunner.query(`ALTER TABLE "corrections" DROP CONSTRAINT "FK_corrections_item"`);
|
||||
|
||||
// Drop Tables
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_validation_responses_user"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_validation_responses_item"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_validation_responses_request"`);
|
||||
await queryRunner.query(`DROP TABLE "validation_responses"`);
|
||||
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_validation_requests_status"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_validation_requests_store"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_validation_requests_user"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_validation_requests_video"`);
|
||||
await queryRunner.query(`DROP TABLE "validation_requests"`);
|
||||
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_product_submissions_barcode"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_product_submissions_status"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_product_submissions_store"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_product_submissions_user"`);
|
||||
await queryRunner.query(`DROP TABLE "product_submissions"`);
|
||||
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_ground_truth_store"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_ground_truth_status"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_ground_truth_video"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_ground_truth_item"`);
|
||||
await queryRunner.query(`DROP TABLE "ground_truth"`);
|
||||
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_corrections_type"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_corrections_store"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_corrections_user"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_corrections_item"`);
|
||||
await queryRunner.query(`DROP TABLE "corrections"`);
|
||||
|
||||
// Drop ENUMs
|
||||
await queryRunner.query(`DROP TYPE "public"."validation_requests_status_enum"`);
|
||||
await queryRunner.query(`DROP TYPE "public"."product_submissions_status_enum"`);
|
||||
await queryRunner.query(`DROP TYPE "public"."ground_truth_status_enum"`);
|
||||
await queryRunner.query(`DROP TYPE "public"."corrections_type_enum"`);
|
||||
}
|
||||
}
|
||||
136
src/migrations/1736600000000-CreateAdminTables.ts
Normal file
136
src/migrations/1736600000000-CreateAdminTables.ts
Normal file
@ -0,0 +1,136 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class CreateAdminTables1736600000000 implements MigrationInterface {
|
||||
name = 'CreateAdminTables1736600000000'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Extend users role enum with new roles
|
||||
await queryRunner.query(`ALTER TYPE "public"."users_role_enum" ADD VALUE IF NOT EXISTS 'SUPER_ADMIN'`);
|
||||
await queryRunner.query(`ALTER TYPE "public"."users_role_enum" ADD VALUE IF NOT EXISTS 'MODERATOR'`);
|
||||
await queryRunner.query(`ALTER TYPE "public"."users_role_enum" ADD VALUE IF NOT EXISTS 'VIEWER'`);
|
||||
|
||||
// Create promotion type enum
|
||||
await queryRunner.query(`CREATE TYPE "public"."promotions_type_enum" AS ENUM('PERCENTAGE', 'FIXED_CREDITS', 'MULTIPLIER')`);
|
||||
|
||||
// Create IA Providers table
|
||||
await queryRunner.query(`CREATE TABLE "ia_providers" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"name" character varying(100) NOT NULL,
|
||||
"code" character varying(50) NOT NULL,
|
||||
"description" text,
|
||||
"costPerFrame" numeric(10,6) NOT NULL DEFAULT '0',
|
||||
"costPerToken" numeric(10,8) NOT NULL DEFAULT '0',
|
||||
"isActive" boolean NOT NULL DEFAULT true,
|
||||
"isDefault" boolean NOT NULL DEFAULT false,
|
||||
"config" jsonb,
|
||||
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "UQ_ia_providers_code" UNIQUE ("code"),
|
||||
CONSTRAINT "PK_ia_providers" PRIMARY KEY ("id")
|
||||
)`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_ia_providers_code" ON "ia_providers" ("code")`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_ia_providers_active" ON "ia_providers" ("isActive")`);
|
||||
|
||||
// Create Promotions table
|
||||
await queryRunner.query(`CREATE TABLE "promotions" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"name" character varying(100) NOT NULL,
|
||||
"code" character varying(50) NOT NULL,
|
||||
"description" text,
|
||||
"type" "public"."promotions_type_enum" NOT NULL,
|
||||
"value" numeric(10,2) NOT NULL,
|
||||
"minPurchaseAmount" numeric(10,2),
|
||||
"maxDiscount" numeric(10,2),
|
||||
"usageLimit" integer,
|
||||
"usageCount" integer NOT NULL DEFAULT '0',
|
||||
"perUserLimit" integer DEFAULT '1',
|
||||
"startsAt" TIMESTAMP NOT NULL,
|
||||
"endsAt" TIMESTAMP NOT NULL,
|
||||
"isActive" boolean NOT NULL DEFAULT true,
|
||||
"applicablePackageIds" uuid[],
|
||||
"createdBy" uuid,
|
||||
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "UQ_promotions_code" UNIQUE ("code"),
|
||||
CONSTRAINT "PK_promotions" PRIMARY KEY ("id")
|
||||
)`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_promotions_code" ON "promotions" ("code")`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_promotions_active_dates" ON "promotions" ("isActive", "startsAt", "endsAt")`);
|
||||
|
||||
// Create Audit Logs table
|
||||
await queryRunner.query(`CREATE TABLE "audit_logs" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"userId" uuid NOT NULL,
|
||||
"action" character varying(100) NOT NULL,
|
||||
"resource" character varying(100) NOT NULL,
|
||||
"resourceId" uuid,
|
||||
"previousValue" jsonb,
|
||||
"newValue" jsonb,
|
||||
"ipAddress" character varying(45),
|
||||
"userAgent" text,
|
||||
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_audit_logs" PRIMARY KEY ("id")
|
||||
)`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_audit_logs_user" ON "audit_logs" ("userId")`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_audit_logs_action" ON "audit_logs" ("action")`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_audit_logs_resource" ON "audit_logs" ("resource", "resourceId")`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_audit_logs_created" ON "audit_logs" ("createdAt")`);
|
||||
|
||||
// Add fraud detection columns to referrals
|
||||
await queryRunner.query(`ALTER TABLE "referrals" ADD "fraudHold" boolean NOT NULL DEFAULT false`);
|
||||
await queryRunner.query(`ALTER TABLE "referrals" ADD "fraudReason" character varying(255)`);
|
||||
await queryRunner.query(`ALTER TABLE "referrals" ADD "reviewedBy" uuid`);
|
||||
await queryRunner.query(`ALTER TABLE "referrals" ADD "reviewedAt" TIMESTAMP`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_referrals_fraud" ON "referrals" ("fraudHold")`);
|
||||
|
||||
// Foreign Keys
|
||||
await queryRunner.query(`ALTER TABLE "promotions" ADD CONSTRAINT "FK_promotions_creator" FOREIGN KEY ("createdBy") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "audit_logs" ADD CONSTRAINT "FK_audit_logs_user" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "referrals" ADD CONSTRAINT "FK_referrals_reviewer" FOREIGN KEY ("reviewedBy") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`);
|
||||
|
||||
// Seed initial IA providers
|
||||
await queryRunner.query(`
|
||||
INSERT INTO "ia_providers" ("name", "code", "description", "costPerFrame", "costPerToken", "isActive", "isDefault")
|
||||
VALUES
|
||||
('OpenAI GPT-4 Vision', 'openai-gpt4v', 'OpenAI GPT-4 con capacidades de vision', 0.01, 0.00003, true, true),
|
||||
('Claude 3 Sonnet', 'claude-sonnet', 'Anthropic Claude 3 Sonnet para analisis de imagenes', 0.003, 0.000015, true, false),
|
||||
('Claude 3 Haiku', 'claude-haiku', 'Anthropic Claude 3 Haiku - rapido y economico', 0.00025, 0.00000125, true, false)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Drop Foreign Keys
|
||||
await queryRunner.query(`ALTER TABLE "referrals" DROP CONSTRAINT IF EXISTS "FK_referrals_reviewer"`);
|
||||
await queryRunner.query(`ALTER TABLE "audit_logs" DROP CONSTRAINT "FK_audit_logs_user"`);
|
||||
await queryRunner.query(`ALTER TABLE "promotions" DROP CONSTRAINT "FK_promotions_creator"`);
|
||||
|
||||
// Remove fraud columns from referrals
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_referrals_fraud"`);
|
||||
await queryRunner.query(`ALTER TABLE "referrals" DROP COLUMN "reviewedAt"`);
|
||||
await queryRunner.query(`ALTER TABLE "referrals" DROP COLUMN "reviewedBy"`);
|
||||
await queryRunner.query(`ALTER TABLE "referrals" DROP COLUMN "fraudReason"`);
|
||||
await queryRunner.query(`ALTER TABLE "referrals" DROP COLUMN "fraudHold"`);
|
||||
|
||||
// Drop Audit Logs table
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_audit_logs_created"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_audit_logs_resource"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_audit_logs_action"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_audit_logs_user"`);
|
||||
await queryRunner.query(`DROP TABLE "audit_logs"`);
|
||||
|
||||
// Drop Promotions table
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_promotions_active_dates"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_promotions_code"`);
|
||||
await queryRunner.query(`DROP TABLE "promotions"`);
|
||||
|
||||
// Drop IA Providers table
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_ia_providers_active"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_ia_providers_code"`);
|
||||
await queryRunner.query(`DROP TABLE "ia_providers"`);
|
||||
|
||||
// Drop ENUMs
|
||||
await queryRunner.query(`DROP TYPE "public"."promotions_type_enum"`);
|
||||
|
||||
// Note: Cannot remove enum values in PostgreSQL, they remain but are unused
|
||||
}
|
||||
}
|
||||
122
src/migrations/1768099560565-Init.ts
Normal file
122
src/migrations/1768099560565-Init.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class Init1768099560565 implements MigrationInterface {
|
||||
name = 'Init1768099560565'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`CREATE TYPE "public"."users_role_enum" AS ENUM('USER', 'ADMIN')`);
|
||||
await queryRunner.query(`CREATE TABLE "users" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "phone" character varying(20), "email" character varying(255), "passwordHash" character varying(255), "name" character varying(100), "businessName" character varying(100), "location" character varying(255), "giro" character varying(50), "role" "public"."users_role_enum" NOT NULL DEFAULT 'USER', "isActive" boolean NOT NULL DEFAULT true, "fcmToken" character varying(255), "stripeCustomerId" character varying(100), "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_a000cca60bcf04454e727699490" UNIQUE ("phone"), CONSTRAINT "UQ_97672ac88f789774dd47f7c8be3" UNIQUE ("email"), CONSTRAINT "PK_a3ffb1c0c8416b9fc6f907b7433" PRIMARY KEY ("id"))`);
|
||||
await queryRunner.query(`CREATE TABLE "stores" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "ownerId" uuid NOT NULL, "name" character varying(100) NOT NULL, "giro" character varying(50), "address" character varying(255), "phone" character varying(20), "logoUrl" character varying(500), "settings" jsonb NOT NULL DEFAULT '{}', "isActive" boolean NOT NULL DEFAULT true, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_7aa6e7d71fa7acdd7ca43d7c9cb" PRIMARY KEY ("id"))`);
|
||||
await queryRunner.query(`CREATE TYPE "public"."videos_status_enum" AS ENUM('PENDING', 'UPLOADING', 'UPLOADED', 'PROCESSING', 'COMPLETED', 'FAILED')`);
|
||||
await queryRunner.query(`CREATE TABLE "videos" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "storeId" uuid NOT NULL, "uploadedById" uuid NOT NULL, "fileName" character varying(255) NOT NULL, "s3Key" character varying(500), "fileSize" bigint, "durationSeconds" integer, "status" "public"."videos_status_enum" NOT NULL DEFAULT 'PENDING', "processingProgress" integer NOT NULL DEFAULT '0', "itemsDetected" integer NOT NULL DEFAULT '0', "creditsUsed" integer NOT NULL DEFAULT '0', "errorMessage" text, "metadata" jsonb, "processedAt" TIMESTAMP, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_e4c86c0cf95aff16e9fb8220f6b" PRIMARY KEY ("id"))`);
|
||||
await queryRunner.query(`CREATE TYPE "public"."store_users_role_enum" AS ENUM('OWNER', 'OPERATOR')`);
|
||||
await queryRunner.query(`CREATE TABLE "store_users" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "store_id" uuid NOT NULL, "user_id" uuid NOT NULL, "role" "public"."store_users_role_enum" NOT NULL, "isActive" boolean NOT NULL DEFAULT true, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_6af90d774177332a7a99a7c1c9d" PRIMARY KEY ("id"))`);
|
||||
await queryRunner.query(`CREATE TYPE "public"."referrals_status_enum" AS ENUM('PENDING', 'REGISTERED', 'QUALIFIED', 'REWARDED')`);
|
||||
await queryRunner.query(`CREATE TABLE "referrals" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "referrerId" uuid NOT NULL, "referredId" uuid, "referralCode" character varying(20) NOT NULL, "status" "public"."referrals_status_enum" NOT NULL DEFAULT 'PENDING', "referrerBonusCredits" integer NOT NULL DEFAULT '0', "referredBonusCredits" integer NOT NULL DEFAULT '0', "registeredAt" TIMESTAMP, "qualifiedAt" TIMESTAMP, "rewardedAt" TIMESTAMP, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_13a1bad9eef9e5cfa4b61765261" UNIQUE ("referralCode"), CONSTRAINT "PK_ea9980e34f738b6252817326c08" PRIMARY KEY ("id"))`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_13a1bad9eef9e5cfa4b6176526" ON "referrals" ("referralCode") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_ad6772c3fcb57375f43114b5cb" ON "referrals" ("referredId") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_59de462f9ce130da142e3b5a9f" ON "referrals" ("referrerId") `);
|
||||
await queryRunner.query(`CREATE TABLE "credit_packages" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying(100) NOT NULL, "description" character varying(255), "credits" integer NOT NULL, "priceMXN" numeric(10,2) NOT NULL, "isPopular" boolean NOT NULL DEFAULT false, "isActive" boolean NOT NULL DEFAULT true, "sortOrder" integer NOT NULL DEFAULT '0', "stripePriceId" character varying(100), "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_c10750b5b0638b06330b0c09bfd" PRIMARY KEY ("id"))`);
|
||||
await queryRunner.query(`CREATE TYPE "public"."payments_method_enum" AS ENUM('CARD', 'OXXO', '7ELEVEN')`);
|
||||
await queryRunner.query(`CREATE TYPE "public"."payments_status_enum" AS ENUM('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', 'REFUNDED', 'EXPIRED')`);
|
||||
await queryRunner.query(`CREATE TABLE "payments" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "userId" uuid NOT NULL, "packageId" uuid, "amountMXN" numeric(10,2) NOT NULL, "creditsGranted" integer NOT NULL, "method" "public"."payments_method_enum" NOT NULL, "status" "public"."payments_status_enum" NOT NULL DEFAULT 'PENDING', "externalId" character varying(255), "provider" character varying(50), "voucherUrl" character varying(500), "voucherCode" character varying(100), "expiresAt" TIMESTAMP, "completedAt" TIMESTAMP, "errorMessage" text, "metadata" jsonb, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_197ab7af18c93fbb0c9b28b4a59" PRIMARY KEY ("id"))`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_c9ca05ceb7cc5eae113c2eb57c" ON "payments" ("externalId") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_3cc6f3e2f26955eaa64fd7f9ea" ON "payments" ("status", "createdAt") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_1c9b5fb7b9f38cccddd7c5761b" ON "payments" ("userId", "createdAt") `);
|
||||
await queryRunner.query(`CREATE TYPE "public"."notifications_type_enum" AS ENUM('VIDEO_PROCESSING_COMPLETE', 'VIDEO_PROCESSING_FAILED', 'LOW_CREDITS', 'PAYMENT_COMPLETE', 'PAYMENT_FAILED', 'REFERRAL_BONUS', 'PROMO', 'SYSTEM')`);
|
||||
await queryRunner.query(`CREATE TABLE "notifications" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "userId" uuid NOT NULL, "type" "public"."notifications_type_enum" NOT NULL, "title" character varying(255) NOT NULL, "body" text, "isRead" boolean NOT NULL DEFAULT false, "isPushSent" boolean NOT NULL DEFAULT false, "data" jsonb, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_6a72c3c0f683f6462415e653c3a" PRIMARY KEY ("id"))`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_5340fc241f57310d243e5ab20b" ON "notifications" ("userId", "isRead") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_21e65af2f4f242d4c85a92aff4" ON "notifications" ("userId", "createdAt") `);
|
||||
await queryRunner.query(`CREATE TABLE "inventory_items" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "storeId" uuid NOT NULL, "detectedByVideoId" uuid, "name" character varying(255) NOT NULL, "category" character varying(100), "subcategory" character varying(100), "barcode" character varying(50), "quantity" integer NOT NULL DEFAULT '0', "minStock" integer, "price" numeric(10,2), "cost" numeric(10,2), "imageUrl" character varying(500), "detectionConfidence" numeric(5,2), "isManuallyEdited" boolean NOT NULL DEFAULT false, "metadata" jsonb, "lastCountedAt" TIMESTAMP, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_cf2f451407242e132547ac19169" PRIMARY KEY ("id"))`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_e6661f3647402a7434bb2135df" ON "inventory_items" ("storeId", "barcode") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_81d46f4b3749d1db8628a33561" ON "inventory_items" ("storeId", "category") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_9ef0ca05424108394eaaa8f511" ON "inventory_items" ("storeId", "name") `);
|
||||
await queryRunner.query(`CREATE TYPE "public"."credit_transactions_type_enum" AS ENUM('PURCHASE', 'CONSUMPTION', 'REFERRAL_BONUS', 'PROMO', 'REFUND')`);
|
||||
await queryRunner.query(`CREATE TABLE "credit_transactions" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "userId" uuid NOT NULL, "type" "public"."credit_transactions_type_enum" NOT NULL, "amount" integer NOT NULL, "balanceAfter" integer NOT NULL, "description" character varying(255), "referenceId" uuid, "referenceType" character varying(50), "metadata" jsonb, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_a408319811d1ab32832ec86fc2c" PRIMARY KEY ("id"))`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_413894b75b111744ab18f39b1e" ON "credit_transactions" ("type", "createdAt") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_f31233f25cf2015095fca7f6b6" ON "credit_transactions" ("userId", "createdAt") `);
|
||||
await queryRunner.query(`CREATE TABLE "credit_balances" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "userId" uuid NOT NULL, "balance" integer NOT NULL DEFAULT '0', "totalPurchased" integer NOT NULL DEFAULT '0', "totalConsumed" integer NOT NULL DEFAULT '0', "totalFromReferrals" integer NOT NULL DEFAULT '0', "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_7f8103cfe175c66f1e5e8acfe23" UNIQUE ("userId"), CONSTRAINT "REL_7f8103cfe175c66f1e5e8acfe2" UNIQUE ("userId"), CONSTRAINT "PK_b9f1be6c9f3f23c5716fa7d8545" PRIMARY KEY ("id"))`);
|
||||
await queryRunner.query(`CREATE TABLE "refresh_tokens" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "userId" uuid NOT NULL, "token" character varying(500) NOT NULL, "deviceInfo" character varying(100), "ipAddress" character varying(50), "isRevoked" boolean NOT NULL DEFAULT false, "expiresAt" TIMESTAMP NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_7d8bee0204106019488c4c50ffa" PRIMARY KEY ("id"))`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_56b91d98f71e3d1b649ed6e9f3" ON "refresh_tokens" ("expiresAt") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_4542dd2f38a61354a040ba9fd5" ON "refresh_tokens" ("token") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_610102b60fea1455310ccd299d" ON "refresh_tokens" ("userId") `);
|
||||
await queryRunner.query(`CREATE TYPE "public"."otps_purpose_enum" AS ENUM('REGISTRATION', 'LOGIN', 'PASSWORD_RESET')`);
|
||||
await queryRunner.query(`CREATE TABLE "otps" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "phone" character varying(20) NOT NULL, "code" character varying(6) NOT NULL, "purpose" "public"."otps_purpose_enum" NOT NULL, "attempts" integer NOT NULL DEFAULT '0', "isUsed" boolean NOT NULL DEFAULT false, "expiresAt" TIMESTAMP NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_91fef5ed60605b854a2115d2410" PRIMARY KEY ("id"))`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_2afebf0234962331e12c59c592" ON "otps" ("expiresAt") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_ee9e52dc460b0ae28fd9f276ce" ON "otps" ("phone", "purpose") `);
|
||||
await queryRunner.query(`ALTER TABLE "stores" ADD CONSTRAINT "FK_a447ba082271c05997a61df26df" FOREIGN KEY ("ownerId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "videos" ADD CONSTRAINT "FK_4177a2c071cf429219b7eb31a43" FOREIGN KEY ("storeId") REFERENCES "stores"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "videos" ADD CONSTRAINT "FK_159f8e5c7959016a0863ec419a3" FOREIGN KEY ("uploadedById") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "store_users" ADD CONSTRAINT "FK_3077a42ec6ad94cfb93f919359d" FOREIGN KEY ("store_id") REFERENCES "stores"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "store_users" ADD CONSTRAINT "FK_d741d647a3ef6419a31592f8ad6" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "referrals" ADD CONSTRAINT "FK_59de462f9ce130da142e3b5a9f4" FOREIGN KEY ("referrerId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "referrals" ADD CONSTRAINT "FK_ad6772c3fcb57375f43114b5cb5" FOREIGN KEY ("referredId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "payments" ADD CONSTRAINT "FK_d35cb3c13a18e1ea1705b2817b1" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "payments" ADD CONSTRAINT "FK_f14a98c8d21e57b7ddf02940bad" FOREIGN KEY ("packageId") REFERENCES "credit_packages"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "notifications" ADD CONSTRAINT "FK_692a909ee0fa9383e7859f9b406" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "inventory_items" ADD CONSTRAINT "FK_3677990d712c77ee30c1c7baa6c" FOREIGN KEY ("storeId") REFERENCES "stores"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "inventory_items" ADD CONSTRAINT "FK_212ff322ac6cd4bc78bec2bd3dd" FOREIGN KEY ("detectedByVideoId") REFERENCES "videos"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "credit_transactions" ADD CONSTRAINT "FK_2121be176f72337ccf7cc4ef04e" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "credit_balances" ADD CONSTRAINT "FK_7f8103cfe175c66f1e5e8acfe23" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "refresh_tokens" ADD CONSTRAINT "FK_610102b60fea1455310ccd299de" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "refresh_tokens" DROP CONSTRAINT "FK_610102b60fea1455310ccd299de"`);
|
||||
await queryRunner.query(`ALTER TABLE "credit_balances" DROP CONSTRAINT "FK_7f8103cfe175c66f1e5e8acfe23"`);
|
||||
await queryRunner.query(`ALTER TABLE "credit_transactions" DROP CONSTRAINT "FK_2121be176f72337ccf7cc4ef04e"`);
|
||||
await queryRunner.query(`ALTER TABLE "inventory_items" DROP CONSTRAINT "FK_212ff322ac6cd4bc78bec2bd3dd"`);
|
||||
await queryRunner.query(`ALTER TABLE "inventory_items" DROP CONSTRAINT "FK_3677990d712c77ee30c1c7baa6c"`);
|
||||
await queryRunner.query(`ALTER TABLE "notifications" DROP CONSTRAINT "FK_692a909ee0fa9383e7859f9b406"`);
|
||||
await queryRunner.query(`ALTER TABLE "payments" DROP CONSTRAINT "FK_f14a98c8d21e57b7ddf02940bad"`);
|
||||
await queryRunner.query(`ALTER TABLE "payments" DROP CONSTRAINT "FK_d35cb3c13a18e1ea1705b2817b1"`);
|
||||
await queryRunner.query(`ALTER TABLE "referrals" DROP CONSTRAINT "FK_ad6772c3fcb57375f43114b5cb5"`);
|
||||
await queryRunner.query(`ALTER TABLE "referrals" DROP CONSTRAINT "FK_59de462f9ce130da142e3b5a9f4"`);
|
||||
await queryRunner.query(`ALTER TABLE "store_users" DROP CONSTRAINT "FK_d741d647a3ef6419a31592f8ad6"`);
|
||||
await queryRunner.query(`ALTER TABLE "store_users" DROP CONSTRAINT "FK_3077a42ec6ad94cfb93f919359d"`);
|
||||
await queryRunner.query(`ALTER TABLE "videos" DROP CONSTRAINT "FK_159f8e5c7959016a0863ec419a3"`);
|
||||
await queryRunner.query(`ALTER TABLE "videos" DROP CONSTRAINT "FK_4177a2c071cf429219b7eb31a43"`);
|
||||
await queryRunner.query(`ALTER TABLE "stores" DROP CONSTRAINT "FK_a447ba082271c05997a61df26df"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_ee9e52dc460b0ae28fd9f276ce"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_2afebf0234962331e12c59c592"`);
|
||||
await queryRunner.query(`DROP TABLE "otps"`);
|
||||
await queryRunner.query(`DROP TYPE "public"."otps_purpose_enum"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_610102b60fea1455310ccd299d"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_4542dd2f38a61354a040ba9fd5"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_56b91d98f71e3d1b649ed6e9f3"`);
|
||||
await queryRunner.query(`DROP TABLE "refresh_tokens"`);
|
||||
await queryRunner.query(`DROP TABLE "credit_balances"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_f31233f25cf2015095fca7f6b6"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_413894b75b111744ab18f39b1e"`);
|
||||
await queryRunner.query(`DROP TABLE "credit_transactions"`);
|
||||
await queryRunner.query(`DROP TYPE "public"."credit_transactions_type_enum"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_9ef0ca05424108394eaaa8f511"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_81d46f4b3749d1db8628a33561"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_e6661f3647402a7434bb2135df"`);
|
||||
await queryRunner.query(`DROP TABLE "inventory_items"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_21e65af2f4f242d4c85a92aff4"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_5340fc241f57310d243e5ab20b"`);
|
||||
await queryRunner.query(`DROP TABLE "notifications"`);
|
||||
await queryRunner.query(`DROP TYPE "public"."notifications_type_enum"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_1c9b5fb7b9f38cccddd7c5761b"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_3cc6f3e2f26955eaa64fd7f9ea"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_c9ca05ceb7cc5eae113c2eb57c"`);
|
||||
await queryRunner.query(`DROP TABLE "payments"`);
|
||||
await queryRunner.query(`DROP TYPE "public"."payments_status_enum"`);
|
||||
await queryRunner.query(`DROP TYPE "public"."payments_method_enum"`);
|
||||
await queryRunner.query(`DROP TABLE "credit_packages"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_59de462f9ce130da142e3b5a9f"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_ad6772c3fcb57375f43114b5cb"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_13a1bad9eef9e5cfa4b6176526"`);
|
||||
await queryRunner.query(`DROP TABLE "referrals"`);
|
||||
await queryRunner.query(`DROP TYPE "public"."referrals_status_enum"`);
|
||||
await queryRunner.query(`DROP TABLE "store_users"`);
|
||||
await queryRunner.query(`DROP TYPE "public"."store_users_role_enum"`);
|
||||
await queryRunner.query(`DROP TABLE "videos"`);
|
||||
await queryRunner.query(`DROP TYPE "public"."videos_status_enum"`);
|
||||
await queryRunner.query(`DROP TABLE "stores"`);
|
||||
await queryRunner.query(`DROP TABLE "users"`);
|
||||
await queryRunner.query(`DROP TYPE "public"."users_role_enum"`);
|
||||
}
|
||||
|
||||
}
|
||||
91
src/migrations/1768200000000-CreateExportsTables.ts
Normal file
91
src/migrations/1768200000000-CreateExportsTables.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateExportsTables1768200000000 implements MigrationInterface {
|
||||
name = 'CreateExportsTables1768200000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Create enum for export format
|
||||
await queryRunner.query(`
|
||||
CREATE TYPE "export_format_enum" AS ENUM ('CSV', 'EXCEL')
|
||||
`);
|
||||
|
||||
// Create enum for export type
|
||||
await queryRunner.query(`
|
||||
CREATE TYPE "export_type_enum" AS ENUM (
|
||||
'INVENTORY',
|
||||
'REPORT_VALUATION',
|
||||
'REPORT_MOVEMENTS',
|
||||
'REPORT_CATEGORIES',
|
||||
'REPORT_LOW_STOCK'
|
||||
)
|
||||
`);
|
||||
|
||||
// Create enum for export status
|
||||
await queryRunner.query(`
|
||||
CREATE TYPE "export_status_enum" AS ENUM (
|
||||
'PENDING',
|
||||
'PROCESSING',
|
||||
'COMPLETED',
|
||||
'FAILED'
|
||||
)
|
||||
`);
|
||||
|
||||
// Create export_jobs table
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE "export_jobs" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"userId" uuid NOT NULL,
|
||||
"storeId" uuid NOT NULL,
|
||||
"format" "export_format_enum" NOT NULL DEFAULT 'CSV',
|
||||
"type" "export_type_enum" NOT NULL DEFAULT 'INVENTORY',
|
||||
"status" "export_status_enum" NOT NULL DEFAULT 'PENDING',
|
||||
"filters" jsonb,
|
||||
"s3Key" varchar(500),
|
||||
"downloadUrl" varchar(1000),
|
||||
"expiresAt" timestamp,
|
||||
"totalRows" integer,
|
||||
"errorMessage" text,
|
||||
"createdAt" timestamp NOT NULL DEFAULT now(),
|
||||
"updatedAt" timestamp NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_export_jobs" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "FK_export_jobs_user" FOREIGN KEY ("userId")
|
||||
REFERENCES "users"("id") ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_export_jobs_store" FOREIGN KEY ("storeId")
|
||||
REFERENCES "stores"("id") ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
// Create indexes
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX "IDX_export_jobs_userId" ON "export_jobs" ("userId")
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX "IDX_export_jobs_storeId" ON "export_jobs" ("storeId")
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX "IDX_export_jobs_status" ON "export_jobs" ("status")
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX "IDX_export_jobs_createdAt" ON "export_jobs" ("createdAt")
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Drop indexes
|
||||
await queryRunner.query(`DROP INDEX "IDX_export_jobs_createdAt"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_export_jobs_status"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_export_jobs_storeId"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_export_jobs_userId"`);
|
||||
|
||||
// Drop table
|
||||
await queryRunner.query(`DROP TABLE "export_jobs"`);
|
||||
|
||||
// Drop enums
|
||||
await queryRunner.query(`DROP TYPE "export_status_enum"`);
|
||||
await queryRunner.query(`DROP TYPE "export_type_enum"`);
|
||||
await queryRunner.query(`DROP TYPE "export_format_enum"`);
|
||||
}
|
||||
}
|
||||
156
src/migrations/1768200001000-CreateInventoryMovements.ts
Normal file
156
src/migrations/1768200001000-CreateInventoryMovements.ts
Normal file
@ -0,0 +1,156 @@
|
||||
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
|
||||
|
||||
export class CreateInventoryMovements1768200001000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'CreateInventoryMovements1768200001000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Create movement_type enum
|
||||
await queryRunner.query(`
|
||||
CREATE TYPE "movement_type_enum" AS ENUM (
|
||||
'DETECTION',
|
||||
'MANUAL_ADJUST',
|
||||
'SALE',
|
||||
'PURCHASE',
|
||||
'CORRECTION',
|
||||
'INITIAL',
|
||||
'POS_SYNC'
|
||||
)
|
||||
`);
|
||||
|
||||
// Create trigger_type enum
|
||||
await queryRunner.query(`
|
||||
CREATE TYPE "trigger_type_enum" AS ENUM (
|
||||
'USER',
|
||||
'VIDEO',
|
||||
'POS',
|
||||
'SYSTEM'
|
||||
)
|
||||
`);
|
||||
|
||||
// Create inventory_movements table
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'inventory_movements',
|
||||
columns: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'uuid',
|
||||
isPrimary: true,
|
||||
generationStrategy: 'uuid',
|
||||
default: 'uuid_generate_v4()',
|
||||
},
|
||||
{
|
||||
name: 'inventoryItemId',
|
||||
type: 'uuid',
|
||||
},
|
||||
{
|
||||
name: 'storeId',
|
||||
type: 'uuid',
|
||||
},
|
||||
{
|
||||
name: 'type',
|
||||
type: 'movement_type_enum',
|
||||
},
|
||||
{
|
||||
name: 'quantityBefore',
|
||||
type: 'int',
|
||||
},
|
||||
{
|
||||
name: 'quantityAfter',
|
||||
type: 'int',
|
||||
},
|
||||
{
|
||||
name: 'quantityChange',
|
||||
type: 'int',
|
||||
},
|
||||
{
|
||||
name: 'reason',
|
||||
type: 'varchar',
|
||||
length: '255',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'triggeredById',
|
||||
type: 'uuid',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'triggerType',
|
||||
type: 'trigger_type_enum',
|
||||
default: `'SYSTEM'`,
|
||||
},
|
||||
{
|
||||
name: 'referenceId',
|
||||
type: 'uuid',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'referenceType',
|
||||
type: 'varchar',
|
||||
length: '50',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'createdAt',
|
||||
type: 'timestamp',
|
||||
default: 'CURRENT_TIMESTAMP',
|
||||
},
|
||||
],
|
||||
foreignKeys: [
|
||||
{
|
||||
columnNames: ['inventoryItemId'],
|
||||
referencedTableName: 'inventory_items',
|
||||
referencedColumnNames: ['id'],
|
||||
onDelete: 'CASCADE',
|
||||
},
|
||||
{
|
||||
columnNames: ['storeId'],
|
||||
referencedTableName: 'stores',
|
||||
referencedColumnNames: ['id'],
|
||||
onDelete: 'CASCADE',
|
||||
},
|
||||
{
|
||||
columnNames: ['triggeredById'],
|
||||
referencedTableName: 'users',
|
||||
referencedColumnNames: ['id'],
|
||||
onDelete: 'SET NULL',
|
||||
},
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
// Create indexes
|
||||
await queryRunner.createIndex(
|
||||
'inventory_movements',
|
||||
new TableIndex({
|
||||
name: 'IDX_inventory_movements_store_created',
|
||||
columnNames: ['storeId', 'createdAt'],
|
||||
}),
|
||||
);
|
||||
|
||||
await queryRunner.createIndex(
|
||||
'inventory_movements',
|
||||
new TableIndex({
|
||||
name: 'IDX_inventory_movements_item_created',
|
||||
columnNames: ['inventoryItemId', 'createdAt'],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropIndex(
|
||||
'inventory_movements',
|
||||
'IDX_inventory_movements_item_created',
|
||||
);
|
||||
await queryRunner.dropIndex(
|
||||
'inventory_movements',
|
||||
'IDX_inventory_movements_store_created',
|
||||
);
|
||||
await queryRunner.dropTable('inventory_movements');
|
||||
await queryRunner.query('DROP TYPE "trigger_type_enum"');
|
||||
await queryRunner.query('DROP TYPE "movement_type_enum"');
|
||||
}
|
||||
}
|
||||
261
src/migrations/1768200002000-CreatePosIntegrations.ts
Normal file
261
src/migrations/1768200002000-CreatePosIntegrations.ts
Normal file
@ -0,0 +1,261 @@
|
||||
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
|
||||
|
||||
export class CreatePosIntegrations1768200002000 implements MigrationInterface {
|
||||
name = 'CreatePosIntegrations1768200002000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Create pos_provider enum
|
||||
await queryRunner.query(`
|
||||
CREATE TYPE "pos_provider_enum" AS ENUM (
|
||||
'SQUARE',
|
||||
'SHOPIFY',
|
||||
'CLOVER',
|
||||
'LIGHTSPEED',
|
||||
'TOAST',
|
||||
'CUSTOM'
|
||||
)
|
||||
`);
|
||||
|
||||
// Create sync_direction enum
|
||||
await queryRunner.query(`
|
||||
CREATE TYPE "sync_direction_enum" AS ENUM (
|
||||
'POS_TO_INVENTORY',
|
||||
'INVENTORY_TO_POS',
|
||||
'BIDIRECTIONAL'
|
||||
)
|
||||
`);
|
||||
|
||||
// Create sync_log_type enum
|
||||
await queryRunner.query(`
|
||||
CREATE TYPE "sync_log_type_enum" AS ENUM (
|
||||
'WEBHOOK_RECEIVED',
|
||||
'MANUAL_SYNC',
|
||||
'SCHEDULED_SYNC'
|
||||
)
|
||||
`);
|
||||
|
||||
// Create sync_log_status enum
|
||||
await queryRunner.query(`
|
||||
CREATE TYPE "sync_log_status_enum" AS ENUM (
|
||||
'SUCCESS',
|
||||
'PARTIAL',
|
||||
'FAILED'
|
||||
)
|
||||
`);
|
||||
|
||||
// Create pos_integrations table
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'pos_integrations',
|
||||
columns: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'uuid',
|
||||
isPrimary: true,
|
||||
generationStrategy: 'uuid',
|
||||
default: 'uuid_generate_v4()',
|
||||
},
|
||||
{
|
||||
name: 'storeId',
|
||||
type: 'uuid',
|
||||
},
|
||||
{
|
||||
name: 'provider',
|
||||
type: 'pos_provider_enum',
|
||||
},
|
||||
{
|
||||
name: 'displayName',
|
||||
type: 'varchar',
|
||||
length: '255',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'credentials',
|
||||
type: 'jsonb',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'webhookSecret',
|
||||
type: 'varchar',
|
||||
length: '255',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'webhookUrl',
|
||||
type: 'varchar',
|
||||
length: '500',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'isActive',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
name: 'syncEnabled',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
name: 'syncDirection',
|
||||
type: 'sync_direction_enum',
|
||||
default: `'POS_TO_INVENTORY'`,
|
||||
},
|
||||
{
|
||||
name: 'syncConfig',
|
||||
type: 'jsonb',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'lastSyncAt',
|
||||
type: 'timestamp',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'lastSyncStatus',
|
||||
type: 'varchar',
|
||||
length: '255',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'syncErrorCount',
|
||||
type: 'int',
|
||||
default: 0,
|
||||
},
|
||||
{
|
||||
name: 'createdAt',
|
||||
type: 'timestamp',
|
||||
default: 'CURRENT_TIMESTAMP',
|
||||
},
|
||||
{
|
||||
name: 'updatedAt',
|
||||
type: 'timestamp',
|
||||
default: 'CURRENT_TIMESTAMP',
|
||||
},
|
||||
],
|
||||
foreignKeys: [
|
||||
{
|
||||
columnNames: ['storeId'],
|
||||
referencedTableName: 'stores',
|
||||
referencedColumnNames: ['id'],
|
||||
onDelete: 'CASCADE',
|
||||
},
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
// Create unique index
|
||||
await queryRunner.createIndex(
|
||||
'pos_integrations',
|
||||
new TableIndex({
|
||||
name: 'IDX_pos_integrations_store_provider',
|
||||
columnNames: ['storeId', 'provider'],
|
||||
isUnique: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Create pos_sync_logs table
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'pos_sync_logs',
|
||||
columns: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'uuid',
|
||||
isPrimary: true,
|
||||
generationStrategy: 'uuid',
|
||||
default: 'uuid_generate_v4()',
|
||||
},
|
||||
{
|
||||
name: 'integrationId',
|
||||
type: 'uuid',
|
||||
},
|
||||
{
|
||||
name: 'type',
|
||||
type: 'sync_log_type_enum',
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
type: 'sync_log_status_enum',
|
||||
},
|
||||
{
|
||||
name: 'itemsProcessed',
|
||||
type: 'int',
|
||||
default: 0,
|
||||
},
|
||||
{
|
||||
name: 'itemsCreated',
|
||||
type: 'int',
|
||||
default: 0,
|
||||
},
|
||||
{
|
||||
name: 'itemsUpdated',
|
||||
type: 'int',
|
||||
default: 0,
|
||||
},
|
||||
{
|
||||
name: 'itemsSkipped',
|
||||
type: 'int',
|
||||
default: 0,
|
||||
},
|
||||
{
|
||||
name: 'itemsFailed',
|
||||
type: 'int',
|
||||
default: 0,
|
||||
},
|
||||
{
|
||||
name: 'details',
|
||||
type: 'jsonb',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'errorMessage',
|
||||
type: 'text',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'createdAt',
|
||||
type: 'timestamp',
|
||||
default: 'CURRENT_TIMESTAMP',
|
||||
},
|
||||
],
|
||||
foreignKeys: [
|
||||
{
|
||||
columnNames: ['integrationId'],
|
||||
referencedTableName: 'pos_integrations',
|
||||
referencedColumnNames: ['id'],
|
||||
onDelete: 'CASCADE',
|
||||
},
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
// Create index for sync logs
|
||||
await queryRunner.createIndex(
|
||||
'pos_sync_logs',
|
||||
new TableIndex({
|
||||
name: 'IDX_pos_sync_logs_integration_created',
|
||||
columnNames: ['integrationId', 'createdAt'],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropIndex(
|
||||
'pos_sync_logs',
|
||||
'IDX_pos_sync_logs_integration_created',
|
||||
);
|
||||
await queryRunner.dropTable('pos_sync_logs');
|
||||
await queryRunner.dropIndex(
|
||||
'pos_integrations',
|
||||
'IDX_pos_integrations_store_provider',
|
||||
);
|
||||
await queryRunner.dropTable('pos_integrations');
|
||||
await queryRunner.query('DROP TYPE "sync_log_status_enum"');
|
||||
await queryRunner.query('DROP TYPE "sync_log_type_enum"');
|
||||
await queryRunner.query('DROP TYPE "sync_direction_enum"');
|
||||
await queryRunner.query('DROP TYPE "pos_provider_enum"');
|
||||
}
|
||||
}
|
||||
248
src/modules/admin/admin.controller.ts
Normal file
248
src/modules/admin/admin.controller.ts
Normal file
@ -0,0 +1,248 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Req,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../../common/guards/roles.guard';
|
||||
import { Roles } from '../../common/decorators/roles.decorator';
|
||||
import { UserRole } from '../users/entities/user.entity';
|
||||
import { AdminDashboardService } from './services/admin-dashboard.service';
|
||||
import { AdminProvidersService } from './services/admin-providers.service';
|
||||
import { AdminPackagesService } from './services/admin-packages.service';
|
||||
import { AdminPromotionsService } from './services/admin-promotions.service';
|
||||
import { AdminModerationService } from './services/admin-moderation.service';
|
||||
import { DashboardQueryDto, DashboardPeriod } from './dto/dashboard.dto';
|
||||
import { UpdateProviderDto } from './dto/provider.dto';
|
||||
import { CreatePackageDto, UpdatePackageDto } from './dto/package.dto';
|
||||
import { CreatePromotionDto, UpdatePromotionDto, ValidatePromoCodeDto } from './dto/promotion.dto';
|
||||
import { ApproveProductDto, RejectProductDto, ApproveReferralDto, RejectReferralDto } from './dto/moderation.dto';
|
||||
|
||||
interface AuthRequest extends Request {
|
||||
user: { id: string; role: string };
|
||||
}
|
||||
|
||||
@Controller('admin')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
export class AdminController {
|
||||
constructor(
|
||||
private readonly dashboardService: AdminDashboardService,
|
||||
private readonly providersService: AdminProvidersService,
|
||||
private readonly packagesService: AdminPackagesService,
|
||||
private readonly promotionsService: AdminPromotionsService,
|
||||
private readonly moderationService: AdminModerationService,
|
||||
) {}
|
||||
|
||||
// Dashboard Endpoints
|
||||
|
||||
@Get('dashboard')
|
||||
@Roles(UserRole.VIEWER)
|
||||
async getDashboard(@Query() query: DashboardQueryDto) {
|
||||
const startDate = query.startDate ? new Date(query.startDate) : undefined;
|
||||
const endDate = query.endDate ? new Date(query.endDate) : undefined;
|
||||
|
||||
const metrics = await this.dashboardService.getDashboardMetrics(startDate, endDate);
|
||||
return { metrics };
|
||||
}
|
||||
|
||||
@Get('dashboard/revenue-series')
|
||||
@Roles(UserRole.ADMIN)
|
||||
async getRevenueSeries(@Query() query: DashboardQueryDto) {
|
||||
const now = new Date();
|
||||
const startDate = query.startDate
|
||||
? new Date(query.startDate)
|
||||
: new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
||||
const endDate = query.endDate ? new Date(query.endDate) : now;
|
||||
const period = query.period || DashboardPeriod.DAY;
|
||||
|
||||
const series = await this.dashboardService.getRevenueSeries(startDate, endDate, period);
|
||||
return { series };
|
||||
}
|
||||
|
||||
// IA Providers Endpoints
|
||||
|
||||
@Get('providers')
|
||||
@Roles(UserRole.ADMIN)
|
||||
async getProviders() {
|
||||
const providers = await this.providersService.findAll();
|
||||
return { providers };
|
||||
}
|
||||
|
||||
@Patch('providers/:id')
|
||||
@Roles(UserRole.SUPER_ADMIN)
|
||||
async updateProvider(
|
||||
@Req() req: AuthRequest,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateProviderDto,
|
||||
) {
|
||||
const provider = await this.providersService.update(id, dto, req.user.id);
|
||||
return {
|
||||
message: 'Proveedor actualizado exitosamente',
|
||||
provider,
|
||||
};
|
||||
}
|
||||
|
||||
// Credit Packages Endpoints
|
||||
|
||||
@Get('packages')
|
||||
@Roles(UserRole.ADMIN)
|
||||
async getPackages(@Query('includeInactive') includeInactive?: string) {
|
||||
const packages = await this.packagesService.findAll(includeInactive === 'true');
|
||||
return { packages };
|
||||
}
|
||||
|
||||
@Post('packages')
|
||||
@Roles(UserRole.ADMIN)
|
||||
async createPackage(@Req() req: AuthRequest, @Body() dto: CreatePackageDto) {
|
||||
const pkg = await this.packagesService.create(dto, req.user.id);
|
||||
return {
|
||||
message: 'Paquete creado exitosamente',
|
||||
package: pkg,
|
||||
};
|
||||
}
|
||||
|
||||
@Patch('packages/:id')
|
||||
@Roles(UserRole.ADMIN)
|
||||
async updatePackage(
|
||||
@Req() req: AuthRequest,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdatePackageDto,
|
||||
) {
|
||||
const pkg = await this.packagesService.update(id, dto, req.user.id);
|
||||
return {
|
||||
message: 'Paquete actualizado exitosamente',
|
||||
package: pkg,
|
||||
};
|
||||
}
|
||||
|
||||
// Promotions Endpoints
|
||||
|
||||
@Get('promotions')
|
||||
@Roles(UserRole.ADMIN)
|
||||
async getPromotions(@Query('includeExpired') includeExpired?: string) {
|
||||
const promotions = await this.promotionsService.findAll(includeExpired === 'true');
|
||||
return { promotions };
|
||||
}
|
||||
|
||||
@Post('promotions')
|
||||
@Roles(UserRole.ADMIN)
|
||||
async createPromotion(@Req() req: AuthRequest, @Body() dto: CreatePromotionDto) {
|
||||
const promotion = await this.promotionsService.create(dto, req.user.id);
|
||||
return {
|
||||
message: 'Promocion creada exitosamente',
|
||||
promotion,
|
||||
};
|
||||
}
|
||||
|
||||
@Patch('promotions/:id')
|
||||
@Roles(UserRole.ADMIN)
|
||||
async updatePromotion(
|
||||
@Req() req: AuthRequest,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdatePromotionDto,
|
||||
) {
|
||||
const promotion = await this.promotionsService.update(id, dto, req.user.id);
|
||||
return {
|
||||
message: 'Promocion actualizada exitosamente',
|
||||
promotion,
|
||||
};
|
||||
}
|
||||
|
||||
@Post('promotions/validate')
|
||||
@Roles(UserRole.VIEWER)
|
||||
async validatePromoCode(@Body() dto: ValidatePromoCodeDto) {
|
||||
return this.promotionsService.validateCode(dto.code, dto.packageId, dto.purchaseAmount);
|
||||
}
|
||||
|
||||
// Product Moderation Endpoints
|
||||
|
||||
@Get('products/pending')
|
||||
@Roles(UserRole.MODERATOR)
|
||||
async getPendingProducts(
|
||||
@Query('page') page?: string,
|
||||
@Query('limit') limit?: string,
|
||||
) {
|
||||
return this.moderationService.getPendingProducts(
|
||||
page ? parseInt(page, 10) : 1,
|
||||
limit ? parseInt(limit, 10) : 20,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('products/:id/approve')
|
||||
@Roles(UserRole.MODERATOR)
|
||||
async approveProduct(
|
||||
@Req() req: AuthRequest,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: ApproveProductDto,
|
||||
) {
|
||||
const product = await this.moderationService.approveProduct(id, dto, req.user.id);
|
||||
return {
|
||||
message: 'Producto aprobado exitosamente',
|
||||
product,
|
||||
};
|
||||
}
|
||||
|
||||
@Post('products/:id/reject')
|
||||
@Roles(UserRole.MODERATOR)
|
||||
async rejectProduct(
|
||||
@Req() req: AuthRequest,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RejectProductDto,
|
||||
) {
|
||||
const product = await this.moderationService.rejectProduct(id, dto, req.user.id);
|
||||
return {
|
||||
message: 'Producto rechazado',
|
||||
product,
|
||||
};
|
||||
}
|
||||
|
||||
// Referral Fraud Moderation Endpoints
|
||||
|
||||
@Get('referrals/fraud-holds')
|
||||
@Roles(UserRole.MODERATOR)
|
||||
async getFraudHoldReferrals(
|
||||
@Query('page') page?: string,
|
||||
@Query('limit') limit?: string,
|
||||
) {
|
||||
return this.moderationService.getFraudHoldReferrals(
|
||||
page ? parseInt(page, 10) : 1,
|
||||
limit ? parseInt(limit, 10) : 20,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('referrals/:id/approve')
|
||||
@Roles(UserRole.MODERATOR)
|
||||
async approveReferral(
|
||||
@Req() req: AuthRequest,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: ApproveReferralDto,
|
||||
) {
|
||||
const referral = await this.moderationService.approveReferral(id, dto, req.user.id);
|
||||
return {
|
||||
message: 'Referido aprobado exitosamente',
|
||||
referral,
|
||||
};
|
||||
}
|
||||
|
||||
@Post('referrals/:id/reject')
|
||||
@Roles(UserRole.MODERATOR)
|
||||
async rejectReferral(
|
||||
@Req() req: AuthRequest,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RejectReferralDto,
|
||||
) {
|
||||
const referral = await this.moderationService.rejectReferral(id, dto, req.user.id);
|
||||
return {
|
||||
message: 'Referido rechazado por fraude',
|
||||
referral,
|
||||
};
|
||||
}
|
||||
}
|
||||
53
src/modules/admin/admin.module.ts
Normal file
53
src/modules/admin/admin.module.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AdminController } from './admin.controller';
|
||||
import { AdminDashboardService } from './services/admin-dashboard.service';
|
||||
import { AdminProvidersService } from './services/admin-providers.service';
|
||||
import { AdminPackagesService } from './services/admin-packages.service';
|
||||
import { AdminPromotionsService } from './services/admin-promotions.service';
|
||||
import { AdminModerationService } from './services/admin-moderation.service';
|
||||
import { AuditLogService } from './services/audit-log.service';
|
||||
import { IaProvider } from './entities/ia-provider.entity';
|
||||
import { Promotion } from './entities/promotion.entity';
|
||||
import { AuditLog } from './entities/audit-log.entity';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
import { Store } from '../stores/entities/store.entity';
|
||||
import { Video } from '../videos/entities/video.entity';
|
||||
import { Payment } from '../payments/entities/payment.entity';
|
||||
import { CreditPackage } from '../credits/entities/credit-package.entity';
|
||||
import { CreditTransaction } from '../credits/entities/credit-transaction.entity';
|
||||
import { ProductSubmission } from '../feedback/entities/product-submission.entity';
|
||||
import { Referral } from '../referrals/entities/referral.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
IaProvider,
|
||||
Promotion,
|
||||
AuditLog,
|
||||
User,
|
||||
Store,
|
||||
Video,
|
||||
Payment,
|
||||
CreditPackage,
|
||||
CreditTransaction,
|
||||
ProductSubmission,
|
||||
Referral,
|
||||
]),
|
||||
],
|
||||
controllers: [AdminController],
|
||||
providers: [
|
||||
AdminDashboardService,
|
||||
AdminProvidersService,
|
||||
AdminPackagesService,
|
||||
AdminPromotionsService,
|
||||
AdminModerationService,
|
||||
AuditLogService,
|
||||
],
|
||||
exports: [
|
||||
AdminProvidersService,
|
||||
AdminPromotionsService,
|
||||
AuditLogService,
|
||||
],
|
||||
})
|
||||
export class AdminModule {}
|
||||
66
src/modules/admin/dto/dashboard.dto.ts
Normal file
66
src/modules/admin/dto/dashboard.dto.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { IsOptional, IsDateString, IsEnum } from 'class-validator';
|
||||
|
||||
export enum DashboardPeriod {
|
||||
DAY = 'day',
|
||||
WEEK = 'week',
|
||||
MONTH = 'month',
|
||||
YEAR = 'year',
|
||||
}
|
||||
|
||||
export class DashboardQueryDto {
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
startDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
endDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(DashboardPeriod)
|
||||
period?: DashboardPeriod;
|
||||
}
|
||||
|
||||
export interface DashboardMetrics {
|
||||
users: {
|
||||
total: number;
|
||||
mau: number;
|
||||
dau: number;
|
||||
newThisPeriod: number;
|
||||
};
|
||||
stores: {
|
||||
total: number;
|
||||
activeThisPeriod: number;
|
||||
};
|
||||
videos: {
|
||||
total: number;
|
||||
processedThisPeriod: number;
|
||||
averageProcessingTime: number;
|
||||
};
|
||||
revenue: {
|
||||
total: number;
|
||||
thisPeriod: number;
|
||||
byPackage: { packageId: string; name: string; amount: number }[];
|
||||
};
|
||||
cogs: {
|
||||
total: number;
|
||||
thisPeriod: number;
|
||||
byProvider: { providerId: string; name: string; cost: number }[];
|
||||
};
|
||||
margin: {
|
||||
gross: number;
|
||||
percentage: number;
|
||||
};
|
||||
credits: {
|
||||
purchased: number;
|
||||
used: number;
|
||||
gifted: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RevenueSeriesPoint {
|
||||
date: string;
|
||||
revenue: number;
|
||||
cogs: number;
|
||||
margin: number;
|
||||
}
|
||||
39
src/modules/admin/dto/moderation.dto.ts
Normal file
39
src/modules/admin/dto/moderation.dto.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { IsString, IsOptional, IsUUID, Length } from 'class-validator';
|
||||
|
||||
export class ApproveProductDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(0, 255)
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export class RejectProductDto {
|
||||
@IsString()
|
||||
@Length(2, 500)
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export class ApproveReferralDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(0, 255)
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class RejectReferralDto {
|
||||
@IsString()
|
||||
@Length(2, 500)
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export class PaginationQueryDto {
|
||||
@IsOptional()
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
limit?: number;
|
||||
}
|
||||
80
src/modules/admin/dto/package.dto.ts
Normal file
80
src/modules/admin/dto/package.dto.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import { IsString, IsNumber, IsBoolean, IsOptional, Min, Max, Length } from 'class-validator';
|
||||
|
||||
export class CreatePackageDto {
|
||||
@IsString()
|
||||
@Length(2, 100)
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(0, 500)
|
||||
description?: string;
|
||||
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
credits: number;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
priceMxn: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
priceUsd?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isPopular?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
discountPercentage?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class UpdatePackageDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(2, 100)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(0, 500)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
credits?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
priceMxn?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
priceUsd?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isPopular?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
discountPercentage?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
137
src/modules/admin/dto/promotion.dto.ts
Normal file
137
src/modules/admin/dto/promotion.dto.ts
Normal file
@ -0,0 +1,137 @@
|
||||
import {
|
||||
IsString,
|
||||
IsNumber,
|
||||
IsBoolean,
|
||||
IsOptional,
|
||||
IsEnum,
|
||||
IsDateString,
|
||||
IsArray,
|
||||
IsUUID,
|
||||
Min,
|
||||
Max,
|
||||
Length,
|
||||
} from 'class-validator';
|
||||
import { PromotionType } from '../entities/promotion.entity';
|
||||
|
||||
export class CreatePromotionDto {
|
||||
@IsString()
|
||||
@Length(2, 100)
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@Length(2, 50)
|
||||
code: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsEnum(PromotionType)
|
||||
type: PromotionType;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
value: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
minPurchaseAmount?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxDiscount?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
usageLimit?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
perUserLimit?: number;
|
||||
|
||||
@IsDateString()
|
||||
startsAt: string;
|
||||
|
||||
@IsDateString()
|
||||
endsAt: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
applicablePackageIds?: string[];
|
||||
}
|
||||
|
||||
export class UpdatePromotionDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(2, 100)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
value?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
minPurchaseAmount?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxDiscount?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
usageLimit?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
perUserLimit?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
startsAt?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
endsAt?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
applicablePackageIds?: string[];
|
||||
}
|
||||
|
||||
export class ValidatePromoCodeDto {
|
||||
@IsString()
|
||||
code: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
packageId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
purchaseAmount?: number;
|
||||
}
|
||||
42
src/modules/admin/dto/provider.dto.ts
Normal file
42
src/modules/admin/dto/provider.dto.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { IsString, IsNumber, IsBoolean, IsOptional, Min } from 'class-validator';
|
||||
|
||||
export class UpdateProviderDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
costPerFrame?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
costPerToken?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isDefault?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
config?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ProviderUsageDto {
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
totalRequests: number;
|
||||
totalFrames: number;
|
||||
totalTokens: number;
|
||||
totalCost: number;
|
||||
period: string;
|
||||
}
|
||||
51
src/modules/admin/entities/audit-log.entity.ts
Normal file
51
src/modules/admin/entities/audit-log.entity.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
|
||||
@Entity('audit_logs')
|
||||
@Index(['userId'])
|
||||
@Index(['action'])
|
||||
@Index(['resource', 'resourceId'])
|
||||
@Index(['createdAt'])
|
||||
export class AuditLog {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
userId: string;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'userId' })
|
||||
user: User;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
action: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
resource: string;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
resourceId: string;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
previousValue: Record<string, any>;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
newValue: Record<string, any>;
|
||||
|
||||
@Column({ type: 'varchar', length: 45, nullable: true })
|
||||
ipAddress: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
userAgent: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
46
src/modules/admin/entities/ia-provider.entity.ts
Normal file
46
src/modules/admin/entities/ia-provider.entity.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('ia_providers')
|
||||
@Index(['code'])
|
||||
@Index(['isActive'])
|
||||
export class IaProvider {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, unique: true })
|
||||
code: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string;
|
||||
|
||||
@Column({ type: 'numeric', precision: 10, scale: 6, default: 0 })
|
||||
costPerFrame: number;
|
||||
|
||||
@Column({ type: 'numeric', precision: 10, scale: 8, default: 0 })
|
||||
costPerToken: number;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isDefault: boolean;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
config: Record<string, any>;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
90
src/modules/admin/entities/promotion.entity.ts
Normal file
90
src/modules/admin/entities/promotion.entity.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
|
||||
export enum PromotionType {
|
||||
PERCENTAGE = 'PERCENTAGE',
|
||||
FIXED_CREDITS = 'FIXED_CREDITS',
|
||||
MULTIPLIER = 'MULTIPLIER',
|
||||
}
|
||||
|
||||
@Entity('promotions')
|
||||
@Index(['code'])
|
||||
@Index(['isActive', 'startsAt', 'endsAt'])
|
||||
export class Promotion {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, unique: true })
|
||||
code: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description: string;
|
||||
|
||||
@Column({ type: 'enum', enum: PromotionType })
|
||||
type: PromotionType;
|
||||
|
||||
@Column({ type: 'numeric', precision: 10, scale: 2 })
|
||||
value: number;
|
||||
|
||||
@Column({ type: 'numeric', precision: 10, scale: 2, nullable: true })
|
||||
minPurchaseAmount: number;
|
||||
|
||||
@Column({ type: 'numeric', precision: 10, scale: 2, nullable: true })
|
||||
maxDiscount: number;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
usageLimit: number;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
usageCount: number;
|
||||
|
||||
@Column({ type: 'int', nullable: true, default: 1 })
|
||||
perUserLimit: number;
|
||||
|
||||
@Column({ type: 'timestamp' })
|
||||
startsAt: Date;
|
||||
|
||||
@Column({ type: 'timestamp' })
|
||||
endsAt: Date;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@Column({ type: 'uuid', array: true, nullable: true })
|
||||
applicablePackageIds: string[];
|
||||
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
createdBy: string;
|
||||
|
||||
@ManyToOne(() => User, { nullable: true })
|
||||
@JoinColumn({ name: 'createdBy' })
|
||||
creator: User;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
|
||||
isValid(): boolean {
|
||||
const now = new Date();
|
||||
return (
|
||||
this.isActive &&
|
||||
this.startsAt <= now &&
|
||||
this.endsAt >= now &&
|
||||
(this.usageLimit === null || this.usageCount < this.usageLimit)
|
||||
);
|
||||
}
|
||||
}
|
||||
206
src/modules/admin/services/admin-dashboard.service.ts
Normal file
206
src/modules/admin/services/admin-dashboard.service.ts
Normal file
@ -0,0 +1,206 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, Between, MoreThanOrEqual } from 'typeorm';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
import { Store } from '../../stores/entities/store.entity';
|
||||
import { Video } from '../../videos/entities/video.entity';
|
||||
import { Payment, PaymentStatus } from '../../payments/entities/payment.entity';
|
||||
import { CreditTransaction, TransactionType } from '../../credits/entities/credit-transaction.entity';
|
||||
import { DashboardMetrics, RevenueSeriesPoint, DashboardPeriod } from '../dto/dashboard.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AdminDashboardService {
|
||||
constructor(
|
||||
@InjectRepository(User)
|
||||
private userRepository: Repository<User>,
|
||||
@InjectRepository(Store)
|
||||
private storeRepository: Repository<Store>,
|
||||
@InjectRepository(Video)
|
||||
private videoRepository: Repository<Video>,
|
||||
@InjectRepository(Payment)
|
||||
private paymentRepository: Repository<Payment>,
|
||||
@InjectRepository(CreditTransaction)
|
||||
private creditTransactionRepository: Repository<CreditTransaction>,
|
||||
) {}
|
||||
|
||||
async getDashboardMetrics(
|
||||
startDate?: Date,
|
||||
endDate?: Date,
|
||||
): Promise<DashboardMetrics> {
|
||||
const now = new Date();
|
||||
const start = startDate || new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const end = endDate || now;
|
||||
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
|
||||
const [
|
||||
totalUsers,
|
||||
newUsers,
|
||||
totalStores,
|
||||
activeStores,
|
||||
totalVideos,
|
||||
processedVideos,
|
||||
payments,
|
||||
creditTransactions,
|
||||
mauCount,
|
||||
dauCount,
|
||||
] = await Promise.all([
|
||||
this.userRepository.count(),
|
||||
this.userRepository.count({
|
||||
where: { createdAt: Between(start, end) },
|
||||
}),
|
||||
this.storeRepository.count(),
|
||||
this.storeRepository
|
||||
.createQueryBuilder('store')
|
||||
.innerJoin('videos', 'video', 'video.storeId = store.id')
|
||||
.where('video.createdAt >= :start', { start })
|
||||
.getCount(),
|
||||
this.videoRepository.count(),
|
||||
this.videoRepository.count({
|
||||
where: { createdAt: Between(start, end) },
|
||||
}),
|
||||
this.paymentRepository.find({
|
||||
where: {
|
||||
status: PaymentStatus.COMPLETED,
|
||||
createdAt: Between(start, end),
|
||||
},
|
||||
relations: ['package'],
|
||||
}),
|
||||
this.creditTransactionRepository.find({
|
||||
where: { createdAt: Between(start, end) },
|
||||
}),
|
||||
this.getActiveUsersCount(thirtyDaysAgo),
|
||||
this.getActiveUsersCount(oneDayAgo),
|
||||
]);
|
||||
|
||||
const revenue = payments.reduce((sum, p) => sum + Number(p.amountMXN), 0);
|
||||
const revenueByPackage = this.groupRevenueByPackage(payments);
|
||||
|
||||
const purchased = creditTransactions
|
||||
.filter(t => t.type === TransactionType.PURCHASE)
|
||||
.reduce((sum, t) => sum + t.amount, 0);
|
||||
const used = creditTransactions
|
||||
.filter(t => t.type === TransactionType.CONSUMPTION)
|
||||
.reduce((sum, t) => sum + Math.abs(t.amount), 0);
|
||||
const gifted = creditTransactions
|
||||
.filter(t => [TransactionType.REFERRAL_BONUS, TransactionType.PROMO].includes(t.type))
|
||||
.reduce((sum, t) => sum + t.amount, 0);
|
||||
|
||||
// Estimate COGS (placeholder - would integrate with IA provider usage)
|
||||
const estimatedCogs = revenue * 0.15; // 15% estimate
|
||||
|
||||
return {
|
||||
users: {
|
||||
total: totalUsers,
|
||||
mau: mauCount,
|
||||
dau: dauCount,
|
||||
newThisPeriod: newUsers,
|
||||
},
|
||||
stores: {
|
||||
total: totalStores,
|
||||
activeThisPeriod: activeStores,
|
||||
},
|
||||
videos: {
|
||||
total: totalVideos,
|
||||
processedThisPeriod: processedVideos,
|
||||
averageProcessingTime: 0, // Would calculate from video metadata
|
||||
},
|
||||
revenue: {
|
||||
total: revenue,
|
||||
thisPeriod: revenue,
|
||||
byPackage: revenueByPackage,
|
||||
},
|
||||
cogs: {
|
||||
total: estimatedCogs,
|
||||
thisPeriod: estimatedCogs,
|
||||
byProvider: [], // Would integrate with IA provider usage tracking
|
||||
},
|
||||
margin: {
|
||||
gross: revenue - estimatedCogs,
|
||||
percentage: revenue > 0 ? ((revenue - estimatedCogs) / revenue) * 100 : 0,
|
||||
},
|
||||
credits: {
|
||||
purchased,
|
||||
used,
|
||||
gifted,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getRevenueSeries(
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
period: DashboardPeriod = DashboardPeriod.DAY,
|
||||
): Promise<RevenueSeriesPoint[]> {
|
||||
const payments = await this.paymentRepository.find({
|
||||
where: {
|
||||
status: PaymentStatus.COMPLETED,
|
||||
createdAt: Between(startDate, endDate),
|
||||
},
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
|
||||
const groupedData = new Map<string, { revenue: number; cogs: number }>();
|
||||
|
||||
for (const payment of payments) {
|
||||
const dateKey = this.getDateKey(payment.createdAt, period);
|
||||
const existing = groupedData.get(dateKey) || { revenue: 0, cogs: 0 };
|
||||
existing.revenue += Number(payment.amountMXN);
|
||||
existing.cogs += Number(payment.amountMXN) * 0.15; // Estimated COGS
|
||||
groupedData.set(dateKey, existing);
|
||||
}
|
||||
|
||||
return Array.from(groupedData.entries()).map(([date, data]) => ({
|
||||
date,
|
||||
revenue: data.revenue,
|
||||
cogs: data.cogs,
|
||||
margin: data.revenue - data.cogs,
|
||||
}));
|
||||
}
|
||||
|
||||
private async getActiveUsersCount(since: Date): Promise<number> {
|
||||
const result = await this.videoRepository
|
||||
.createQueryBuilder('video')
|
||||
.select('COUNT(DISTINCT video.uploadedById)', 'count')
|
||||
.where('video.createdAt >= :since', { since })
|
||||
.getRawOne();
|
||||
|
||||
return parseInt(result?.count || '0', 10);
|
||||
}
|
||||
|
||||
private groupRevenueByPackage(payments: Payment[]): { packageId: string; name: string; amount: number }[] {
|
||||
const grouped = new Map<string, { name: string; amount: number }>();
|
||||
|
||||
for (const payment of payments) {
|
||||
if (payment.packageId && payment.package) {
|
||||
const existing = grouped.get(payment.packageId) || { name: payment.package.name, amount: 0 };
|
||||
existing.amount += Number(payment.amountMXN);
|
||||
grouped.set(payment.packageId, existing);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(grouped.entries()).map(([packageId, data]) => ({
|
||||
packageId,
|
||||
name: data.name,
|
||||
amount: data.amount,
|
||||
}));
|
||||
}
|
||||
|
||||
private getDateKey(date: Date, period: DashboardPeriod): string {
|
||||
switch (period) {
|
||||
case DashboardPeriod.DAY:
|
||||
return date.toISOString().split('T')[0];
|
||||
case DashboardPeriod.WEEK:
|
||||
const weekStart = new Date(date);
|
||||
weekStart.setDate(date.getDate() - date.getDay());
|
||||
return weekStart.toISOString().split('T')[0];
|
||||
case DashboardPeriod.MONTH:
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||
case DashboardPeriod.YEAR:
|
||||
return String(date.getFullYear());
|
||||
default:
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
264
src/modules/admin/services/admin-moderation.service.ts
Normal file
264
src/modules/admin/services/admin-moderation.service.ts
Normal file
@ -0,0 +1,264 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ProductSubmission } from '../../feedback/entities/product-submission.entity';
|
||||
import { Referral, ReferralStatus } from '../../referrals/entities/referral.entity';
|
||||
import { AuditLogService } from './audit-log.service';
|
||||
import { ApproveProductDto, RejectProductDto, ApproveReferralDto, RejectReferralDto } from '../dto/moderation.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AdminModerationService {
|
||||
constructor(
|
||||
@InjectRepository(ProductSubmission)
|
||||
private productSubmissionRepository: Repository<ProductSubmission>,
|
||||
@InjectRepository(Referral)
|
||||
private referralRepository: Repository<Referral>,
|
||||
private auditLogService: AuditLogService,
|
||||
) {}
|
||||
|
||||
// Product Moderation
|
||||
|
||||
async getPendingProducts(page = 1, limit = 20): Promise<{
|
||||
products: ProductSubmission[];
|
||||
total: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
}> {
|
||||
const [products, total] = await this.productSubmissionRepository.findAndCount({
|
||||
where: { status: 'PENDING' as any },
|
||||
order: { createdAt: 'ASC' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
relations: ['user', 'store'],
|
||||
});
|
||||
|
||||
return {
|
||||
products,
|
||||
total,
|
||||
page,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
async approveProduct(
|
||||
id: string,
|
||||
dto: ApproveProductDto,
|
||||
userId: string,
|
||||
): Promise<ProductSubmission> {
|
||||
const product = await this.productSubmissionRepository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!product) {
|
||||
throw new NotFoundException('Producto no encontrado');
|
||||
}
|
||||
|
||||
if (product.status !== 'PENDING') {
|
||||
throw new BadRequestException('El producto ya fue procesado');
|
||||
}
|
||||
|
||||
const previousValue = { status: product.status };
|
||||
|
||||
product.status = 'APPROVED' as any;
|
||||
product.reviewedBy = userId;
|
||||
product.reviewNotes = dto.notes;
|
||||
if (dto.category) {
|
||||
product.category = dto.category;
|
||||
}
|
||||
|
||||
const updated = await this.productSubmissionRepository.save(product);
|
||||
|
||||
await this.auditLogService.log({
|
||||
userId,
|
||||
action: 'APPROVE_PRODUCT',
|
||||
resource: 'product_submissions',
|
||||
resourceId: id,
|
||||
previousValue,
|
||||
newValue: { status: 'APPROVED', notes: dto.notes },
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async rejectProduct(
|
||||
id: string,
|
||||
dto: RejectProductDto,
|
||||
userId: string,
|
||||
): Promise<ProductSubmission> {
|
||||
const product = await this.productSubmissionRepository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!product) {
|
||||
throw new NotFoundException('Producto no encontrado');
|
||||
}
|
||||
|
||||
if (product.status !== 'PENDING') {
|
||||
throw new BadRequestException('El producto ya fue procesado');
|
||||
}
|
||||
|
||||
const previousValue = { status: product.status };
|
||||
|
||||
product.status = 'REJECTED' as any;
|
||||
product.reviewedBy = userId;
|
||||
product.reviewNotes = dto.reason;
|
||||
|
||||
const updated = await this.productSubmissionRepository.save(product);
|
||||
|
||||
await this.auditLogService.log({
|
||||
userId,
|
||||
action: 'REJECT_PRODUCT',
|
||||
resource: 'product_submissions',
|
||||
resourceId: id,
|
||||
previousValue,
|
||||
newValue: { status: 'REJECTED', reason: dto.reason },
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
// Referral Moderation (Fraud Detection)
|
||||
|
||||
async getFraudHoldReferrals(page = 1, limit = 20): Promise<{
|
||||
referrals: Referral[];
|
||||
total: number;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
}> {
|
||||
const [referrals, total] = await this.referralRepository.findAndCount({
|
||||
where: { fraudHold: true },
|
||||
order: { createdAt: 'ASC' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
relations: ['referrer', 'referred'],
|
||||
});
|
||||
|
||||
return {
|
||||
referrals,
|
||||
total,
|
||||
page,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
async approveReferral(
|
||||
id: string,
|
||||
dto: ApproveReferralDto,
|
||||
userId: string,
|
||||
): Promise<Referral> {
|
||||
const referral = await this.referralRepository.findOne({
|
||||
where: { id },
|
||||
relations: ['referrer', 'referred'],
|
||||
});
|
||||
|
||||
if (!referral) {
|
||||
throw new NotFoundException('Referido no encontrado');
|
||||
}
|
||||
|
||||
if (!referral.fraudHold) {
|
||||
throw new BadRequestException('El referido no esta en revision de fraude');
|
||||
}
|
||||
|
||||
const previousValue = {
|
||||
fraudHold: referral.fraudHold,
|
||||
fraudReason: referral.fraudReason,
|
||||
};
|
||||
|
||||
referral.fraudHold = false;
|
||||
referral.fraudReason = undefined as any;
|
||||
referral.reviewedBy = userId;
|
||||
referral.reviewedAt = new Date();
|
||||
|
||||
const updated = await this.referralRepository.save(referral);
|
||||
|
||||
await this.auditLogService.log({
|
||||
userId,
|
||||
action: 'APPROVE_REFERRAL',
|
||||
resource: 'referrals',
|
||||
resourceId: id,
|
||||
previousValue,
|
||||
newValue: { fraudHold: false, notes: dto.notes },
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async rejectReferral(
|
||||
id: string,
|
||||
dto: RejectReferralDto,
|
||||
userId: string,
|
||||
): Promise<Referral> {
|
||||
const referral = await this.referralRepository.findOne({
|
||||
where: { id },
|
||||
relations: ['referrer', 'referred'],
|
||||
});
|
||||
|
||||
if (!referral) {
|
||||
throw new NotFoundException('Referido no encontrado');
|
||||
}
|
||||
|
||||
if (!referral.fraudHold) {
|
||||
throw new BadRequestException('El referido no esta en revision de fraude');
|
||||
}
|
||||
|
||||
const previousValue = {
|
||||
fraudHold: referral.fraudHold,
|
||||
fraudReason: referral.fraudReason,
|
||||
status: referral.status,
|
||||
};
|
||||
|
||||
referral.fraudHold = true;
|
||||
referral.fraudReason = dto.reason;
|
||||
referral.status = ReferralStatus.PENDING; // Reset status
|
||||
referral.reviewedBy = userId;
|
||||
referral.reviewedAt = new Date();
|
||||
|
||||
const updated = await this.referralRepository.save(referral);
|
||||
|
||||
await this.auditLogService.log({
|
||||
userId,
|
||||
action: 'REJECT_REFERRAL',
|
||||
resource: 'referrals',
|
||||
resourceId: id,
|
||||
previousValue,
|
||||
newValue: { fraudHold: true, fraudReason: dto.reason },
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async flagReferralForFraud(
|
||||
id: string,
|
||||
reason: string,
|
||||
userId: string,
|
||||
): Promise<Referral> {
|
||||
const referral = await this.referralRepository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!referral) {
|
||||
throw new NotFoundException('Referido no encontrado');
|
||||
}
|
||||
|
||||
const previousValue = {
|
||||
fraudHold: referral.fraudHold,
|
||||
fraudReason: referral.fraudReason,
|
||||
};
|
||||
|
||||
referral.fraudHold = true;
|
||||
referral.fraudReason = reason;
|
||||
|
||||
const updated = await this.referralRepository.save(referral);
|
||||
|
||||
await this.auditLogService.log({
|
||||
userId,
|
||||
action: 'FLAG_REFERRAL_FRAUD',
|
||||
resource: 'referrals',
|
||||
resourceId: id,
|
||||
previousValue,
|
||||
newValue: { fraudHold: true, fraudReason: reason },
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
101
src/modules/admin/services/admin-packages.service.ts
Normal file
101
src/modules/admin/services/admin-packages.service.ts
Normal file
@ -0,0 +1,101 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { CreditPackage } from '../../credits/entities/credit-package.entity';
|
||||
import { CreatePackageDto, UpdatePackageDto } from '../dto/package.dto';
|
||||
import { AuditLogService } from './audit-log.service';
|
||||
|
||||
@Injectable()
|
||||
export class AdminPackagesService {
|
||||
constructor(
|
||||
@InjectRepository(CreditPackage)
|
||||
private packageRepository: Repository<CreditPackage>,
|
||||
private auditLogService: AuditLogService,
|
||||
) {}
|
||||
|
||||
async findAll(includeInactive = false): Promise<CreditPackage[]> {
|
||||
const where = includeInactive ? {} : { isActive: true };
|
||||
return this.packageRepository.find({
|
||||
where,
|
||||
order: { sortOrder: 'ASC', credits: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<CreditPackage> {
|
||||
const pkg = await this.packageRepository.findOne({ where: { id } });
|
||||
if (!pkg) {
|
||||
throw new NotFoundException('Paquete de creditos no encontrado');
|
||||
}
|
||||
return pkg;
|
||||
}
|
||||
|
||||
async create(dto: CreatePackageDto, userId: string): Promise<CreditPackage> {
|
||||
const maxOrder = await this.packageRepository
|
||||
.createQueryBuilder('pkg')
|
||||
.select('MAX(pkg.sortOrder)', 'max')
|
||||
.getRawOne();
|
||||
|
||||
const pkg = this.packageRepository.create({
|
||||
...dto,
|
||||
priceMXN: dto.priceMxn,
|
||||
sortOrder: (maxOrder?.max || 0) + 1,
|
||||
});
|
||||
|
||||
const saved = await this.packageRepository.save(pkg);
|
||||
|
||||
await this.auditLogService.log({
|
||||
userId,
|
||||
action: 'CREATE_PACKAGE',
|
||||
resource: 'credit_packages',
|
||||
resourceId: saved.id,
|
||||
newValue: { ...saved },
|
||||
});
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdatePackageDto,
|
||||
userId: string,
|
||||
): Promise<CreditPackage> {
|
||||
const pkg = await this.findOne(id);
|
||||
const previousValue = { ...pkg };
|
||||
|
||||
if (dto.priceMxn !== undefined) {
|
||||
(dto as any).priceMXN = dto.priceMxn;
|
||||
delete dto.priceMxn;
|
||||
}
|
||||
|
||||
Object.assign(pkg, dto);
|
||||
const updated = await this.packageRepository.save(pkg);
|
||||
|
||||
await this.auditLogService.log({
|
||||
userId,
|
||||
action: 'UPDATE_PACKAGE',
|
||||
resource: 'credit_packages',
|
||||
resourceId: id,
|
||||
previousValue,
|
||||
newValue: { ...updated },
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async deactivate(id: string, userId: string): Promise<void> {
|
||||
const pkg = await this.findOne(id);
|
||||
const previousValue = { ...pkg };
|
||||
|
||||
pkg.isActive = false;
|
||||
await this.packageRepository.save(pkg);
|
||||
|
||||
await this.auditLogService.log({
|
||||
userId,
|
||||
action: 'DEACTIVATE_PACKAGE',
|
||||
resource: 'credit_packages',
|
||||
resourceId: id,
|
||||
previousValue,
|
||||
newValue: { isActive: false },
|
||||
});
|
||||
}
|
||||
}
|
||||
151
src/modules/admin/services/admin-promotions.service.ts
Normal file
151
src/modules/admin/services/admin-promotions.service.ts
Normal file
@ -0,0 +1,151 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
||||
import { Promotion, PromotionType } from '../entities/promotion.entity';
|
||||
import { CreatePromotionDto, UpdatePromotionDto } from '../dto/promotion.dto';
|
||||
import { AuditLogService } from './audit-log.service';
|
||||
|
||||
@Injectable()
|
||||
export class AdminPromotionsService {
|
||||
constructor(
|
||||
@InjectRepository(Promotion)
|
||||
private promotionRepository: Repository<Promotion>,
|
||||
private auditLogService: AuditLogService,
|
||||
) {}
|
||||
|
||||
async findAll(includeExpired = false): Promise<Promotion[]> {
|
||||
const qb = this.promotionRepository.createQueryBuilder('promotion');
|
||||
|
||||
if (!includeExpired) {
|
||||
const now = new Date();
|
||||
qb.where('promotion.endsAt >= :now', { now });
|
||||
}
|
||||
|
||||
return qb.orderBy('promotion.createdAt', 'DESC').getMany();
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<Promotion> {
|
||||
const promotion = await this.promotionRepository.findOne({ where: { id } });
|
||||
if (!promotion) {
|
||||
throw new NotFoundException('Promocion no encontrada');
|
||||
}
|
||||
return promotion;
|
||||
}
|
||||
|
||||
async findByCode(code: string): Promise<Promotion | null> {
|
||||
return this.promotionRepository.findOne({
|
||||
where: { code: code.toUpperCase() },
|
||||
});
|
||||
}
|
||||
|
||||
async create(dto: CreatePromotionDto, userId: string): Promise<Promotion> {
|
||||
const existingCode = await this.findByCode(dto.code);
|
||||
if (existingCode) {
|
||||
throw new BadRequestException('El codigo de promocion ya existe');
|
||||
}
|
||||
|
||||
const promotion = this.promotionRepository.create({
|
||||
...dto,
|
||||
code: dto.code.toUpperCase(),
|
||||
createdBy: userId,
|
||||
startsAt: new Date(dto.startsAt),
|
||||
endsAt: new Date(dto.endsAt),
|
||||
});
|
||||
|
||||
const saved = await this.promotionRepository.save(promotion);
|
||||
|
||||
await this.auditLogService.log({
|
||||
userId,
|
||||
action: 'CREATE_PROMOTION',
|
||||
resource: 'promotions',
|
||||
resourceId: saved.id,
|
||||
newValue: { ...saved },
|
||||
});
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdatePromotionDto,
|
||||
userId: string,
|
||||
): Promise<Promotion> {
|
||||
const promotion = await this.findOne(id);
|
||||
const previousValue = { ...promotion };
|
||||
|
||||
if (dto.startsAt) {
|
||||
(dto as any).startsAt = new Date(dto.startsAt);
|
||||
}
|
||||
if (dto.endsAt) {
|
||||
(dto as any).endsAt = new Date(dto.endsAt);
|
||||
}
|
||||
|
||||
Object.assign(promotion, dto);
|
||||
const updated = await this.promotionRepository.save(promotion);
|
||||
|
||||
await this.auditLogService.log({
|
||||
userId,
|
||||
action: 'UPDATE_PROMOTION',
|
||||
resource: 'promotions',
|
||||
resourceId: id,
|
||||
previousValue,
|
||||
newValue: { ...updated },
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async validateCode(
|
||||
code: string,
|
||||
packageId?: string,
|
||||
purchaseAmount?: number,
|
||||
): Promise<{ valid: boolean; promotion?: Promotion; discount?: number; message?: string }> {
|
||||
const promotion = await this.findByCode(code);
|
||||
|
||||
if (!promotion) {
|
||||
return { valid: false, message: 'Codigo de promocion no encontrado' };
|
||||
}
|
||||
|
||||
if (!promotion.isValid()) {
|
||||
return { valid: false, message: 'Promocion expirada o no disponible' };
|
||||
}
|
||||
|
||||
if (promotion.minPurchaseAmount && purchaseAmount && purchaseAmount < Number(promotion.minPurchaseAmount)) {
|
||||
return {
|
||||
valid: false,
|
||||
message: `Monto minimo de compra: $${promotion.minPurchaseAmount} MXN`,
|
||||
};
|
||||
}
|
||||
|
||||
if (promotion.applicablePackageIds?.length && packageId) {
|
||||
if (!promotion.applicablePackageIds.includes(packageId)) {
|
||||
return { valid: false, message: 'Promocion no valida para este paquete' };
|
||||
}
|
||||
}
|
||||
|
||||
let discount = 0;
|
||||
if (purchaseAmount) {
|
||||
switch (promotion.type) {
|
||||
case PromotionType.PERCENTAGE:
|
||||
discount = purchaseAmount * (Number(promotion.value) / 100);
|
||||
break;
|
||||
case PromotionType.FIXED_CREDITS:
|
||||
discount = Number(promotion.value);
|
||||
break;
|
||||
case PromotionType.MULTIPLIER:
|
||||
discount = purchaseAmount * (Number(promotion.value) - 1);
|
||||
break;
|
||||
}
|
||||
|
||||
if (promotion.maxDiscount && discount > Number(promotion.maxDiscount)) {
|
||||
discount = Number(promotion.maxDiscount);
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true, promotion, discount };
|
||||
}
|
||||
|
||||
async incrementUsage(id: string): Promise<void> {
|
||||
await this.promotionRepository.increment({ id }, 'usageCount', 1);
|
||||
}
|
||||
}
|
||||
82
src/modules/admin/services/admin-providers.service.ts
Normal file
82
src/modules/admin/services/admin-providers.service.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { IaProvider } from '../entities/ia-provider.entity';
|
||||
import { UpdateProviderDto } from '../dto/provider.dto';
|
||||
import { AuditLogService } from './audit-log.service';
|
||||
|
||||
@Injectable()
|
||||
export class AdminProvidersService {
|
||||
constructor(
|
||||
@InjectRepository(IaProvider)
|
||||
private providerRepository: Repository<IaProvider>,
|
||||
private auditLogService: AuditLogService,
|
||||
) {}
|
||||
|
||||
async findAll(): Promise<IaProvider[]> {
|
||||
return this.providerRepository.find({
|
||||
order: { name: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<IaProvider> {
|
||||
const provider = await this.providerRepository.findOne({ where: { id } });
|
||||
if (!provider) {
|
||||
throw new NotFoundException('Proveedor IA no encontrado');
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
async findDefault(): Promise<IaProvider | null> {
|
||||
return this.providerRepository.findOne({
|
||||
where: { isDefault: true, isActive: true },
|
||||
});
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateProviderDto,
|
||||
userId: string,
|
||||
): Promise<IaProvider> {
|
||||
const provider = await this.findOne(id);
|
||||
const previousValue = { ...provider };
|
||||
|
||||
// If setting as default, unset other defaults
|
||||
if (dto.isDefault === true) {
|
||||
await this.providerRepository.update(
|
||||
{ isDefault: true },
|
||||
{ isDefault: false },
|
||||
);
|
||||
}
|
||||
|
||||
// Cannot unset the only default
|
||||
if (dto.isDefault === false && provider.isDefault) {
|
||||
const otherDefaults = await this.providerRepository.count({
|
||||
where: { isDefault: true },
|
||||
});
|
||||
if (otherDefaults <= 1) {
|
||||
throw new BadRequestException('Debe haber al menos un proveedor por defecto');
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(provider, dto);
|
||||
const updated = await this.providerRepository.save(provider);
|
||||
|
||||
await this.auditLogService.log({
|
||||
userId,
|
||||
action: 'UPDATE_PROVIDER',
|
||||
resource: 'ia_providers',
|
||||
resourceId: id,
|
||||
previousValue,
|
||||
newValue: { ...updated },
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async getProviderByCode(code: string): Promise<IaProvider | null> {
|
||||
return this.providerRepository.findOne({
|
||||
where: { code, isActive: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
57
src/modules/admin/services/audit-log.service.ts
Normal file
57
src/modules/admin/services/audit-log.service.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AuditLog } from '../entities/audit-log.entity';
|
||||
|
||||
export interface AuditLogInput {
|
||||
userId: string;
|
||||
action: string;
|
||||
resource: string;
|
||||
resourceId?: string;
|
||||
previousValue?: Record<string, any>;
|
||||
newValue?: Record<string, any>;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuditLogService {
|
||||
constructor(
|
||||
@InjectRepository(AuditLog)
|
||||
private auditLogRepository: Repository<AuditLog>,
|
||||
) {}
|
||||
|
||||
async log(input: AuditLogInput): Promise<AuditLog> {
|
||||
const auditLog = this.auditLogRepository.create(input);
|
||||
return this.auditLogRepository.save(auditLog);
|
||||
}
|
||||
|
||||
async findByResource(
|
||||
resource: string,
|
||||
resourceId: string,
|
||||
limit = 50,
|
||||
): Promise<AuditLog[]> {
|
||||
return this.auditLogRepository.find({
|
||||
where: { resource, resourceId },
|
||||
order: { createdAt: 'DESC' },
|
||||
take: limit,
|
||||
relations: ['user'],
|
||||
});
|
||||
}
|
||||
|
||||
async findByUser(userId: string, limit = 50): Promise<AuditLog[]> {
|
||||
return this.auditLogRepository.find({
|
||||
where: { userId },
|
||||
order: { createdAt: 'DESC' },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
async findRecent(limit = 100): Promise<AuditLog[]> {
|
||||
return this.auditLogRepository.find({
|
||||
order: { createdAt: 'DESC' },
|
||||
take: limit,
|
||||
relations: ['user'],
|
||||
});
|
||||
}
|
||||
}
|
||||
52
src/modules/auth/auth.controller.ts
Normal file
52
src/modules/auth/auth.controller.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { AuthService } from './auth.service';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { VerifyOtpDto } from './dto/verify-otp.dto';
|
||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('register')
|
||||
@ApiOperation({ summary: 'Iniciar registro con OTP' })
|
||||
@ApiResponse({ status: 201, description: 'OTP enviado exitosamente' })
|
||||
async register(@Body() registerDto: RegisterDto) {
|
||||
return this.authService.initiateRegistration(registerDto);
|
||||
}
|
||||
|
||||
@Post('verify-otp')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Verificar OTP y crear cuenta' })
|
||||
@ApiResponse({ status: 200, description: 'Cuenta creada exitosamente' })
|
||||
async verifyOtp(@Body() verifyOtpDto: VerifyOtpDto) {
|
||||
return this.authService.verifyOtpAndCreateAccount(verifyOtpDto);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Iniciar sesion' })
|
||||
@ApiResponse({ status: 200, description: 'Login exitoso' })
|
||||
async login(@Body() loginDto: LoginDto) {
|
||||
return this.authService.login(loginDto);
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Renovar tokens' })
|
||||
@ApiResponse({ status: 200, description: 'Tokens renovados' })
|
||||
async refresh(@Body() refreshTokenDto: RefreshTokenDto) {
|
||||
return this.authService.refreshTokens(refreshTokenDto);
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Cerrar sesion' })
|
||||
@ApiResponse({ status: 200, description: 'Sesion cerrada' })
|
||||
async logout(@Body() body: { refreshToken: string }) {
|
||||
return this.authService.logout(body.refreshToken);
|
||||
}
|
||||
}
|
||||
34
src/modules/auth/auth.module.ts
Normal file
34
src/modules/auth/auth.module.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { Otp } from './entities/otp.entity';
|
||||
import { RefreshToken } from './entities/refresh-token.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
UsersModule,
|
||||
TypeOrmModule.forFeature([Otp, RefreshToken]),
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: async (configService: ConfigService) => ({
|
||||
secret: configService.get('JWT_SECRET', 'your-secret-key'),
|
||||
signOptions: {
|
||||
expiresIn: configService.get('JWT_EXPIRES_IN', '15m'),
|
||||
},
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
exports: [AuthService, JwtModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
259
src/modules/auth/auth.service.ts
Normal file
259
src/modules/auth/auth.service.ts
Normal file
@ -0,0 +1,259 @@
|
||||
import {
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, MoreThan } from 'typeorm';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { VerifyOtpDto } from './dto/verify-otp.dto';
|
||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||
import { Otp, OtpPurpose } from './entities/otp.entity';
|
||||
import { RefreshToken } from './entities/refresh-token.entity';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly usersService: UsersService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
@InjectRepository(Otp)
|
||||
private readonly otpRepository: Repository<Otp>,
|
||||
@InjectRepository(RefreshToken)
|
||||
private readonly refreshTokenRepository: Repository<RefreshToken>,
|
||||
) {}
|
||||
|
||||
async initiateRegistration(registerDto: RegisterDto) {
|
||||
const { phone, name } = registerDto;
|
||||
|
||||
// Check if user already exists
|
||||
const existingUser = await this.usersService.findByPhone(phone);
|
||||
if (existingUser) {
|
||||
throw new BadRequestException('Usuario ya registrado con este telefono');
|
||||
}
|
||||
|
||||
// Invalidate any existing OTPs for this phone
|
||||
await this.otpRepository.update(
|
||||
{ phone, purpose: OtpPurpose.REGISTRATION, isUsed: false },
|
||||
{ isUsed: true },
|
||||
);
|
||||
|
||||
// Generate and save OTP
|
||||
const otp = this.generateOtp();
|
||||
const expiresAt = new Date(Date.now() + 5 * 60 * 1000); // 5 minutes
|
||||
|
||||
await this.otpRepository.save({
|
||||
phone,
|
||||
code: otp,
|
||||
purpose: OtpPurpose.REGISTRATION,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
// TODO: Send OTP via SMS (Twilio)
|
||||
// In development, log the OTP
|
||||
if (this.configService.get('NODE_ENV') === 'development') {
|
||||
console.warn(`[DEV] OTP for ${phone}: ${otp}`);
|
||||
}
|
||||
|
||||
return {
|
||||
message: 'Codigo de verificacion enviado',
|
||||
expiresIn: 300,
|
||||
};
|
||||
}
|
||||
|
||||
async verifyOtpAndCreateAccount(verifyOtpDto: VerifyOtpDto) {
|
||||
const { phone, otp, password, name } = verifyOtpDto;
|
||||
|
||||
// Find valid OTP
|
||||
const otpRecord = await this.otpRepository.findOne({
|
||||
where: {
|
||||
phone,
|
||||
code: otp,
|
||||
purpose: OtpPurpose.REGISTRATION,
|
||||
isUsed: false,
|
||||
expiresAt: MoreThan(new Date()),
|
||||
},
|
||||
});
|
||||
|
||||
// In development, accept 123456 as test OTP
|
||||
const isDev = this.configService.get('NODE_ENV') === 'development';
|
||||
if (!otpRecord && !(isDev && otp === '123456')) {
|
||||
throw new BadRequestException('Codigo invalido o expirado');
|
||||
}
|
||||
|
||||
// Check attempts
|
||||
if (otpRecord && otpRecord.attempts >= 3) {
|
||||
throw new BadRequestException(
|
||||
'Demasiados intentos. Solicita un nuevo codigo',
|
||||
);
|
||||
}
|
||||
|
||||
// Update attempts if wrong
|
||||
if (otpRecord) {
|
||||
await this.otpRepository.update(otpRecord.id, {
|
||||
isUsed: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
|
||||
// Create user
|
||||
const user = await this.usersService.create({
|
||||
phone,
|
||||
name,
|
||||
passwordHash,
|
||||
});
|
||||
|
||||
// Generate tokens
|
||||
const tokens = await this.generateTokens(user.id);
|
||||
|
||||
// Save refresh token
|
||||
await this.saveRefreshToken(user.id, tokens.refreshToken);
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
phone: user.phone,
|
||||
},
|
||||
...tokens,
|
||||
};
|
||||
}
|
||||
|
||||
async login(loginDto: LoginDto) {
|
||||
const { phone, password } = loginDto;
|
||||
|
||||
const user = await this.usersService.findByPhone(phone);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Credenciales invalidas');
|
||||
}
|
||||
|
||||
if (!user.passwordHash) {
|
||||
throw new UnauthorizedException(
|
||||
'Usuario sin contrasena configurada. Use OTP para acceder.',
|
||||
);
|
||||
}
|
||||
|
||||
const isValid = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!isValid) {
|
||||
throw new UnauthorizedException('Credenciales invalidas');
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
throw new UnauthorizedException('Cuenta desactivada');
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
const tokens = await this.generateTokens(user.id);
|
||||
|
||||
// Save refresh token
|
||||
await this.saveRefreshToken(user.id, tokens.refreshToken);
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
phone: user.phone,
|
||||
},
|
||||
...tokens,
|
||||
};
|
||||
}
|
||||
|
||||
async refreshTokens(refreshTokenDto: RefreshTokenDto) {
|
||||
const { refreshToken } = refreshTokenDto;
|
||||
|
||||
try {
|
||||
const payload = this.jwtService.verify(refreshToken, {
|
||||
secret: this.configService.get('JWT_REFRESH_SECRET', 'refresh-secret'),
|
||||
});
|
||||
|
||||
// Verify refresh token in database
|
||||
const tokenRecord = await this.refreshTokenRepository.findOne({
|
||||
where: {
|
||||
userId: payload.sub,
|
||||
token: refreshToken,
|
||||
isRevoked: false,
|
||||
expiresAt: MoreThan(new Date()),
|
||||
},
|
||||
});
|
||||
|
||||
if (!tokenRecord) {
|
||||
throw new UnauthorizedException('Refresh token invalido o expirado');
|
||||
}
|
||||
|
||||
// Revoke old token
|
||||
await this.refreshTokenRepository.update(tokenRecord.id, {
|
||||
isRevoked: true,
|
||||
});
|
||||
|
||||
// Generate new tokens
|
||||
const tokens = await this.generateTokens(payload.sub);
|
||||
|
||||
// Save new refresh token
|
||||
await this.saveRefreshToken(payload.sub, tokens.refreshToken);
|
||||
|
||||
return tokens;
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedException) {
|
||||
throw error;
|
||||
}
|
||||
throw new UnauthorizedException('Refresh token invalido');
|
||||
}
|
||||
}
|
||||
|
||||
async logout(refreshToken: string) {
|
||||
// Revoke refresh token
|
||||
await this.refreshTokenRepository.update(
|
||||
{ token: refreshToken },
|
||||
{ isRevoked: true },
|
||||
);
|
||||
|
||||
return { message: 'Sesion cerrada exitosamente' };
|
||||
}
|
||||
|
||||
async revokeAllUserTokens(userId: string) {
|
||||
await this.refreshTokenRepository.update(
|
||||
{ userId, isRevoked: false },
|
||||
{ isRevoked: true },
|
||||
);
|
||||
}
|
||||
|
||||
private async generateTokens(userId: string) {
|
||||
const payload = { sub: userId };
|
||||
|
||||
const [accessToken, refreshToken] = await Promise.all([
|
||||
this.jwtService.signAsync(payload),
|
||||
this.jwtService.signAsync(payload, {
|
||||
secret: this.configService.get('JWT_REFRESH_SECRET', 'refresh-secret'),
|
||||
expiresIn: this.configService.get('JWT_REFRESH_EXPIRES_IN', '7d'),
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresIn: 900, // 15 minutes in seconds
|
||||
};
|
||||
}
|
||||
|
||||
private async saveRefreshToken(userId: string, token: string) {
|
||||
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
|
||||
|
||||
await this.refreshTokenRepository.save({
|
||||
userId,
|
||||
token,
|
||||
expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
private generateOtp(): string {
|
||||
return Math.floor(100000 + Math.random() * 900000).toString();
|
||||
}
|
||||
}
|
||||
22
src/modules/auth/dto/login.dto.ts
Normal file
22
src/modules/auth/dto/login.dto.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { IsString, IsNotEmpty, Matches, MinLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class LoginDto {
|
||||
@ApiProperty({
|
||||
description: 'Numero de telefono (10 digitos)',
|
||||
example: '5512345678',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Matches(/^[0-9]{10}$/, { message: 'Telefono debe tener 10 digitos' })
|
||||
phone: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Contrasena',
|
||||
example: 'miContrasena123',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(6)
|
||||
password: string;
|
||||
}
|
||||
11
src/modules/auth/dto/refresh-token.dto.ts
Normal file
11
src/modules/auth/dto/refresh-token.dto.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { IsString, IsNotEmpty } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@ApiProperty({
|
||||
description: 'Refresh token',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
refreshToken: string;
|
||||
}
|
||||
23
src/modules/auth/dto/register.dto.ts
Normal file
23
src/modules/auth/dto/register.dto.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { IsString, IsNotEmpty, Matches, MinLength, MaxLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class RegisterDto {
|
||||
@ApiProperty({
|
||||
description: 'Numero de telefono (10 digitos)',
|
||||
example: '5512345678',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Matches(/^[0-9]{10}$/, { message: 'Telefono debe tener 10 digitos' })
|
||||
phone: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Nombre del usuario',
|
||||
example: 'Juan Perez',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(2)
|
||||
@MaxLength(100)
|
||||
name: string;
|
||||
}
|
||||
49
src/modules/auth/dto/verify-otp.dto.ts
Normal file
49
src/modules/auth/dto/verify-otp.dto.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import {
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
Length,
|
||||
Matches,
|
||||
MinLength,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class VerifyOtpDto {
|
||||
@ApiProperty({
|
||||
description: 'Numero de telefono (10 digitos)',
|
||||
example: '5512345678',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Matches(/^[0-9]{10}$/, { message: 'Telefono debe tener 10 digitos' })
|
||||
phone: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Codigo OTP de 6 digitos',
|
||||
example: '123456',
|
||||
})
|
||||
@IsString()
|
||||
@Length(6, 6, { message: 'El codigo debe tener 6 digitos' })
|
||||
@Matches(/^[0-9]+$/, { message: 'El codigo solo debe contener numeros' })
|
||||
otp: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Contrasena para la cuenta',
|
||||
example: 'miContrasena123',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(6, { message: 'La contrasena debe tener al menos 6 caracteres' })
|
||||
@MaxLength(50)
|
||||
password: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Nombre del usuario',
|
||||
example: 'Juan Perez',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(2)
|
||||
@MaxLength(100)
|
||||
name: string;
|
||||
}
|
||||
42
src/modules/auth/entities/otp.entity.ts
Normal file
42
src/modules/auth/entities/otp.entity.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
export enum OtpPurpose {
|
||||
REGISTRATION = 'REGISTRATION',
|
||||
LOGIN = 'LOGIN',
|
||||
PASSWORD_RESET = 'PASSWORD_RESET',
|
||||
}
|
||||
|
||||
@Entity('otps')
|
||||
@Index(['phone', 'purpose'])
|
||||
@Index(['expiresAt'])
|
||||
export class Otp {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20 })
|
||||
phone: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 6 })
|
||||
code: string;
|
||||
|
||||
@Column({ type: 'enum', enum: OtpPurpose })
|
||||
purpose: OtpPurpose;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
attempts: number;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isUsed: boolean;
|
||||
|
||||
@Column({ type: 'timestamp' })
|
||||
expiresAt: Date;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
44
src/modules/auth/entities/refresh-token.entity.ts
Normal file
44
src/modules/auth/entities/refresh-token.entity.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
|
||||
@Entity('refresh_tokens')
|
||||
@Index(['userId'])
|
||||
@Index(['token'])
|
||||
@Index(['expiresAt'])
|
||||
export class RefreshToken {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
userId: string;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'userId' })
|
||||
user: User;
|
||||
|
||||
@Column({ type: 'varchar', length: 500 })
|
||||
token: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||
deviceInfo: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, nullable: true })
|
||||
ipAddress: string;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isRevoked: boolean;
|
||||
|
||||
@Column({ type: 'timestamp' })
|
||||
expiresAt: Date;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
9
src/modules/auth/guards/jwt-auth.guard.ts
Normal file
9
src/modules/auth/guards/jwt-auth.guard.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Injectable, ExecutionContext } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
canActivate(context: ExecutionContext) {
|
||||
return super.canActivate(context);
|
||||
}
|
||||
}
|
||||
27
src/modules/auth/strategies/jwt.strategy.ts
Normal file
27
src/modules/auth/strategies/jwt.strategy.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { UsersService } from '../../users/users.service';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly usersService: UsersService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get('JWT_SECRET', 'your-secret-key'),
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: { sub: string }) {
|
||||
const user = await this.usersService.findById(payload.sub);
|
||||
if (!user || !user.isActive) {
|
||||
throw new UnauthorizedException('Usuario no autorizado');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
72
src/modules/credits/credits.controller.ts
Normal file
72
src/modules/credits/credits.controller.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
ParseIntPipe,
|
||||
DefaultValuePipe,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
ApiQuery,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CreditsService } from './credits.service';
|
||||
import { AuthenticatedRequest } from '../../common/interfaces/authenticated-request.interface';
|
||||
|
||||
@ApiTags('credits')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('credits')
|
||||
export class CreditsController {
|
||||
constructor(private readonly creditsService: CreditsService) {}
|
||||
|
||||
@Get('balance')
|
||||
@ApiOperation({ summary: 'Obtener balance de creditos' })
|
||||
@ApiResponse({ status: 200, description: 'Balance actual' })
|
||||
async getBalance(@Request() req: AuthenticatedRequest) {
|
||||
const balance = await this.creditsService.getBalance(req.user.id);
|
||||
return {
|
||||
balance: balance.balance,
|
||||
totalPurchased: balance.totalPurchased,
|
||||
totalConsumed: balance.totalConsumed,
|
||||
totalFromReferrals: balance.totalFromReferrals,
|
||||
};
|
||||
}
|
||||
|
||||
@Get('transactions')
|
||||
@ApiOperation({ summary: 'Obtener historial de transacciones' })
|
||||
@ApiResponse({ status: 200, description: 'Lista de transacciones' })
|
||||
@ApiQuery({ name: 'page', required: false, type: Number })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||
async getTransactions(
|
||||
@Request() req: AuthenticatedRequest,
|
||||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
||||
@Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number,
|
||||
) {
|
||||
const { transactions, total } = await this.creditsService.getTransactions(
|
||||
req.user.id,
|
||||
page,
|
||||
Math.min(limit, 100),
|
||||
);
|
||||
|
||||
return {
|
||||
transactions,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
hasMore: page * limit < total,
|
||||
};
|
||||
}
|
||||
|
||||
@Get('packages')
|
||||
@ApiOperation({ summary: 'Obtener paquetes de creditos disponibles' })
|
||||
@ApiResponse({ status: 200, description: 'Lista de paquetes' })
|
||||
async getPackages() {
|
||||
return this.creditsService.getPackages();
|
||||
}
|
||||
}
|
||||
17
src/modules/credits/credits.module.ts
Normal file
17
src/modules/credits/credits.module.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { CreditsController } from './credits.controller';
|
||||
import { CreditsService } from './credits.service';
|
||||
import { CreditBalance } from './entities/credit-balance.entity';
|
||||
import { CreditTransaction } from './entities/credit-transaction.entity';
|
||||
import { CreditPackage } from './entities/credit-package.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([CreditBalance, CreditTransaction, CreditPackage]),
|
||||
],
|
||||
controllers: [CreditsController],
|
||||
providers: [CreditsService],
|
||||
exports: [CreditsService],
|
||||
})
|
||||
export class CreditsModule {}
|
||||
211
src/modules/credits/credits.service.ts
Normal file
211
src/modules/credits/credits.service.ts
Normal file
@ -0,0 +1,211 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import { CreditBalance } from './entities/credit-balance.entity';
|
||||
import {
|
||||
CreditTransaction,
|
||||
TransactionType,
|
||||
} from './entities/credit-transaction.entity';
|
||||
import { CreditPackage } from './entities/credit-package.entity';
|
||||
|
||||
@Injectable()
|
||||
export class CreditsService {
|
||||
constructor(
|
||||
@InjectRepository(CreditBalance)
|
||||
private readonly balanceRepository: Repository<CreditBalance>,
|
||||
@InjectRepository(CreditTransaction)
|
||||
private readonly transactionRepository: Repository<CreditTransaction>,
|
||||
@InjectRepository(CreditPackage)
|
||||
private readonly packageRepository: Repository<CreditPackage>,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async getBalance(userId: string): Promise<CreditBalance> {
|
||||
let balance = await this.balanceRepository.findOne({ where: { userId } });
|
||||
|
||||
if (!balance) {
|
||||
// Create initial balance for new users
|
||||
balance = this.balanceRepository.create({
|
||||
userId,
|
||||
balance: 0,
|
||||
totalPurchased: 0,
|
||||
totalConsumed: 0,
|
||||
totalFromReferrals: 0,
|
||||
});
|
||||
await this.balanceRepository.save(balance);
|
||||
}
|
||||
|
||||
return balance;
|
||||
}
|
||||
|
||||
async hasMinimumCredits(userId: string, minimum: number): Promise<boolean> {
|
||||
const balance = await this.getBalance(userId);
|
||||
return balance.balance >= minimum;
|
||||
}
|
||||
|
||||
async addCredits(
|
||||
userId: string,
|
||||
amount: number,
|
||||
type: TransactionType,
|
||||
description: string,
|
||||
referenceId?: string,
|
||||
referenceType?: string,
|
||||
): Promise<CreditTransaction> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const balanceRepo = manager.getRepository(CreditBalance);
|
||||
const transactionRepo = manager.getRepository(CreditTransaction);
|
||||
|
||||
// Get or create balance
|
||||
let balance = await balanceRepo.findOne({ where: { userId } });
|
||||
if (!balance) {
|
||||
balance = balanceRepo.create({
|
||||
userId,
|
||||
balance: 0,
|
||||
totalPurchased: 0,
|
||||
totalConsumed: 0,
|
||||
totalFromReferrals: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Update balance
|
||||
balance.balance += amount;
|
||||
|
||||
if (type === TransactionType.PURCHASE) {
|
||||
balance.totalPurchased += amount;
|
||||
} else if (type === TransactionType.REFERRAL_BONUS) {
|
||||
balance.totalFromReferrals += amount;
|
||||
}
|
||||
|
||||
await balanceRepo.save(balance);
|
||||
|
||||
// Create transaction record
|
||||
const transaction = transactionRepo.create({
|
||||
userId,
|
||||
type,
|
||||
amount,
|
||||
balanceAfter: balance.balance,
|
||||
description,
|
||||
referenceId,
|
||||
referenceType,
|
||||
});
|
||||
|
||||
return transactionRepo.save(transaction);
|
||||
});
|
||||
}
|
||||
|
||||
async consume(
|
||||
userId: string,
|
||||
amount: number,
|
||||
description: string,
|
||||
referenceId?: string,
|
||||
referenceType?: string,
|
||||
): Promise<CreditTransaction> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const balanceRepo = manager.getRepository(CreditBalance);
|
||||
const transactionRepo = manager.getRepository(CreditTransaction);
|
||||
|
||||
const balance = await balanceRepo.findOne({ where: { userId } });
|
||||
if (!balance || balance.balance < amount) {
|
||||
throw new BadRequestException('Creditos insuficientes');
|
||||
}
|
||||
|
||||
// Deduct credits
|
||||
balance.balance -= amount;
|
||||
balance.totalConsumed += amount;
|
||||
await balanceRepo.save(balance);
|
||||
|
||||
// Create transaction record
|
||||
const transaction = transactionRepo.create({
|
||||
userId,
|
||||
type: TransactionType.CONSUMPTION,
|
||||
amount: -amount,
|
||||
balanceAfter: balance.balance,
|
||||
description,
|
||||
referenceId,
|
||||
referenceType,
|
||||
});
|
||||
|
||||
return transactionRepo.save(transaction);
|
||||
});
|
||||
}
|
||||
|
||||
async getTransactions(
|
||||
userId: string,
|
||||
page = 1,
|
||||
limit = 20,
|
||||
): Promise<{ transactions: CreditTransaction[]; total: number }> {
|
||||
const [transactions, total] = await this.transactionRepository.findAndCount(
|
||||
{
|
||||
where: { userId },
|
||||
order: { createdAt: 'DESC' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
},
|
||||
);
|
||||
|
||||
return { transactions, total };
|
||||
}
|
||||
|
||||
async getPackages(): Promise<CreditPackage[]> {
|
||||
return this.packageRepository.find({
|
||||
where: { isActive: true },
|
||||
order: { sortOrder: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async getPackageById(packageId: string): Promise<CreditPackage> {
|
||||
const pkg = await this.packageRepository.findOne({
|
||||
where: { id: packageId, isActive: true },
|
||||
});
|
||||
|
||||
if (!pkg) {
|
||||
throw new NotFoundException('Paquete no encontrado');
|
||||
}
|
||||
|
||||
return pkg;
|
||||
}
|
||||
|
||||
async grantWelcomeCredits(userId: string): Promise<void> {
|
||||
const welcomeCredits = 50; // Free credits for new users
|
||||
|
||||
await this.addCredits(
|
||||
userId,
|
||||
welcomeCredits,
|
||||
TransactionType.PROMO,
|
||||
'Creditos de bienvenida',
|
||||
);
|
||||
}
|
||||
|
||||
async grantReferralBonus(
|
||||
referrerId: string,
|
||||
referredId: string,
|
||||
referralId: string,
|
||||
): Promise<void> {
|
||||
const referrerBonus = 100;
|
||||
const referredBonus = 50;
|
||||
|
||||
// Give bonus to referrer
|
||||
await this.addCredits(
|
||||
referrerId,
|
||||
referrerBonus,
|
||||
TransactionType.REFERRAL_BONUS,
|
||||
'Bonus por referido',
|
||||
referralId,
|
||||
'referral',
|
||||
);
|
||||
|
||||
// Give bonus to referred user
|
||||
await this.addCredits(
|
||||
referredId,
|
||||
referredBonus,
|
||||
TransactionType.REFERRAL_BONUS,
|
||||
'Bonus por usar codigo de referido',
|
||||
referralId,
|
||||
'referral',
|
||||
);
|
||||
}
|
||||
}
|
||||
41
src/modules/credits/entities/credit-balance.entity.ts
Normal file
41
src/modules/credits/entities/credit-balance.entity.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
OneToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
|
||||
@Entity('credit_balances')
|
||||
export class CreditBalance {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid', unique: true })
|
||||
userId: string;
|
||||
|
||||
@OneToOne(() => User)
|
||||
@JoinColumn({ name: 'userId' })
|
||||
user: User;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
balance: number;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
totalPurchased: number;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
totalConsumed: number;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
totalFromReferrals: number;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
43
src/modules/credits/entities/credit-package.entity.ts
Normal file
43
src/modules/credits/entities/credit-package.entity.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('credit_packages')
|
||||
export class CreditPackage {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
description: string;
|
||||
|
||||
@Column({ type: 'int' })
|
||||
credits: number;
|
||||
|
||||
@Column({ type: 'decimal', precision: 10, scale: 2 })
|
||||
priceMXN: number;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isPopular: boolean;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
isActive: boolean;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
sortOrder: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||
stripePriceId: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
57
src/modules/credits/entities/credit-transaction.entity.ts
Normal file
57
src/modules/credits/entities/credit-transaction.entity.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
|
||||
export enum TransactionType {
|
||||
PURCHASE = 'PURCHASE',
|
||||
CONSUMPTION = 'CONSUMPTION',
|
||||
REFERRAL_BONUS = 'REFERRAL_BONUS',
|
||||
PROMO = 'PROMO',
|
||||
REFUND = 'REFUND',
|
||||
}
|
||||
|
||||
@Entity('credit_transactions')
|
||||
@Index(['userId', 'createdAt'])
|
||||
@Index(['type', 'createdAt'])
|
||||
export class CreditTransaction {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
userId: string;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'userId' })
|
||||
user: User;
|
||||
|
||||
@Column({ type: 'enum', enum: TransactionType })
|
||||
type: TransactionType;
|
||||
|
||||
@Column({ type: 'int' })
|
||||
amount: number;
|
||||
|
||||
@Column({ type: 'int' })
|
||||
balanceAfter: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
description: string;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
referenceId: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, nullable: true })
|
||||
referenceType: string;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata: Record<string, unknown>;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
77
src/modules/exports/dto/export-request.dto.ts
Normal file
77
src/modules/exports/dto/export-request.dto.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
} from 'class-validator';
|
||||
import { ExportFormat, ExportType } from '../entities/export-job.entity';
|
||||
|
||||
export class ExportInventoryDto {
|
||||
@ApiProperty({
|
||||
description: 'Formato de exportación',
|
||||
enum: ExportFormat,
|
||||
example: ExportFormat.CSV,
|
||||
})
|
||||
@IsEnum(ExportFormat)
|
||||
format: ExportFormat;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Filtrar por categoría',
|
||||
example: 'Bebidas',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
category?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Solo items con stock bajo',
|
||||
example: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
lowStockOnly?: boolean;
|
||||
}
|
||||
|
||||
export class ExportReportDto {
|
||||
@ApiProperty({
|
||||
description: 'Tipo de reporte',
|
||||
enum: ExportType,
|
||||
example: ExportType.REPORT_VALUATION,
|
||||
})
|
||||
@IsEnum(ExportType)
|
||||
type: ExportType;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Formato de exportación',
|
||||
enum: ExportFormat,
|
||||
example: ExportFormat.EXCEL,
|
||||
})
|
||||
@IsEnum(ExportFormat)
|
||||
format: ExportFormat;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Fecha de inicio del periodo',
|
||||
example: '2024-01-01',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
startDate?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Fecha de fin del periodo',
|
||||
example: '2024-01-31',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
export class ExportJobResponseDto {
|
||||
@ApiProperty({ description: 'ID del trabajo de exportación' })
|
||||
jobId: string;
|
||||
|
||||
@ApiProperty({ description: 'Mensaje informativo' })
|
||||
message: string;
|
||||
}
|
||||
47
src/modules/exports/dto/export-status.dto.ts
Normal file
47
src/modules/exports/dto/export-status.dto.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
ExportFormat,
|
||||
ExportType,
|
||||
ExportStatus,
|
||||
ExportFilters,
|
||||
} from '../entities/export-job.entity';
|
||||
|
||||
export class ExportStatusDto {
|
||||
@ApiProperty({ description: 'ID del trabajo' })
|
||||
id: string;
|
||||
|
||||
@ApiProperty({ description: 'Estado actual', enum: ExportStatus })
|
||||
status: ExportStatus;
|
||||
|
||||
@ApiProperty({ description: 'Formato', enum: ExportFormat })
|
||||
format: ExportFormat;
|
||||
|
||||
@ApiProperty({ description: 'Tipo de exportación', enum: ExportType })
|
||||
type: ExportType;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filtros aplicados' })
|
||||
filters?: ExportFilters;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Total de filas exportadas' })
|
||||
totalRows?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Mensaje de error si falló' })
|
||||
errorMessage?: string;
|
||||
|
||||
@ApiProperty({ description: 'Fecha de creación' })
|
||||
createdAt: Date;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Fecha de expiración de descarga' })
|
||||
expiresAt?: Date;
|
||||
}
|
||||
|
||||
export class ExportDownloadDto {
|
||||
@ApiProperty({ description: 'URL de descarga presignada' })
|
||||
url: string;
|
||||
|
||||
@ApiProperty({ description: 'Fecha de expiración de la URL' })
|
||||
expiresAt: Date;
|
||||
|
||||
@ApiProperty({ description: 'Nombre del archivo' })
|
||||
filename: string;
|
||||
}
|
||||
103
src/modules/exports/entities/export-job.entity.ts
Normal file
103
src/modules/exports/entities/export-job.entity.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
import { Store } from '../../stores/entities/store.entity';
|
||||
|
||||
export enum ExportFormat {
|
||||
CSV = 'CSV',
|
||||
EXCEL = 'EXCEL',
|
||||
}
|
||||
|
||||
export enum ExportType {
|
||||
INVENTORY = 'INVENTORY',
|
||||
REPORT_VALUATION = 'REPORT_VALUATION',
|
||||
REPORT_MOVEMENTS = 'REPORT_MOVEMENTS',
|
||||
REPORT_CATEGORIES = 'REPORT_CATEGORIES',
|
||||
REPORT_LOW_STOCK = 'REPORT_LOW_STOCK',
|
||||
}
|
||||
|
||||
export enum ExportStatus {
|
||||
PENDING = 'PENDING',
|
||||
PROCESSING = 'PROCESSING',
|
||||
COMPLETED = 'COMPLETED',
|
||||
FAILED = 'FAILED',
|
||||
}
|
||||
|
||||
export interface ExportFilters {
|
||||
category?: string;
|
||||
lowStockOnly?: boolean;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
}
|
||||
|
||||
@Entity('export_jobs')
|
||||
export class ExportJob {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
userId: string;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'userId' })
|
||||
user: User;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
storeId: string;
|
||||
|
||||
@ManyToOne(() => Store)
|
||||
@JoinColumn({ name: 'storeId' })
|
||||
store: Store;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ExportFormat,
|
||||
default: ExportFormat.CSV,
|
||||
})
|
||||
format: ExportFormat;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ExportType,
|
||||
default: ExportType.INVENTORY,
|
||||
})
|
||||
type: ExportType;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ExportStatus,
|
||||
default: ExportStatus.PENDING,
|
||||
})
|
||||
status: ExportStatus;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
filters: ExportFilters;
|
||||
|
||||
@Column({ type: 'varchar', length: 500, nullable: true })
|
||||
s3Key: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 1000, nullable: true })
|
||||
downloadUrl: string;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
expiresAt: Date;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
totalRows: number;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
errorMessage: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
127
src/modules/exports/exports.controller.ts
Normal file
127
src/modules/exports/exports.controller.ts
Normal file
@ -0,0 +1,127 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Param,
|
||||
Body,
|
||||
UseGuards,
|
||||
Request,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
ApiParam,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { ExportsService } from './exports.service';
|
||||
import {
|
||||
ExportInventoryDto,
|
||||
ExportReportDto,
|
||||
ExportJobResponseDto,
|
||||
} from './dto/export-request.dto';
|
||||
import { ExportStatusDto, ExportDownloadDto } from './dto/export-status.dto';
|
||||
import { AuthenticatedRequest } from '../../common/interfaces/authenticated-request.interface';
|
||||
|
||||
@ApiTags('exports')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('stores/:storeId/exports')
|
||||
export class ExportsController {
|
||||
constructor(private readonly exportsService: ExportsService) {}
|
||||
|
||||
@Post('inventory')
|
||||
@ApiOperation({ summary: 'Solicitar exportación de inventario' })
|
||||
@ApiParam({ name: 'storeId', description: 'ID de la tienda' })
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: 'Trabajo de exportación creado',
|
||||
type: ExportJobResponseDto,
|
||||
})
|
||||
async requestInventoryExport(
|
||||
@Param('storeId', ParseUUIDPipe) storeId: string,
|
||||
@Body() dto: ExportInventoryDto,
|
||||
@Request() req: AuthenticatedRequest,
|
||||
): Promise<ExportJobResponseDto> {
|
||||
const result = await this.exportsService.requestInventoryExport(
|
||||
req.user.id,
|
||||
storeId,
|
||||
dto.format,
|
||||
{
|
||||
category: dto.category,
|
||||
lowStockOnly: dto.lowStockOnly,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
jobId: result.jobId,
|
||||
message:
|
||||
'Exportación iniciada. Consulta el estado con GET /exports/:jobId',
|
||||
};
|
||||
}
|
||||
|
||||
@Post('report')
|
||||
@ApiOperation({ summary: 'Solicitar exportación de reporte' })
|
||||
@ApiParam({ name: 'storeId', description: 'ID de la tienda' })
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: 'Trabajo de exportación creado',
|
||||
type: ExportJobResponseDto,
|
||||
})
|
||||
async requestReportExport(
|
||||
@Param('storeId', ParseUUIDPipe) storeId: string,
|
||||
@Body() dto: ExportReportDto,
|
||||
@Request() req: AuthenticatedRequest,
|
||||
): Promise<ExportJobResponseDto> {
|
||||
const result = await this.exportsService.requestReportExport(
|
||||
req.user.id,
|
||||
storeId,
|
||||
dto.type,
|
||||
dto.format,
|
||||
{
|
||||
startDate: dto.startDate ? new Date(dto.startDate) : undefined,
|
||||
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
jobId: result.jobId,
|
||||
message:
|
||||
'Exportación de reporte iniciada. Consulta el estado con GET /exports/:jobId',
|
||||
};
|
||||
}
|
||||
|
||||
@Get(':jobId')
|
||||
@ApiOperation({ summary: 'Obtener estado de exportación' })
|
||||
@ApiParam({ name: 'storeId', description: 'ID de la tienda' })
|
||||
@ApiParam({ name: 'jobId', description: 'ID del trabajo de exportación' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Estado del trabajo',
|
||||
type: ExportStatusDto,
|
||||
})
|
||||
async getExportStatus(
|
||||
@Param('jobId', ParseUUIDPipe) jobId: string,
|
||||
@Request() req: AuthenticatedRequest,
|
||||
): Promise<ExportStatusDto> {
|
||||
return this.exportsService.getExportStatus(jobId, req.user.id);
|
||||
}
|
||||
|
||||
@Get(':jobId/download')
|
||||
@ApiOperation({ summary: 'Obtener URL de descarga' })
|
||||
@ApiParam({ name: 'storeId', description: 'ID de la tienda' })
|
||||
@ApiParam({ name: 'jobId', description: 'ID del trabajo de exportación' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'URL de descarga presignada',
|
||||
type: ExportDownloadDto,
|
||||
})
|
||||
async getDownloadUrl(
|
||||
@Param('jobId', ParseUUIDPipe) jobId: string,
|
||||
@Request() req: AuthenticatedRequest,
|
||||
): Promise<ExportDownloadDto> {
|
||||
return this.exportsService.getDownloadUrl(jobId, req.user.id);
|
||||
}
|
||||
}
|
||||
23
src/modules/exports/exports.module.ts
Normal file
23
src/modules/exports/exports.module.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BullModule } from '@nestjs/bull';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ExportsController } from './exports.controller';
|
||||
import { ExportsService } from './exports.service';
|
||||
import { ExportsProcessor } from './exports.processor';
|
||||
import { ExportJob } from './entities/export-job.entity';
|
||||
import { InventoryItem } from '../inventory/entities/inventory-item.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([ExportJob, InventoryItem]),
|
||||
BullModule.registerQueue({
|
||||
name: 'exports',
|
||||
}),
|
||||
ConfigModule,
|
||||
],
|
||||
controllers: [ExportsController],
|
||||
providers: [ExportsService, ExportsProcessor],
|
||||
exports: [ExportsService],
|
||||
})
|
||||
export class ExportsModule {}
|
||||
30
src/modules/exports/exports.processor.ts
Normal file
30
src/modules/exports/exports.processor.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { Process, Processor } from '@nestjs/bull';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Job } from 'bull';
|
||||
import { ExportsService, ExportJobData } from './exports.service';
|
||||
|
||||
@Processor('exports')
|
||||
export class ExportsProcessor {
|
||||
private readonly logger = new Logger(ExportsProcessor.name);
|
||||
|
||||
constructor(private readonly exportsService: ExportsService) {}
|
||||
|
||||
@Process('generate-export')
|
||||
async handleExport(job: Job<ExportJobData>): Promise<void> {
|
||||
this.logger.log(
|
||||
`Processing export job ${job.data.jobId} for store ${job.data.storeId}`,
|
||||
);
|
||||
|
||||
try {
|
||||
await this.exportsService.processExport(job.data);
|
||||
|
||||
this.logger.log(`Export job ${job.data.jobId} completed successfully`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Export job ${job.data.jobId} failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
413
src/modules/exports/exports.service.ts
Normal file
413
src/modules/exports/exports.service.ts
Normal file
@ -0,0 +1,413 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { InjectQueue } from '@nestjs/bull';
|
||||
import { Queue } from 'bull';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
GetObjectCommand,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import * as fastCsv from 'fast-csv';
|
||||
import * as ExcelJS from 'exceljs';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
import {
|
||||
ExportJob,
|
||||
ExportFormat,
|
||||
ExportType,
|
||||
ExportStatus,
|
||||
ExportFilters,
|
||||
} from './entities/export-job.entity';
|
||||
import { InventoryItem } from '../inventory/entities/inventory-item.entity';
|
||||
import { ExportStatusDto, ExportDownloadDto } from './dto/export-status.dto';
|
||||
|
||||
export interface ExportJobData {
|
||||
jobId: string;
|
||||
userId: string;
|
||||
storeId: string;
|
||||
format: ExportFormat;
|
||||
type: ExportType;
|
||||
filters?: ExportFilters;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ExportsService {
|
||||
private s3Client: S3Client;
|
||||
private bucket: string;
|
||||
private urlExpiry: number;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ExportJob)
|
||||
private exportJobRepository: Repository<ExportJob>,
|
||||
@InjectRepository(InventoryItem)
|
||||
private inventoryRepository: Repository<InventoryItem>,
|
||||
@InjectQueue('exports')
|
||||
private exportQueue: Queue<ExportJobData>,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
this.bucket = this.configService.get('S3_BUCKET', 'miinventario');
|
||||
this.urlExpiry = this.configService.get('EXPORT_URL_EXPIRY', 3600);
|
||||
|
||||
const endpoint = this.configService.get('S3_ENDPOINT');
|
||||
if (endpoint) {
|
||||
this.s3Client = new S3Client({
|
||||
endpoint,
|
||||
region: this.configService.get('S3_REGION', 'us-east-1'),
|
||||
credentials: {
|
||||
accessKeyId: this.configService.get('S3_ACCESS_KEY', 'minioadmin'),
|
||||
secretAccessKey: this.configService.get('S3_SECRET_KEY', 'minioadmin'),
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async requestInventoryExport(
|
||||
userId: string,
|
||||
storeId: string,
|
||||
format: ExportFormat,
|
||||
filters?: ExportFilters,
|
||||
): Promise<{ jobId: string }> {
|
||||
const job = this.exportJobRepository.create({
|
||||
userId,
|
||||
storeId,
|
||||
format,
|
||||
type: ExportType.INVENTORY,
|
||||
status: ExportStatus.PENDING,
|
||||
filters,
|
||||
});
|
||||
|
||||
const savedJob = await this.exportJobRepository.save(job);
|
||||
|
||||
await this.exportQueue.add('generate-export', {
|
||||
jobId: savedJob.id,
|
||||
userId,
|
||||
storeId,
|
||||
format,
|
||||
type: ExportType.INVENTORY,
|
||||
filters,
|
||||
});
|
||||
|
||||
return { jobId: savedJob.id };
|
||||
}
|
||||
|
||||
async requestReportExport(
|
||||
userId: string,
|
||||
storeId: string,
|
||||
type: ExportType,
|
||||
format: ExportFormat,
|
||||
filters?: ExportFilters,
|
||||
): Promise<{ jobId: string }> {
|
||||
const job = this.exportJobRepository.create({
|
||||
userId,
|
||||
storeId,
|
||||
format,
|
||||
type,
|
||||
status: ExportStatus.PENDING,
|
||||
filters,
|
||||
});
|
||||
|
||||
const savedJob = await this.exportJobRepository.save(job);
|
||||
|
||||
await this.exportQueue.add('generate-export', {
|
||||
jobId: savedJob.id,
|
||||
userId,
|
||||
storeId,
|
||||
format,
|
||||
type,
|
||||
filters,
|
||||
});
|
||||
|
||||
return { jobId: savedJob.id };
|
||||
}
|
||||
|
||||
async getExportStatus(
|
||||
jobId: string,
|
||||
userId: string,
|
||||
): Promise<ExportStatusDto> {
|
||||
const job = await this.exportJobRepository.findOne({
|
||||
where: { id: jobId, userId },
|
||||
});
|
||||
|
||||
if (!job) {
|
||||
throw new NotFoundException('Export job not found');
|
||||
}
|
||||
|
||||
return {
|
||||
id: job.id,
|
||||
status: job.status,
|
||||
format: job.format,
|
||||
type: job.type,
|
||||
filters: job.filters,
|
||||
totalRows: job.totalRows,
|
||||
errorMessage: job.errorMessage,
|
||||
createdAt: job.createdAt,
|
||||
expiresAt: job.expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
async getDownloadUrl(
|
||||
jobId: string,
|
||||
userId: string,
|
||||
): Promise<ExportDownloadDto> {
|
||||
const job = await this.exportJobRepository.findOne({
|
||||
where: { id: jobId, userId },
|
||||
});
|
||||
|
||||
if (!job) {
|
||||
throw new NotFoundException('Export job not found');
|
||||
}
|
||||
|
||||
if (job.status !== ExportStatus.COMPLETED) {
|
||||
throw new BadRequestException(
|
||||
`Export is not ready. Current status: ${job.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!job.s3Key) {
|
||||
throw new BadRequestException('Export file not found');
|
||||
}
|
||||
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: job.s3Key,
|
||||
});
|
||||
|
||||
const url = await getSignedUrl(this.s3Client, command, {
|
||||
expiresIn: this.urlExpiry,
|
||||
});
|
||||
|
||||
const expiresAt = new Date(Date.now() + this.urlExpiry * 1000);
|
||||
const extension = job.format === ExportFormat.CSV ? 'csv' : 'xlsx';
|
||||
const filename = `inventory_export_${job.id}.${extension}`;
|
||||
|
||||
return { url, expiresAt, filename };
|
||||
}
|
||||
|
||||
async processExport(data: ExportJobData): Promise<void> {
|
||||
const { jobId, storeId, format, type, filters } = data;
|
||||
|
||||
await this.exportJobRepository.update(jobId, {
|
||||
status: ExportStatus.PROCESSING,
|
||||
});
|
||||
|
||||
try {
|
||||
let buffer: Buffer;
|
||||
let totalRows = 0;
|
||||
|
||||
if (type === ExportType.INVENTORY) {
|
||||
const result = await this.generateInventoryExport(
|
||||
storeId,
|
||||
format,
|
||||
filters,
|
||||
);
|
||||
buffer = result.buffer;
|
||||
totalRows = result.totalRows;
|
||||
} else {
|
||||
throw new Error(`Unsupported export type: ${type}`);
|
||||
}
|
||||
|
||||
const extension = format === ExportFormat.CSV ? 'csv' : 'xlsx';
|
||||
const s3Key = `exports/${storeId}/${jobId}.${extension}`;
|
||||
|
||||
await this.uploadToS3(s3Key, buffer, format);
|
||||
|
||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
|
||||
await this.exportJobRepository.update(jobId, {
|
||||
status: ExportStatus.COMPLETED,
|
||||
s3Key,
|
||||
totalRows,
|
||||
expiresAt,
|
||||
});
|
||||
} catch (error) {
|
||||
await this.exportJobRepository.update(jobId, {
|
||||
status: ExportStatus.FAILED,
|
||||
errorMessage: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async generateInventoryExport(
|
||||
storeId: string,
|
||||
format: ExportFormat,
|
||||
filters?: ExportFilters,
|
||||
): Promise<{ buffer: Buffer; totalRows: number }> {
|
||||
const queryBuilder = this.inventoryRepository
|
||||
.createQueryBuilder('item')
|
||||
.where('item.storeId = :storeId', { storeId })
|
||||
.orderBy('item.name', 'ASC');
|
||||
|
||||
if (filters?.category) {
|
||||
queryBuilder.andWhere('item.category = :category', {
|
||||
category: filters.category,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters?.lowStockOnly) {
|
||||
queryBuilder.andWhere('item.quantity <= item.minStock');
|
||||
}
|
||||
|
||||
const items = await queryBuilder.getMany();
|
||||
|
||||
if (format === ExportFormat.CSV) {
|
||||
return this.generateCsv(items);
|
||||
} else {
|
||||
return this.generateExcel(items, storeId);
|
||||
}
|
||||
}
|
||||
|
||||
private async generateCsv(
|
||||
items: InventoryItem[],
|
||||
): Promise<{ buffer: Buffer; totalRows: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const rows: string[][] = [];
|
||||
|
||||
const csvStream = fastCsv.format({ headers: true });
|
||||
|
||||
csvStream.on('data', (chunk) => rows.push(chunk));
|
||||
csvStream.on('end', () => {
|
||||
const buffer = Buffer.from(rows.join(''));
|
||||
resolve({ buffer, totalRows: items.length });
|
||||
});
|
||||
csvStream.on('error', reject);
|
||||
|
||||
for (const item of items) {
|
||||
csvStream.write({
|
||||
ID: item.id,
|
||||
Nombre: item.name,
|
||||
Categoria: item.category || '',
|
||||
Subcategoria: item.subcategory || '',
|
||||
'Codigo de Barras': item.barcode || '',
|
||||
Cantidad: item.quantity,
|
||||
'Stock Minimo': item.minStock || 0,
|
||||
Precio: item.price || 0,
|
||||
Costo: item.cost || 0,
|
||||
'Valor Total': (item.quantity * (item.price || 0)).toFixed(2),
|
||||
'Confianza Deteccion': item.detectionConfidence
|
||||
? (item.detectionConfidence * 100).toFixed(1) + '%'
|
||||
: '',
|
||||
'Stock Bajo': item.quantity <= (item.minStock || 0) ? 'Sí' : 'No',
|
||||
'Fecha Creacion': item.createdAt?.toISOString() || '',
|
||||
'Ultima Actualizacion': item.updatedAt?.toISOString() || '',
|
||||
});
|
||||
}
|
||||
|
||||
csvStream.end();
|
||||
});
|
||||
}
|
||||
|
||||
private async generateExcel(
|
||||
items: InventoryItem[],
|
||||
storeId: string,
|
||||
): Promise<{ buffer: Buffer; totalRows: number }> {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
workbook.creator = 'MiInventario';
|
||||
workbook.created = new Date();
|
||||
|
||||
const summarySheet = workbook.addWorksheet('Resumen');
|
||||
const totalItems = items.length;
|
||||
const totalValue = items.reduce(
|
||||
(sum, item) => sum + item.quantity * (item.price || 0),
|
||||
0,
|
||||
);
|
||||
const totalCost = items.reduce(
|
||||
(sum, item) => sum + item.quantity * (item.cost || 0),
|
||||
0,
|
||||
);
|
||||
const lowStockCount = items.filter(
|
||||
(item) => item.quantity <= (item.minStock || 0),
|
||||
).length;
|
||||
const categories = [...new Set(items.map((item) => item.category).filter(Boolean))];
|
||||
|
||||
summarySheet.columns = [
|
||||
{ header: 'Métrica', key: 'metric', width: 30 },
|
||||
{ header: 'Valor', key: 'value', width: 20 },
|
||||
];
|
||||
|
||||
summarySheet.addRows([
|
||||
{ metric: 'Total de Productos', value: totalItems },
|
||||
{ metric: 'Valor Total (Precio)', value: `$${totalValue.toFixed(2)}` },
|
||||
{ metric: 'Costo Total', value: `$${totalCost.toFixed(2)}` },
|
||||
{ metric: 'Margen Potencial', value: `$${(totalValue - totalCost).toFixed(2)}` },
|
||||
{ metric: 'Productos con Stock Bajo', value: lowStockCount },
|
||||
{ metric: 'Categorías', value: categories.length },
|
||||
{ metric: 'Fecha de Exportación', value: new Date().toLocaleString('es-MX') },
|
||||
]);
|
||||
|
||||
summarySheet.getRow(1).font = { bold: true };
|
||||
|
||||
const inventorySheet = workbook.addWorksheet('Inventario');
|
||||
inventorySheet.columns = [
|
||||
{ header: 'Nombre', key: 'name', width: 30 },
|
||||
{ header: 'Categoría', key: 'category', width: 15 },
|
||||
{ header: 'Código', key: 'barcode', width: 15 },
|
||||
{ header: 'Cantidad', key: 'quantity', width: 10 },
|
||||
{ header: 'Stock Mín.', key: 'minStock', width: 10 },
|
||||
{ header: 'Precio', key: 'price', width: 12 },
|
||||
{ header: 'Costo', key: 'cost', width: 12 },
|
||||
{ header: 'Valor Total', key: 'totalValue', width: 12 },
|
||||
{ header: 'Confianza', key: 'confidence', width: 10 },
|
||||
{ header: 'Stock Bajo', key: 'lowStock', width: 10 },
|
||||
];
|
||||
|
||||
for (const item of items) {
|
||||
const row = inventorySheet.addRow({
|
||||
name: item.name,
|
||||
category: item.category || '',
|
||||
barcode: item.barcode || '',
|
||||
quantity: item.quantity,
|
||||
minStock: item.minStock || 0,
|
||||
price: item.price || 0,
|
||||
cost: item.cost || 0,
|
||||
totalValue: item.quantity * (item.price || 0),
|
||||
confidence: item.detectionConfidence
|
||||
? `${(item.detectionConfidence * 100).toFixed(0)}%`
|
||||
: '',
|
||||
lowStock: item.quantity <= (item.minStock || 0) ? 'Sí' : 'No',
|
||||
});
|
||||
|
||||
if (item.quantity <= (item.minStock || 0)) {
|
||||
row.getCell('lowStock').fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FFFFCCCC' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
inventorySheet.getRow(1).font = { bold: true };
|
||||
inventorySheet.getRow(1).fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FFE0E0E0' },
|
||||
};
|
||||
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
return { buffer: Buffer.from(buffer), totalRows: items.length };
|
||||
}
|
||||
|
||||
private async uploadToS3(
|
||||
key: string,
|
||||
buffer: Buffer,
|
||||
format: ExportFormat,
|
||||
): Promise<void> {
|
||||
const contentType =
|
||||
format === ExportFormat.CSV
|
||||
? 'text/csv'
|
||||
: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
ContentType: contentType,
|
||||
});
|
||||
|
||||
await this.s3Client.send(command);
|
||||
}
|
||||
}
|
||||
12
src/modules/feedback/dto/correct-quantity.dto.ts
Normal file
12
src/modules/feedback/dto/correct-quantity.dto.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { IsInt, IsOptional, IsString, Min, MaxLength } from 'class-validator';
|
||||
|
||||
export class CorrectQuantityDto {
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
quantity: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
reason?: string;
|
||||
}
|
||||
23
src/modules/feedback/dto/correct-sku.dto.ts
Normal file
23
src/modules/feedback/dto/correct-sku.dto.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { IsString, IsOptional, MaxLength, IsNotEmpty } from 'class-validator';
|
||||
|
||||
export class CorrectSkuDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(255)
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
category?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
barcode?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
reason?: string;
|
||||
}
|
||||
46
src/modules/feedback/dto/submit-product.dto.ts
Normal file
46
src/modules/feedback/dto/submit-product.dto.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsUUID,
|
||||
IsInt,
|
||||
IsObject,
|
||||
MaxLength,
|
||||
IsNotEmpty,
|
||||
} from 'class-validator';
|
||||
|
||||
export class SubmitProductDto {
|
||||
@IsUUID()
|
||||
storeId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
videoId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(255)
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
category?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
barcode?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
imageUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
frameTimestamp?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
boundingBox?: Record<string, any>;
|
||||
}
|
||||
67
src/modules/feedback/entities/correction.entity.ts
Normal file
67
src/modules/feedback/entities/correction.entity.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
import { Store } from '../../stores/entities/store.entity';
|
||||
import { InventoryItem } from '../../inventory/entities/inventory-item.entity';
|
||||
|
||||
export enum CorrectionType {
|
||||
QUANTITY = 'QUANTITY',
|
||||
SKU = 'SKU',
|
||||
CONFIRMATION = 'CONFIRMATION',
|
||||
}
|
||||
|
||||
@Entity('corrections')
|
||||
@Index(['inventoryItemId'])
|
||||
@Index(['userId'])
|
||||
@Index(['storeId'])
|
||||
@Index(['type', 'createdAt'])
|
||||
export class Correction {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column('uuid')
|
||||
inventoryItemId: string;
|
||||
|
||||
@Column('uuid')
|
||||
userId: string;
|
||||
|
||||
@Column('uuid')
|
||||
storeId: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: CorrectionType,
|
||||
})
|
||||
type: CorrectionType;
|
||||
|
||||
@Column('jsonb')
|
||||
previousValue: Record<string, any>;
|
||||
|
||||
@Column('jsonb')
|
||||
newValue: Record<string, any>;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
reason?: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@ManyToOne(() => InventoryItem, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'inventoryItemId' })
|
||||
inventoryItem: InventoryItem;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'userId' })
|
||||
user: User;
|
||||
|
||||
@ManyToOne(() => Store)
|
||||
@JoinColumn({ name: 'storeId' })
|
||||
store: Store;
|
||||
}
|
||||
86
src/modules/feedback/entities/ground-truth.entity.ts
Normal file
86
src/modules/feedback/entities/ground-truth.entity.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
import { Store } from '../../stores/entities/store.entity';
|
||||
import { Video } from '../../videos/entities/video.entity';
|
||||
import { InventoryItem } from '../../inventory/entities/inventory-item.entity';
|
||||
|
||||
export enum GroundTruthStatus {
|
||||
PENDING = 'PENDING',
|
||||
APPROVED = 'APPROVED',
|
||||
REJECTED = 'REJECTED',
|
||||
}
|
||||
|
||||
@Entity('ground_truth')
|
||||
@Index(['inventoryItemId'])
|
||||
@Index(['videoId'])
|
||||
@Index(['status'])
|
||||
@Index(['storeId'])
|
||||
export class GroundTruth {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column('uuid')
|
||||
inventoryItemId: string;
|
||||
|
||||
@Column('uuid')
|
||||
videoId: string;
|
||||
|
||||
@Column('uuid')
|
||||
storeId: string;
|
||||
|
||||
@Column('jsonb')
|
||||
originalDetection: Record<string, any>;
|
||||
|
||||
@Column('jsonb')
|
||||
correctedData: Record<string, any>;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
frameTimestamp?: number;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
boundingBox?: Record<string, any>;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: GroundTruthStatus,
|
||||
default: GroundTruthStatus.PENDING,
|
||||
})
|
||||
status: GroundTruthStatus;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
validatedBy?: string;
|
||||
|
||||
@Column({ type: 'decimal', precision: 5, scale: 2, nullable: true })
|
||||
validationScore?: number;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
|
||||
@ManyToOne(() => InventoryItem, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'inventoryItemId' })
|
||||
inventoryItem: InventoryItem;
|
||||
|
||||
@ManyToOne(() => Video)
|
||||
@JoinColumn({ name: 'videoId' })
|
||||
video: Video;
|
||||
|
||||
@ManyToOne(() => Store)
|
||||
@JoinColumn({ name: 'storeId' })
|
||||
store: Store;
|
||||
|
||||
@ManyToOne(() => User, { nullable: true })
|
||||
@JoinColumn({ name: 'validatedBy' })
|
||||
validator?: User;
|
||||
}
|
||||
91
src/modules/feedback/entities/product-submission.entity.ts
Normal file
91
src/modules/feedback/entities/product-submission.entity.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
import { Store } from '../../stores/entities/store.entity';
|
||||
import { Video } from '../../videos/entities/video.entity';
|
||||
|
||||
export enum ProductSubmissionStatus {
|
||||
PENDING = 'PENDING',
|
||||
APPROVED = 'APPROVED',
|
||||
REJECTED = 'REJECTED',
|
||||
}
|
||||
|
||||
@Entity('product_submissions')
|
||||
@Index(['userId'])
|
||||
@Index(['storeId'])
|
||||
@Index(['status'])
|
||||
@Index(['barcode'])
|
||||
export class ProductSubmission {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column('uuid')
|
||||
userId: string;
|
||||
|
||||
@Column('uuid')
|
||||
storeId: string;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
videoId?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||
category?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, nullable: true })
|
||||
barcode?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 500, nullable: true })
|
||||
imageUrl?: string;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
frameTimestamp?: number;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
boundingBox?: Record<string, any>;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: ProductSubmissionStatus,
|
||||
default: ProductSubmissionStatus.PENDING,
|
||||
})
|
||||
status: ProductSubmissionStatus;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
reviewedBy?: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
reviewNotes?: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'userId' })
|
||||
user: User;
|
||||
|
||||
@ManyToOne(() => Store)
|
||||
@JoinColumn({ name: 'storeId' })
|
||||
store: Store;
|
||||
|
||||
@ManyToOne(() => Video, { nullable: true })
|
||||
@JoinColumn({ name: 'videoId' })
|
||||
video?: Video;
|
||||
|
||||
@ManyToOne(() => User, { nullable: true })
|
||||
@JoinColumn({ name: 'reviewedBy' })
|
||||
reviewer?: User;
|
||||
}
|
||||
170
src/modules/feedback/feedback.controller.ts
Normal file
170
src/modules/feedback/feedback.controller.ts
Normal file
@ -0,0 +1,170 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Req,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { FeedbackService } from './feedback.service';
|
||||
import { CorrectQuantityDto } from './dto/correct-quantity.dto';
|
||||
import { CorrectSkuDto } from './dto/correct-sku.dto';
|
||||
import { SubmitProductDto } from './dto/submit-product.dto';
|
||||
|
||||
interface AuthRequest extends Request {
|
||||
user: { id: string };
|
||||
}
|
||||
|
||||
@Controller()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class FeedbackController {
|
||||
constructor(private readonly feedbackService: FeedbackService) {}
|
||||
|
||||
@Patch('stores/:storeId/inventory/:itemId/correct-quantity')
|
||||
async correctQuantity(
|
||||
@Req() req: AuthRequest,
|
||||
@Param('storeId', ParseUUIDPipe) storeId: string,
|
||||
@Param('itemId', ParseUUIDPipe) itemId: string,
|
||||
@Body() dto: CorrectQuantityDto,
|
||||
) {
|
||||
const { correction, item } = await this.feedbackService.correctQuantity(
|
||||
req.user.id,
|
||||
storeId,
|
||||
itemId,
|
||||
dto,
|
||||
);
|
||||
return {
|
||||
message: 'Cantidad corregida exitosamente',
|
||||
correction: {
|
||||
id: correction.id,
|
||||
type: correction.type,
|
||||
previousValue: correction.previousValue,
|
||||
newValue: correction.newValue,
|
||||
createdAt: correction.createdAt,
|
||||
},
|
||||
item: {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
quantity: item.quantity,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Patch('stores/:storeId/inventory/:itemId/correct-sku')
|
||||
async correctSku(
|
||||
@Req() req: AuthRequest,
|
||||
@Param('storeId', ParseUUIDPipe) storeId: string,
|
||||
@Param('itemId', ParseUUIDPipe) itemId: string,
|
||||
@Body() dto: CorrectSkuDto,
|
||||
) {
|
||||
const { correction, item } = await this.feedbackService.correctSku(
|
||||
req.user.id,
|
||||
storeId,
|
||||
itemId,
|
||||
dto,
|
||||
);
|
||||
return {
|
||||
message: 'Nombre/SKU corregido exitosamente',
|
||||
correction: {
|
||||
id: correction.id,
|
||||
type: correction.type,
|
||||
previousValue: correction.previousValue,
|
||||
newValue: correction.newValue,
|
||||
createdAt: correction.createdAt,
|
||||
},
|
||||
item: {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
category: item.category,
|
||||
barcode: item.barcode,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Post('stores/:storeId/inventory/:itemId/confirm')
|
||||
async confirmItem(
|
||||
@Req() req: AuthRequest,
|
||||
@Param('storeId', ParseUUIDPipe) storeId: string,
|
||||
@Param('itemId', ParseUUIDPipe) itemId: string,
|
||||
) {
|
||||
const { correction, item } = await this.feedbackService.confirmItem(
|
||||
req.user.id,
|
||||
storeId,
|
||||
itemId,
|
||||
);
|
||||
return {
|
||||
message: 'Item confirmado exitosamente',
|
||||
correction: {
|
||||
id: correction.id,
|
||||
type: correction.type,
|
||||
createdAt: correction.createdAt,
|
||||
},
|
||||
item: {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
quantity: item.quantity,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Get('stores/:storeId/inventory/:itemId/history')
|
||||
async getCorrectionHistory(
|
||||
@Req() req: AuthRequest,
|
||||
@Param('storeId', ParseUUIDPipe) storeId: string,
|
||||
@Param('itemId', ParseUUIDPipe) itemId: string,
|
||||
) {
|
||||
const corrections = await this.feedbackService.getCorrectionHistory(
|
||||
req.user.id,
|
||||
storeId,
|
||||
itemId,
|
||||
);
|
||||
return {
|
||||
corrections: corrections.map((c) => ({
|
||||
id: c.id,
|
||||
type: c.type,
|
||||
previousValue: c.previousValue,
|
||||
newValue: c.newValue,
|
||||
reason: c.reason,
|
||||
createdAt: c.createdAt,
|
||||
user: c.user ? { id: c.user.id, name: c.user.name } : null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@Post('products/submit')
|
||||
async submitProduct(@Req() req: AuthRequest, @Body() dto: SubmitProductDto) {
|
||||
const submission = await this.feedbackService.submitProduct(req.user.id, dto);
|
||||
return {
|
||||
message: 'Producto enviado para revision',
|
||||
submission: {
|
||||
id: submission.id,
|
||||
name: submission.name,
|
||||
status: submission.status,
|
||||
createdAt: submission.createdAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@Get('products/search')
|
||||
async searchProducts(@Query('q') query: string, @Query('limit') limit?: string) {
|
||||
const products = await this.feedbackService.searchProducts(
|
||||
query || '',
|
||||
limit ? parseInt(limit, 10) : 10,
|
||||
);
|
||||
return {
|
||||
products: products.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
category: p.category,
|
||||
barcode: p.barcode,
|
||||
imageUrl: p.imageUrl,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
25
src/modules/feedback/feedback.module.ts
Normal file
25
src/modules/feedback/feedback.module.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { FeedbackController } from './feedback.controller';
|
||||
import { FeedbackService } from './feedback.service';
|
||||
import { Correction } from './entities/correction.entity';
|
||||
import { GroundTruth } from './entities/ground-truth.entity';
|
||||
import { ProductSubmission } from './entities/product-submission.entity';
|
||||
import { InventoryItem } from '../inventory/entities/inventory-item.entity';
|
||||
import { StoreUser } from '../stores/entities/store-user.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
Correction,
|
||||
GroundTruth,
|
||||
ProductSubmission,
|
||||
InventoryItem,
|
||||
StoreUser,
|
||||
]),
|
||||
],
|
||||
controllers: [FeedbackController],
|
||||
providers: [FeedbackService],
|
||||
exports: [FeedbackService],
|
||||
})
|
||||
export class FeedbackModule {}
|
||||
239
src/modules/feedback/feedback.service.ts
Normal file
239
src/modules/feedback/feedback.service.ts
Normal file
@ -0,0 +1,239 @@
|
||||
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Correction, CorrectionType } from './entities/correction.entity';
|
||||
import { GroundTruth, GroundTruthStatus } from './entities/ground-truth.entity';
|
||||
import { ProductSubmission } from './entities/product-submission.entity';
|
||||
import { InventoryItem } from '../inventory/entities/inventory-item.entity';
|
||||
import { StoreUser } from '../stores/entities/store-user.entity';
|
||||
import { CorrectQuantityDto } from './dto/correct-quantity.dto';
|
||||
import { CorrectSkuDto } from './dto/correct-sku.dto';
|
||||
import { SubmitProductDto } from './dto/submit-product.dto';
|
||||
|
||||
@Injectable()
|
||||
export class FeedbackService {
|
||||
constructor(
|
||||
@InjectRepository(Correction)
|
||||
private correctionRepository: Repository<Correction>,
|
||||
@InjectRepository(GroundTruth)
|
||||
private groundTruthRepository: Repository<GroundTruth>,
|
||||
@InjectRepository(ProductSubmission)
|
||||
private productSubmissionRepository: Repository<ProductSubmission>,
|
||||
@InjectRepository(InventoryItem)
|
||||
private inventoryItemRepository: Repository<InventoryItem>,
|
||||
@InjectRepository(StoreUser)
|
||||
private storeUserRepository: Repository<StoreUser>,
|
||||
) {}
|
||||
|
||||
async verifyStoreAccess(userId: string, storeId: string): Promise<void> {
|
||||
const storeUser = await this.storeUserRepository.findOne({
|
||||
where: { userId, storeId, isActive: true },
|
||||
});
|
||||
if (!storeUser) {
|
||||
throw new ForbiddenException('No tienes acceso a esta tienda');
|
||||
}
|
||||
}
|
||||
|
||||
async getInventoryItem(itemId: string, storeId: string): Promise<InventoryItem> {
|
||||
const item = await this.inventoryItemRepository.findOne({
|
||||
where: { id: itemId, storeId },
|
||||
});
|
||||
if (!item) {
|
||||
throw new NotFoundException('Item de inventario no encontrado');
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
async correctQuantity(
|
||||
userId: string,
|
||||
storeId: string,
|
||||
itemId: string,
|
||||
dto: CorrectQuantityDto,
|
||||
): Promise<{ correction: Correction; item: InventoryItem }> {
|
||||
await this.verifyStoreAccess(userId, storeId);
|
||||
const item = await this.getInventoryItem(itemId, storeId);
|
||||
|
||||
const previousValue = { quantity: item.quantity };
|
||||
const newValue = { quantity: dto.quantity };
|
||||
|
||||
// Create correction record
|
||||
const correction = this.correctionRepository.create({
|
||||
inventoryItemId: itemId,
|
||||
userId,
|
||||
storeId,
|
||||
type: CorrectionType.QUANTITY,
|
||||
previousValue,
|
||||
newValue,
|
||||
reason: dto.reason,
|
||||
});
|
||||
await this.correctionRepository.save(correction);
|
||||
|
||||
// Update item
|
||||
item.quantity = dto.quantity;
|
||||
item.isManuallyEdited = true;
|
||||
await this.inventoryItemRepository.save(item);
|
||||
|
||||
// Create ground truth entry if item was detected from video
|
||||
if (item.detectedByVideoId) {
|
||||
await this.createGroundTruth(item, previousValue, newValue);
|
||||
}
|
||||
|
||||
return { correction, item };
|
||||
}
|
||||
|
||||
async correctSku(
|
||||
userId: string,
|
||||
storeId: string,
|
||||
itemId: string,
|
||||
dto: CorrectSkuDto,
|
||||
): Promise<{ correction: Correction; item: InventoryItem }> {
|
||||
await this.verifyStoreAccess(userId, storeId);
|
||||
const item = await this.getInventoryItem(itemId, storeId);
|
||||
|
||||
const previousValue = {
|
||||
name: item.name,
|
||||
category: item.category,
|
||||
barcode: item.barcode,
|
||||
};
|
||||
const newValue = {
|
||||
name: dto.name,
|
||||
category: dto.category ?? item.category,
|
||||
barcode: dto.barcode ?? item.barcode,
|
||||
};
|
||||
|
||||
// Create correction record
|
||||
const correction = this.correctionRepository.create({
|
||||
inventoryItemId: itemId,
|
||||
userId,
|
||||
storeId,
|
||||
type: CorrectionType.SKU,
|
||||
previousValue,
|
||||
newValue,
|
||||
reason: dto.reason,
|
||||
});
|
||||
await this.correctionRepository.save(correction);
|
||||
|
||||
// Update item
|
||||
item.name = dto.name;
|
||||
if (dto.category) item.category = dto.category;
|
||||
if (dto.barcode) item.barcode = dto.barcode;
|
||||
item.isManuallyEdited = true;
|
||||
await this.inventoryItemRepository.save(item);
|
||||
|
||||
// Create ground truth entry if item was detected from video
|
||||
if (item.detectedByVideoId) {
|
||||
await this.createGroundTruth(item, previousValue, newValue);
|
||||
}
|
||||
|
||||
return { correction, item };
|
||||
}
|
||||
|
||||
async confirmItem(
|
||||
userId: string,
|
||||
storeId: string,
|
||||
itemId: string,
|
||||
): Promise<{ correction: Correction; item: InventoryItem }> {
|
||||
await this.verifyStoreAccess(userId, storeId);
|
||||
const item = await this.getInventoryItem(itemId, storeId);
|
||||
|
||||
const currentValue = {
|
||||
name: item.name,
|
||||
quantity: item.quantity,
|
||||
category: item.category,
|
||||
};
|
||||
|
||||
// Create confirmation record
|
||||
const correction = this.correctionRepository.create({
|
||||
inventoryItemId: itemId,
|
||||
userId,
|
||||
storeId,
|
||||
type: CorrectionType.CONFIRMATION,
|
||||
previousValue: currentValue,
|
||||
newValue: currentValue,
|
||||
});
|
||||
await this.correctionRepository.save(correction);
|
||||
|
||||
// Mark as manually verified
|
||||
item.isManuallyEdited = true;
|
||||
await this.inventoryItemRepository.save(item);
|
||||
|
||||
// Create approved ground truth if from video
|
||||
if (item.detectedByVideoId) {
|
||||
const groundTruth = this.groundTruthRepository.create({
|
||||
inventoryItemId: itemId,
|
||||
videoId: item.detectedByVideoId,
|
||||
storeId,
|
||||
originalDetection: currentValue,
|
||||
correctedData: currentValue,
|
||||
status: GroundTruthStatus.APPROVED,
|
||||
validatedBy: userId,
|
||||
validationScore: 1.0,
|
||||
});
|
||||
await this.groundTruthRepository.save(groundTruth);
|
||||
}
|
||||
|
||||
return { correction, item };
|
||||
}
|
||||
|
||||
async getCorrectionHistory(
|
||||
userId: string,
|
||||
storeId: string,
|
||||
itemId: string,
|
||||
): Promise<Correction[]> {
|
||||
await this.verifyStoreAccess(userId, storeId);
|
||||
await this.getInventoryItem(itemId, storeId);
|
||||
|
||||
return this.correctionRepository.find({
|
||||
where: { inventoryItemId: itemId },
|
||||
order: { createdAt: 'DESC' },
|
||||
relations: ['user'],
|
||||
});
|
||||
}
|
||||
|
||||
async submitProduct(userId: string, dto: SubmitProductDto): Promise<ProductSubmission> {
|
||||
await this.verifyStoreAccess(userId, dto.storeId);
|
||||
|
||||
const submission = this.productSubmissionRepository.create({
|
||||
userId,
|
||||
storeId: dto.storeId,
|
||||
videoId: dto.videoId,
|
||||
name: dto.name,
|
||||
category: dto.category,
|
||||
barcode: dto.barcode,
|
||||
imageUrl: dto.imageUrl,
|
||||
frameTimestamp: dto.frameTimestamp,
|
||||
boundingBox: dto.boundingBox,
|
||||
});
|
||||
|
||||
return this.productSubmissionRepository.save(submission);
|
||||
}
|
||||
|
||||
async searchProducts(query: string, limit = 10): Promise<ProductSubmission[]> {
|
||||
return this.productSubmissionRepository
|
||||
.createQueryBuilder('ps')
|
||||
.where('ps.status = :status', { status: 'APPROVED' })
|
||||
.andWhere('(ps.name ILIKE :query OR ps.barcode = :exactQuery)', {
|
||||
query: `%${query}%`,
|
||||
exactQuery: query,
|
||||
})
|
||||
.orderBy('ps.name', 'ASC')
|
||||
.limit(limit)
|
||||
.getMany();
|
||||
}
|
||||
|
||||
private async createGroundTruth(
|
||||
item: InventoryItem,
|
||||
originalDetection: Record<string, any>,
|
||||
correctedData: Record<string, any>,
|
||||
): Promise<GroundTruth> {
|
||||
const groundTruth = this.groundTruthRepository.create({
|
||||
inventoryItemId: item.id,
|
||||
videoId: item.detectedByVideoId!,
|
||||
storeId: item.storeId,
|
||||
originalDetection,
|
||||
correctedData,
|
||||
status: GroundTruthStatus.PENDING,
|
||||
});
|
||||
return this.groundTruthRepository.save(groundTruth);
|
||||
}
|
||||
}
|
||||
27
src/modules/health/health.controller.ts
Normal file
27
src/modules/health/health.controller.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
|
||||
@ApiTags('health')
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Health check' })
|
||||
check() {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'miinventario-api',
|
||||
version: '0.1.0',
|
||||
};
|
||||
}
|
||||
|
||||
@Get('ready')
|
||||
@ApiOperation({ summary: 'Readiness check' })
|
||||
ready() {
|
||||
// TODO: Add database and redis connectivity checks
|
||||
return {
|
||||
status: 'ready',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
7
src/modules/health/health.module.ts
Normal file
7
src/modules/health/health.module.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
8
src/modules/ia-provider/ia-provider.module.ts
Normal file
8
src/modules/ia-provider/ia-provider.module.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { IAProviderService } from './ia-provider.service';
|
||||
|
||||
@Module({
|
||||
providers: [IAProviderService],
|
||||
exports: [IAProviderService],
|
||||
})
|
||||
export class IAProviderModule {}
|
||||
548
src/modules/ia-provider/ia-provider.service.ts
Normal file
548
src/modules/ia-provider/ia-provider.service.ts
Normal file
@ -0,0 +1,548 @@
|
||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import OpenAI from 'openai';
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
import * as crypto from 'crypto';
|
||||
import Redis from 'ioredis';
|
||||
import { DetectedItem } from '../inventory/inventory.service';
|
||||
|
||||
export interface IAProvider {
|
||||
name: string;
|
||||
detectInventory(frames: string[], context?: string): Promise<DetectedItem[]>;
|
||||
}
|
||||
|
||||
export interface ProviderMetrics {
|
||||
provider: string;
|
||||
calls: number;
|
||||
errors: number;
|
||||
avgLatencyMs: number;
|
||||
lastLatencyMs: number;
|
||||
cacheHits: number;
|
||||
cacheMisses: number;
|
||||
}
|
||||
|
||||
interface ProviderConfig {
|
||||
timeoutMs: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const INVENTORY_DETECTION_PROMPT = `Eres un sistema de vision por computadora especializado en detectar productos de inventario en tiendas mexicanas (abarrotes, miscelaneas, tienditas).
|
||||
|
||||
Analiza las imagenes proporcionadas y detecta todos los productos visibles en los estantes.
|
||||
|
||||
Para cada producto detectado, proporciona:
|
||||
1. name: Nombre del producto incluyendo marca y presentacion (ej: "Coca Cola 600ml", "Sabritas Original 45g")
|
||||
2. quantity: Cantidad estimada visible
|
||||
3. confidence: Tu nivel de confianza (0.0 a 1.0)
|
||||
4. category: Categoria del producto (Bebidas, Botanas, Lacteos, Panaderia, Abarrotes, Limpieza, etc.)
|
||||
5. barcode: Codigo de barras si es visible (opcional)
|
||||
|
||||
Responde UNICAMENTE con un JSON array valido, sin texto adicional. Ejemplo:
|
||||
[
|
||||
{"name": "Coca Cola 600ml", "quantity": 24, "confidence": 0.95, "category": "Bebidas", "barcode": "7501055303045"},
|
||||
{"name": "Sabritas Original 45g", "quantity": 15, "confidence": 0.92, "category": "Botanas"}
|
||||
]
|
||||
|
||||
Si no puedes detectar productos, responde con un array vacio: []`;
|
||||
|
||||
@Injectable()
|
||||
export class IAProviderService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(IAProviderService.name);
|
||||
private activeProvider: string;
|
||||
private openai: OpenAI | null = null;
|
||||
private anthropic: Anthropic | null = null;
|
||||
private redis: Redis | null = null;
|
||||
|
||||
// Provider configurations
|
||||
private providerConfigs: Map<string, ProviderConfig> = new Map([
|
||||
['openai', { timeoutMs: 60000, enabled: true }],
|
||||
['claude', { timeoutMs: 90000, enabled: true }],
|
||||
]);
|
||||
|
||||
// Metrics tracking
|
||||
private metrics: Map<string, ProviderMetrics> = new Map([
|
||||
['openai', { provider: 'openai', calls: 0, errors: 0, avgLatencyMs: 0, lastLatencyMs: 0, cacheHits: 0, cacheMisses: 0 }],
|
||||
['claude', { provider: 'claude', calls: 0, errors: 0, avgLatencyMs: 0, lastLatencyMs: 0, cacheHits: 0, cacheMisses: 0 }],
|
||||
]);
|
||||
|
||||
// Cache TTL in seconds (24 hours)
|
||||
private readonly CACHE_TTL = 24 * 60 * 60;
|
||||
private readonly CACHE_PREFIX = 'ia:detection:';
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.activeProvider = this.configService.get('IA_PROVIDER', 'openai');
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
this.initializeClients();
|
||||
this.initializeRedis();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
if (this.redis) {
|
||||
await this.redis.quit();
|
||||
}
|
||||
}
|
||||
|
||||
private initializeRedis() {
|
||||
try {
|
||||
const redisHost = this.configService.get('REDIS_HOST', 'localhost');
|
||||
const redisPort = this.configService.get('REDIS_PORT', 6380);
|
||||
const redisPassword = this.configService.get('REDIS_PASSWORD');
|
||||
|
||||
this.redis = new Redis({
|
||||
host: redisHost,
|
||||
port: redisPort,
|
||||
password: redisPassword,
|
||||
maxRetriesPerRequest: 3,
|
||||
lazyConnect: true,
|
||||
});
|
||||
|
||||
this.redis.on('connect', () => {
|
||||
this.logger.log('Redis cache connected for IA provider');
|
||||
});
|
||||
|
||||
this.redis.on('error', (err) => {
|
||||
this.logger.warn(`Redis cache error: ${err.message}`);
|
||||
});
|
||||
|
||||
this.redis.connect().catch(() => {
|
||||
this.logger.warn('Redis not available for caching');
|
||||
this.redis = null;
|
||||
});
|
||||
} catch {
|
||||
this.logger.warn('Failed to initialize Redis cache');
|
||||
this.redis = null;
|
||||
}
|
||||
}
|
||||
|
||||
private initializeClients() {
|
||||
// Initialize OpenAI
|
||||
const openaiKey = this.configService.get('OPENAI_API_KEY');
|
||||
if (openaiKey && !openaiKey.includes('your-openai-key')) {
|
||||
this.openai = new OpenAI({ apiKey: openaiKey });
|
||||
this.logger.log('OpenAI client initialized');
|
||||
}
|
||||
|
||||
// Initialize Anthropic
|
||||
const anthropicKey = this.configService.get('ANTHROPIC_API_KEY');
|
||||
if (anthropicKey && !anthropicKey.includes('your-anthropic-key')) {
|
||||
this.anthropic = new Anthropic({ apiKey: anthropicKey });
|
||||
this.logger.log('Anthropic client initialized');
|
||||
}
|
||||
|
||||
if (!this.openai && !this.anthropic) {
|
||||
this.logger.warn('No IA providers configured - will use mock detection');
|
||||
}
|
||||
}
|
||||
|
||||
async detectInventory(
|
||||
frames: string[],
|
||||
storeId: string,
|
||||
): Promise<DetectedItem[]> {
|
||||
this.logger.log(
|
||||
`Detecting inventory from ${frames.length} frames using ${this.activeProvider}`,
|
||||
);
|
||||
|
||||
// Generate cache key from frames hash
|
||||
const cacheKey = this.generateCacheKey(frames, storeId);
|
||||
|
||||
// Try to get from cache
|
||||
const cached = await this.getFromCache(cacheKey);
|
||||
if (cached) {
|
||||
this.logger.log('Cache hit for detection request');
|
||||
this.updateMetrics(this.activeProvider, 0, false, true);
|
||||
return cached;
|
||||
}
|
||||
|
||||
this.logger.log('Cache miss, calling IA provider');
|
||||
|
||||
const startTime = Date.now();
|
||||
let result: DetectedItem[] = [];
|
||||
let usedProvider = this.activeProvider;
|
||||
|
||||
try {
|
||||
result = await this.detectWithTimeout(
|
||||
this.activeProvider,
|
||||
frames,
|
||||
storeId,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn(`Primary provider (${this.activeProvider}) failed: ${error.message}`);
|
||||
|
||||
// Try fallback provider
|
||||
const fallbackProvider = this.activeProvider === 'openai' ? 'claude' : 'openai';
|
||||
const fallbackConfig = this.providerConfigs.get(fallbackProvider);
|
||||
|
||||
if (fallbackConfig?.enabled && this.getProviderClient(fallbackProvider)) {
|
||||
this.logger.log(`Trying fallback provider: ${fallbackProvider}`);
|
||||
try {
|
||||
result = await this.detectWithTimeout(fallbackProvider, frames, storeId);
|
||||
usedProvider = fallbackProvider;
|
||||
} catch (fallbackError) {
|
||||
this.logger.error(`Fallback provider also failed: ${fallbackError.message}`);
|
||||
this.updateMetrics(fallbackProvider, Date.now() - startTime, true, false);
|
||||
|
||||
// Last resort: development mock
|
||||
if (this.configService.get('NODE_ENV') === 'development') {
|
||||
this.logger.warn('Falling back to mock detection');
|
||||
return this.getMockDetection();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
if (this.configService.get('NODE_ENV') === 'development') {
|
||||
return this.getMockDetection();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const latency = Date.now() - startTime;
|
||||
this.updateMetrics(usedProvider, latency, false, false);
|
||||
|
||||
// Cache the result
|
||||
await this.saveToCache(cacheKey, result);
|
||||
|
||||
this.logger.log(`Detection completed in ${latency}ms using ${usedProvider}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async detectWithTimeout(
|
||||
provider: string,
|
||||
frames: string[],
|
||||
storeId: string,
|
||||
): Promise<DetectedItem[]> {
|
||||
const config = this.providerConfigs.get(provider);
|
||||
const timeoutMs = config?.timeoutMs || 60000;
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error(`Provider ${provider} timeout after ${timeoutMs}ms`)), timeoutMs);
|
||||
});
|
||||
|
||||
const detectPromise = provider === 'openai'
|
||||
? this.detectWithOpenAI(frames, storeId)
|
||||
: this.detectWithClaude(frames, storeId);
|
||||
|
||||
return Promise.race([detectPromise, timeoutPromise]);
|
||||
}
|
||||
|
||||
private getProviderClient(provider: string): boolean {
|
||||
if (provider === 'openai') return this.openai !== null;
|
||||
if (provider === 'claude') return this.anthropic !== null;
|
||||
return false;
|
||||
}
|
||||
|
||||
private generateCacheKey(frames: string[], storeId: string): string {
|
||||
// Create hash from first 5 frames to identify unique video content
|
||||
const framesHash = crypto
|
||||
.createHash('sha256')
|
||||
.update(frames.slice(0, 5).join(''))
|
||||
.digest('hex')
|
||||
.substring(0, 16);
|
||||
return `${this.CACHE_PREFIX}${storeId}:${framesHash}`;
|
||||
}
|
||||
|
||||
private async getFromCache(key: string): Promise<DetectedItem[] | null> {
|
||||
if (!this.redis) return null;
|
||||
|
||||
try {
|
||||
const cached = await this.redis.get(key);
|
||||
if (cached) {
|
||||
return JSON.parse(cached);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`Cache read error: ${err.message}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async saveToCache(key: string, data: DetectedItem[]): Promise<void> {
|
||||
if (!this.redis) return;
|
||||
|
||||
try {
|
||||
await this.redis.setex(key, this.CACHE_TTL, JSON.stringify(data));
|
||||
} catch (err) {
|
||||
this.logger.warn(`Cache write error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private updateMetrics(
|
||||
provider: string,
|
||||
latencyMs: number,
|
||||
isError: boolean,
|
||||
isCacheHit: boolean,
|
||||
): void {
|
||||
const metrics = this.metrics.get(provider);
|
||||
if (!metrics) return;
|
||||
|
||||
metrics.calls++;
|
||||
if (isError) {
|
||||
metrics.errors++;
|
||||
} else if (isCacheHit) {
|
||||
metrics.cacheHits++;
|
||||
} else {
|
||||
metrics.cacheMisses++;
|
||||
metrics.lastLatencyMs = latencyMs;
|
||||
// Running average
|
||||
metrics.avgLatencyMs = Math.round(
|
||||
(metrics.avgLatencyMs * (metrics.calls - 1) + latencyMs) / metrics.calls,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getMetrics(): ProviderMetrics[] {
|
||||
return Array.from(this.metrics.values());
|
||||
}
|
||||
|
||||
getProviderConfig(provider: string): ProviderConfig | undefined {
|
||||
return this.providerConfigs.get(provider);
|
||||
}
|
||||
|
||||
setProviderTimeout(provider: string, timeoutMs: number): void {
|
||||
const config = this.providerConfigs.get(provider);
|
||||
if (config) {
|
||||
config.timeoutMs = timeoutMs;
|
||||
this.logger.log(`Updated ${provider} timeout to ${timeoutMs}ms`);
|
||||
}
|
||||
}
|
||||
|
||||
private async detectWithOpenAI(
|
||||
frames: string[],
|
||||
storeId: string,
|
||||
): Promise<DetectedItem[]> {
|
||||
if (!this.openai) {
|
||||
this.logger.warn('OpenAI not configured, using mock data');
|
||||
return this.getMockDetection();
|
||||
}
|
||||
|
||||
this.logger.log(`Calling OpenAI Vision API with ${frames.length} images`);
|
||||
|
||||
// Prepare image content for the API
|
||||
const imageContent: OpenAI.Chat.Completions.ChatCompletionContentPart[] =
|
||||
frames.slice(0, 10).map((frame) => ({
|
||||
type: 'image_url' as const,
|
||||
image_url: {
|
||||
url: frame.startsWith('data:') ? frame : `data:image/jpeg;base64,${frame}`,
|
||||
detail: 'high' as const,
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
const response = await this.openai.chat.completions.create({
|
||||
model: 'gpt-4o',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: INVENTORY_DETECTION_PROMPT },
|
||||
...imageContent,
|
||||
],
|
||||
},
|
||||
],
|
||||
max_tokens: 4096,
|
||||
temperature: 0.1,
|
||||
});
|
||||
|
||||
const content = response.choices[0]?.message?.content;
|
||||
if (!content) {
|
||||
this.logger.warn('Empty response from OpenAI');
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.parseDetectionResponse(content);
|
||||
} catch (error) {
|
||||
this.logger.error(`OpenAI API error: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async detectWithClaude(
|
||||
frames: string[],
|
||||
storeId: string,
|
||||
): Promise<DetectedItem[]> {
|
||||
if (!this.anthropic) {
|
||||
this.logger.warn('Anthropic not configured, using mock data');
|
||||
return this.getMockDetection();
|
||||
}
|
||||
|
||||
this.logger.log(`Calling Claude Vision API with ${frames.length} images`);
|
||||
|
||||
// Prepare image content for Claude
|
||||
const imageContent: Anthropic.Messages.ImageBlockParam[] = frames
|
||||
.slice(0, 10)
|
||||
.map((frame) => ({
|
||||
type: 'image' as const,
|
||||
source: {
|
||||
type: 'base64' as const,
|
||||
media_type: 'image/jpeg' as const,
|
||||
data: frame.startsWith('data:')
|
||||
? frame.replace(/^data:image\/\w+;base64,/, '')
|
||||
: frame,
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
const response = await this.anthropic.messages.create({
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
max_tokens: 4096,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
...imageContent,
|
||||
{ type: 'text', text: INVENTORY_DETECTION_PROMPT },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const content = response.content[0];
|
||||
if (content.type !== 'text') {
|
||||
this.logger.warn('Unexpected response type from Claude');
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.parseDetectionResponse(content.text);
|
||||
} catch (error) {
|
||||
this.logger.error(`Claude API error: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private parseDetectionResponse(content: string): DetectedItem[] {
|
||||
try {
|
||||
// Try to extract JSON from the response
|
||||
let jsonStr = content.trim();
|
||||
|
||||
// Handle markdown code blocks
|
||||
const jsonMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/);
|
||||
if (jsonMatch) {
|
||||
jsonStr = jsonMatch[1].trim();
|
||||
}
|
||||
|
||||
// Find the JSON array
|
||||
const arrayMatch = jsonStr.match(/\[[\s\S]*\]/);
|
||||
if (arrayMatch) {
|
||||
jsonStr = arrayMatch[0];
|
||||
}
|
||||
|
||||
const items = JSON.parse(jsonStr);
|
||||
|
||||
if (!Array.isArray(items)) {
|
||||
this.logger.warn('Response is not an array');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Validate and normalize items
|
||||
return items
|
||||
.filter(
|
||||
(item: any) =>
|
||||
item.name &&
|
||||
typeof item.quantity === 'number' &&
|
||||
item.quantity >= 0,
|
||||
)
|
||||
.map((item: any) => ({
|
||||
name: String(item.name).trim(),
|
||||
quantity: Math.max(0, Math.floor(item.quantity)),
|
||||
confidence: Math.min(1, Math.max(0, Number(item.confidence) || 0.5)),
|
||||
category: item.category ? String(item.category).trim() : undefined,
|
||||
barcode: item.barcode ? String(item.barcode).trim() : undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to parse detection response: ${error.message}`);
|
||||
this.logger.debug(`Raw response: ${content.substring(0, 500)}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private getMockDetection(): DetectedItem[] {
|
||||
// Simulated detection results for Mexican tiendita products
|
||||
return [
|
||||
{
|
||||
name: 'Coca Cola 600ml',
|
||||
quantity: 24,
|
||||
confidence: 0.95,
|
||||
category: 'Bebidas',
|
||||
barcode: '7501055303045',
|
||||
},
|
||||
{
|
||||
name: 'Sabritas Original 45g',
|
||||
quantity: 15,
|
||||
confidence: 0.92,
|
||||
category: 'Botanas',
|
||||
},
|
||||
{
|
||||
name: 'Maruchan Pollo',
|
||||
quantity: 30,
|
||||
confidence: 0.88,
|
||||
category: 'Sopas',
|
||||
},
|
||||
{
|
||||
name: 'Bimbo Pan Blanco',
|
||||
quantity: 8,
|
||||
confidence: 0.91,
|
||||
category: 'Panaderia',
|
||||
},
|
||||
{
|
||||
name: 'Leche Lala 1L',
|
||||
quantity: 12,
|
||||
confidence: 0.89,
|
||||
category: 'Lacteos',
|
||||
},
|
||||
{
|
||||
name: 'Huevos San Juan (12)',
|
||||
quantity: 6,
|
||||
confidence: 0.87,
|
||||
category: 'Huevos',
|
||||
},
|
||||
{
|
||||
name: 'Aceite 1-2-3 1L',
|
||||
quantity: 5,
|
||||
confidence: 0.93,
|
||||
category: 'Aceites',
|
||||
},
|
||||
{
|
||||
name: 'Azucar Zulka 1kg',
|
||||
quantity: 10,
|
||||
confidence: 0.90,
|
||||
category: 'Abarrotes',
|
||||
},
|
||||
{
|
||||
name: 'Frijoles La Costena 400g',
|
||||
quantity: 18,
|
||||
confidence: 0.86,
|
||||
category: 'Enlatados',
|
||||
},
|
||||
{
|
||||
name: 'Jabon Roma 250g',
|
||||
quantity: 20,
|
||||
confidence: 0.94,
|
||||
category: 'Limpieza',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
getActiveProvider(): string {
|
||||
return this.activeProvider;
|
||||
}
|
||||
|
||||
setActiveProvider(provider: string): void {
|
||||
if (!['openai', 'claude'].includes(provider)) {
|
||||
throw new Error(`Unknown provider: ${provider}`);
|
||||
}
|
||||
this.activeProvider = provider;
|
||||
this.logger.log(`Switched to provider: ${provider}`);
|
||||
}
|
||||
|
||||
isConfigured(): boolean {
|
||||
return this.openai !== null || this.anthropic !== null;
|
||||
}
|
||||
|
||||
getAvailableProviders(): string[] {
|
||||
const providers: string[] = [];
|
||||
if (this.openai) providers.push('openai');
|
||||
if (this.anthropic) providers.push('claude');
|
||||
return providers;
|
||||
}
|
||||
}
|
||||
95
src/modules/integrations/entities/pos-integration.entity.ts
Normal file
95
src/modules/integrations/entities/pos-integration.entity.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Store } from '../../stores/entities/store.entity';
|
||||
|
||||
export enum PosProvider {
|
||||
SQUARE = 'SQUARE',
|
||||
SHOPIFY = 'SHOPIFY',
|
||||
CLOVER = 'CLOVER',
|
||||
LIGHTSPEED = 'LIGHTSPEED',
|
||||
TOAST = 'TOAST',
|
||||
CUSTOM = 'CUSTOM',
|
||||
}
|
||||
|
||||
export enum SyncDirection {
|
||||
POS_TO_INVENTORY = 'POS_TO_INVENTORY',
|
||||
INVENTORY_TO_POS = 'INVENTORY_TO_POS',
|
||||
BIDIRECTIONAL = 'BIDIRECTIONAL',
|
||||
}
|
||||
|
||||
@Entity('pos_integrations')
|
||||
@Index(['storeId', 'provider'], { unique: true })
|
||||
export class PosIntegration {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
storeId: string;
|
||||
|
||||
@ManyToOne(() => Store, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'storeId' })
|
||||
store: Store;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: PosProvider,
|
||||
})
|
||||
provider: PosProvider;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
displayName: string;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
credentials: Record<string, unknown>;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
webhookSecret: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 500, nullable: true })
|
||||
webhookUrl: string;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isActive: boolean;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
syncEnabled: boolean;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: SyncDirection,
|
||||
default: SyncDirection.POS_TO_INVENTORY,
|
||||
})
|
||||
syncDirection: SyncDirection;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
syncConfig: {
|
||||
syncOnSale?: boolean;
|
||||
syncOnRestock?: boolean;
|
||||
syncCategories?: boolean;
|
||||
autoCreateItems?: boolean;
|
||||
conflictResolution?: 'pos_wins' | 'inventory_wins' | 'newest_wins';
|
||||
};
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
lastSyncAt: Date;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
lastSyncStatus: string;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
syncErrorCount: number;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
77
src/modules/integrations/entities/pos-sync-log.entity.ts
Normal file
77
src/modules/integrations/entities/pos-sync-log.entity.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { PosIntegration } from './pos-integration.entity';
|
||||
|
||||
export enum SyncLogType {
|
||||
WEBHOOK_RECEIVED = 'WEBHOOK_RECEIVED',
|
||||
MANUAL_SYNC = 'MANUAL_SYNC',
|
||||
SCHEDULED_SYNC = 'SCHEDULED_SYNC',
|
||||
}
|
||||
|
||||
export enum SyncLogStatus {
|
||||
SUCCESS = 'SUCCESS',
|
||||
PARTIAL = 'PARTIAL',
|
||||
FAILED = 'FAILED',
|
||||
}
|
||||
|
||||
@Entity('pos_sync_logs')
|
||||
@Index(['integrationId', 'createdAt'])
|
||||
export class PosSyncLog {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
integrationId: string;
|
||||
|
||||
@ManyToOne(() => PosIntegration, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'integrationId' })
|
||||
integration: PosIntegration;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: SyncLogType,
|
||||
})
|
||||
type: SyncLogType;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: SyncLogStatus,
|
||||
})
|
||||
status: SyncLogStatus;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
itemsProcessed: number;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
itemsCreated: number;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
itemsUpdated: number;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
itemsSkipped: number;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
itemsFailed: number;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
details: {
|
||||
webhookEventType?: string;
|
||||
webhookEventId?: string;
|
||||
errors?: { itemId: string; error: string }[];
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
errorMessage: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
22
src/modules/integrations/integrations.module.ts
Normal file
22
src/modules/integrations/integrations.module.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { PosIntegration } from './entities/pos-integration.entity';
|
||||
import { PosSyncLog } from './entities/pos-sync-log.entity';
|
||||
import { InventoryItem } from '../inventory/entities/inventory-item.entity';
|
||||
import { PosController } from './pos/pos.controller';
|
||||
import { PosWebhookService } from './pos/services/pos-webhook.service';
|
||||
import { InventorySyncService } from './pos/services/inventory-sync.service';
|
||||
import { StoresModule } from '../stores/stores.module';
|
||||
import { ReportsModule } from '../reports/reports.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([PosIntegration, PosSyncLog, InventoryItem]),
|
||||
StoresModule,
|
||||
ReportsModule,
|
||||
],
|
||||
controllers: [PosController],
|
||||
providers: [PosWebhookService, InventorySyncService],
|
||||
exports: [PosWebhookService, InventorySyncService],
|
||||
})
|
||||
export class IntegrationsModule {}
|
||||
85
src/modules/integrations/pos/adapters/base-pos.adapter.ts
Normal file
85
src/modules/integrations/pos/adapters/base-pos.adapter.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import * as crypto from 'crypto';
|
||||
import { PosProvider } from '../../entities/pos-integration.entity';
|
||||
import {
|
||||
IPosAdapter,
|
||||
PosAdapterConfig,
|
||||
PosProduct,
|
||||
PosSale,
|
||||
PosInventoryUpdate,
|
||||
} from '../interfaces/pos-adapter.interface';
|
||||
|
||||
export abstract class BasePosAdapter implements IPosAdapter {
|
||||
abstract readonly provider: PosProvider;
|
||||
protected config: PosAdapterConfig;
|
||||
|
||||
async initialize(config: PosAdapterConfig): Promise<void> {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
abstract validateCredentials(): Promise<boolean>;
|
||||
abstract getProducts(): Promise<PosProduct[]>;
|
||||
abstract getProduct(externalId: string): Promise<PosProduct | null>;
|
||||
abstract updateInventory(updates: PosInventoryUpdate[]): Promise<void>;
|
||||
abstract getSales(since: Date): Promise<PosSale[]>;
|
||||
|
||||
generateWebhookSecret(): string {
|
||||
return crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
verifyWebhookSignature(
|
||||
payload: string,
|
||||
signature: string,
|
||||
secret: string,
|
||||
): boolean {
|
||||
const expectedSignature = crypto
|
||||
.createHmac('sha256', secret)
|
||||
.update(payload)
|
||||
.digest('hex');
|
||||
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(signature),
|
||||
Buffer.from(expectedSignature),
|
||||
);
|
||||
}
|
||||
|
||||
protected getCredential<T>(key: string, defaultValue?: T): T {
|
||||
const value = this.config?.credentials?.[key];
|
||||
if (value === undefined) {
|
||||
if (defaultValue !== undefined) {
|
||||
return defaultValue;
|
||||
}
|
||||
throw new Error(`Missing credential: ${key}`);
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder adapter for custom POS integrations
|
||||
* This adapter is meant to be extended for specific POS implementations
|
||||
*/
|
||||
export class CustomPosAdapter extends BasePosAdapter {
|
||||
readonly provider = PosProvider.CUSTOM;
|
||||
|
||||
async validateCredentials(): Promise<boolean> {
|
||||
// Custom adapters always return true - validation is handled externally
|
||||
return true;
|
||||
}
|
||||
|
||||
async getProducts(): Promise<PosProduct[]> {
|
||||
// Custom adapters don't fetch products - they receive webhooks
|
||||
return [];
|
||||
}
|
||||
|
||||
async getProduct(): Promise<PosProduct | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async updateInventory(): Promise<void> {
|
||||
// Custom adapters don't push updates - they receive webhooks
|
||||
}
|
||||
|
||||
async getSales(): Promise<PosSale[]> {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,87 @@
|
||||
import { PosProvider } from '../../entities/pos-integration.entity';
|
||||
|
||||
export interface PosProduct {
|
||||
externalId: string;
|
||||
name: string;
|
||||
sku?: string;
|
||||
barcode?: string;
|
||||
category?: string;
|
||||
quantity: number;
|
||||
price?: number;
|
||||
cost?: number;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
export interface PosSaleItem {
|
||||
externalProductId: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
totalPrice: number;
|
||||
}
|
||||
|
||||
export interface PosSale {
|
||||
externalId: string;
|
||||
items: PosSaleItem[];
|
||||
totalAmount: number;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface PosInventoryUpdate {
|
||||
externalProductId: string;
|
||||
newQuantity: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface PosAdapterConfig {
|
||||
provider: PosProvider;
|
||||
credentials: Record<string, unknown>;
|
||||
storeId: string;
|
||||
}
|
||||
|
||||
export interface IPosAdapter {
|
||||
readonly provider: PosProvider;
|
||||
|
||||
/**
|
||||
* Initialize the adapter with credentials
|
||||
*/
|
||||
initialize(config: PosAdapterConfig): Promise<void>;
|
||||
|
||||
/**
|
||||
* Validate the credentials are correct
|
||||
*/
|
||||
validateCredentials(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Fetch all products from POS
|
||||
*/
|
||||
getProducts(): Promise<PosProduct[]>;
|
||||
|
||||
/**
|
||||
* Fetch a single product by external ID
|
||||
*/
|
||||
getProduct(externalId: string): Promise<PosProduct | null>;
|
||||
|
||||
/**
|
||||
* Update inventory quantity in POS
|
||||
*/
|
||||
updateInventory(updates: PosInventoryUpdate[]): Promise<void>;
|
||||
|
||||
/**
|
||||
* Fetch recent sales
|
||||
*/
|
||||
getSales(since: Date): Promise<PosSale[]>;
|
||||
|
||||
/**
|
||||
* Generate webhook secret for this integration
|
||||
*/
|
||||
generateWebhookSecret(): string;
|
||||
|
||||
/**
|
||||
* Verify webhook signature
|
||||
*/
|
||||
verifyWebhookSignature(
|
||||
payload: string,
|
||||
signature: string,
|
||||
secret: string,
|
||||
): boolean;
|
||||
}
|
||||
@ -0,0 +1,90 @@
|
||||
import { PosProvider } from '../../entities/pos-integration.entity';
|
||||
|
||||
export enum PosWebhookEventType {
|
||||
SALE_CREATED = 'SALE_CREATED',
|
||||
SALE_UPDATED = 'SALE_UPDATED',
|
||||
SALE_REFUNDED = 'SALE_REFUNDED',
|
||||
INVENTORY_UPDATED = 'INVENTORY_UPDATED',
|
||||
PRODUCT_CREATED = 'PRODUCT_CREATED',
|
||||
PRODUCT_UPDATED = 'PRODUCT_UPDATED',
|
||||
PRODUCT_DELETED = 'PRODUCT_DELETED',
|
||||
}
|
||||
|
||||
export interface PosWebhookPayload {
|
||||
provider: PosProvider;
|
||||
eventType: PosWebhookEventType;
|
||||
eventId: string;
|
||||
timestamp: Date;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
export interface SaleWebhookData {
|
||||
saleId: string;
|
||||
items: {
|
||||
productId: string;
|
||||
productName?: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
}[];
|
||||
totalAmount: number;
|
||||
transactionTime: Date;
|
||||
}
|
||||
|
||||
export interface InventoryWebhookData {
|
||||
productId: string;
|
||||
productName?: string;
|
||||
previousQuantity?: number;
|
||||
newQuantity: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ProductWebhookData {
|
||||
productId: string;
|
||||
name: string;
|
||||
sku?: string;
|
||||
barcode?: string;
|
||||
category?: string;
|
||||
price?: number;
|
||||
cost?: number;
|
||||
quantity?: number;
|
||||
}
|
||||
|
||||
export interface IPosWebhookHandler {
|
||||
/**
|
||||
* Handle incoming webhook from POS
|
||||
*/
|
||||
handleWebhook(
|
||||
storeId: string,
|
||||
provider: PosProvider,
|
||||
rawPayload: string,
|
||||
signature: string,
|
||||
): Promise<{ success: boolean; message: string }>;
|
||||
|
||||
/**
|
||||
* Process a sale event
|
||||
*/
|
||||
processSaleEvent(
|
||||
storeId: string,
|
||||
integrationId: string,
|
||||
data: SaleWebhookData,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Process an inventory update event
|
||||
*/
|
||||
processInventoryEvent(
|
||||
storeId: string,
|
||||
integrationId: string,
|
||||
data: InventoryWebhookData,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Process a product event
|
||||
*/
|
||||
processProductEvent(
|
||||
storeId: string,
|
||||
integrationId: string,
|
||||
eventType: PosWebhookEventType,
|
||||
data: ProductWebhookData,
|
||||
): Promise<void>;
|
||||
}
|
||||
310
src/modules/integrations/pos/pos.controller.ts
Normal file
310
src/modules/integrations/pos/pos.controller.ts
Normal file
@ -0,0 +1,310 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
Headers,
|
||||
UseGuards,
|
||||
Request,
|
||||
ParseUUIDPipe,
|
||||
RawBodyRequest,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
ApiHeader,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { StoresService } from '../../stores/stores.service';
|
||||
import {
|
||||
PosIntegration,
|
||||
PosProvider,
|
||||
} from '../entities/pos-integration.entity';
|
||||
import { PosSyncLog } from '../entities/pos-sync-log.entity';
|
||||
import { PosWebhookService } from './services/pos-webhook.service';
|
||||
import { AuthenticatedRequest } from '../../../common/interfaces/authenticated-request.interface';
|
||||
|
||||
// DTOs
|
||||
class CreatePosIntegrationDto {
|
||||
provider: PosProvider;
|
||||
displayName?: string;
|
||||
credentials?: Record<string, unknown>;
|
||||
syncConfig?: {
|
||||
syncOnSale?: boolean;
|
||||
syncOnRestock?: boolean;
|
||||
syncCategories?: boolean;
|
||||
autoCreateItems?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
class UpdatePosIntegrationDto {
|
||||
displayName?: string;
|
||||
credentials?: Record<string, unknown>;
|
||||
isActive?: boolean;
|
||||
syncEnabled?: boolean;
|
||||
syncConfig?: {
|
||||
syncOnSale?: boolean;
|
||||
syncOnRestock?: boolean;
|
||||
syncCategories?: boolean;
|
||||
autoCreateItems?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@ApiTags('integrations')
|
||||
@Controller()
|
||||
export class PosController {
|
||||
constructor(
|
||||
@InjectRepository(PosIntegration)
|
||||
private integrationRepository: Repository<PosIntegration>,
|
||||
@InjectRepository(PosSyncLog)
|
||||
private syncLogRepository: Repository<PosSyncLog>,
|
||||
private storesService: StoresService,
|
||||
private webhookService: PosWebhookService,
|
||||
) {}
|
||||
|
||||
// ============ Protected Endpoints (Require Auth) ============
|
||||
|
||||
@Get('stores/:storeId/integrations/pos')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOperation({ summary: 'Listar integraciones POS de una tienda' })
|
||||
@ApiResponse({ status: 200, description: 'Lista de integraciones' })
|
||||
async listIntegrations(
|
||||
@Request() req: AuthenticatedRequest,
|
||||
@Param('storeId', ParseUUIDPipe) storeId: string,
|
||||
) {
|
||||
await this.storesService.verifyOwnership(storeId, req.user.id);
|
||||
|
||||
const integrations = await this.integrationRepository.find({
|
||||
where: { storeId },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
// Remove sensitive data
|
||||
return integrations.map((i) => ({
|
||||
...i,
|
||||
credentials: undefined,
|
||||
webhookSecret: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
@Post('stores/:storeId/integrations/pos')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOperation({ summary: 'Crear nueva integración POS' })
|
||||
@ApiResponse({ status: 201, description: 'Integración creada' })
|
||||
async createIntegration(
|
||||
@Request() req: AuthenticatedRequest,
|
||||
@Param('storeId', ParseUUIDPipe) storeId: string,
|
||||
@Body() dto: CreatePosIntegrationDto,
|
||||
) {
|
||||
await this.storesService.verifyOwnership(storeId, req.user.id);
|
||||
|
||||
// Generate webhook secret
|
||||
const webhookSecret = require('crypto').randomBytes(32).toString('hex');
|
||||
|
||||
const integration = this.integrationRepository.create({
|
||||
storeId,
|
||||
provider: dto.provider,
|
||||
displayName: dto.displayName || dto.provider,
|
||||
credentials: dto.credentials,
|
||||
webhookSecret,
|
||||
syncConfig: dto.syncConfig || {
|
||||
syncOnSale: true,
|
||||
autoCreateItems: true,
|
||||
},
|
||||
});
|
||||
|
||||
const saved = await this.integrationRepository.save(integration);
|
||||
|
||||
// Generate webhook URL
|
||||
const webhookUrl = `/api/v1/webhooks/pos/${dto.provider.toLowerCase()}/${storeId}`;
|
||||
|
||||
await this.integrationRepository.update(saved.id, { webhookUrl });
|
||||
|
||||
return {
|
||||
...saved,
|
||||
webhookUrl,
|
||||
webhookSecret, // Only returned once on creation
|
||||
credentials: undefined, // Don't return credentials
|
||||
};
|
||||
}
|
||||
|
||||
@Get('stores/:storeId/integrations/pos/:integrationId')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOperation({ summary: 'Obtener detalles de integración POS' })
|
||||
@ApiResponse({ status: 200, description: 'Detalles de integración' })
|
||||
async getIntegration(
|
||||
@Request() req: AuthenticatedRequest,
|
||||
@Param('storeId', ParseUUIDPipe) storeId: string,
|
||||
@Param('integrationId', ParseUUIDPipe) integrationId: string,
|
||||
) {
|
||||
await this.storesService.verifyOwnership(storeId, req.user.id);
|
||||
|
||||
const integration = await this.integrationRepository.findOne({
|
||||
where: { id: integrationId, storeId },
|
||||
});
|
||||
|
||||
if (!integration) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...integration,
|
||||
credentials: undefined,
|
||||
webhookSecret: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@Patch('stores/:storeId/integrations/pos/:integrationId')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOperation({ summary: 'Actualizar integración POS' })
|
||||
@ApiResponse({ status: 200, description: 'Integración actualizada' })
|
||||
async updateIntegration(
|
||||
@Request() req: AuthenticatedRequest,
|
||||
@Param('storeId', ParseUUIDPipe) storeId: string,
|
||||
@Param('integrationId', ParseUUIDPipe) integrationId: string,
|
||||
@Body() dto: UpdatePosIntegrationDto,
|
||||
) {
|
||||
await this.storesService.verifyOwnership(storeId, req.user.id);
|
||||
|
||||
const updateData: {
|
||||
displayName?: string;
|
||||
isActive?: boolean;
|
||||
syncEnabled?: boolean;
|
||||
syncConfig?: Record<string, unknown>;
|
||||
} = {};
|
||||
|
||||
if (dto.displayName !== undefined) updateData.displayName = dto.displayName;
|
||||
if (dto.isActive !== undefined) updateData.isActive = dto.isActive;
|
||||
if (dto.syncEnabled !== undefined) updateData.syncEnabled = dto.syncEnabled;
|
||||
if (dto.syncConfig !== undefined) updateData.syncConfig = dto.syncConfig;
|
||||
|
||||
await this.integrationRepository.update(
|
||||
{ id: integrationId, storeId },
|
||||
updateData,
|
||||
);
|
||||
|
||||
return this.getIntegration(req, storeId, integrationId);
|
||||
}
|
||||
|
||||
@Delete('stores/:storeId/integrations/pos/:integrationId')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOperation({ summary: 'Eliminar integración POS' })
|
||||
@ApiResponse({ status: 200, description: 'Integración eliminada' })
|
||||
async deleteIntegration(
|
||||
@Request() req: AuthenticatedRequest,
|
||||
@Param('storeId', ParseUUIDPipe) storeId: string,
|
||||
@Param('integrationId', ParseUUIDPipe) integrationId: string,
|
||||
) {
|
||||
await this.storesService.verifyOwnership(storeId, req.user.id);
|
||||
|
||||
await this.integrationRepository.delete({ id: integrationId, storeId });
|
||||
|
||||
return { message: 'Integration deleted successfully' };
|
||||
}
|
||||
|
||||
@Get('stores/:storeId/integrations/pos/:integrationId/logs')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOperation({ summary: 'Obtener logs de sincronización' })
|
||||
@ApiResponse({ status: 200, description: 'Logs de sincronización' })
|
||||
async getSyncLogs(
|
||||
@Request() req: AuthenticatedRequest,
|
||||
@Param('storeId', ParseUUIDPipe) storeId: string,
|
||||
@Param('integrationId', ParseUUIDPipe) integrationId: string,
|
||||
@Query('page') page = 1,
|
||||
@Query('limit') limit = 20,
|
||||
) {
|
||||
await this.storesService.verifyOwnership(storeId, req.user.id);
|
||||
|
||||
const [logs, total] = await this.syncLogRepository.findAndCount({
|
||||
where: { integrationId },
|
||||
order: { createdAt: 'DESC' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return {
|
||||
logs,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
hasMore: page * limit < total,
|
||||
};
|
||||
}
|
||||
|
||||
@Post('stores/:storeId/integrations/pos/:integrationId/activate')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOperation({ summary: 'Activar integración POS' })
|
||||
@ApiResponse({ status: 200, description: 'Integración activada' })
|
||||
async activateIntegration(
|
||||
@Request() req: AuthenticatedRequest,
|
||||
@Param('storeId', ParseUUIDPipe) storeId: string,
|
||||
@Param('integrationId', ParseUUIDPipe) integrationId: string,
|
||||
) {
|
||||
await this.storesService.verifyOwnership(storeId, req.user.id);
|
||||
|
||||
await this.integrationRepository.update(
|
||||
{ id: integrationId, storeId },
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
return { message: 'Integration activated' };
|
||||
}
|
||||
|
||||
@Post('stores/:storeId/integrations/pos/:integrationId/deactivate')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOperation({ summary: 'Desactivar integración POS' })
|
||||
@ApiResponse({ status: 200, description: 'Integración desactivada' })
|
||||
async deactivateIntegration(
|
||||
@Request() req: AuthenticatedRequest,
|
||||
@Param('storeId', ParseUUIDPipe) storeId: string,
|
||||
@Param('integrationId', ParseUUIDPipe) integrationId: string,
|
||||
) {
|
||||
await this.storesService.verifyOwnership(storeId, req.user.id);
|
||||
|
||||
await this.integrationRepository.update(
|
||||
{ id: integrationId, storeId },
|
||||
{ isActive: false },
|
||||
);
|
||||
|
||||
return { message: 'Integration deactivated' };
|
||||
}
|
||||
|
||||
// ============ Webhook Endpoint (No Auth) ============
|
||||
|
||||
@Post('webhooks/pos/:provider/:storeId')
|
||||
@ApiOperation({ summary: 'Recibir webhook de POS' })
|
||||
@ApiHeader({ name: 'x-webhook-signature', description: 'Firma HMAC del payload' })
|
||||
@ApiResponse({ status: 200, description: 'Webhook procesado' })
|
||||
async handleWebhook(
|
||||
@Param('provider') provider: string,
|
||||
@Param('storeId', ParseUUIDPipe) storeId: string,
|
||||
@Headers('x-webhook-signature') signature: string,
|
||||
@Body() rawBody: string,
|
||||
) {
|
||||
const posProvider = provider.toUpperCase() as PosProvider;
|
||||
|
||||
return this.webhookService.handleWebhook(
|
||||
storeId,
|
||||
posProvider,
|
||||
typeof rawBody === 'string' ? rawBody : JSON.stringify(rawBody),
|
||||
signature || '',
|
||||
);
|
||||
}
|
||||
}
|
||||
348
src/modules/integrations/pos/services/inventory-sync.service.ts
Normal file
348
src/modules/integrations/pos/services/inventory-sync.service.ts
Normal file
@ -0,0 +1,348 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { InventoryItem } from '../../../inventory/entities/inventory-item.entity';
|
||||
import { InventoryReportsService } from '../../../reports/services/inventory-reports.service';
|
||||
import {
|
||||
MovementType,
|
||||
TriggerType,
|
||||
} from '../../../reports/entities/inventory-movement.entity';
|
||||
import {
|
||||
PosIntegration,
|
||||
SyncDirection,
|
||||
} from '../../entities/pos-integration.entity';
|
||||
import {
|
||||
PosSyncLog,
|
||||
SyncLogType,
|
||||
SyncLogStatus,
|
||||
} from '../../entities/pos-sync-log.entity';
|
||||
import { PosProduct } from '../interfaces/pos-adapter.interface';
|
||||
|
||||
export interface SyncResult {
|
||||
itemsProcessed: number;
|
||||
itemsCreated: number;
|
||||
itemsUpdated: number;
|
||||
itemsSkipped: number;
|
||||
itemsFailed: number;
|
||||
errors: { itemId: string; error: string }[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class InventorySyncService {
|
||||
private readonly logger = new Logger(InventorySyncService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(InventoryItem)
|
||||
private inventoryRepository: Repository<InventoryItem>,
|
||||
@InjectRepository(PosSyncLog)
|
||||
private syncLogRepository: Repository<PosSyncLog>,
|
||||
private reportsService: InventoryReportsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Sync products from POS to inventory
|
||||
*/
|
||||
async syncFromPos(
|
||||
integration: PosIntegration,
|
||||
products: PosProduct[],
|
||||
logType: SyncLogType = SyncLogType.WEBHOOK_RECEIVED,
|
||||
): Promise<SyncResult> {
|
||||
const startTime = Date.now();
|
||||
const result: SyncResult = {
|
||||
itemsProcessed: 0,
|
||||
itemsCreated: 0,
|
||||
itemsUpdated: 0,
|
||||
itemsSkipped: 0,
|
||||
itemsFailed: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
const syncConfig = integration.syncConfig || {};
|
||||
|
||||
for (const product of products) {
|
||||
result.itemsProcessed++;
|
||||
|
||||
try {
|
||||
// Find existing item by barcode or external reference
|
||||
let existingItem = await this.findExistingItem(
|
||||
integration.storeId,
|
||||
product,
|
||||
);
|
||||
|
||||
if (existingItem) {
|
||||
// Update existing item
|
||||
const quantityBefore = existingItem.quantity;
|
||||
const updated = await this.updateItem(
|
||||
existingItem,
|
||||
product,
|
||||
syncConfig,
|
||||
);
|
||||
|
||||
if (updated) {
|
||||
result.itemsUpdated++;
|
||||
|
||||
// Record movement if quantity changed
|
||||
if (quantityBefore !== existingItem.quantity) {
|
||||
await this.reportsService.recordMovement(
|
||||
existingItem.id,
|
||||
integration.storeId,
|
||||
MovementType.POS_SYNC,
|
||||
quantityBefore,
|
||||
existingItem.quantity,
|
||||
undefined,
|
||||
TriggerType.POS,
|
||||
`POS sync from ${integration.provider}`,
|
||||
integration.id,
|
||||
'pos_integration',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
result.itemsSkipped++;
|
||||
}
|
||||
} else if (syncConfig.autoCreateItems !== false) {
|
||||
// Create new item
|
||||
const newItem = await this.createItem(integration.storeId, product);
|
||||
result.itemsCreated++;
|
||||
|
||||
// Record initial movement
|
||||
await this.reportsService.recordMovement(
|
||||
newItem.id,
|
||||
integration.storeId,
|
||||
MovementType.INITIAL,
|
||||
0,
|
||||
newItem.quantity,
|
||||
undefined,
|
||||
TriggerType.POS,
|
||||
`Created from POS ${integration.provider}`,
|
||||
integration.id,
|
||||
'pos_integration',
|
||||
);
|
||||
} else {
|
||||
result.itemsSkipped++;
|
||||
}
|
||||
} catch (error) {
|
||||
result.itemsFailed++;
|
||||
result.errors.push({
|
||||
itemId: product.externalId,
|
||||
error: error.message,
|
||||
});
|
||||
this.logger.error(
|
||||
`Failed to sync product ${product.externalId}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Log the sync
|
||||
await this.logSync(integration.id, logType, result, Date.now() - startTime);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update inventory quantity from a sale
|
||||
*/
|
||||
async processSale(
|
||||
integration: PosIntegration,
|
||||
saleItems: { productId: string; quantity: number }[],
|
||||
saleId: string,
|
||||
): Promise<SyncResult> {
|
||||
const startTime = Date.now();
|
||||
const result: SyncResult = {
|
||||
itemsProcessed: 0,
|
||||
itemsCreated: 0,
|
||||
itemsUpdated: 0,
|
||||
itemsSkipped: 0,
|
||||
itemsFailed: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
for (const saleItem of saleItems) {
|
||||
result.itemsProcessed++;
|
||||
|
||||
try {
|
||||
const item = await this.findItemByExternalId(
|
||||
integration.storeId,
|
||||
saleItem.productId,
|
||||
);
|
||||
|
||||
if (item) {
|
||||
const quantityBefore = item.quantity;
|
||||
const newQuantity = Math.max(0, item.quantity - saleItem.quantity);
|
||||
|
||||
await this.inventoryRepository.update(item.id, {
|
||||
quantity: newQuantity,
|
||||
});
|
||||
|
||||
result.itemsUpdated++;
|
||||
|
||||
await this.reportsService.recordMovement(
|
||||
item.id,
|
||||
integration.storeId,
|
||||
MovementType.SALE,
|
||||
quantityBefore,
|
||||
newQuantity,
|
||||
undefined,
|
||||
TriggerType.POS,
|
||||
`Sale from ${integration.provider}`,
|
||||
saleId,
|
||||
'pos_sale',
|
||||
);
|
||||
} else {
|
||||
result.itemsSkipped++;
|
||||
this.logger.warn(
|
||||
`Product ${saleItem.productId} not found in inventory for sale sync`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
result.itemsFailed++;
|
||||
result.errors.push({
|
||||
itemId: saleItem.productId,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await this.logSync(
|
||||
integration.id,
|
||||
SyncLogType.WEBHOOK_RECEIVED,
|
||||
result,
|
||||
Date.now() - startTime,
|
||||
{ saleId },
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async findExistingItem(
|
||||
storeId: string,
|
||||
product: PosProduct,
|
||||
): Promise<InventoryItem | null> {
|
||||
// Try to find by barcode first
|
||||
if (product.barcode) {
|
||||
const item = await this.inventoryRepository.findOne({
|
||||
where: { storeId, barcode: product.barcode },
|
||||
});
|
||||
if (item) return item;
|
||||
}
|
||||
|
||||
// Try to find by external ID stored in metadata
|
||||
return this.findItemByExternalId(storeId, product.externalId);
|
||||
}
|
||||
|
||||
private async findItemByExternalId(
|
||||
storeId: string,
|
||||
externalId: string,
|
||||
): Promise<InventoryItem | null> {
|
||||
const items = await this.inventoryRepository
|
||||
.createQueryBuilder('item')
|
||||
.where('item.storeId = :storeId', { storeId })
|
||||
.andWhere("item.metadata->>'posExternalId' = :externalId", { externalId })
|
||||
.getOne();
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
private async updateItem(
|
||||
item: InventoryItem,
|
||||
product: PosProduct,
|
||||
syncConfig: PosIntegration['syncConfig'],
|
||||
): Promise<boolean> {
|
||||
const updates: {
|
||||
quantity?: number;
|
||||
price?: number;
|
||||
cost?: number;
|
||||
category?: string;
|
||||
} = {};
|
||||
let hasChanges = false;
|
||||
|
||||
// Update quantity
|
||||
if (product.quantity !== item.quantity) {
|
||||
updates.quantity = product.quantity;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
// Update price if POS has it and we should sync
|
||||
if (product.price !== undefined && product.price !== item.price) {
|
||||
updates.price = product.price;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
// Update cost if POS has it
|
||||
if (product.cost !== undefined && product.cost !== item.cost) {
|
||||
updates.cost = product.cost;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
// Update category if enabled
|
||||
if (
|
||||
syncConfig?.syncCategories &&
|
||||
product.category &&
|
||||
product.category !== item.category
|
||||
) {
|
||||
updates.category = product.category;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
await this.inventoryRepository.update(item.id, updates);
|
||||
Object.assign(item, updates);
|
||||
}
|
||||
|
||||
return hasChanges;
|
||||
}
|
||||
|
||||
private async createItem(
|
||||
storeId: string,
|
||||
product: PosProduct,
|
||||
): Promise<InventoryItem> {
|
||||
const item = this.inventoryRepository.create({
|
||||
storeId,
|
||||
name: product.name,
|
||||
category: product.category,
|
||||
barcode: product.barcode,
|
||||
quantity: product.quantity,
|
||||
price: product.price,
|
||||
cost: product.cost,
|
||||
imageUrl: product.imageUrl,
|
||||
metadata: {
|
||||
posExternalId: product.externalId,
|
||||
posSku: product.sku,
|
||||
},
|
||||
});
|
||||
|
||||
return this.inventoryRepository.save(item);
|
||||
}
|
||||
|
||||
private async logSync(
|
||||
integrationId: string,
|
||||
type: SyncLogType,
|
||||
result: SyncResult,
|
||||
duration: number,
|
||||
additionalDetails?: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const status =
|
||||
result.itemsFailed === 0
|
||||
? SyncLogStatus.SUCCESS
|
||||
: result.itemsFailed < result.itemsProcessed
|
||||
? SyncLogStatus.PARTIAL
|
||||
: SyncLogStatus.FAILED;
|
||||
|
||||
const log = this.syncLogRepository.create({
|
||||
integrationId,
|
||||
type,
|
||||
status,
|
||||
itemsProcessed: result.itemsProcessed,
|
||||
itemsCreated: result.itemsCreated,
|
||||
itemsUpdated: result.itemsUpdated,
|
||||
itemsSkipped: result.itemsSkipped,
|
||||
itemsFailed: result.itemsFailed,
|
||||
details: {
|
||||
...additionalDetails,
|
||||
errors: result.errors.length > 0 ? result.errors : undefined,
|
||||
duration,
|
||||
},
|
||||
});
|
||||
|
||||
await this.syncLogRepository.save(log);
|
||||
}
|
||||
}
|
||||
263
src/modules/integrations/pos/services/pos-webhook.service.ts
Normal file
263
src/modules/integrations/pos/services/pos-webhook.service.ts
Normal file
@ -0,0 +1,263 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import * as crypto from 'crypto';
|
||||
import {
|
||||
PosIntegration,
|
||||
PosProvider,
|
||||
} from '../../entities/pos-integration.entity';
|
||||
import { InventorySyncService } from './inventory-sync.service';
|
||||
import {
|
||||
IPosWebhookHandler,
|
||||
PosWebhookEventType,
|
||||
SaleWebhookData,
|
||||
InventoryWebhookData,
|
||||
ProductWebhookData,
|
||||
} from '../interfaces/pos-webhook.interface';
|
||||
|
||||
@Injectable()
|
||||
export class PosWebhookService implements IPosWebhookHandler {
|
||||
private readonly logger = new Logger(PosWebhookService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(PosIntegration)
|
||||
private integrationRepository: Repository<PosIntegration>,
|
||||
private inventorySyncService: InventorySyncService,
|
||||
) {}
|
||||
|
||||
private verifyWebhookSignature(
|
||||
payload: string,
|
||||
signature: string,
|
||||
secret: string,
|
||||
): boolean {
|
||||
try {
|
||||
const expectedSignature = crypto
|
||||
.createHmac('sha256', secret)
|
||||
.update(payload)
|
||||
.digest('hex');
|
||||
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(signature),
|
||||
Buffer.from(expectedSignature),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async handleWebhook(
|
||||
storeId: string,
|
||||
provider: PosProvider,
|
||||
rawPayload: string,
|
||||
signature: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
this.logger.log(
|
||||
`Received webhook from ${provider} for store ${storeId}`,
|
||||
);
|
||||
|
||||
// Find integration
|
||||
const integration = await this.integrationRepository.findOne({
|
||||
where: { storeId, provider, isActive: true },
|
||||
});
|
||||
|
||||
if (!integration) {
|
||||
throw new NotFoundException(
|
||||
`No active integration found for provider ${provider}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
if (integration.webhookSecret && signature) {
|
||||
const isValid = this.verifyWebhookSignature(
|
||||
rawPayload,
|
||||
signature,
|
||||
integration.webhookSecret,
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
this.logger.warn(
|
||||
`Invalid webhook signature for integration ${integration.id}`,
|
||||
);
|
||||
return { success: false, message: 'Invalid signature' };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(rawPayload);
|
||||
await this.processWebhookPayload(integration, payload);
|
||||
|
||||
return { success: true, message: 'Webhook processed successfully' };
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to process webhook: ${error.message}`);
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
private async processWebhookPayload(
|
||||
integration: PosIntegration,
|
||||
payload: {
|
||||
eventType: PosWebhookEventType;
|
||||
eventId?: string;
|
||||
data: unknown;
|
||||
},
|
||||
): Promise<void> {
|
||||
const { eventType, data } = payload;
|
||||
|
||||
switch (eventType) {
|
||||
case PosWebhookEventType.SALE_CREATED:
|
||||
case PosWebhookEventType.SALE_UPDATED:
|
||||
await this.processSaleEvent(
|
||||
integration.storeId,
|
||||
integration.id,
|
||||
data as SaleWebhookData,
|
||||
);
|
||||
break;
|
||||
|
||||
case PosWebhookEventType.SALE_REFUNDED:
|
||||
// Handle refunds - increase inventory
|
||||
await this.processSaleRefund(
|
||||
integration.storeId,
|
||||
integration.id,
|
||||
data as SaleWebhookData,
|
||||
);
|
||||
break;
|
||||
|
||||
case PosWebhookEventType.INVENTORY_UPDATED:
|
||||
await this.processInventoryEvent(
|
||||
integration.storeId,
|
||||
integration.id,
|
||||
data as InventoryWebhookData,
|
||||
);
|
||||
break;
|
||||
|
||||
case PosWebhookEventType.PRODUCT_CREATED:
|
||||
case PosWebhookEventType.PRODUCT_UPDATED:
|
||||
case PosWebhookEventType.PRODUCT_DELETED:
|
||||
await this.processProductEvent(
|
||||
integration.storeId,
|
||||
integration.id,
|
||||
eventType,
|
||||
data as ProductWebhookData,
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
this.logger.warn(`Unknown event type: ${eventType}`);
|
||||
}
|
||||
}
|
||||
|
||||
async processSaleEvent(
|
||||
storeId: string,
|
||||
integrationId: string,
|
||||
data: SaleWebhookData,
|
||||
): Promise<void> {
|
||||
const integration = await this.integrationRepository.findOneOrFail({
|
||||
where: { id: integrationId },
|
||||
});
|
||||
|
||||
if (!integration.syncConfig?.syncOnSale) {
|
||||
this.logger.log('Sale sync disabled for this integration, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
const saleItems = data.items.map((item) => ({
|
||||
productId: item.productId,
|
||||
quantity: item.quantity,
|
||||
}));
|
||||
|
||||
await this.inventorySyncService.processSale(
|
||||
integration,
|
||||
saleItems,
|
||||
data.saleId,
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Processed sale ${data.saleId} with ${saleItems.length} items`,
|
||||
);
|
||||
}
|
||||
|
||||
private async processSaleRefund(
|
||||
storeId: string,
|
||||
integrationId: string,
|
||||
data: SaleWebhookData,
|
||||
): Promise<void> {
|
||||
// For refunds, we add the quantity back
|
||||
const integration = await this.integrationRepository.findOneOrFail({
|
||||
where: { id: integrationId },
|
||||
});
|
||||
|
||||
// Convert refund to inventory updates (positive quantities)
|
||||
const products = data.items.map((item) => ({
|
||||
externalId: item.productId,
|
||||
name: item.productName || `Product ${item.productId}`,
|
||||
quantity: item.quantity, // This will be added back
|
||||
}));
|
||||
|
||||
await this.inventorySyncService.syncFromPos(integration, products);
|
||||
|
||||
this.logger.log(
|
||||
`Processed refund for sale ${data.saleId} with ${products.length} items`,
|
||||
);
|
||||
}
|
||||
|
||||
async processInventoryEvent(
|
||||
storeId: string,
|
||||
integrationId: string,
|
||||
data: InventoryWebhookData,
|
||||
): Promise<void> {
|
||||
const integration = await this.integrationRepository.findOneOrFail({
|
||||
where: { id: integrationId },
|
||||
});
|
||||
|
||||
const products = [
|
||||
{
|
||||
externalId: data.productId,
|
||||
name: data.productName || `Product ${data.productId}`,
|
||||
quantity: data.newQuantity,
|
||||
},
|
||||
];
|
||||
|
||||
await this.inventorySyncService.syncFromPos(integration, products);
|
||||
|
||||
this.logger.log(
|
||||
`Processed inventory update for product ${data.productId}: ${data.newQuantity}`,
|
||||
);
|
||||
}
|
||||
|
||||
async processProductEvent(
|
||||
storeId: string,
|
||||
integrationId: string,
|
||||
eventType: PosWebhookEventType,
|
||||
data: ProductWebhookData,
|
||||
): Promise<void> {
|
||||
const integration = await this.integrationRepository.findOneOrFail({
|
||||
where: { id: integrationId },
|
||||
});
|
||||
|
||||
if (eventType === PosWebhookEventType.PRODUCT_DELETED) {
|
||||
// We don't delete items from our inventory when deleted from POS
|
||||
// Just log it
|
||||
this.logger.log(`Product ${data.productId} deleted in POS, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
const products = [
|
||||
{
|
||||
externalId: data.productId,
|
||||
name: data.name,
|
||||
sku: data.sku,
|
||||
barcode: data.barcode,
|
||||
category: data.category,
|
||||
quantity: data.quantity || 0,
|
||||
price: data.price,
|
||||
cost: data.cost,
|
||||
},
|
||||
];
|
||||
|
||||
await this.inventorySyncService.syncFromPos(integration, products);
|
||||
|
||||
this.logger.log(
|
||||
`Processed product ${eventType} for ${data.productId}: ${data.name}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
82
src/modules/inventory/dto/update-inventory-item.dto.ts
Normal file
82
src/modules/inventory/dto/update-inventory-item.dto.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import {
|
||||
IsString,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
Min,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class UpdateInventoryItemDto {
|
||||
@ApiPropertyOptional({
|
||||
description: 'Nombre del producto',
|
||||
example: 'Coca Cola 600ml',
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(255)
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Cantidad en stock',
|
||||
example: 24,
|
||||
})
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@Min(0)
|
||||
quantity?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Categoria',
|
||||
example: 'Bebidas',
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(100)
|
||||
category?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Subcategoria',
|
||||
example: 'Refrescos',
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(100)
|
||||
subcategory?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Codigo de barras',
|
||||
example: '7501055303045',
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(50)
|
||||
barcode?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Stock minimo para alerta',
|
||||
example: 5,
|
||||
})
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@Min(0)
|
||||
minStock?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Precio de venta',
|
||||
example: 18.5,
|
||||
})
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@Min(0)
|
||||
price?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Costo',
|
||||
example: 12.0,
|
||||
})
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
@Min(0)
|
||||
cost?: number;
|
||||
}
|
||||
80
src/modules/inventory/entities/inventory-item.entity.ts
Normal file
80
src/modules/inventory/entities/inventory-item.entity.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Store } from '../../stores/entities/store.entity';
|
||||
import { Video } from '../../videos/entities/video.entity';
|
||||
|
||||
@Entity('inventory_items')
|
||||
@Index(['storeId', 'name'])
|
||||
@Index(['storeId', 'category'])
|
||||
@Index(['storeId', 'barcode'])
|
||||
export class InventoryItem {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
storeId: string;
|
||||
|
||||
@ManyToOne(() => Store)
|
||||
@JoinColumn({ name: 'storeId' })
|
||||
store: Store;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
detectedByVideoId: string;
|
||||
|
||||
@ManyToOne(() => Video, { nullable: true })
|
||||
@JoinColumn({ name: 'detectedByVideoId' })
|
||||
detectedByVideo: Video;
|
||||
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
name: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||
category: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||
subcategory: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, nullable: true })
|
||||
barcode: string;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
quantity: number;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
minStock: number;
|
||||
|
||||
@Column({ type: 'decimal', precision: 10, scale: 2, nullable: true })
|
||||
price: number;
|
||||
|
||||
@Column({ type: 'decimal', precision: 10, scale: 2, nullable: true })
|
||||
cost: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 500, nullable: true })
|
||||
imageUrl: string;
|
||||
|
||||
@Column({ type: 'decimal', precision: 5, scale: 2, nullable: true })
|
||||
detectionConfidence: number;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isManuallyEdited: boolean;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata: Record<string, unknown>;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
lastCountedAt: Date;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
135
src/modules/inventory/inventory.controller.ts
Normal file
135
src/modules/inventory/inventory.controller.ts
Normal file
@ -0,0 +1,135 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
ParseUUIDPipe,
|
||||
ParseIntPipe,
|
||||
DefaultValuePipe,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
ApiQuery,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { InventoryService } from './inventory.service';
|
||||
import { StoresService } from '../stores/stores.service';
|
||||
import { UpdateInventoryItemDto } from './dto/update-inventory-item.dto';
|
||||
import { AuthenticatedRequest } from '../../common/interfaces/authenticated-request.interface';
|
||||
|
||||
@ApiTags('inventory')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('stores/:storeId/inventory')
|
||||
export class InventoryController {
|
||||
constructor(
|
||||
private readonly inventoryService: InventoryService,
|
||||
private readonly storesService: StoresService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Listar inventario de una tienda' })
|
||||
@ApiResponse({ status: 200, description: 'Lista de productos' })
|
||||
@ApiQuery({ name: 'page', required: false, type: Number })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||
async findAll(
|
||||
@Request() req: AuthenticatedRequest,
|
||||
@Param('storeId', ParseUUIDPipe) storeId: string,
|
||||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
||||
@Query('limit', new DefaultValuePipe(50), ParseIntPipe) limit: number,
|
||||
) {
|
||||
await this.storesService.verifyOwnership(storeId, req.user.id);
|
||||
|
||||
const { items, total } = await this.inventoryService.findAllByStore(
|
||||
storeId,
|
||||
page,
|
||||
Math.min(limit, 100),
|
||||
);
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
hasMore: page * limit < total,
|
||||
};
|
||||
}
|
||||
|
||||
@Get('statistics')
|
||||
@ApiOperation({ summary: 'Obtener estadisticas del inventario' })
|
||||
@ApiResponse({ status: 200, description: 'Estadisticas' })
|
||||
async getStatistics(
|
||||
@Request() req: AuthenticatedRequest,
|
||||
@Param('storeId', ParseUUIDPipe) storeId: string,
|
||||
) {
|
||||
await this.storesService.verifyOwnership(storeId, req.user.id);
|
||||
return this.inventoryService.getStatistics(storeId);
|
||||
}
|
||||
|
||||
@Get('low-stock')
|
||||
@ApiOperation({ summary: 'Obtener productos con bajo stock' })
|
||||
@ApiResponse({ status: 200, description: 'Lista de productos con bajo stock' })
|
||||
async getLowStock(
|
||||
@Request() req: AuthenticatedRequest,
|
||||
@Param('storeId', ParseUUIDPipe) storeId: string,
|
||||
) {
|
||||
await this.storesService.verifyOwnership(storeId, req.user.id);
|
||||
return this.inventoryService.getLowStockItems(storeId);
|
||||
}
|
||||
|
||||
@Get('categories')
|
||||
@ApiOperation({ summary: 'Obtener categorias del inventario' })
|
||||
@ApiResponse({ status: 200, description: 'Lista de categorias' })
|
||||
async getCategories(
|
||||
@Request() req: AuthenticatedRequest,
|
||||
@Param('storeId', ParseUUIDPipe) storeId: string,
|
||||
) {
|
||||
await this.storesService.verifyOwnership(storeId, req.user.id);
|
||||
return this.inventoryService.getCategories(storeId);
|
||||
}
|
||||
|
||||
@Get(':itemId')
|
||||
@ApiOperation({ summary: 'Obtener un producto' })
|
||||
@ApiResponse({ status: 200, description: 'Producto encontrado' })
|
||||
async findOne(
|
||||
@Request() req: AuthenticatedRequest,
|
||||
@Param('storeId', ParseUUIDPipe) storeId: string,
|
||||
@Param('itemId', ParseUUIDPipe) itemId: string,
|
||||
) {
|
||||
await this.storesService.verifyOwnership(storeId, req.user.id);
|
||||
return this.inventoryService.findById(storeId, itemId);
|
||||
}
|
||||
|
||||
@Patch(':itemId')
|
||||
@ApiOperation({ summary: 'Actualizar un producto' })
|
||||
@ApiResponse({ status: 200, description: 'Producto actualizado' })
|
||||
async update(
|
||||
@Request() req: AuthenticatedRequest,
|
||||
@Param('storeId', ParseUUIDPipe) storeId: string,
|
||||
@Param('itemId', ParseUUIDPipe) itemId: string,
|
||||
@Body() dto: UpdateInventoryItemDto,
|
||||
) {
|
||||
await this.storesService.verifyOwnership(storeId, req.user.id);
|
||||
return this.inventoryService.update(storeId, itemId, dto, req.user.id);
|
||||
}
|
||||
|
||||
@Delete(':itemId')
|
||||
@ApiOperation({ summary: 'Eliminar un producto' })
|
||||
@ApiResponse({ status: 200, description: 'Producto eliminado' })
|
||||
async delete(
|
||||
@Request() req: AuthenticatedRequest,
|
||||
@Param('storeId', ParseUUIDPipe) storeId: string,
|
||||
@Param('itemId', ParseUUIDPipe) itemId: string,
|
||||
) {
|
||||
await this.storesService.verifyOwnership(storeId, req.user.id);
|
||||
return this.inventoryService.delete(storeId, itemId);
|
||||
}
|
||||
}
|
||||
19
src/modules/inventory/inventory.module.ts
Normal file
19
src/modules/inventory/inventory.module.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { InventoryController } from './inventory.controller';
|
||||
import { InventoryService } from './inventory.service';
|
||||
import { InventoryItem } from './entities/inventory-item.entity';
|
||||
import { StoresModule } from '../stores/stores.module';
|
||||
import { ReportsModule } from '../reports/reports.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([InventoryItem]),
|
||||
forwardRef(() => StoresModule),
|
||||
forwardRef(() => ReportsModule),
|
||||
],
|
||||
controllers: [InventoryController],
|
||||
providers: [InventoryService],
|
||||
exports: [InventoryService],
|
||||
})
|
||||
export class InventoryModule {}
|
||||
230
src/modules/inventory/inventory.service.ts
Normal file
230
src/modules/inventory/inventory.service.ts
Normal file
@ -0,0 +1,230 @@
|
||||
import { Injectable, NotFoundException, Inject, forwardRef } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { InventoryItem } from './entities/inventory-item.entity';
|
||||
import { InventoryReportsService } from '../reports/services/inventory-reports.service';
|
||||
import {
|
||||
MovementType,
|
||||
TriggerType,
|
||||
} from '../reports/entities/inventory-movement.entity';
|
||||
|
||||
export interface DetectedItem {
|
||||
name: string;
|
||||
quantity: number;
|
||||
confidence: number;
|
||||
category?: string;
|
||||
barcode?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class InventoryService {
|
||||
constructor(
|
||||
@InjectRepository(InventoryItem)
|
||||
private readonly inventoryRepository: Repository<InventoryItem>,
|
||||
@Inject(forwardRef(() => InventoryReportsService))
|
||||
private readonly reportsService: InventoryReportsService,
|
||||
) {}
|
||||
|
||||
async findAllByStore(
|
||||
storeId: string,
|
||||
page = 1,
|
||||
limit = 50,
|
||||
): Promise<{ items: InventoryItem[]; total: number }> {
|
||||
const [items, total] = await this.inventoryRepository.findAndCount({
|
||||
where: { storeId },
|
||||
order: { name: 'ASC' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
async findById(storeId: string, itemId: string): Promise<InventoryItem> {
|
||||
const item = await this.inventoryRepository.findOne({
|
||||
where: { id: itemId, storeId },
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
throw new NotFoundException('Producto no encontrado');
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
async update(
|
||||
storeId: string,
|
||||
itemId: string,
|
||||
data: Partial<InventoryItem>,
|
||||
userId?: string,
|
||||
): Promise<InventoryItem> {
|
||||
const item = await this.findById(storeId, itemId);
|
||||
const quantityBefore = item.quantity;
|
||||
|
||||
Object.assign(item, data, { isManuallyEdited: true });
|
||||
const savedItem = await this.inventoryRepository.save(item);
|
||||
|
||||
// Record movement if quantity changed
|
||||
if (data.quantity !== undefined && data.quantity !== quantityBefore) {
|
||||
await this.reportsService.recordMovement(
|
||||
itemId,
|
||||
storeId,
|
||||
MovementType.MANUAL_ADJUST,
|
||||
quantityBefore,
|
||||
data.quantity,
|
||||
userId,
|
||||
userId ? TriggerType.USER : TriggerType.SYSTEM,
|
||||
'Manual inventory adjustment',
|
||||
);
|
||||
}
|
||||
|
||||
return savedItem;
|
||||
}
|
||||
|
||||
async delete(storeId: string, itemId: string): Promise<void> {
|
||||
const item = await this.findById(storeId, itemId);
|
||||
await this.inventoryRepository.remove(item);
|
||||
}
|
||||
|
||||
async bulkUpsertFromDetection(
|
||||
storeId: string,
|
||||
videoId: string,
|
||||
detectedItems: DetectedItem[],
|
||||
): Promise<InventoryItem[]> {
|
||||
const results: InventoryItem[] = [];
|
||||
|
||||
for (const detected of detectedItems) {
|
||||
// Try to find existing item by name or barcode
|
||||
let existing = await this.inventoryRepository.findOne({
|
||||
where: [
|
||||
{ storeId, name: detected.name },
|
||||
...(detected.barcode
|
||||
? [{ storeId, barcode: detected.barcode }]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
const quantityBefore = existing.quantity;
|
||||
|
||||
// Update existing item if not manually edited or if confidence is high
|
||||
if (!existing.isManuallyEdited || detected.confidence > 0.95) {
|
||||
existing.quantity = detected.quantity;
|
||||
existing.detectionConfidence = detected.confidence;
|
||||
existing.detectedByVideoId = videoId;
|
||||
existing.lastCountedAt = new Date();
|
||||
|
||||
if (detected.category && !existing.category) {
|
||||
existing.category = detected.category;
|
||||
}
|
||||
|
||||
await this.inventoryRepository.save(existing);
|
||||
|
||||
// Record movement if quantity changed
|
||||
if (detected.quantity !== quantityBefore) {
|
||||
await this.reportsService.recordMovement(
|
||||
existing.id,
|
||||
storeId,
|
||||
MovementType.DETECTION,
|
||||
quantityBefore,
|
||||
detected.quantity,
|
||||
undefined,
|
||||
TriggerType.VIDEO,
|
||||
`Video detection (confidence: ${(detected.confidence * 100).toFixed(1)}%)`,
|
||||
videoId,
|
||||
'video',
|
||||
);
|
||||
}
|
||||
}
|
||||
results.push(existing);
|
||||
} else {
|
||||
// Create new item
|
||||
const newItem = this.inventoryRepository.create({
|
||||
storeId,
|
||||
detectedByVideoId: videoId,
|
||||
name: detected.name,
|
||||
quantity: detected.quantity,
|
||||
category: detected.category,
|
||||
barcode: detected.barcode,
|
||||
detectionConfidence: detected.confidence,
|
||||
lastCountedAt: new Date(),
|
||||
});
|
||||
|
||||
const savedItem = await this.inventoryRepository.save(newItem);
|
||||
|
||||
// Record initial movement for new item
|
||||
await this.reportsService.recordMovement(
|
||||
savedItem.id,
|
||||
storeId,
|
||||
MovementType.INITIAL,
|
||||
0,
|
||||
detected.quantity,
|
||||
undefined,
|
||||
TriggerType.VIDEO,
|
||||
`Initial detection from video (confidence: ${(detected.confidence * 100).toFixed(1)}%)`,
|
||||
videoId,
|
||||
'video',
|
||||
);
|
||||
|
||||
results.push(savedItem);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async getCategories(storeId: string): Promise<string[]> {
|
||||
const result = await this.inventoryRepository
|
||||
.createQueryBuilder('item')
|
||||
.select('DISTINCT item.category', 'category')
|
||||
.where('item.storeId = :storeId', { storeId })
|
||||
.andWhere('item.category IS NOT NULL')
|
||||
.getRawMany();
|
||||
|
||||
return result.map((r) => r.category);
|
||||
}
|
||||
|
||||
async getLowStockItems(storeId: string): Promise<InventoryItem[]> {
|
||||
return this.inventoryRepository
|
||||
.createQueryBuilder('item')
|
||||
.where('item.storeId = :storeId', { storeId })
|
||||
.andWhere('item.minStock IS NOT NULL')
|
||||
.andWhere('item.quantity <= item.minStock')
|
||||
.orderBy('item.quantity', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async getStatistics(storeId: string) {
|
||||
const totalItems = await this.inventoryRepository.count({
|
||||
where: { storeId },
|
||||
});
|
||||
|
||||
const lowStockCount = await this.inventoryRepository
|
||||
.createQueryBuilder('item')
|
||||
.where('item.storeId = :storeId', { storeId })
|
||||
.andWhere('item.minStock IS NOT NULL')
|
||||
.andWhere('item.quantity <= item.minStock')
|
||||
.getCount();
|
||||
|
||||
const categoriesResult = await this.inventoryRepository
|
||||
.createQueryBuilder('item')
|
||||
.select('COUNT(DISTINCT item.category)', 'count')
|
||||
.where('item.storeId = :storeId', { storeId })
|
||||
.andWhere('item.category IS NOT NULL')
|
||||
.getRawOne();
|
||||
|
||||
const totalValue = await this.inventoryRepository
|
||||
.createQueryBuilder('item')
|
||||
.select('SUM(item.quantity * item.price)', 'total')
|
||||
.where('item.storeId = :storeId', { storeId })
|
||||
.andWhere('item.price IS NOT NULL')
|
||||
.getRawOne();
|
||||
|
||||
return {
|
||||
totalItems,
|
||||
lowStockCount,
|
||||
categoriesCount: parseInt(categoriesResult?.count || '0', 10),
|
||||
totalValue: parseFloat(totalValue?.total || '0'),
|
||||
};
|
||||
}
|
||||
}
|
||||
57
src/modules/notifications/entities/notification.entity.ts
Normal file
57
src/modules/notifications/entities/notification.entity.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
|
||||
export enum NotificationType {
|
||||
VIDEO_PROCESSING_COMPLETE = 'VIDEO_PROCESSING_COMPLETE',
|
||||
VIDEO_PROCESSING_FAILED = 'VIDEO_PROCESSING_FAILED',
|
||||
LOW_CREDITS = 'LOW_CREDITS',
|
||||
PAYMENT_COMPLETE = 'PAYMENT_COMPLETE',
|
||||
PAYMENT_FAILED = 'PAYMENT_FAILED',
|
||||
REFERRAL_BONUS = 'REFERRAL_BONUS',
|
||||
PROMO = 'PROMO',
|
||||
SYSTEM = 'SYSTEM',
|
||||
}
|
||||
|
||||
@Entity('notifications')
|
||||
@Index(['userId', 'createdAt'])
|
||||
@Index(['userId', 'isRead'])
|
||||
export class Notification {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
userId: string;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'userId' })
|
||||
user: User;
|
||||
|
||||
@Column({ type: 'enum', enum: NotificationType })
|
||||
type: NotificationType;
|
||||
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
title: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
body: string;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isRead: boolean;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isPushSent: boolean;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
data: Record<string, unknown>;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
}
|
||||
100
src/modules/notifications/notifications.controller.ts
Normal file
100
src/modules/notifications/notifications.controller.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Param,
|
||||
Query,
|
||||
Body,
|
||||
UseGuards,
|
||||
Request,
|
||||
ParseUUIDPipe,
|
||||
ParseIntPipe,
|
||||
DefaultValuePipe,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
ApiQuery,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { AuthenticatedRequest } from '../../common/interfaces/authenticated-request.interface';
|
||||
|
||||
@ApiTags('notifications')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('notifications')
|
||||
export class NotificationsController {
|
||||
constructor(
|
||||
private readonly notificationsService: NotificationsService,
|
||||
private readonly usersService: UsersService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Obtener notificaciones del usuario' })
|
||||
@ApiResponse({ status: 200, description: 'Lista de notificaciones' })
|
||||
@ApiQuery({ name: 'page', required: false, type: Number })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||
async getNotifications(
|
||||
@Request() req: AuthenticatedRequest,
|
||||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
||||
@Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number,
|
||||
) {
|
||||
const { notifications, total } =
|
||||
await this.notificationsService.getUserNotifications(
|
||||
req.user.id,
|
||||
page,
|
||||
Math.min(limit, 100),
|
||||
);
|
||||
|
||||
return {
|
||||
notifications,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
hasMore: page * limit < total,
|
||||
};
|
||||
}
|
||||
|
||||
@Get('unread-count')
|
||||
@ApiOperation({ summary: 'Obtener cantidad de notificaciones sin leer' })
|
||||
@ApiResponse({ status: 200, description: 'Cantidad de no leidas' })
|
||||
async getUnreadCount(@Request() req: AuthenticatedRequest) {
|
||||
const count = await this.notificationsService.getUnreadCount(req.user.id);
|
||||
return { count };
|
||||
}
|
||||
|
||||
@Patch(':notificationId/read')
|
||||
@ApiOperation({ summary: 'Marcar notificacion como leida' })
|
||||
@ApiResponse({ status: 200, description: 'Notificacion marcada' })
|
||||
async markAsRead(
|
||||
@Request() req: AuthenticatedRequest,
|
||||
@Param('notificationId', ParseUUIDPipe) notificationId: string,
|
||||
) {
|
||||
await this.notificationsService.markAsRead(req.user.id, notificationId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post('mark-all-read')
|
||||
@ApiOperation({ summary: 'Marcar todas como leidas' })
|
||||
@ApiResponse({ status: 200, description: 'Notificaciones marcadas' })
|
||||
async markAllAsRead(@Request() req: AuthenticatedRequest) {
|
||||
await this.notificationsService.markAllAsRead(req.user.id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post('register-token')
|
||||
@ApiOperation({ summary: 'Registrar token FCM' })
|
||||
@ApiResponse({ status: 200, description: 'Token registrado' })
|
||||
async registerToken(
|
||||
@Request() req: AuthenticatedRequest,
|
||||
@Body('token') token: string,
|
||||
) {
|
||||
await this.usersService.updateFcmToken(req.user.id, token);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
17
src/modules/notifications/notifications.module.ts
Normal file
17
src/modules/notifications/notifications.module.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { NotificationsController } from './notifications.controller';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
import { Notification } from './entities/notification.entity';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Notification]),
|
||||
UsersModule,
|
||||
],
|
||||
controllers: [NotificationsController],
|
||||
providers: [NotificationsService],
|
||||
exports: [NotificationsService],
|
||||
})
|
||||
export class NotificationsModule {}
|
||||
228
src/modules/notifications/notifications.service.ts
Normal file
228
src/modules/notifications/notifications.service.ts
Normal file
@ -0,0 +1,228 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as admin from 'firebase-admin';
|
||||
import {
|
||||
Notification,
|
||||
NotificationType,
|
||||
} from './entities/notification.entity';
|
||||
import { UsersService } from '../users/users.service';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationsService {
|
||||
private readonly logger = new Logger(NotificationsService.name);
|
||||
private firebaseApp: admin.app.App | null = null;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Notification)
|
||||
private readonly notificationsRepository: Repository<Notification>,
|
||||
private readonly usersService: UsersService,
|
||||
private readonly configService: ConfigService,
|
||||
) {
|
||||
this.initializeFirebase();
|
||||
}
|
||||
|
||||
private initializeFirebase() {
|
||||
const projectId = this.configService.get('FIREBASE_PROJECT_ID');
|
||||
const clientEmail = this.configService.get('FIREBASE_CLIENT_EMAIL');
|
||||
const privateKey = this.configService.get('FIREBASE_PRIVATE_KEY');
|
||||
|
||||
if (projectId && clientEmail && privateKey && !privateKey.includes('YOUR_KEY')) {
|
||||
try {
|
||||
this.firebaseApp = admin.initializeApp({
|
||||
credential: admin.credential.cert({
|
||||
projectId,
|
||||
clientEmail,
|
||||
privateKey: privateKey.replace(/\\n/g, '\n'),
|
||||
}),
|
||||
});
|
||||
this.logger.log('Firebase initialized successfully');
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to initialize Firebase:', error.message);
|
||||
}
|
||||
} else {
|
||||
this.logger.warn('Firebase not configured - push notifications disabled');
|
||||
}
|
||||
}
|
||||
|
||||
async sendPush(
|
||||
userId: string,
|
||||
type: NotificationType,
|
||||
title: string,
|
||||
body: string,
|
||||
data?: Record<string, unknown>,
|
||||
): Promise<Notification> {
|
||||
// Create notification record
|
||||
const notification = this.notificationsRepository.create({
|
||||
userId,
|
||||
type,
|
||||
title,
|
||||
body,
|
||||
data,
|
||||
});
|
||||
|
||||
await this.notificationsRepository.save(notification);
|
||||
|
||||
// Try to send push notification
|
||||
const user = await this.usersService.findById(userId);
|
||||
if (user?.fcmToken && this.firebaseApp) {
|
||||
try {
|
||||
await admin.messaging().send({
|
||||
token: user.fcmToken,
|
||||
notification: { title, body },
|
||||
data: data ? this.stringifyData(data) : undefined,
|
||||
android: {
|
||||
priority: 'high',
|
||||
notification: {
|
||||
channelId: 'miinventario_default',
|
||||
},
|
||||
},
|
||||
apns: {
|
||||
payload: {
|
||||
aps: {
|
||||
sound: 'default',
|
||||
badge: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
notification.isPushSent = true;
|
||||
await this.notificationsRepository.save(notification);
|
||||
this.logger.log(`Push sent to user ${userId}: ${title}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send push to ${userId}:`, error.message);
|
||||
|
||||
// If token is invalid, clear it
|
||||
if (error.code === 'messaging/invalid-registration-token' ||
|
||||
error.code === 'messaging/registration-token-not-registered') {
|
||||
await this.usersService.updateFcmToken(userId, null);
|
||||
}
|
||||
}
|
||||
} else if (!this.firebaseApp) {
|
||||
this.logger.warn(`Push simulation for ${userId}: ${title}`);
|
||||
}
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
async notifyVideoProcessingComplete(
|
||||
userId: string,
|
||||
videoId: string,
|
||||
itemsDetected: number,
|
||||
) {
|
||||
return this.sendPush(
|
||||
userId,
|
||||
NotificationType.VIDEO_PROCESSING_COMPLETE,
|
||||
'Video procesado',
|
||||
`Se detectaron ${itemsDetected} productos en tu anaquel`,
|
||||
{ videoId, itemsDetected },
|
||||
);
|
||||
}
|
||||
|
||||
async notifyVideoProcessingFailed(
|
||||
userId: string,
|
||||
videoId: string,
|
||||
errorMessage: string,
|
||||
) {
|
||||
return this.sendPush(
|
||||
userId,
|
||||
NotificationType.VIDEO_PROCESSING_FAILED,
|
||||
'Error al procesar video',
|
||||
'Hubo un problema al procesar tu video. Intenta de nuevo.',
|
||||
{ videoId, error: errorMessage },
|
||||
);
|
||||
}
|
||||
|
||||
async notifyLowCredits(userId: string, currentBalance: number) {
|
||||
return this.sendPush(
|
||||
userId,
|
||||
NotificationType.LOW_CREDITS,
|
||||
'Creditos bajos',
|
||||
`Te quedan ${currentBalance} creditos. Recarga para seguir escaneando.`,
|
||||
{ balance: currentBalance },
|
||||
);
|
||||
}
|
||||
|
||||
async notifyPaymentComplete(
|
||||
userId: string,
|
||||
paymentId: string,
|
||||
creditsGranted: number,
|
||||
) {
|
||||
return this.sendPush(
|
||||
userId,
|
||||
NotificationType.PAYMENT_COMPLETE,
|
||||
'Pago completado',
|
||||
`Se agregaron ${creditsGranted} creditos a tu cuenta`,
|
||||
{ paymentId, creditsGranted },
|
||||
);
|
||||
}
|
||||
|
||||
async notifyPaymentFailed(userId: string, paymentId: string) {
|
||||
return this.sendPush(
|
||||
userId,
|
||||
NotificationType.PAYMENT_FAILED,
|
||||
'Pago fallido',
|
||||
'Tu pago no pudo ser procesado. Intenta con otro metodo.',
|
||||
{ paymentId },
|
||||
);
|
||||
}
|
||||
|
||||
async notifyReferralBonus(
|
||||
userId: string,
|
||||
bonusCredits: number,
|
||||
referredName: string,
|
||||
) {
|
||||
return this.sendPush(
|
||||
userId,
|
||||
NotificationType.REFERRAL_BONUS,
|
||||
'Bonus de referido',
|
||||
`${referredName} uso tu codigo. Ganaste ${bonusCredits} creditos.`,
|
||||
{ bonusCredits, referredName },
|
||||
);
|
||||
}
|
||||
|
||||
async getUserNotifications(
|
||||
userId: string,
|
||||
page = 1,
|
||||
limit = 20,
|
||||
): Promise<{ notifications: Notification[]; total: number }> {
|
||||
const [notifications, total] = await this.notificationsRepository.findAndCount({
|
||||
where: { userId },
|
||||
order: { createdAt: 'DESC' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return { notifications, total };
|
||||
}
|
||||
|
||||
async markAsRead(userId: string, notificationId: string): Promise<void> {
|
||||
await this.notificationsRepository.update(
|
||||
{ id: notificationId, userId },
|
||||
{ isRead: true },
|
||||
);
|
||||
}
|
||||
|
||||
async markAllAsRead(userId: string): Promise<void> {
|
||||
await this.notificationsRepository.update(
|
||||
{ userId, isRead: false },
|
||||
{ isRead: true },
|
||||
);
|
||||
}
|
||||
|
||||
async getUnreadCount(userId: string): Promise<number> {
|
||||
return this.notificationsRepository.count({
|
||||
where: { userId, isRead: false },
|
||||
});
|
||||
}
|
||||
|
||||
private stringifyData(data: Record<string, unknown>): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
result[key] = typeof value === 'string' ? value : JSON.stringify(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
29
src/modules/payments/dto/create-payment.dto.ts
Normal file
29
src/modules/payments/dto/create-payment.dto.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { IsString, IsNotEmpty, IsEnum, IsOptional } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { PaymentMethod } from '../entities/payment.entity';
|
||||
|
||||
export class CreatePaymentDto {
|
||||
@ApiProperty({
|
||||
description: 'ID del paquete de creditos',
|
||||
example: 'uuid-del-paquete',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
packageId: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Metodo de pago',
|
||||
enum: PaymentMethod,
|
||||
example: PaymentMethod.CARD,
|
||||
})
|
||||
@IsEnum(PaymentMethod)
|
||||
method: PaymentMethod;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'ID del metodo de pago guardado (para tarjetas)',
|
||||
example: 'pm_1234567890',
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
paymentMethodId?: string;
|
||||
}
|
||||
92
src/modules/payments/entities/payment.entity.ts
Normal file
92
src/modules/payments/entities/payment.entity.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
import { CreditPackage } from '../../credits/entities/credit-package.entity';
|
||||
|
||||
export enum PaymentStatus {
|
||||
PENDING = 'PENDING',
|
||||
PROCESSING = 'PROCESSING',
|
||||
COMPLETED = 'COMPLETED',
|
||||
FAILED = 'FAILED',
|
||||
REFUNDED = 'REFUNDED',
|
||||
EXPIRED = 'EXPIRED',
|
||||
}
|
||||
|
||||
export enum PaymentMethod {
|
||||
CARD = 'CARD',
|
||||
OXXO = 'OXXO',
|
||||
SEVEN_ELEVEN = '7ELEVEN',
|
||||
}
|
||||
|
||||
@Entity('payments')
|
||||
@Index(['userId', 'createdAt'])
|
||||
@Index(['status', 'createdAt'])
|
||||
@Index(['externalId'])
|
||||
export class Payment {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
userId: string;
|
||||
|
||||
@ManyToOne(() => User)
|
||||
@JoinColumn({ name: 'userId' })
|
||||
user: User;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
packageId: string;
|
||||
|
||||
@ManyToOne(() => CreditPackage, { nullable: true })
|
||||
@JoinColumn({ name: 'packageId' })
|
||||
package: CreditPackage;
|
||||
|
||||
@Column({ type: 'decimal', precision: 10, scale: 2 })
|
||||
amountMXN: number;
|
||||
|
||||
@Column({ type: 'int' })
|
||||
creditsGranted: number;
|
||||
|
||||
@Column({ type: 'enum', enum: PaymentMethod })
|
||||
method: PaymentMethod;
|
||||
|
||||
@Column({ type: 'enum', enum: PaymentStatus, default: PaymentStatus.PENDING })
|
||||
status: PaymentStatus;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
externalId: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, nullable: true })
|
||||
provider: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 500, nullable: true })
|
||||
voucherUrl?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||
voucherCode?: string;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
expiresAt: Date;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
completedAt: Date;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
errorMessage: string;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata: Record<string, unknown>;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
103
src/modules/payments/payments.controller.ts
Normal file
103
src/modules/payments/payments.controller.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
Headers,
|
||||
RawBodyRequest,
|
||||
Req,
|
||||
ParseUUIDPipe,
|
||||
ParseIntPipe,
|
||||
DefaultValuePipe,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiBearerAuth,
|
||||
ApiQuery,
|
||||
} from '@nestjs/swagger';
|
||||
import { Request as ExpressRequest } from 'express';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { PaymentsService } from './payments.service';
|
||||
import { CreatePaymentDto } from './dto/create-payment.dto';
|
||||
import { AuthenticatedRequest } from '../../common/interfaces/authenticated-request.interface';
|
||||
|
||||
@ApiTags('payments')
|
||||
@Controller('payments')
|
||||
export class PaymentsController {
|
||||
constructor(private readonly paymentsService: PaymentsService) {}
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Crear un nuevo pago' })
|
||||
@ApiResponse({ status: 201, description: 'Pago creado' })
|
||||
@ApiResponse({ status: 400, description: 'Error en el pago' })
|
||||
create(
|
||||
@Request() req: AuthenticatedRequest,
|
||||
@Body() dto: CreatePaymentDto,
|
||||
) {
|
||||
return this.paymentsService.createPayment(req.user.id, dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Obtener historial de pagos' })
|
||||
@ApiResponse({ status: 200, description: 'Lista de pagos' })
|
||||
@ApiQuery({ name: 'page', required: false, type: Number })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number })
|
||||
async getHistory(
|
||||
@Request() req: AuthenticatedRequest,
|
||||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
||||
@Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number,
|
||||
) {
|
||||
const { payments, total } = await this.paymentsService.getPaymentHistory(
|
||||
req.user.id,
|
||||
page,
|
||||
Math.min(limit, 100),
|
||||
);
|
||||
|
||||
return {
|
||||
payments,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
hasMore: page * limit < total,
|
||||
};
|
||||
}
|
||||
|
||||
@Get(':paymentId')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Obtener detalle de un pago' })
|
||||
@ApiResponse({ status: 200, description: 'Detalle del pago' })
|
||||
@ApiResponse({ status: 404, description: 'Pago no encontrado' })
|
||||
getPayment(
|
||||
@Request() req: AuthenticatedRequest,
|
||||
@Param('paymentId', ParseUUIDPipe) paymentId: string,
|
||||
) {
|
||||
return this.paymentsService.getPaymentById(paymentId, req.user.id);
|
||||
}
|
||||
|
||||
@Post('webhook/stripe')
|
||||
@ApiOperation({ summary: 'Webhook de Stripe' })
|
||||
@ApiResponse({ status: 200, description: 'Webhook procesado' })
|
||||
@ApiResponse({ status: 400, description: 'Firma invalida' })
|
||||
async handleStripeWebhook(
|
||||
@Req() req: RawBodyRequest<ExpressRequest>,
|
||||
@Headers('stripe-signature') signature: string,
|
||||
) {
|
||||
const rawBody = req.rawBody;
|
||||
if (!rawBody) {
|
||||
return { received: true, error: 'No raw body available' };
|
||||
}
|
||||
|
||||
return this.paymentsService.handleStripeWebhook(rawBody, signature);
|
||||
}
|
||||
}
|
||||
21
src/modules/payments/payments.module.ts
Normal file
21
src/modules/payments/payments.module.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { PaymentsController } from './payments.controller';
|
||||
import { PaymentsService } from './payments.service';
|
||||
import { Payment } from './entities/payment.entity';
|
||||
import { CreditsModule } from '../credits/credits.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Payment]),
|
||||
CreditsModule,
|
||||
UsersModule,
|
||||
forwardRef(() => NotificationsModule),
|
||||
],
|
||||
controllers: [PaymentsController],
|
||||
providers: [PaymentsService],
|
||||
exports: [PaymentsService],
|
||||
})
|
||||
export class PaymentsModule {}
|
||||
416
src/modules/payments/payments.service.ts
Normal file
416
src/modules/payments/payments.service.ts
Normal file
@ -0,0 +1,416 @@
|
||||
import {
|
||||
Injectable,
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
Logger,
|
||||
Inject,
|
||||
forwardRef,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import Stripe from 'stripe';
|
||||
import {
|
||||
Payment,
|
||||
PaymentStatus,
|
||||
PaymentMethod,
|
||||
} from './entities/payment.entity';
|
||||
import { CreatePaymentDto } from './dto/create-payment.dto';
|
||||
import { CreditsService } from '../credits/credits.service';
|
||||
import { TransactionType } from '../credits/entities/credit-transaction.entity';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
|
||||
@Injectable()
|
||||
export class PaymentsService {
|
||||
private readonly logger = new Logger(PaymentsService.name);
|
||||
private stripe: Stripe;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Payment)
|
||||
private readonly paymentsRepository: Repository<Payment>,
|
||||
private readonly creditsService: CreditsService,
|
||||
private readonly usersService: UsersService,
|
||||
private readonly configService: ConfigService,
|
||||
@Inject(forwardRef(() => NotificationsService))
|
||||
private readonly notificationsService: NotificationsService,
|
||||
) {
|
||||
const stripeKey = this.configService.get('STRIPE_SECRET_KEY');
|
||||
if (stripeKey && stripeKey !== 'sk_test_your_stripe_key') {
|
||||
this.stripe = new Stripe(stripeKey, { apiVersion: '2023-10-16' });
|
||||
}
|
||||
}
|
||||
|
||||
async createPayment(userId: string, dto: CreatePaymentDto) {
|
||||
// Get package
|
||||
const pkg = await this.creditsService.getPackageById(dto.packageId);
|
||||
|
||||
// Get or create Stripe customer
|
||||
const user = await this.usersService.findById(userId);
|
||||
if (!user) {
|
||||
throw new BadRequestException('Usuario no encontrado');
|
||||
}
|
||||
|
||||
let customerId = user.stripeCustomerId;
|
||||
|
||||
if (!customerId && this.stripe) {
|
||||
const customer = await this.stripe.customers.create({
|
||||
phone: user.phone || undefined,
|
||||
name: user.name || undefined,
|
||||
metadata: { userId },
|
||||
});
|
||||
customerId = customer.id;
|
||||
await this.usersService.update(userId, { stripeCustomerId: customerId });
|
||||
}
|
||||
|
||||
// Create payment record
|
||||
const payment = this.paymentsRepository.create({
|
||||
userId,
|
||||
packageId: pkg.id,
|
||||
amountMXN: Number(pkg.priceMXN),
|
||||
creditsGranted: pkg.credits,
|
||||
method: dto.method,
|
||||
status: PaymentStatus.PENDING,
|
||||
provider: 'stripe',
|
||||
});
|
||||
|
||||
await this.paymentsRepository.save(payment);
|
||||
|
||||
try {
|
||||
switch (dto.method) {
|
||||
case PaymentMethod.CARD:
|
||||
return this.processCardPayment(payment, customerId, dto.paymentMethodId);
|
||||
case PaymentMethod.OXXO:
|
||||
return this.processOxxoPayment(payment, customerId);
|
||||
case PaymentMethod.SEVEN_ELEVEN:
|
||||
return this.process7ElevenPayment(payment);
|
||||
default:
|
||||
throw new BadRequestException('Metodo de pago no soportado');
|
||||
}
|
||||
} catch (error) {
|
||||
payment.status = PaymentStatus.FAILED;
|
||||
payment.errorMessage = error.message;
|
||||
await this.paymentsRepository.save(payment);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async processCardPayment(
|
||||
payment: Payment,
|
||||
customerId: string,
|
||||
paymentMethodId?: string,
|
||||
) {
|
||||
if (!this.stripe) {
|
||||
// Development mode - simulate success
|
||||
return this.simulatePaymentSuccess(payment);
|
||||
}
|
||||
|
||||
if (!paymentMethodId) {
|
||||
// Create PaymentIntent for new card
|
||||
const paymentIntent = await this.stripe.paymentIntents.create({
|
||||
amount: Math.round(payment.amountMXN * 100),
|
||||
currency: 'mxn',
|
||||
customer: customerId,
|
||||
metadata: { paymentId: payment.id },
|
||||
});
|
||||
|
||||
payment.externalId = paymentIntent.id;
|
||||
await this.paymentsRepository.save(payment);
|
||||
|
||||
return {
|
||||
paymentId: payment.id,
|
||||
clientSecret: paymentIntent.client_secret,
|
||||
status: 'requires_payment_method',
|
||||
};
|
||||
}
|
||||
|
||||
// Charge with existing payment method
|
||||
const paymentIntent = await this.stripe.paymentIntents.create({
|
||||
amount: Math.round(payment.amountMXN * 100),
|
||||
currency: 'mxn',
|
||||
customer: customerId,
|
||||
payment_method: paymentMethodId,
|
||||
confirm: true,
|
||||
metadata: { paymentId: payment.id },
|
||||
});
|
||||
|
||||
payment.externalId = paymentIntent.id;
|
||||
|
||||
if (paymentIntent.status === 'succeeded') {
|
||||
await this.completePayment(payment);
|
||||
return {
|
||||
paymentId: payment.id,
|
||||
status: 'completed',
|
||||
creditsGranted: payment.creditsGranted,
|
||||
};
|
||||
}
|
||||
|
||||
payment.status = PaymentStatus.PROCESSING;
|
||||
await this.paymentsRepository.save(payment);
|
||||
|
||||
return {
|
||||
paymentId: payment.id,
|
||||
status: paymentIntent.status,
|
||||
};
|
||||
}
|
||||
|
||||
private async processOxxoPayment(payment: Payment, customerId: string) {
|
||||
if (!this.stripe) {
|
||||
return this.simulateOxxoVoucher(payment);
|
||||
}
|
||||
|
||||
// Create OXXO payment intent
|
||||
const paymentIntent = await this.stripe.paymentIntents.create({
|
||||
amount: Math.round(payment.amountMXN * 100),
|
||||
currency: 'mxn',
|
||||
customer: customerId,
|
||||
payment_method_types: ['oxxo'],
|
||||
metadata: { paymentId: payment.id },
|
||||
});
|
||||
|
||||
// Confirm with OXXO
|
||||
const confirmedIntent = await this.stripe.paymentIntents.confirm(
|
||||
paymentIntent.id,
|
||||
{
|
||||
payment_method_data: {
|
||||
type: 'oxxo',
|
||||
billing_details: {
|
||||
name: 'Cliente MiInventario',
|
||||
email: 'cliente@miinventario.com',
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
payment.externalId = paymentIntent.id;
|
||||
payment.status = PaymentStatus.PENDING;
|
||||
payment.expiresAt = new Date(Date.now() + 72 * 60 * 60 * 1000); // 72 hours
|
||||
|
||||
// Get OXXO voucher details
|
||||
const nextAction = confirmedIntent.next_action;
|
||||
if (nextAction?.oxxo_display_details) {
|
||||
payment.voucherCode = nextAction.oxxo_display_details.number || undefined;
|
||||
payment.voucherUrl = nextAction.oxxo_display_details.hosted_voucher_url || undefined;
|
||||
}
|
||||
|
||||
await this.paymentsRepository.save(payment);
|
||||
|
||||
return {
|
||||
paymentId: payment.id,
|
||||
status: 'pending',
|
||||
method: 'oxxo',
|
||||
voucherCode: payment.voucherCode,
|
||||
voucherUrl: payment.voucherUrl,
|
||||
expiresAt: payment.expiresAt,
|
||||
amountMXN: payment.amountMXN,
|
||||
};
|
||||
}
|
||||
|
||||
private async process7ElevenPayment(payment: Payment) {
|
||||
// TODO: Implement Conekta 7-Eleven integration
|
||||
// For now, return simulated voucher
|
||||
return this.simulate7ElevenVoucher(payment);
|
||||
}
|
||||
|
||||
private async simulatePaymentSuccess(payment: Payment) {
|
||||
this.logger.warn('Development mode: Simulating payment success');
|
||||
payment.externalId = `sim_${Date.now()}`;
|
||||
await this.completePayment(payment);
|
||||
|
||||
return {
|
||||
paymentId: payment.id,
|
||||
status: 'completed',
|
||||
creditsGranted: payment.creditsGranted,
|
||||
simulated: true,
|
||||
};
|
||||
}
|
||||
|
||||
private async simulateOxxoVoucher(payment: Payment) {
|
||||
this.logger.warn('Development mode: Simulating OXXO voucher');
|
||||
|
||||
payment.externalId = `sim_oxxo_${Date.now()}`;
|
||||
payment.voucherCode = Math.random().toString().slice(2, 16);
|
||||
payment.voucherUrl = `https://example.com/voucher/${payment.id}`;
|
||||
payment.expiresAt = new Date(Date.now() + 72 * 60 * 60 * 1000);
|
||||
await this.paymentsRepository.save(payment);
|
||||
|
||||
return {
|
||||
paymentId: payment.id,
|
||||
status: 'pending',
|
||||
method: 'oxxo',
|
||||
voucherCode: payment.voucherCode,
|
||||
voucherUrl: payment.voucherUrl,
|
||||
expiresAt: payment.expiresAt,
|
||||
amountMXN: payment.amountMXN,
|
||||
simulated: true,
|
||||
};
|
||||
}
|
||||
|
||||
private async simulate7ElevenVoucher(payment: Payment) {
|
||||
this.logger.warn('Development mode: Simulating 7-Eleven voucher');
|
||||
|
||||
payment.externalId = `sim_7eleven_${Date.now()}`;
|
||||
payment.voucherCode = Math.random().toString().slice(2, 14);
|
||||
payment.expiresAt = new Date(Date.now() + 48 * 60 * 60 * 1000);
|
||||
await this.paymentsRepository.save(payment);
|
||||
|
||||
return {
|
||||
paymentId: payment.id,
|
||||
status: 'pending',
|
||||
method: '7eleven',
|
||||
voucherCode: payment.voucherCode,
|
||||
expiresAt: payment.expiresAt,
|
||||
amountMXN: payment.amountMXN,
|
||||
simulated: true,
|
||||
};
|
||||
}
|
||||
|
||||
async handleStripeWebhook(payload: Buffer, signature: string) {
|
||||
const webhookSecret = this.configService.get('STRIPE_WEBHOOK_SECRET');
|
||||
|
||||
if (!this.stripe || !webhookSecret) {
|
||||
this.logger.warn('Stripe webhook not configured');
|
||||
return { received: true, processed: false };
|
||||
}
|
||||
|
||||
let event: Stripe.Event;
|
||||
|
||||
try {
|
||||
event = this.stripe.webhooks.constructEvent(
|
||||
payload,
|
||||
signature,
|
||||
webhookSecret,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(`Webhook signature verification failed: ${err.message}`);
|
||||
throw new BadRequestException('Webhook signature verification failed');
|
||||
}
|
||||
|
||||
this.logger.log(`Processing Stripe webhook: ${event.type}`);
|
||||
|
||||
switch (event.type) {
|
||||
case 'payment_intent.succeeded':
|
||||
await this.handlePaymentSuccess(event.data.object as Stripe.PaymentIntent);
|
||||
break;
|
||||
case 'payment_intent.payment_failed':
|
||||
await this.handlePaymentFailed(event.data.object as Stripe.PaymentIntent);
|
||||
break;
|
||||
case 'payment_intent.processing':
|
||||
await this.handlePaymentProcessing(event.data.object as Stripe.PaymentIntent);
|
||||
break;
|
||||
case 'charge.succeeded':
|
||||
// Additional confirmation for OXXO/cash payments
|
||||
this.logger.log(`Charge succeeded: ${(event.data.object as Stripe.Charge).id}`);
|
||||
break;
|
||||
case 'charge.pending':
|
||||
// OXXO payment is pending (customer needs to pay at store)
|
||||
this.logger.log(`Charge pending: ${(event.data.object as Stripe.Charge).id}`);
|
||||
break;
|
||||
default:
|
||||
this.logger.log(`Unhandled event type: ${event.type}`);
|
||||
}
|
||||
|
||||
return { received: true, processed: true, eventType: event.type };
|
||||
}
|
||||
|
||||
private async handlePaymentProcessing(paymentIntent: Stripe.PaymentIntent) {
|
||||
const paymentId = paymentIntent.metadata.paymentId;
|
||||
if (!paymentId) return;
|
||||
|
||||
await this.paymentsRepository.update(paymentId, {
|
||||
status: PaymentStatus.PROCESSING,
|
||||
});
|
||||
|
||||
this.logger.log(`Payment ${paymentId} is now processing`);
|
||||
}
|
||||
|
||||
private async handlePaymentSuccess(paymentIntent: Stripe.PaymentIntent) {
|
||||
const paymentId = paymentIntent.metadata.paymentId;
|
||||
if (!paymentId) return;
|
||||
|
||||
const payment = await this.paymentsRepository.findOne({
|
||||
where: { id: paymentId },
|
||||
});
|
||||
|
||||
if (payment && payment.status !== PaymentStatus.COMPLETED) {
|
||||
await this.completePayment(payment);
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePaymentFailed(paymentIntent: Stripe.PaymentIntent) {
|
||||
const paymentId = paymentIntent.metadata.paymentId;
|
||||
if (!paymentId) return;
|
||||
|
||||
const payment = await this.paymentsRepository.findOne({
|
||||
where: { id: paymentId },
|
||||
});
|
||||
|
||||
if (payment) {
|
||||
await this.paymentsRepository.update(paymentId, {
|
||||
status: PaymentStatus.FAILED,
|
||||
errorMessage: paymentIntent.last_payment_error?.message,
|
||||
});
|
||||
|
||||
// Send notification
|
||||
try {
|
||||
await this.notificationsService.notifyPaymentFailed(payment.userId, paymentId);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send payment failed notification: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async completePayment(payment: Payment) {
|
||||
payment.status = PaymentStatus.COMPLETED;
|
||||
payment.completedAt = new Date();
|
||||
await this.paymentsRepository.save(payment);
|
||||
|
||||
// Grant credits
|
||||
await this.creditsService.addCredits(
|
||||
payment.userId,
|
||||
payment.creditsGranted,
|
||||
TransactionType.PURCHASE,
|
||||
`Compra de ${payment.creditsGranted} creditos`,
|
||||
payment.id,
|
||||
'payment',
|
||||
);
|
||||
|
||||
// Send notification
|
||||
try {
|
||||
await this.notificationsService.notifyPaymentComplete(
|
||||
payment.userId,
|
||||
payment.id,
|
||||
payment.creditsGranted,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send payment notification: ${error.message}`);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Payment ${payment.id} completed. Granted ${payment.creditsGranted} credits to user ${payment.userId}`,
|
||||
);
|
||||
}
|
||||
|
||||
async getPaymentHistory(userId: string, page = 1, limit = 20) {
|
||||
const [payments, total] = await this.paymentsRepository.findAndCount({
|
||||
where: { userId },
|
||||
order: { createdAt: 'DESC' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return { payments, total };
|
||||
}
|
||||
|
||||
async getPaymentById(paymentId: string, userId: string) {
|
||||
const payment = await this.paymentsRepository.findOne({
|
||||
where: { id: paymentId, userId },
|
||||
});
|
||||
|
||||
if (!payment) {
|
||||
throw new NotFoundException('Pago no encontrado');
|
||||
}
|
||||
|
||||
return payment;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user